# まじん式 PDF v1.0b（書籍版） — 構成の解説

依頼された帳票を作る**単一HTMLのWebアプリ**を出力する。左に入力フォーム、右に紙面。
PDFに元データを埋め込み、そのPDFを読み込むと編集を再開できる。

**添付ファイルに全文がある。細部は添付を写す。下に字面があるものは下の字面を使う。**

---

## 1.5. デザイン規則（帳票の種類で表現を変える）

**構成や配置は指定しない。デザインの方向だけを示す。**
構成まで指定すると（「1行に収める」「下端まで使い切る」等）ページが増え、中身が空になる（実測）。

- **子ども向け**（学級通信・園だより・旅行のしおり・子ども向けの案内）は、
  **開いた瞬間に子どもが声をあげるような、絵本のような紙面**にする。
- **和食・ラーメン・定食などの飲食店の販促物**（POP・品書き・メニュー）は、
  **老舗の名店が掲げる品書きのような、味のある紙面**にする。
- **案内・告知**は渡す相手で変える。社外・株主など**格式が要る相手**には
  **落ち着いた案内状**、社内の親睦・家族向けには**賑やかなお祭りのポスター**。
  迷ったら落ち着いた方を選ぶ。

**依頼文にデザインの方向が書かれていれば、必ずそちらを優先する。**
ここに書いた方向は、指定が無いときの既定にすぎない。

---

## 1. 全体の形

```
<!DOCTYPE html>
<html lang="ja">
<head>… CDN4本 ＋ <style> …</head>
<body class="h-screen flex flex-row">
 <aside id="left-pane" class="w-[380px] h-full bg-white overflow-y-auto p-5 flex-none border-r">
   … 入力フォーム …
 </aside>
 <div id="right-pane" class="flex-1 flex flex-col overflow-hidden bg-slate-200 relative">
  <main id="preview-container" class="flex-1 overflow-auto bg-slate-200 relative">
   <div class="preview-wrapper">
    <div class="capture-area-container" style="position: relative;">
     <div class="pdf-page relative serif" data-page="0">
      <div class="page-inner flex flex-col h-full p-[50px] text-sm"> … 紙面 … </div>
     </div>
    </div>
   </div>
  </main>
  <div class="floating-zoom"> … ズーム（§1c） … </div>
 </div>
 <script id="main-script">(function(){ … 全JS … })();</script>
</body>
</html>
```

**4層を1段も省略しない**（欠落40件中30件。紙面が右にずれて切れた）。

## 1b. 左ペインの先頭は操作ボタン1行（この字面のまま）

```html
<aside id="left-pane" class="w-[380px] h-full bg-white overflow-y-auto p-5 flex-none border-r shadow-2xl z-20 text-slate-700">
 <div class="flex items-center gap-2 mb-5">
  <input type="file" id="fileInput" accept=".pdf" class="hidden">
  <button id="btn-open-newtab" class="p-1.5 text-slate-300 hover:text-blue-500 transition-all shrink-0" title="別タブで開く">
   <i data-lucide="external-link" class="w-4 h-4"></i></button>
  <button id="btn-upload-trigger" class="px-3 py-2 bg-white border border-slate-200 text-slate-600 rounded-lg font-bold text-xs hover:bg-slate-50 whitespace-nowrap">PDF読込</button>
  <button id="btn-copy-code" class="px-3 py-2 bg-white border border-slate-200 text-slate-600 rounded-lg font-bold text-xs hover:bg-slate-50 whitespace-nowrap">コードコピー</button>
  <button id="btn-download" class="bg-slate-900 text-white px-3 py-2 rounded-lg font-bold text-xs hover:bg-blue-700 shadow-lg active:scale-95 whitespace-nowrap">
   <span id="downloadBtnText">PDF保存</span></button>
 </div>
 … 見出し＋入力欄。明細には `btn-add-item` の「＋ 追加」…
</aside>
```

**id は8つとも変えない。**右ペインや画面上部にボタンを置かない。

## 1c. ズーム（`.floating-zoom`。**`#right-pane` の中・`</main>` の直後**に置く）

```html
<div class="floating-zoom">
 <div class="flex items-center bg-white/90 backdrop-blur-md rounded-xl p-1 gap-0.5 border shadow-lg">
  <button id="btn-zoom-out" class="p-1.5 hover:bg-slate-100 rounded-lg text-slate-600"><i data-lucide="minus" class="w-4 h-4"></i></button>
  <button id="btn-zoom-fit" class="p-1.5 hover:bg-slate-100 rounded-lg text-slate-600"><i data-lucide="maximize" class="w-4 h-4"></i></button>
  <button id="btn-zoom-in" class="p-1.5 hover:bg-slate-100 rounded-lg text-slate-600"><i data-lucide="plus" class="w-4 h-4"></i></button>
  <div id="zoom-level-text" class="px-2 text-[13px] font-black text-slate-500 min-w-[45px] text-center">100%</div>
 </div>
</div>
```

```css
.floating-zoom { position: absolute; left: 1.5rem; bottom: 1.5rem; z-index: 30; }
```

**`#right-pane` の外（`<body>` 直下）に出さない。** `relative` な親が無くなり、画面の左下＝左ペインの上に重なる（実測）。左ペインの中にも紙面の中にも置かない。

**`place-items` の1つ目は `start`。**`center` だと紙面が top 945px から始まり画面外に出た。
**紙面の上には必ず余白を空ける。`padding` で付ける（`24px 0 16px`）。**
上下の合計40pxは `fitToScreen` が引く40pxと一致させる。ずらすと縦スクロールが出る
（実測: 上24px・スクロールなし・倍率は変わらず）。

**この字面のまま。`Math.max(0.1, …)` の下限を外さない。**

```javascript
function updateZoomUI() {
 const pw = currentPageSize.w, ph = currentPageSize.h;
 document.querySelectorAll('.pdf-page').forEach(el => el.style.transform = `scale(${currentScale})`);
 document.querySelectorAll('.capture-area-container').forEach(c => {
  c.style.width = `${pw * currentScale}px`; c.style.height = `${ph * currentScale}px`; });
 document.getElementById('zoom-level-text').innerText = `${Math.round(currentScale * 100)}%`;
}
function adjustZoom(newScale) { currentScale = newScale; updateZoomUI(); }
function fitToScreen() {
 const c = document.getElementById('preview-container');
 if (!c) return;
 const pw = currentPageSize.w, ph = currentPageSize.h;
 currentScale = Math.max(0.1, Math.min((c.clientWidth - 20) / pw, (c.clientHeight - 40) / ph, 1.0));
 updateZoomUI();
}
```

下限を外すと `init()` 時点の `clientWidth=0` で `currentScale=−0.0716` になり、
**紙面が左右反転して 40×57px に潰れる**（表示「−7%」）。

**`lucide.createIcons()` を `init()` の最後で呼ぶ。**アイコンはSVGなので
`.className =` で触らず `setAttribute('class', …)` を使う。

## 2. `<head>` の先頭（この字面のまま。順序も同じ）

```html
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>（帳票名）</title>
<link rel="stylesheet" crossorigin="anonymous" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&family=Noto+Sans+JP:wght@400;500;700;900&display=swap">
<script src="https://cdn.tailwindcss.com/3.4.16"></script>
<script src="https://unpkg.com/lucide@0.344.0"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
```

- **`<meta charset="UTF-8">` は1行目**（先頭1024バイト以内。無いと全て文字化け）
- **フォントの `<link>` に `crossorigin="anonymous"`。**無いと `getFontEmbedCSS()` が
  `null` を返し、**PDFだけ別フォントで組まれて字が折り返す**（対照実験で確認）
- **`<script>` 4本には `crossorigin` を付けない。**`@latest` にしない。
  `[…](…)` 形式にしない（どちらも読み込まれずデザインが当たらない）

## 3. CSS の要

```css
:root { --page-w: 794px; --page-h: 1123px; }   /* A4縦。★列が7つ以上なら 1123px/794px に入れ替える */
body { overflow: hidden; margin: 0; }
.preview-wrapper { display: grid; place-items: start center; padding: 24px 0 16px; }  /* flex は使わない。上の余白は必ず付ける */
.pdf-page { width: var(--page-w); height: var(--page-h); box-sizing: border-box;
  background: #fff; overflow: hidden; transform-origin: top left; flex-shrink: 0; }
.capture-area-container { position: relative; height: 100%; }
.page-inner { height: 100%; min-height: 0; box-sizing: border-box; }

/* 紙は横にスクロールできない。文字を「…」で切らず、必ず折り返す */
.pdf-page .truncate { overflow: visible; text-overflow: clip; white-space: normal; }
/* 紙の枠の中で、文字を上下にも切らない（紙そのものの縁 .pdf-page だけは切る） */
.pdf-page .overflow-hidden:not(.pdf-page) { overflow: visible; }
```

**紙面に `truncate` / `text-ellipsis` / `whitespace-nowrap` を書かない。
枠に `overflow-hidden` を掛けない。**
画面なら「…」の先も、隠れた行もスクロールで見られるが、**紙は切れたら読めない**。
入りきらないなら折り返す。折り返しても入らないなら、分量を減らすか枠を広げる。

症 実測：家族カレンダーで**横に6箇所**が「…」で切れた（「緑ヶ丘運動公園グ…」
   「パパ配車（車出し…」——場所も当番も紙からは読めない）。
   BBQの案内では**縦に2箇所・計37px**が切れ、最終行「20:30 中締め・順次解散」が
   枠の下端で断ち切られていた
正 上の2行を入れた。**横の切れ 6→0、縦の切れ 2→0。**
   どちらもはみ出しも縮小も起きなかった

```javascript
const PAGE_SIZES = {
 A4:{w:794,h:1123,pdf:'a4',orient:'p',mmW:210,mmH:297},
 A4_LANDSCAPE:{w:1123,h:794,pdf:'a4',orient:'l',mmW:297,mmH:210},
 A5:{w:559,h:794,pdf:'a5',orient:'p',mmW:148,mmH:210},
 HAGAKI:{w:378,h:559,pdf:[100,148],orient:'p',mmW:100,mmH:148} };
let currentPageSize = PAGE_SIZES.A4;   // 帳票固有。★曜日7列の表（シフト・当番・時間割）は
                                       //   A4_LANDSCAPE。:root と必ず同時に変える
```

**`.preview-wrapper` は `grid`。`flex` は縮小時に紙面が右へずれる。**
拡大縮小したら `.capture-area-container` の `width`/`height` も同じ倍率で更新する。

## 4. JS の構成（`#main-script` の中。この順）

```
定数   APP_ID / RECOVERY_KEY / PAGE_SIZES / LAYOUT
state  帳票のデータ。日付は new Date() から作る（固定文字列は書かない）
```

**関数は25個。1つも省略しない。**
`escapeHtml` `fmtDate` `renderItemsForm` `syncFormInputs` `updatePreviewField`
`autoResizeTextarea` `updatePreview` → **仕上げ7個**（§6）→ `updateZoomUI` `adjustZoom`
`fitToScreen` `setPageSize` `safeBase64Encode` `safeBase64Decode` `generateCleanSource`
`handleDownloadPDF` `handleFileUpload` `handleCopyCode` `openHtmlInNewTab` `handleOpenNewTab`
`showNotification` `attachEventListeners` `init`

**`init()` は最後に1回だけ。`attachEventListeners` が無いとボタンが動かない。
追加機能もこのIIFEの中に**（`<script>` を2つに分けない）。
**関数は使う前に定義する。正規表現をコードに書かない**（写すと `SyntaxError`）。

## 5. PDF往復（識別子は1字も変えない）

**キャプチャ設定はこの字面のまま。**省くと**画面とPDFがずれる**。

```javascript
await document.fonts.ready;                        // ★ Webフォントの読込を待つ
const pages = document.querySelectorAll('.pdf-page');
const pw = currentPageSize.w, ph = currentPageSize.h;
const pdf = new jspdf.jsPDF({ orientation: currentPageSize.orient || 'p', unit:'mm', format: currentPageSize.pdf });
const opts = {
 pixelRatio: 3, quality: 0.95, backgroundColor: '#ffffff',
 width: pw, height: ph,                            // ★ 紙面の実寸で切る
 skipFonts: false, preferredFontFormat: 'woff2',   // ★ フォントを埋め込む
 style: { transform: 'none', boxShadow: 'none' }   // ★ 画面用の拡大縮小・影を打ち消す
};
const fontCss = await buildFontEmbedCSS(pages);    // ★ 実フォントをCSSごと埋める
if (fontCss) opts.fontEmbedCSS = fontCss;
for (let i = 0; i < pages.length; i++) {
 if (i > 0) pdf.addPage();
 const canvas = await htmlToImage.toCanvas(pages[i], opts);
 pdf.addImage(canvas.toDataURL('image/jpeg',0.95),'JPEG',0,0,currentPageSize.mmW,currentPageSize.mmH);
}
```

`buildFontEmbedCSS()` は `htmlToImage.getFontEmbedCSS(pages[0])` を**1回だけ**呼ぶ。
`html-to-image` は `<foreignObject>` で描き直すため、埋めないと字幅が変わる。

**末尾に追記する3行はこの字面のまま。**

```javascript
const payload = { data: state, sourceCode: generateCleanSource(), appId: APP_ID };
const enc = safeBase64Encode(JSON.stringify(payload));   // ★ JSON をBase64。ソースを直接入れない
const blob = new Blob([pdf.output('arraybuffer'),
  new TextEncoder().encode(`\n[START_FULL_DATA]${enc}[END_FULL_DATA]\n`)]);
```

`safeBase64Encode(generateCleanSource())` のように**ソースをそのまま入れると、読込側の
`JSON.parse` が `Unexpected token '<'` で落ちてPDFから復元できない**（実測 10本中3本）。

**読込**：この字面のまま。`payload.sourceCode` の注入マーカーを今の `data` で置換する。

```javascript
const txt = new TextDecoder().decode(await file.arrayBuffer());
const a = txt.indexOf('[START_FULL_DATA]'), b = txt.indexOf('[END_FULL_DATA]');
const payload = JSON.parse(safeBase64Decode(txt.slice(a + 17, b)));
let src = payload.sourceCode, json = JSON.stringify(payload.data).replace(/</g, '\\u003c');
```

```javascript
src = src.replace(/\/\* __INJECTED_DATA_START__ \*\/[\s\S]*?\/\* __INJECTED_DATA_END__ \*\//,
  `/* __INJECTED_DATA_START__ */ const __EMBEDDED_DATA__ = ${json}; /* __INJECTED_DATA_END__ */`);
```

置換先のマーカーは**ソースに最初から書いておく**。

```javascript
/* __INJECTED_DATA_START__ */ /* __INJECTED_DATA_END__ */
```

```javascript
const RECOVERY_KEY = "__SCRES_RECOVERY_PAYLOAD__";
```

## 5b. コードを取り出す・別タブで開く（この字面のまま）

**`generateCleanSource()` は本体スクリプトを消さない。** 消すと、コードコピーで取れる
HTMLもPDFに埋まる `sourceCode` も**開いても動かない静止HTML**になる（実測: `updatePreview`
が消え、`<html>` が2個になった）。`<html>` を自分で書き足さない。

```javascript
function generateCleanSource() {
 return '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
}
```

**別タブは `location.href` を開き直さない。** Canvas のプレビューは `blob:` の iframe の中で
動くので、`location.href` は別タブから解決できず **`ERR_FILE_NOT_FOUND`** になる
（ダウンロードした HTML では起きないので気づけない）。中身を書き込む。

```javascript
function openHtmlInNewTab(src) {
 const w = window.open('', '_blank');
 if (!w) { alert('ポップアップがブロックされました。'); return; }
 w.document.open(); w.document.write(src); w.document.close();
}
```

```javascript
function handleOpenNewTab() {
 let src = generateCleanSource();
 const json = JSON.stringify(state).replace(/</g, '\\u003c');
 src = src.replace(/\/\* __INJECTED_DATA_START__ \*\/[\s\S]*?\/\* __INJECTED_DATA_END__ \*\//,
   `/* __INJECTED_DATA_START__ */ const __EMBEDDED_DATA__ = ${json}; /* __INJECTED_DATA_END__ */`);
 openHtmlInNewTab(src);
}
```

**PDF読込は今のタブを置き換える。`window.open` を使わない。**
別タブはポップアップとして塞がれることがあり、塞がれるとデータが戻らない（実測）。
`sessionStorage` に預けるだけで終わらせない（版D5-11 は復元が3本中1本しか通らなかった）。

```javascript
document.open(); document.write(src); document.close();
```

```javascript
} else if (typeof __EMBEDDED_DATA__ !== 'undefined') {
  state = { ...state, ...__EMBEDDED_DATA__ };
```

**`state` を埋めるときは `<` をエスケープする**（`.replace(/</g, '\\u003c')`）。
**ボタンの文言は「PDF読込」「コードコピー」「PDF保存」。**

## 6. 紙面を整える処理

```javascript
function attachEventListeners() {
 document.getElementById('btn-download').addEventListener('click', handleDownloadPDF);
 document.getElementById('btn-upload-trigger').addEventListener('click',
   () => document.getElementById('fileInput').click());
 document.getElementById('fileInput').addEventListener('change', handleFileUpload);  // ★この1行を落とすと「PDF読込」が無反応（実測9本中3本で欠落）
 document.getElementById('btn-copy-code').addEventListener('click', handleCopyCode);
 document.getElementById('btn-open-newtab').addEventListener('click', handleOpenNewTab);
 // ★ 左ペインの入力を紙面に反映する。**この結び付けが無いと、
 //   編集しても紙面が1文字も変わらない**（実測：請求書で全入力欄が死んでいた）
 //   入力欄の id は `input-` ＋ state のキー名にそろえる
 document.querySelectorAll('#left-pane input, #left-pane textarea, #left-pane select')
  .forEach(el => {
   if (!el.id.startsWith('input-')) return;
   el.addEventListener('input', () => {
    const k = el.id.slice(6);
    state[k] = (el.type === 'checkbox') ? el.checked
             : (el.type === 'number') ? Number(el.value) : el.value;
    updatePreview();
   });
  });
 window.addEventListener('resize', fitToScreen);
}

function init() {
 // 復元データを state に反映（sessionStorage → __EMBEDDED_DATA__ の順）
 renderItemsForm();
 syncFormToState();     // ★ state → フォーム（この向き。逆をやると初期値が消える）
 attachEventListeners();
 updatePreview();       // ★ 必ずここで1回呼ぶ。呼ばないと紙面が空のまま
 lucide.createIcons();
 fitToScreen();
 // ★ 紙面が縮んでいたら測り直す。**縮んだときだけ動く。**
 //   症 実測：初回だけ 0.70 倍まで縮み、下に261pxの余白が残った
 //      （アイコンと表の幅が確定する前に高さを測るため。1.5秒待っても足りない）
 //   正 収まるまで500msごとに最大4回やり直す。縮んでいなければ何もしない
 (function recheck(n) { if (n > 4) return;          // ★条件を付けない。必ず測り直す
  setTimeout(function () { updatePreview(); fitToScreen(); recheck(n + 1); }, 500);
 })(0);
}
init();
```

**`updatePreview()` の冒頭で `syncFormInputs()`（フォーム → state）を呼ばない。**
フォームは `state` から描く。逆をやると初期表示で `state.items` が空になり明細が1行も出ない。
フォーム → state は入力欄の `oninput` からだけ呼ぶ。

```javascript
// updatePreview() の最後。この字面・この順で置く。**2つとも実装する**
[balanceGaps, fitToPage]
 .forEach(fn => { try { fn(); } catch (e) { console.warn(e); } });  // 1つ落ちても全部を止めない
```

**2つとも中身を書く。**（全部空だった回に、A5アンケートの最下段で
クーポン枠と謝辞が重なったまま紙面の下端で切れた）

```javascript
// 中身の下端。scrollHeight は transform を見ないので使わない
function lowestBottom(inner) {
 let b = -Infinity;
 inner.querySelectorAll('*').forEach(e => {
  const r = e.getBoundingClientRect();
  if (r.height > 0 && r.bottom > b) b = r.bottom;
 });
 return b;
}
function scaleInner(inner, k) {
 inner.style.transformOrigin = 'top left';   // ★ 'top center' にすると紙面が左へずれる（実測59px）
 inner.style.width = (100 / k).toFixed(2) + '%';
 inner.style.transform = `scale(${k.toFixed(3)})`;
}
function fitToPage() {
 document.querySelectorAll('.pdf-page').forEach(page => {
  const inner = page.querySelector('.page-inner');
  if (!inner) return;
  inner.style.transform = ''; inner.style.width = '';   // 戻してから測る
  const z = page.getBoundingClientRect().height / page.offsetHeight || 1;   // ★表示倍率
  const padB = (parseFloat(getComputedStyle(inner).paddingBottom) || 0) * z;
  const limitY = page.getBoundingClientRect().bottom - padB;
  const topY = inner.getBoundingClientRect().top;
  const used = lowestBottom(inner) - topY, room = limitY - topY;
  if (used > room && room > 0) { const k = room / used;
   // ★3%以内の溢れでは縮めない。1文字打っただけで 0.986 倍に縮み、
   //   「急に小さくなった」と見える（実測）。一度縮むと戻らないため
   if (k < 0.97) scaleInner(inner, Math.max(0.70, k)); }  // はみ出しだけ縮める
 });
}
// **`padB` は表示倍率で縮めてから引く。** `getBoundingClientRect()` は倍率が掛かった
// 画面の値、`paddingBottom` は倍率の掛かっていないCSSの値。そのまま引くと単位が混ざり、
// **縮小表示のときほど「残り高さ」を小さく見積もる**（実測: 倍率0.10 で k が 0.947→0.589 になり、
// 下限の0.70倍に張り付いて紙面の下に 229px の白が残った。倍率1.00 では誤差ゼロなので気づけない）。
// 紙面の空白を配り直す。途中のすき間と末尾の余りの、大きいほうを手前のブロックに配る
// **測る前に、前回の縮小と前回配った余白を必ず消す。**消さないと `base` が前回の値を含み、
// 更新のたびに積み上がる（実測: 0 → 432.83 → 865.66 → 1298.49px。読み込んだだけで4回分たまり、
// 紙面が 0.879 倍に縮んで下に 180px の白が残った）。`fitToPage` と同じ作法にそろえる。
function balanceGaps() {
 document.querySelectorAll('.pdf-page').forEach(page => {
  const inner = page.querySelector('.page-inner'); if (!inner) return;
  inner.style.transform = ''; inner.style.width = '';   // ★前回の縮小を外してから測る
  const kids = [...inner.children].filter(e => e.getBoundingClientRect().height > 0);
  if (kids.length < 2) return;
  kids.forEach(e => e.style.marginBottom = '');         // ★前回配った余白を消してから測る
  const z = page.getBoundingClientRect().height / page.offsetHeight || 1;   // ★表示倍率
  const padB = (parseFloat(getComputedStyle(inner).paddingBottom) || 0) * z;
  const limitY = page.getBoundingClientRect().bottom - padB;
  let g = limitY - lowestBottom(inner), targets = kids.slice(0, -1);   // ② 末尾に残った余り
  const c0 = [...kids[0].children].filter(e => e.getBoundingClientRect().height > 0);
  if (targets.length < 2 && c0.length >= 2) targets = c0;   // 配り先が1つだと1本の帯になる
  for (let i = 1; i < kids.length; i++) {                              // ① 途中の最大のすき間
   const d = kids[i].getBoundingClientRect().top - kids[i-1].getBoundingClientRect().bottom;
   if (d > g) { g = d;
    const c = [...kids[i-1].children].filter(e => e.getBoundingClientRect().height > 0);
    targets = c.length ? c : kids.slice(0, i); }
  }
  if (g < 40 || !targets.length) return;
  const base = targets.map(e => parseFloat(getComputedStyle(e).marginBottom) || 0);
  kids.filter(e => getComputedStyle(e).marginTop === 'auto')
      .forEach(e => e.style.marginTop = '0px');       // mt-auto が余りを独占するのを解く
  let add = g / targets.length;
  for (let k = 0; k < 2; k++) {                       // 付与 → はみ出した分だけ戻す
   targets.forEach((e, i) => e.style.marginBottom = (base[i] + add) + 'px');
   const over = lowestBottom(inner) - limitY;
   if (over <= 1) return;
   add = Math.max(0, add - (over + 1) / targets.length);
  }
  if (lowestBottom(inner) > limitY + 1)
   targets.forEach((e, i) => e.style.marginBottom = base[i] + 'px');
 });
}
// 紙面の途中の空白を潰す。fitToPage より先に呼ぶ
```

- `fitToPage()` … 最後の砦。**はみ出しを縮めるだけ。拡げない。**
  `.page-inner` は `height:100%` なので拡大すると必ず紙面からはみ出し、安全弁で戻される（実測）

- `balanceGaps()` … **空白を配り直す唯一の機構。**空きは**紙面高の15%以内**に収める。
  余りが1か所に集まる主因は**フッターの `mt-auto`**。
  実測 **途中のすき間 228px→53px / 末尾の余り 使用率 83%→94%**
- **測るのは `getBoundingClientRect()`。**`scrollHeight` は `transform` を見ない。
  高さは `querySelectorAll('*')` で**枠も余白も含めて**測る

## 7. 紙面の作り方

- **実際の値で埋める。**`○○` `XXXX` `未定` `No. general` を出さない。依頼文に `〇〇` が
  あっても具体名に置き換える
- **枠を作ったら中身も入れる。**カレンダー8件／しおり1日4件／シフト表は全員全日／
  持ち物6件／明細3行／予定表5件
- **日付は `new Date()` から導く**（ベタ書きすると3か月前の請求書が出る）

```javascript
const today = new Date();
const iso = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const days = n => iso(new Date(today.getTime() + n*86400000));   // n日後（負なら過去）
let state = {
 issueDate: iso(today),   // 発行日・作成日・通知日 → 今日
 dueDate:   days(25),     // 支払期限・回答期限・提出期限 → 未来
 startDate: days(31),     // 入社日・開催日・訪問予定日 → 未来
 examDate:  days(-7),     // 試験日・実施日など「済んだこと」だけ過去
};
```

**`<input type="date">` の値も `state` から埋める。日付欄が1つでも空の出力は不可。**
発行日は今日、期限・入社日・開催日は未来、済んだことだけ過去。カレンダーは曜日を合わせる。

- **`<input>` `<select>` `操作案内` を紙面に置かない**（入力は左ペインだけ）
- **装飾を絞る。囲み枠2〜3個、角丸は `rounded-lg` まで、影とグラデはヘッダー帯の
  1箇所まで、色は3色**（全ブロックをカードで囲むと17〜20点、絞ると22点）
- **見出しと本文のサイズ差をつける。**タイトル `text-2xl font-black tracking-[0.3em]`
  （掲示物 `text-4xl`）／見出し `text-lg font-bold`／本文 `text-sm leading-relaxed`／
  補足 `text-[13px] text-slate-500`／表のセル `text-[13px]`。
  **紙面のどの文字も `text-[13px]`（9.75pt）を下回らない。**`text-xs`（12px）を本文に使わない、

- **Tailwind に無い数のクラスを書かない**（`grid-cols-` 1〜12、`grid-rows-` 1〜6）
- **キリトリ線**は `border-top: 1px dashed border-slate-300` の細い1本
- **縦書きは中の帯だけ**（`.pdf-page` 全体に掛け `height` が無いと1文字に潰れる）
- **割引はマイナス金額の明細行。**小計はその行を含めた合計。下で再度引かない

## 7c. 数と条件

**紙面に出す数は、すべてプログラムで導く。**手で書いた数を `state` に置かない。

**条件（必要人数・合計・残数・曜日）は、満たしてから紙面に出す。**
満たせない割当を出して「要調整」「未達」と添えない
（実測 シフト表で7日中5日が「要調整」のまま印刷された）。

```javascript
// 制約つきの割当（シフト・当番・座席）は、この形で**プログラムが作る**
function buildPlan() {
 const need = { 早番: 2, 遅番: 2 };                      // 帳票固有: 各枠の必要人数
 const plans = DAYS.map(day => {
  const free = STAFF.filter(s => !s.off.includes(day));  // 希望休を除いた出勤可能者
  const rest = [...free], plan = {};
  for (const slot of Object.keys(need)) plan[slot] = rest.splice(0, need[slot]);
  const ok = Object.keys(need).every(slot => plan[slot].length === need[slot]);
  return { day, plan, ok };
 });
 // ★ ok が false の日が残ったまま紙面に出さない
 return plans;
}
```

**満たせない原因は割当ではなく元データのことが多い。**先に**データを条件内に収める**。

```javascript
// 同じ日に休める人数の上限 = 人数 − 必要人数の合計。超えた分の希望休は捨てる
const maxOff = STAFF.length - (need.早番 + need.遅番);
const cnt = Array(7).fill(0);
STAFF.forEach(s => s.off = s.off.filter(d => cnt[d] < maxOff && ++cnt[d]));
```

実測: 5名・必要4名/日で**同じ日に2名休ませ、出勤可3名 < 必要4名**となり、
水・木・土が「遅: 1/2 ▲要確認」のまま印刷された。

**検算欄を置くなら、全部が「○」になる状態で出す。**

**曜日の文字を手で書かない。**`(土)` `(日)` を文字列で書くと必ずずれる。
月のマス目も曜日名も、**この字面のまま `Date` から導く**。

```javascript
const WD = ['月','火','水','木','金','土','日'];        // 月曜始まり
const wd  = d => WD[(d.getDay() + 6) % 7];             // 日付 → 曜日名
const isWeekend = d => d.getDay() === 0 || d.getDay() === 6;   // 土日
// 月のマス目。m は 1〜12
function monthCells(y, m) {
 const head = (new Date(y, m - 1, 1).getDay() + 6) % 7;  // 先頭の空きマス数
 const last = new Date(y, m, 0).getDate();               // その月の日数
 const cells = Array(head).fill(null);
 for (let d = 1; d <= last; d++) cells.push(new Date(y, m - 1, d));
 while (cells.length % 7) cells.push(null);              // 末尾を7の倍数に
 return cells;
}
```

**マス目・予定・一覧を、同じ `cells` から作る。**別々に書くと月がずれる。
使う側もこの字面のまま。

```javascript
const base = new Date(); base.setDate(1); base.setMonth(base.getMonth() + 1);  // 来月
const [Y, M] = [base.getFullYear(), base.getMonth() + 1];   // setDate(1) を先に。31日だと月が飛ぶ
const cells  = monthCells(Y, M);                       // ★ 紙面のマス目はこれだけを使う
const 予定日 = cells.filter(d => d && isWeekend(d));    // 「週末に」なら週末のマスだけ
state.events = 予定日.slice(0, 8).map(d => ({          // ★ 8件以上
 d, label: `${M}/${d.getDate()}(${wd(d)})`,            // ★ 曜日は必ず wd(d)。手で書かない
 title: '…'
}));
```

**予定はマスの中にも描く。**下の一覧だけにすると、マスが全部空のカレンダーになる。

症 実測1：2026年10月のカレンダーで**1日を水曜の位置**に置いた（正しくは木曜）。
症 実測2：ヘルパーを写しておきながら**使わず**、`10/4(土)` `10/5(日)` `10/11(土)`
   `10/12(日)` `10/18(土)` `10/25(土)` と手で書いた。実際はすべて日曜・月曜で**6件とも1日ずれた**。
症 実測3：`isWeekend` を写したのに使わず、**9/1(火) 9/2(水) 9/4(金) 9/8(火) 9/11(金)
   9/18(金) 9/25(金) の7日（平日）に試合**を入れた。
症 実測4：見出しは**9月**なのに予定一覧は**10月**の日付で、月が食い違った。
症 実測5：`items: [{ date: `${Y}-${M}-04` }, …-11, …-18, …-25]` と**日付を手で並べ**、
   予定が4件しか入らずマスの大半が空になった。
症 実測6：見出しは `` `${M}月 …` `` と正しく組んだのに、**`state` の自由記述欄**
   （合言葉・メッセージ・ひとこと）に「楽しい**1月**にしよう！」とベタ書きし、
   10月の紙面に1月が出た。**自由記述の初期値にも月を書かない。**
正 `monthCells()` `wd()` `isWeekend()` を**宣言するだけでなく、上の字面のまま使う。**

## 7c2. 消費税

**外税。**明細の合計（マイナス行を含む）が小計。小計×0.10 を切り捨てて消費税。
**「内消費税」で逆算しない。**

```javascript
const subtotal = state.items.reduce((s, it) => s + it.amount, 0);  // マイナス行も含む
const tax = Math.floor(subtotal * 0.10);
const total = subtotal + tax;
```

## 7d. 写真を添付されたとき（**最も危ない**）

**写真は素材にすぎない。作るものは同じ帳票アプリのまま。**（写真を付けると `.pdf-page` も
`PAGE_SIZES` も無い別ページ5,938字が出て、外すと22,433字の正しい帳票が出た）
**先に §1 の骨格を書く。**

**依頼に写真が添付されたときだけ**、左ペインに受け皿を、紙面に枠を置く。
**写真の添付が無い依頼では作らない**（頼まれていない写真欄が付く）。
`state.photo = ''` で始め、**空の `<img src="">` は絶対に置かない**
（`html-to-image` のキャプチャが必ず失敗し、PDF保存が一切できなくなる。実測）。
写真が無い間は `img` を出さず、**点線の枠**を出す。

左ペイン（フォームの中）
```html
<input type="file" id="imgInput" accept="image/*"
  class="w-full text-xs border rounded p-2 mt-1">
```

読み込み（`attachEventListeners` の中）
```js
document.getElementById('imgInput').addEventListener('change', e => {
  const f = e.target.files[0]; if (!f) return;
  const r = new FileReader();
  r.onload = ev => { state.photo = ev.target.result; updatePreview(); };
  r.readAsDataURL(f);
});
```

紙面（`updatePreview` の中。**三項で分ける**）
```js
${state.photo
  ? `<img src="${state.photo}" class="w-full h-full object-cover">`
  : `<div class="w-full h-full border-2 border-dashed border-slate-300
       grid place-items-center text-slate-400 text-sm">写真をここに</div>`}
```

**紙面に操作の説明を書かない。**紙面は人に渡す紙で、アプリの画面ではない。
「左パネルより追加」「クリックして選択」は左ペイン側だけに書く。

症 実測：POPの写真枠に「写真読込可能（左パネルより追加）」が印刷され、店頭に貼る紙に残った
正 枠に置くのは写真が無いとわかる**短い言葉だけ**（「写真をここに」）

## 7g. チェック項目は、必ず左ペインで付け外しできるようにする

症 実測：健康管理シートで、紙面にチェック済みの印が描かれているのに
   **左ペインにチェックが1つも無く、外すことも付けることもできなかった**。
   記入用紙として使えない。
正 チェックを作るときは**左ペインに `<input type="checkbox">` を置き、
   紙面は `state` から ☑ / ☐ の文字で描く**。
   **紙面には `<input>` を置かない**（印刷物にフォーム部品は載らない）。

```html
<!-- 左ペイン。id は input- ＋ state のキー -->
<label class="flex items-center gap-2 text-xs">
 <input type="checkbox" id="input-chkSeimei"> 清明
</label>
```
```js
// 紙面（updatePreview の中）
`<span>${state.chkSeimei ? '☑' : '☐'} 清明</span>`
```

## 7f. 文字の大きさと分量は、書き始める前に決める

症 実測：分量を決めずに書き始め、あとから紙面ごと縮小して辻褄を合わせた。
   結果、**下余白が30px〜337pxと11倍もばらつき**、カレンダーは予定を4件足しただけで
   **0.70倍**まで縮んで文字が読みにくくなった。

正 **書き始める前に次の2つを決め、最後まで変えない。**
① **文字の大きさ**——誰がどの距離で読むかで決める。紙面は 794px＝210mm なので
   **1px＝0.75pt**。事務文書の本文は 10.5pt（Word の既定）、注記の下限は 8pt が慣行。
   - 手元で読む書類（請求書・通知書・記録・アンケート・しおり・通信）：
     本文 `text-sm`（14px＝10.5pt）／補足 `text-[13px]`（9.75pt）／表のセル `text-[13px]`
   - 壁に貼る書類（POP・案内・カレンダー）：本文 `text-base`（16px＝12pt）／
     タイトル `text-4xl`〜`text-5xl`（36〜48px）
   症 実測：本文と補足を上の基準より小さく組んだ回が10本中8本。
      アンケートは文字の42%が印刷物の下限を割っていた。
   **どの帳票でも、紙面の文字が虫めがねの要る大きさになってはいけない。**
② **分量**——その大きさのまま**紙面が自然に埋まる量**を書く。
   足りなければ説明や項目を増やし、多ければ削る。
   **縮小して収めない。** 紙面ごと縮めるのは、分量を誤ったときの後始末にすぎない。

**確認**：出来上がった紙面を見て
「文字を小さくせずに、余白が心地よく残っているか」を自問する。
**下に大きく空きが残る／全体が縮んで文字が小さい**——どちらも設計の失敗である。

### 枠の中が空いてしまったら

枠（カード・囲み）が紙面の残りを埋めて縦に伸び、中の項目だけが上に詰まることがある。
**項目を上に詰めて下を空けたままにしない。項目そのものを枠の高さまで広げる。**
1行に詰め込まず、**名前を大きく上に、補足を下に**置いて、枠に見合う厚みを持たせる。

**項目自身に `flex-1` を持たせる。**間隔だけ開ける `justify-between` では、
項目の高さが変わらないので枠の空きは埋まらない。

```html
<!-- 枠が縦に余るとき。並べる側と、項目そのものの両方を伸ばす -->
<div class="flex-1 flex flex-col gap-2">
 <div class="flex-1 flex flex-col justify-center gap-1 px-3 py-2.5 rounded-xl border bg-white/80">
  <div class="flex items-center gap-2">…日付…<span class="font-bold text-sm">…名前…</span></div>
  <div class="text-[13px] text-slate-500 leading-snug pl-6">…補足…</div>
 </div>
</div>
```

症 実測：学級通信の「来週の持ち物」で、枠の高さ492pxのうち**316px（64%）が空白**。
   4項目が1行ずつ上に詰まり、下がまるごと空いていた
正 項目に `flex-1` を与え、中身を2段にして **下の空き 316px→0px**

依頼文に文字の大きさや分量の指定があれば、必ずそちらを優先する。

## 7e. 記入用紙（アンケート・チェックリスト・管理シート）

**印刷して手で書く紙**なので `checkbox` ではなく**罫線と枠**で作る。

```html
<!-- 5段階評価。★は5つとも同色。一部を金・残りを灰に塗り分けない（回答済みに見える） -->
<div class="flex items-center gap-3 shrink-0 whitespace-nowrap">
  <span class="text-2xl tracking-[0.6em] text-amber-500">★★★★★</span>
  <span class="text-[13px] text-slate-500">★1つ=不満 / ★5つ=大変満足</span>
</div>
<!-- 設問の説明文と選択肢。この大きさより小さくしない -->
<div class="text-[13px] text-slate-500">温度・盛り付け・おいしさ</div>
<div class="flex flex-wrap gap-x-5 text-[13px]"><span>□ 10代</span><span>□ 20代</span><span>□ 30代</span></div>
<!-- フッターの謝辞・案内・英字ラベルも同じ下限。クラスを省かない -->
<div class="text-[13px] text-slate-500">ご協力ありがとうございました。</div>
<!-- チェックボックス -->
<span class="inline-block w-4 h-4 border-2 border-slate-500 rounded-sm shrink-0 align-middle"></span>
<!-- 記述欄 -->
<div class="border border-slate-400 rounded min-h-[90px]"></div>
<!-- 単位付きの記入欄 -->
<span class="whitespace-nowrap">体温 <span class="inline-block w-16 border-b border-slate-400"></span> ℃</span>
```

- **★だけを裸で置かない。**「★1つ=不満 / ★5つ=大変満足」の注記を必ず添える
- **自由記述欄に例文を書き込まない**（A5の下半分44%が1つの空箱になった）。
  `min-h-[90px]`〜`[120px]` を目安に、**余った高さは設問側に配る**
- **設問は3問で終わらせない。**来店回数・年代などを足して**下端まで埋める**
- 見出しの列と中身の列は**同じ `grid-template-columns`**／罫線は**下端まで等間隔**
- A5指定は `:root` と `currentPageSize` の両方を A5（559/794）に

## 8. 返答の形

- `<!DOCTYPE html>` から `</html>` まで**全文**を1つのコードブロックで。抜粋は禁止
- **返すコードブロックは、ぜんぶで1つだけ。**
  この指示書には見本のコードブロックが31個あるが、**それは読むためのもの**で、
  出力の区切りではない。CSS も JS も左ペインも紙面も、
  **同じ1つの ```html の中**に続けて書く。部分ごとに分けて出さない。
  症 実測：BBQの案内で、紙面デザインの部分だけが `</div></div>` まで
     きちんと閉じた4,193字のブロックとして出た。学級通信でも同じことが起きた（5,075字）。
     どちらも途中で力尽きたのではなく、**部分を1ブロックとして出し切っていた**
- **右ペイン（Canvas）に、次の形の完全なブロックで出す。**
  開始行は ```` ```html:帳票タイトル:index.html ````、終了行は ```` ```eof ````。
  チャット欄に生のコードを貼らない。空のコードブロックを出さない
- `<!-- 中略 -->` `...` `（以下同様）` を書かない。CSS も JS も分けずに1ファイルへ統合する
- ブロックの外は、始める前の導入（「作成します」のような進行形）を数行だけ。
  「作成しました」のような過去形は書かない。Python やシェルを返さない

## 9. 出力の直前に確認する

`<meta charset="UTF-8">` が1行目／フォント `<link>` に `crossorigin`（`<script>` には無し）／
4層が揃う／`[START_FULL_DATA]` `[END_FULL_DATA]` `__EMBEDDED_DATA__`
`__INJECTED_DATA_START__` `__SCRES_RECOVERY_PAYLOAD__` `sourceCode` `appId` が全部ある／
`opts` に `width` `height` `skipFonts` `preferredFontFormat` `style:{transform:'none'}`
`fontEmbedCSS`／**`enc` が `safeBase64Encode(JSON.stringify(payload))`**／
`Math.max(0.1, …)` と `fitToPage()` の実装／ボタン3つの文言／
紙面に `○○` `<input>` `<select>` なし・日付欄が全て埋まる／
ブロックの開始行が ```` ```html:帳票タイトル:index.html ````・終了行が ```` ```eof ````

**添付の「§2-E 禁止事項」「§4 自己検閲チェックリスト」も確認する。**

---

## 出力前の最終確認

出力する直前に、次の文字列がコードの中にあるか**1つずつ検索する**。
1つでも無ければ、書き足してから出す。

```
  pdf-page   page-inner   left-pane   PAGE_SIZES
  jspdf   html-to-image   generateCleanSource   START_FULL_DATA
```

症 実測：内定通知書で、この8つが**すべて0個**の別アプリが出た。
   ヘッダーナビ付きの「印刷できるWebページ」で、左ペインも紙面もPDF機構も無い。
   帳票の内容は正しいのに、**アプリとして何も動かない**
正 上の8つは、どの帳票でも必ずコードに現れる。
   1つでも欠けていたら、それは**まじん式の帳票アプリではない**。書き直す

つづけて、**左ペインの入力欄を1つずつ指さして**、`id="input-〇〇"` の `〇〇` が
紙面に `state.〇〇` として出るか確かめる。無い欄は**入力しても紙面が変わらない宙に浮いた欄**。
紙面に出す場所を作るか、その欄ごと消す。逆も同じ。**左ペインと紙面は1対1。**

症 実測：健康管理シートに「呼吸数 (回/分)」の欄と `respRate: 16` があるのに、
   紙面のバイタルは4枚だけで呼吸数のカードが無く、打っても紙面が動かなかった
