# すべての不具合を赤で示したゲラ刷り

> 2度校正する新聞のゲラ。エンジンが報告する不具合とCodePenのサンプルが自分で見つける不具合を、ページ上に赤で示し、Markdownにリンクします。

- HTML版: https://postext.dev/ja/cookbook/proof-sheet-diagnostics
- レシピ No. 055 · 出力と組み込み · 難易度 3 (上級) · 出力: Canvas, ライブ操作
- ジャンル: 新聞とニュースレター
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.9.2 ／テスト日 2026-10-01
- ページ: [1](https://postext.dev/cookbook/proof-sheet-diagnostics/en/p01.webp?v=a178eccc), [2](https://postext.dev/cookbook/proof-sheet-diagnostics/en/p02.webp?v=a178eccc)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=proof-sheet-diagnostics&lang=en (.postext: https://postext.dev/cookbook/proof-sheet-diagnostics/en/proof-sheet-diagnostics.postext)
- 最終更新: 2026-09-27
- 他の言語: [en](https://postext.dev/en/cookbook/proof-sheet-diagnostics.md), [es](https://postext.dev/es/cookbook/proof-sheet-diagnostics.md), [ca](https://postext.dev/ca/cookbook/proof-sheet-diagnostics.md), [zh](https://postext.dev/zh/cookbook/proof-sheet-diagnostics.md), [ar](https://postext.dev/ar/cookbook/proof-sheet-diagnostics.md)

## かんたんな説明

地方週刊紙のニュース記事を、2度にわたって誤りがないか確かめます。不具合をページ上に赤で示し、それを生んだテキストの行に戻れるようにする方法を示します。

## できあがり

郡の週刊紙『The Tideline』の第41号は、ソーヤー川を渡る夜行フェリーの復活をトップに据えています。CodePenのサンプルはこの記事を2度校正します。初校は入稿時の原稿で、校正済みの記事に6つの誤りを戻したものです。その中には、存在しないディレクティブとして打たれたスタンプや、閉じられないデータの囲みがあります。この6つの誤りで、10個の印が付きます。カードと同じく、各不具合はそれが組まれた行に赤い下線で示され（ゆるい行は下線ではなく地色で示します）、余白の番号はページの横のリストの項目番号と一致します。行や項目をクリックすると、そのMarkdownが選択されます。下に表示しているページは再校です。検査では何も見つからず、右上の角にPROOF · 2ND PASSのスタンプが留められます。

**このレシピが答える問い:**

- 文書のどこがおかしいかを調べ、ページ上の各不具合をMarkdownまでたどるには？
- エディターを作るために、描画したページ上のクリック位置をMarkdownのソースに対応づけるには？
- ある行だけが不ぞろいに組まれたり、伸びすぎたりするのはなぜですか（URL、長い複合語、セル内の長い単語）？

## 手短な答え

```js
// script.js, 行 170–203
const MM = DPI / 25.4; // page px per mm
function proof(md, doc) {
  const faults = [];
  const add = (kind, from, to, detail, at) => faults.push({ kind, from, to, detail, at });
  const { blocks, issues } = parseMarkdownWithIssues(md); // a $, $$ or ::: left open
  for (const i of issues) add(i.kind, i.sourceStart, i.sourceEnd);
  for (const w of doc.warnings ?? []) { // calloutOverflow: a box no cut could split
    add(w.kind, w.sourceStart, w.sourceEnd, Math.round(w.overflowPx / MM), w);
  }
  if (!doc.converged) add('unsettled', 0, 1, doc.iterationCount); // the layout never settled
  // 1.4.1 reports none of these (gotcha: sandbox-only-warnings).
  const ids = new Set(resources.map((r) => r.id)); // an unknown id prints '?'
  for (const m of md.matchAll(/:ref\{id="([^"]*)"|^::resource\{id="([^"]*)"/gm)) {
    if (!ids.has(m[1] ?? m[2])) add('unknownResourceId', m.index, m.index + m[0].length);
  }
  for (const m of md.matchAll(/^:::?([a-z][\w-]*)/gim)) { // an unknown fence prints as text
    const known = KNOWN_CONTAINERS.has(m[1]) || KNOWN_DIRECTIVES.has(m[1]) || m[1] === 'resource';
    if (!known) add('unknownDirective', m.index, m.index + m[0].length);
  }
  for (const b of blocks) { // formulas count as faults only because this story has none
    if (b.startNumber > 999) add('yearList', b.sourceStart, b.sourceEnd); // 1971. opens a list
    for (const s of b.spans ?? []) if (s.math) add('formula', s.math.sourceStart, s.math.sourceEnd);
  }
  // The lines as set: spaces past maxWordSpacing, a line set ragged, a hyphen in an address.
  const max = doc.config.bodyText.maxWordSpacing;
  for (const l of doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks))
    .flatMap((b) => b.lines ?? [])) {
    const ratio = Math.round(l.justifiedSpaceRatio * 100) / 100; // as the report prints it
    if (ratio > max) add('looseLine', l.sourceStart, l.sourceEnd, ratio);
    if (l.ragged) add('raggedLine', l.sourceStart, l.sourceEnd); // past 3×: no ratio left
    if (l.hyphenated && /[./]\S+-$/.test(l.text)) add('addressHyphen', l.sourceStart, l.sourceEnd);
  }
  return faults.sort((a, b) => a.from - b.from);
}
```

## 材料

**学べること**

- [警告と診断](https://postext.dev/ja/docs/configuration.md#デバッグ): レイアウトの警告、解析上の問題、収束の状況、「ゆるい行の強調」と「ページのネガ表示」の補助機能、Sandboxの検査パネルを扱います。
- [エスケープとそのままの文字](https://postext.dev/ja/docs/document-format.md#記述の決まりごと): バックスラッシュによるエスケープと単語結合子を使い、ドル記号、アスタリスク、会話のダッシュ、段落頭の年号がマークアップとして読まれないようにします。
- [分割する囲み、分割しない囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー): 長い囲みは段やページをまたいで分割され、枠は保ちタイトルは省きます。短い囲みは丸ごと移ります。どこにも収まらない囲みはcalloutOverflowを出します。

**ほかに使うもの**

- [最適な改行（Knuth–Plass）](https://postext.dev/ja/docs/justification.md#knuth-plass段落全体を見渡す)
- [固定位置の囲みとバッジ](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [図を配置する参照](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形)
- [フロート用の余白段](https://postext.dev/ja/docs/configuration.md#レイアウト)
- [欄外の注](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [囲みの中の段組み](https://postext.dev/ja/docs/document-format.md#columns)
- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [用紙の色](https://postext.dev/ja/docs/configuration.md#ページ)
- citations
- [1段半組み](https://postext.dev/ja/docs/configuration.md#レイアウトの種類)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類)

**API**

- [`KNOWN_CONTAINERS`](https://postext.dev/ja/docs/document-format.md#コンテナー), [`KNOWN_DIRECTIVES`](https://postext.dev/ja/docs/document-format.md#ディレクティブ), [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`parseMarkdownWithIssues`](https://postext.dev/ja/docs/configuration.md#解析), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Charis SIL (OFL-1.1), Chivo (OFL-1.1), Fragment Mono (OFL-1.1)

## 作り方

### 1 · エンジンの報告を読み、残りを自分で確かめる

コードは前出の[手短な答え](#手短な答え)にあります。`parseMarkdownWithIssues()`は、開いたままの`$`、`$$`、`:::`ごとに問題を1件返し、`doc.warnings`には、どの分割位置でも分けられなかった囲みごとに`calloutOverflow`が入ります。1.4.1のエンジンが報告するのはこれだけです。`doc.converged`と`doc.iterationCount`はレイアウトが収束したかどうかを示し、この記事は不具合の有無にかかわらず1回の反復で収束します。[サイドバーのパネル](/ja/docs/sandbox#サイドバーのパネル)で説明しているSandboxの「検査」パネルは、未知のid、未知のディレクティブ、ゆるい行も一覧にしますが、これらはSandboxが自分で計算しているので、`proof()`が同じ処理を繰り返します。`:ref`と`::resource`のidをビルドの`resources`と、フェンスの名前を`KNOWN_CONTAINERS`と`KNOWN_DIRECTIVES`と照合します。この記事に限っては、`startNumber`が年になったリストのブロックと、記事にはひとつもないはずの数式も見つけ出します。さらに組まれた行を読み、`maxWordSpacing`を超えて引き伸ばされたスペース、左そろえ（行末不ぞろい）で組まれた行、アドレスの中に追加されたハイフンを調べます。

### 2 · 校正済みの原稿に不具合を戻す

```js
// script.js, 行 158–166
const FIRST_PASS = [ // [corrected, faulty], replaced wherever it occurs
  [':::callout{type="stamp"}\n:::', t({ en: ':::stamp', es: ':::sello' })], // no such directive
  [':ref{id="route"}', ':ref{id="route-map"}'], // an id no resource has
  ['\\$', '$'], // bare dollars (gotcha: dollar-math)
  [' https://', ' '], // a web address without its scheme
  [':::\n:::\n', ''], // the fact box's two closing fences
  ['\u20601971.', '1971.'], // the word joiner before 1971 (gotcha: digit-period-list)
];
const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md);
```

CodePenのサンプルが持つMarkdownのファイルは校正済みの記事ひとつだけで、6つの修正を取り消して初校を作ります。こうすれば2つの版が食い違うことはなく、どの印も原因がわかっています。校正済みの原稿は最後にビルドし、下のページはそのビルドです。`\$`は価格のドル記号を[エスケープ](/ja/docs/document-format#記述の決まりごと)するので、数式が開きません。2ページ目の年表の各年の前に置いたワードジョイナー（U+2060）は、*1971. The Tern…*のような項目が、その年から番号を振るリストとして始まるのを防ぎます。初校で外すのは1971の前のものだけです。1.4.1は、`https://`のスキームが付いたWebアドレスをアドレスとして組みます。ハイフネーションは行わず、改行は区切りの位置だけで、1ページ目の*.example*の前がその例です。スキームがないとアドレスは普通の単語になり、初校では*night-ferry*とハイフンで分割されます。

### 3 · 各不具合を組まれた場所に示す

```js
// script.js, 行 207–245
const linesOf = (page) => [...page.columns.flatMap((c) => c.blocks), ...(page.floats ?? [])]
  .flatMap((b) => (b.lines ?? []).map((l) => ({ ...l.bbox, from: l.sourceStart, to: l.sourceEnd,
    width: l.justifiedSpaceRatio // a bbox is the natural width; a justified line fills the block
      ? b.bbox.x + b.bbox.width - l.bbox.x : l.bbox.width })));
const REACH = 80; // characters after a fence where its first line may start
function spotOf(page, f) { // where a fault shows on this page: its first line, in page px
  if (f.at) { // a box that ran off its column: a bar under the column's foot
    const c = page.columns[f.at.columnIndex]?.bbox;
    return f.at.pageIndex === page.index ? { ...c, y: c.y + c.height, height: 1.6 * MM } : null;
  }
  const lines = linesOf(page); // a fence sets no line of its own: then the first line after it
  return lines.find((r) => r.from < f.to && r.to > f.from)
    ?? lines.find((r) => r.from >= f.from && r.from - f.to < REACH);
}
function paintMarks(canvas, page, faults, scale) {
  const ctx = canvas.getContext('2d');
  ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
  Object.assign(ctx, { font: `${3 * MM}px "${MONO}"`, textAlign: 'center' });
  const taken = []; // the numbers set so far: two on one line sit side by side
  faults.forEach((f, n) => {
    const r = spotOf(page, f);
    if (!r) return;
    const wash = f.kind === 'looseLine'; // a loose line is washed, any other fault underlined
    Object.assign(ctx, { fillStyle: palette.proof, globalAlpha: wash ? 0.2 : 1 });
    ctx.fillRect(r.x, wash ? r.y : r.y + r.height, r.width, wash ? r.height : 0.45 * MM);
    const [left, y] = [r.x + r.width / 2 < page.width / 2, r.y + r.height / 2]; // nearest margin
    const shift = taken.filter((ty) => Math.abs(ty - y) < 4.5 * MM).length * 5.2 * MM;
    const [x, edge] = [left ? 6.5 * MM + shift : page.width - 6.5 * MM - shift,
      left ? r.x : r.x + r.width]; // the number, and a leader from it to the text
    taken.push(y);
    ctx.globalAlpha = 1;
    ctx.fillRect(Math.min(x, edge), y - 0.12 * MM, Math.abs(x - edge), 0.24 * MM);
    ctx.beginPath();
    ctx.arc(x, y, 2.4 * MM, 0, 2 * Math.PI);
    ctx.fill();
    ctx.fillStyle = palette.paper;
    ctx.fillText(String(n + 1), x, y + 1.05 * MM);
  });
}
```

VDTのどの行も`sourceStart`と`sourceEnd`を持つので、不具合のソース範囲からそれを刷った行にたどり着けます。コンテナーのフェンスは自分の行を刷らないため、その印は、フェンスの後`REACH`（80）字以内で始まる最初の行に付けます。`calloutOverflow`には、それが示す段の地の下に棒を付けます。ひとつの誤りが複数の印を残すこともあります。エスケープしていないドル記号は`$2.50 on foot, $`を数式に変えますが、このサンプルは数式エンジンを読み込まないので何も刷られません。3つ目のドル記号は閉じられない数式を開きます。この語句が抜けると、運賃の段落の1行が、スペースを通常の1.9倍に広げて組まれます。2つのフェンスが欠けていると、囲みとその`:::columns`のグループが記事の終わりまで開いたままになります。段組みのグループは分割されないので、囲みは段から256 mmはみ出します。`proof()`は、`justifiedSpaceRatio`が`maxWordSpacing`（ここでは1.6）を超える行をゆるい行とみなします。スペースが3倍を超えてしまう行には比べる比率がありません。1.4.1はそうした行を[左そろえで組む](/ja/docs/justification#行分割で埋めきれない行)からです。そこで`proof()`は`ragged`も確かめます。

### 4 · 行の背後にあるMarkdownを選択する

```js
// script.js, 行 249–267
function selectSource(from, to) {
  const all = source.value; // measure the wrapped height of the text before the selection
  source.value = all.slice(0, from);
  const top = source.scrollHeight;
  source.value = all;
  source.focus({ preventScroll: true });
  source.setSelectionRange(from, to); // the offsets the parser and the layout give
  source.scrollTop = top > source.clientHeight ? top - source.clientHeight / 3 : 0;
}
function onPageClick(canvas, page) {
  canvas.onclick = ({ clientX, clientY }) => {
    const box = canvas.getBoundingClientRect(); // CSS px to page px
    const [x, y] = [(clientX - box.left) * (page.width / box.width),
      (clientY - box.top) * (page.height / box.height)];
    const hit = linesOf(page).find((r) => x >= r.x && x <= r.x + r.width && y >= r.y
      && y <= r.y + r.height);
    if (hit?.from !== undefined) selectSource(hit.from, hit.to);
  };
}
```

クリックの座標はCSSピクセルで届きます。`onPageClick()`はそれをページのピクセルに換算し、`bbox`がその点を含む行を見つけて、その行のソース範囲をテキストエリアで選択します。リストの項目も同じように、その不具合の範囲を選択します。`selectSource()`はページをスクロールさせずにテキストエリアにフォーカスを移し、選択範囲より前のテキストの高さから`scrollTop`を設定します。1.4.1では、両端そろえの行の`bbox`が、描画された幅ではなく行の自然な幅のままです。そこで`linesOf()`がそれをブロックの右端まで広げます。そうしないと地色が余白の手前で止まり、右端近くをクリックしても何も選択されません。

### 5 · データの囲みを分割させ、スタンプを留める

```js
// script.js, 行 55–69
const boxes = [
  // keepTogether: false: cut between two blocks at the page foot; a line of white closes it.
  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') },
    padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') },
    body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) },
    lists: { bulletChar: '■', color: col('proof'), bulletFontSize: pt(5) } },
  // 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title.
  { id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false,
    title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }),
    fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } },
    border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1),
    padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) },
    titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } },
];
```

`keepTogether: false`により、データの囲みは1ページ目の地で始まり、裏のページで赤い罫の下にタイトルなしで続きます。囲みには背景も地の罫もないので、2ページ目で時刻表と記事を隔てるのは余白だけです。そのため`marginBottom`を行送りまるごと1行、14 ptにします。4 ptでは、時刻表が次の段落の1.5 mm上で終わってしまいます。スタンプは`placement: 'fixed'`で、本文の流れから外れて天の端から8 mm下に留められます。`width: 'auto'`はスタンプをタイトルの幅まで縮め、主段の110 mmではなく42 mm幅にします。

## レシピの全体

下のファイルはレシピのフォルダーから合成したもので、サンプルのテキストとレシピ集の共通キットを埋め込んであります。1つのページとして動かすには、HTMLを`<body>`に、CSSを`<style>`要素に、スクリプトを`<script type="module">`に入れます。新しいCodePenのpenの各パネルに貼り付けてもかまいません（JSはモジュールとして）。スクリプトはesm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/proof-sheet-diagnostics

### index.html

```html
<section id="proof">
  <header>
    <p id="slug" data-en="The Tideline 41 · news galley · proof desk" data-es="La Marea 41 · galerada · mesa de pruebas"></p>
    <h2 id="verdict"></h2>
    <p id="passes"></p>
  </header>
  <div class="desk">
    <div id="galley"></div>
    <ol id="report"></ol>
  </div>
  <div class="source">
    <label for="source" data-en="The galley’s Markdown: click a line on a page to select it here" data-es="El Markdown de la galerada: pulsa una línea de una página para seleccionarla aquí"></label>
    <textarea id="source" spellcheck="false"></textarea>
    <p>
      <button id="again" type="button" data-en="Proof again" data-es="Revisar de nuevo"></button>
      <button id="fixed" type="button" data-en="Load the second pass" data-es="Cargar las segundas pruebas"></button>
    </p>
  </div>
  <template id="words"><!-- the report's words, in the language of the sample -->
    <i data-key="unclosedMath" data-en="Unclosed maths" data-es="Fórmula sin cerrar"></i>
    <i data-key="unclosedMathBlock" data-en="Unclosed display maths" data-es="Fórmula en bloque sin cerrar"></i>
    <i data-key="formula" data-en="Stray formula" data-es="Fórmula perdida"></i>
    <i data-key="unclosedContainer" data-en="Unclosed fence" data-es="Valla sin cerrar"></i>
    <i data-key="calloutOverflow" data-en="Box runs {} mm past its column" data-es="El recuadro desborda su columna en {} mm"></i>
    <i data-key="unknownResourceId" data-en="Unknown id: prints “?”" data-es="Id desconocido: imprime «?»"></i>
    <i data-key="unknownDirective" data-en="Unknown directive: prints as text" data-es="Directiva desconocida: sale como texto"></i>
    <i data-key="yearList" data-en="A year opened a numbered list" data-es="Un año abrió una lista numerada"></i>
    <i data-key="looseLine" data-en="Loose line, {}×" data-es="Línea floja, {}×"></i>
    <i data-key="raggedLine" data-en="Line set ragged: its spaces would pass 3×" data-es="Línea en bandera: sus espacios pasarían de 3×"></i>
    <i data-key="addressHyphen" data-en="Hyphen added inside an address" data-es="Guion añadido dentro de una dirección"></i>
    <i data-key="unsettled" data-en="Layout did not settle in {} passes" data-es="La composición no se asentó en {} pasadas"></i>
    <i data-key="faults" data-en="{} faults" data-es="{} fallos"></i>
    <i data-key="fault" data-en="{} fault" data-es="{} fallo"></i>
    <i data-key="clean" data-en="Nothing to mark" data-es="Nada que marcar"></i>
    <i data-key="first" data-en="on the first pass" data-es="en las primeras pruebas"></i>
    <i data-key="second" data-en="on the second pass" data-es="en las segundas pruebas"></i>
    <i data-key="edited" data-en="in the edited copy" data-es="en la copia editada"></i>
    <i data-key="page" data-en="Proof, page {}" data-es="Prueba, página {}"></i>
    <i data-key="title" data-en="The Tideline · galley proof" data-es="La Marea · galerada"></i>
  </template>
</section>
<main id="pages"></main>
```

### style.css

```css
/* The proof desk: the first pass marked in red beside its report, the Markdown under them.
   Colours repeat the palette in script.js: newsprint, ink, the proofreader's red. */
#proof {
  background: #e6e0d2; color: #1d1d1b; padding: 36px 32px 40px;
  font: 15px/1.45 "Charis SIL", Georgia, serif;
}
#proof > * { max-width: 1216px; margin-inline: auto; }
#proof header { margin-bottom: 24px; }
#slug, #passes {
  margin: 0; font: 12px/1.2 "Fragment Mono", monospace; letter-spacing: .14em;
  text-transform: uppercase;
}
#slug { color: #b81d31; margin-bottom: 10px; }
#passes { color: #5f5b54; margin-top: 12px; text-transform: none; letter-spacing: .04em; }
#verdict { margin: 0; font: 900 clamp(38px, 6vw, 72px)/.95 Chivo, sans-serif; letter-spacing: -.01em; }
.desk { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 32px; align-items: start; }
#galley { display: flex; gap: 14px; }
#galley canvas {
  display: block; width: 420px; aspect-ratio: 190 / 253; cursor: text; /* DESK_W */
  box-shadow: 0 1px 2px rgb(40 30 20 / .25), 0 18px 36px -18px rgb(40 30 20 / .55);
}
#report { list-style: none; margin: 0; padding: 0; border-top: 3px solid #d7263d; }
#report button {
  all: unset; box-sizing: border-box; display: grid; grid-template-columns: 30px minmax(0, 1fr);
  column-gap: 12px; width: 100%; padding: 9px 0 10px; border-bottom: 1px solid #bdb8aa; cursor: pointer;
}
#report button:hover span, #report button:focus-visible span { color: #b81d31; }
#report b {
  grid-row: span 2; width: 26px; height: 26px; border-radius: 50%; background: #d7263d;
  color: #f6f3ea; font: 13px/26px "Fragment Mono", monospace; text-align: center;
}
#report span { font: 400 16px/1.25 Chivo, sans-serif; }
#report code {
  font: 12px/1.5 "Fragment Mono", monospace; color: #5f5b54;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.source { margin-top: 28px; }
.source label {
  display: block; margin-bottom: 8px; font: 12px/1.3 "Fragment Mono", monospace; color: #5f5b54;
}
#source {
  display: block; width: 100%; height: 280px; box-sizing: border-box; padding: 14px 16px;
  font: 13px/1.6 "Fragment Mono", monospace; color: #1d1d1b; background: #f6f3ea;
  border: 1px solid #bdb8aa; resize: vertical;
}
#source::selection { background: #d7263d; color: #f6f3ea; }
.source p { margin: 12px 0 0; display: flex; gap: 10px; flex-wrap: wrap; }
.source button {
  font: 12px/1 "Fragment Mono", monospace; letter-spacing: .12em; text-transform: uppercase;
  color: #f6f3ea; background: #1d1d1b; border: 0; padding: 10px 14px; cursor: pointer;
}
.source button + button { color: #1d1d1b; background: none; box-shadow: inset 0 0 0 1px #1d1d1b; }
@media (max-width: 1240px) {
  .desk { grid-template-columns: minmax(0, 1fr); }
  #galley canvas { width: calc(50% - 7px); }
}
@media (max-width: 560px) {
  #proof { padding: 24px 16px 28px; }
  #galley { flex-direction: column; }
  #galley canvas { width: 100%; }
}
```

### script.js

```js
// ═══ Postext Cookbook · Nº 055 · A galley proof with every fault marked in red ══════
// https://postext.dev/en/cookbook/proof-sheet-diagnostics
// Code: MIT · Text: original (CC BY 4.0) · Map: code (CC BY 4.0) · Photo: diffusion models
// Fonts: Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseMarkdownWithIssues, KNOWN_DIRECTIVES, KNOWN_CONTAINERS,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'proof-sheet-diagnostics';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// col() writes the hex beside each paletteId (gotcha: palette-skips-designs).
const palette = { // proof: the one accent and the marks; rule: the map's banks; muted: furniture
  ink: '#1d1d1b', proof: '#d7263d', paper: '#f6f3ea', rule: '#bdb8aa', muted: '#76726a' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.proof]] // every default
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // on main-color: red
const [TEXT, DISPLAY, MONO] = ['Charis SIL', 'Chivo', 'Fragment Mono'];
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [190, 253, 22, 20, 17, 14]; // mm, mirrored
const MEASURE = TRIM_W - INNER - OUTER; // mm: 159, the text block the opener spans
const [LEAD, ART_H, DPI] = [14, 50, 150]; // pt: leading; mm: the photograph; page px/inch
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const caps = (size) => ({ fontFamily: MONO, fontSize: pt(size), letterSpacing: pt(size * 0.12),
  textTransform: 'uppercase', fontWeight: 400 });

const below = (id, gap, width = MEASURE) => ({ anchor: { to: id, edge: id === 'container'
  ? 'top-left' : 'below' }, offset: { x: mm(0), y: mm(gap) }, size: { width: mm(width) } });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...extra });
const opener = { enabled: true, slot: { elements: [ // the drawing, then the words under it
  { kind: 'image', id: 'art', resourceId: 'crossing', placement: { ...below('container', 0),
    size: { width: mm(MEASURE), height: mm(ART_H) } } },
  // The words reserve the drawing's height; an image never does (gotcha: opener-image-no-reserve).
  text('kicker', '{attr.kicker}', MONO, 8, 'proof', below('container', ART_H + 6), caps(8)),
  text('title', '{titleText}', DISPLAY, 50, 'ink', below('#kicker', 1.4),
    { fontWeight: 900, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple)
  text('standfirst', '{attr.standfirst}', TEXT, 11.5, 'ink', below('#title', 4, 136),
    { italic: true, lineHeight: 1.3 }),
  text('byline', '{attr.byline}', MONO, 7.5, 'muted', below('#standfirst', 3), caps(7.5))] } };
const head = (id, content, parity, edge, x, y = 12) => text(id, content, MONO, 7.5, 'muted',
  { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, { ...caps(7.5), parity,
    pages: 'body', align: edge.split('-')[1] ?? 'center' }); // y: mm from the trim
const header = { elements: [
  head('verso', t({ en: '{pageNumber}   The Tideline · issue 41',
    es: '{pageNumber}   La Marea · número 41' }), 'even', 'top-left', OUTER),
  head('recto', t({ en: 'News · the night ferry   {pageNumber}',
    es: 'Noticias · la barcaza nocturna   {pageNumber}' }), 'odd', 'top-right', -OUTER)] };
const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, -11),
  pages: 'opener' }] }; // the opener's folio drops to its foot

// #region boxes: the fact box splits where the page ends; the stamp is pinned to a corner
const boxes = [
  // keepTogether: false: cut between two blocks at the page foot; a line of white closes it.
  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') },
    padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') },
    body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) },
    lists: { bulletChar: '■', color: col('proof'), bulletFontSize: pt(5) } },
  // 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title.
  { id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false,
    title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }),
    fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } },
    border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1),
    padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) },
    titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } },
];
// #endregion
const side = (id, body, fontFamily = DISPLAY) => ({ id, span: 'side', backgroundEnabled: false,
  padding: NONE, titleStyle: { ...caps(7), color: col('proof'), gap: mm(2) }, // outer column
  body: { fontFamily, textAlign: 'left', firstLineIndent: pt(0), ...body } });
const calloutStyles = [...boxes, side('quote', { fontSize: pt(12.5), lineHeight: pt(15) }),
  side('dates', { fontSize: pt(9), lineHeight: pt(12), paragraphSpacing: true }),
  side('colophon', { fontSize: pt(6.5), lineHeight: pt(9.5), color: col('muted') }, MONO)];

const config = () => ({ // a fresh object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), colorPalette, // exact codes (gotcha: hyphenation-locales)
  resourceTypes: [{ id: 'figure', name: t({ en: 'Map', es: 'Mapa' }),
    captionPrefix: t({ en: 'Map', es: 'Mapa' }), shortLabel: t({ en: 'map', es: 'mapa' }),
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: DPI,
    backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM),
      left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: 27, sideColumnRole: 'floats',
    sideColumnSide: 'outer', gutterWidth: mm(6) },
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), // a :ref (map 1) takes boldColor
    firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6,
    maxRuntTracking: 0 }, // gotcha: runt-tracking-unpainted
  headings: { fontFamily: DISPLAY, color: col('ink'), levels: [ // the H1 break restated, as a
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, // headings object
      advancedDesign: opener }, // drops it (gotcha: headings-drop-h1-break)
  ] },
  calloutStyles, header, footer,
  captionStyle: { fontFamily: DISPLAY, fontSize: pt(8), lineHeight: pt(11), color: col('ink'),
    labelBold: true, labelColor: col('proof') },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# The Night Ferry Returns {kicker="Estuary · Transport" standfirst="After eleven winters without a late crossing, the Port Alder ferry sails after dark again from Friday, on a battery boat and a timetable built round the cannery’s night shift." byline="Nora Ekdahl · Galley for issue 41, October 1, 2026"}

:::callout{type="stamp"}
:::

For the first time since 2015, the last boat across the Sawyer will leave after the last bus. From Friday, October 2, the county ferry *Marram* adds eight evening crossings to her day timetable, the last from the Water Street slip in Port Alder at 11:40 p.m. and the last back from Crane Landing, on the route in :ref{id="route"}, at midnight. The night boats run every day of the year except December 25, the first at 8:20 p.m.

Fares do not change: \$2.50 on foot, \$9 with a car and driver, and \$40 for a monthly pass; riders over 65 or under 18 travel at half price. The winter timetable is posted at both slips and at https://aldercounty.example/nightferry.

:::callout{type="facts" title="The crossing in figures"}
- **Vessel.** *Marram*, battery-electric, 82 feet, built 2026.
- **Capacity.** 149 passengers, 18 cars, 12 bicycles.
- **Crossing.** 1.4 miles; 12 minutes on the flood, 15 on the ebb.
- **Crew.** A master, a deckhand and an engineer on every night sailing.

:::columns{count=2 breaks="2"}
**From Water Street** 8:20, 9:40, 10:40 and 11:40 p.m.

**From Crane Landing** 8:40, 10:00, 11:00 p.m. and midnight.
:::
:::

:::callout{type="quote" title="Dana Whitcomb"}
“On the bus I got home at ten past one. On the midnight boat I’ll be in by half past twelve.”
:::

The old *Tern*, a diesel boat built in 1971, broke her port shaft twice in six weeks in the winter of 2015, and the county stopped sailing her after dark. Since then the Crane Landing cannery has run a bus for its night line round by the Highway 9 bridge, 19 miles each way.

The *Marram* arrived from the builder’s yard in June and has run the day timetable since August 3. She charges from shore power at both slips while she loads, and that is what pays for the late sailings: the county puts her energy bill for a night of eight crossings at less than the *Tern* burned in diesel on two.

Her master, Ilse Brandauer, learned to handle passenger boats on the Danube at Passau, where she spent nine years before she married a Port Alder boatbuilder. Asked what she misses, she named a word, *Donaudampfschifffahrtsgesellschaftskapitän*, the “Danube steamship company captain” that German children use as a tongue-twister.

:::callout{type="dates" title="Timeline"}
⁠1971. The *Tern*, a diesel boat, enters service on the Sawyer.

⁠2015. She breaks her port shaft twice in six weeks; the night sailings end.

⁠2026. The *Marram* takes the day timetable on August 3 and the night sailings on October 2.
:::

The route bows upstream of the Coffin Rock shoal on the ebb, when the current would set the boat onto it, and runs straight across on the flood, which is why the timetable gives two crossing times. Sailings held for fog are announced by text alert half an hour before departure.

Dana Whitcomb packs salmon on the night line and has ridden the cannery bus since the *Tern* stopped. “On the bus I got home at ten past one. On the midnight boat I’ll be in by half past twelve,” she said. The cannery has moved the end of its night shift from 11:45 to 11:30 so the line can make the last boat.

Six households on the slip side of Water Street have written to the county commission about cars queuing at their doors after dark. The ferry office says queuing cars will wait in the lot behind the old cannery office, not on the street.

The commission agreed the night service for three years at its September meeting, by four votes to one. The dissenting commissioner, Ray Tolliver, asked for a count of night riders by the end of January before any money is committed for a fourth year. The ferry office will count every crossing from the first night.

The *Tern* has been laid up at the county yard since August and is for sale. The ferry office has had two offers, both from oyster growers up the coast who want her as a work boat, and expects to decide in November.

:::callout{type="colophon"}
The Tideline, issue 41. Set in Charis SIL, Chivo and Fragment Mono (SIL OFL). Text and map CC BY 4.0; generated photograph. Port Alder and its people are fictional.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region first-pass: the galley as filed, the corrected text with its faults put back
const FIRST_PASS = [ // [corrected, faulty], replaced wherever it occurs
  [':::callout{type="stamp"}\n:::', t({ en: ':::stamp', es: ':::sello' })], // no such directive
  [':ref{id="route"}', ':ref{id="route-map"}'], // an id no resource has
  ['\\$', '$'], // bare dollars (gotcha: dollar-math)
  [' https://', ' '], // a web address without its scheme
  [':::\n:::\n', ''], // the fact box's two closing fences
  ['\u20601971.', '1971.'], // the word joiner before 1971 (gotcha: digit-period-list)
];
const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md);
// #endregion

// #region answer: the checks: what the parser and the layout report, and what they leave to you
const MM = DPI / 25.4; // page px per mm
function proof(md, doc) {
  const faults = [];
  const add = (kind, from, to, detail, at) => faults.push({ kind, from, to, detail, at });
  const { blocks, issues } = parseMarkdownWithIssues(md); // a $, $$ or ::: left open
  for (const i of issues) add(i.kind, i.sourceStart, i.sourceEnd);
  for (const w of doc.warnings ?? []) { // calloutOverflow: a box no cut could split
    add(w.kind, w.sourceStart, w.sourceEnd, Math.round(w.overflowPx / MM), w);
  }
  if (!doc.converged) add('unsettled', 0, 1, doc.iterationCount); // the layout never settled
  // 1.4.1 reports none of these (gotcha: sandbox-only-warnings).
  const ids = new Set(resources.map((r) => r.id)); // an unknown id prints '?'
  for (const m of md.matchAll(/:ref\{id="([^"]*)"|^::resource\{id="([^"]*)"/gm)) {
    if (!ids.has(m[1] ?? m[2])) add('unknownResourceId', m.index, m.index + m[0].length);
  }
  for (const m of md.matchAll(/^:::?([a-z][\w-]*)/gim)) { // an unknown fence prints as text
    const known = KNOWN_CONTAINERS.has(m[1]) || KNOWN_DIRECTIVES.has(m[1]) || m[1] === 'resource';
    if (!known) add('unknownDirective', m.index, m.index + m[0].length);
  }
  for (const b of blocks) { // formulas count as faults only because this story has none
    if (b.startNumber > 999) add('yearList', b.sourceStart, b.sourceEnd); // 1971. opens a list
    for (const s of b.spans ?? []) if (s.math) add('formula', s.math.sourceStart, s.math.sourceEnd);
  }
  // The lines as set: spaces past maxWordSpacing, a line set ragged, a hyphen in an address.
  const max = doc.config.bodyText.maxWordSpacing;
  for (const l of doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks))
    .flatMap((b) => b.lines ?? [])) {
    const ratio = Math.round(l.justifiedSpaceRatio * 100) / 100; // as the report prints it
    if (ratio > max) add('looseLine', l.sourceStart, l.sourceEnd, ratio);
    if (l.ragged) add('raggedLine', l.sourceStart, l.sourceEnd); // past 3×: no ratio left
    if (l.hyphenated && /[./]\S+-$/.test(l.text)) add('addressHyphen', l.sourceStart, l.sourceEnd);
  }
  return faults.sort((a, b) => a.from - b.from);
}
// #endregion

// #region marks: each fault underlined in red where it was set, numbered in the nearest margin
const linesOf = (page) => [...page.columns.flatMap((c) => c.blocks), ...(page.floats ?? [])]
  .flatMap((b) => (b.lines ?? []).map((l) => ({ ...l.bbox, from: l.sourceStart, to: l.sourceEnd,
    width: l.justifiedSpaceRatio // a bbox is the natural width; a justified line fills the block
      ? b.bbox.x + b.bbox.width - l.bbox.x : l.bbox.width })));
const REACH = 80; // characters after a fence where its first line may start
function spotOf(page, f) { // where a fault shows on this page: its first line, in page px
  if (f.at) { // a box that ran off its column: a bar under the column's foot
    const c = page.columns[f.at.columnIndex]?.bbox;
    return f.at.pageIndex === page.index ? { ...c, y: c.y + c.height, height: 1.6 * MM } : null;
  }
  const lines = linesOf(page); // a fence sets no line of its own: then the first line after it
  return lines.find((r) => r.from < f.to && r.to > f.from)
    ?? lines.find((r) => r.from >= f.from && r.from - f.to < REACH);
}
function paintMarks(canvas, page, faults, scale) {
  const ctx = canvas.getContext('2d');
  ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
  Object.assign(ctx, { font: `${3 * MM}px "${MONO}"`, textAlign: 'center' });
  const taken = []; // the numbers set so far: two on one line sit side by side
  faults.forEach((f, n) => {
    const r = spotOf(page, f);
    if (!r) return;
    const wash = f.kind === 'looseLine'; // a loose line is washed, any other fault underlined
    Object.assign(ctx, { fillStyle: palette.proof, globalAlpha: wash ? 0.2 : 1 });
    ctx.fillRect(r.x, wash ? r.y : r.y + r.height, r.width, wash ? r.height : 0.45 * MM);
    const [left, y] = [r.x + r.width / 2 < page.width / 2, r.y + r.height / 2]; // nearest margin
    const shift = taken.filter((ty) => Math.abs(ty - y) < 4.5 * MM).length * 5.2 * MM;
    const [x, edge] = [left ? 6.5 * MM + shift : page.width - 6.5 * MM - shift,
      left ? r.x : r.x + r.width]; // the number, and a leader from it to the text
    taken.push(y);
    ctx.globalAlpha = 1;
    ctx.fillRect(Math.min(x, edge), y - 0.12 * MM, Math.abs(x - edge), 0.24 * MM);
    ctx.beginPath();
    ctx.arc(x, y, 2.4 * MM, 0, 2 * Math.PI);
    ctx.fill();
    ctx.fillStyle = palette.paper;
    ctx.fillText(String(n + 1), x, y + 1.05 * MM);
  });
}
// #endregion

// #region source: a click on a proof page selects the Markdown that set the line
function selectSource(from, to) {
  const all = source.value; // measure the wrapped height of the text before the selection
  source.value = all.slice(0, from);
  const top = source.scrollHeight;
  source.value = all;
  source.focus({ preventScroll: true });
  source.setSelectionRange(from, to); // the offsets the parser and the layout give
  source.scrollTop = top > source.clientHeight ? top - source.clientHeight / 3 : 0;
}
function onPageClick(canvas, page) {
  canvas.onclick = ({ clientX, clientY }) => {
    const box = canvas.getBoundingClientRect(); // CSS px to page px
    const [x, y] = [(clientX - box.left) * (page.width / box.width),
      (clientY - box.top) * (page.height / box.height)];
    const hit = linesOf(page).find((r) => x >= r.x && x <= r.x + r.width && y >= r.y
      && y <= r.y + r.height);
    if (hit?.from !== undefined) selectSource(hit.from, hit.to);
  };
}
// #endregion

const DESK_W = 420; // CSS px: a page's width on the proof desk (style.css); words: index.html
const say = (key, value = '') => $('words').content.querySelector(`[data-key="${key}"]`)
  .dataset[LANG].replace('{}', value.toLocaleString(LANG));
const passOf = (md) => md === markdown ? 'second' : md === firstPass(markdown) ? 'first' : 'edited';
function proofDesk(md, draft) {
  const faults = proof(md, draft);
  $('galley').replaceChildren(...draft.pages.map((page) => {
    const canvas = Object.assign(document.createElement('canvas'), { role: 'img',
      ariaLabel: say('page', page.index + 1) });
    const scale = (Math.min(devicePixelRatio, 2) * DESK_W) / page.width;
    renderPageToCanvas(page, draft, canvas, { scale });
    paintMarks(canvas, page, faults, scale);
    onPageClick(canvas, page);
    return canvas;
  }));
  const [n, copy] = [faults.length, say(passOf(md))]; // copy: first pass, second pass, an edit
  $('verdict').textContent = `${say(n > 1 ? 'faults' : n ? 'fault' : 'clean', n)} ${copy}`;
  $('passes').textContent = `iterationCount ${draft.iterationCount} · converged ${draft.converged}`;
  $('report').replaceChildren(...faults.map((f, n) => {
    const li = document.createElement('li');
    li.innerHTML = '<button type="button"><b></b><span></span><code></code></button>';
    const [b, span, code] = li.firstChild.children;
    [b.textContent, span.textContent] = [n + 1, say(f.kind, f.detail)];
    code.textContent = `${f.kind} · ${md.slice(f.from, f.to).split('\n')[0]}`;
    li.firstChild.onclick = () => selectSource(f.from, f.to);
    return li;
  }));
  return faults;
}

// #region art: the route map, drawn in the page's palette; the crossing is a photograph
const R = (v) => Math.round(v * 100) / 100;
const rect = (x, y, w, h, fill, opacity = 1) => `<rect x="${R(x)}" y="${R(y)}" width="${R(w)}" `
  + `height="${R(h)}" fill="${fill}" opacity="${R(opacity)}"/>`;
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" `
  + `viewBox="0 0 ${w} ${h}">${body}</svg>`;
function routeMap() { // 42 × 44 mm, 10 units a mm: south bank at the foot, the sea to the right
  const [W, H] = [420, 440];
  const { ink, paper, proof, rule } = palette;
  const line = (d, color, extra = '') =>
    `<path d="${d}" fill="none" stroke="${color}" stroke-width="5" ${extra}/>`;
  return svg(W, H, rect(0, 0, W, H, paper)
    + `<path d="M0 0H${W}V58C340 76 250 50 170 68S60 56 0 80Z" fill="${rule}"/>` // the banks
    + `<path d="M0 ${H}H${W}V372C330 356 250 388 170 370S60 384 0 360Z" fill="${rule}"/>`
    + `<ellipse cx="262" cy="214" rx="58" ry="26" fill="none" stroke="${ink}" stroke-width="3"`
    + ' stroke-dasharray="6 7"/>' // the shoal
    + line('M164 370V70', ink) // the flood: straight across
    + line('M164 370C150 300 84 270 86 214S150 110 164 70', proof, 'stroke-dasharray="14 9"')
    + rect(150, 362, 28, 20, ink) + rect(150, 56, 28, 20, ink) // the two slips
    + line('M300 318h72', ink) + `<path d="M394 318l-26-11v22Z" fill="${ink}"/>` // the ebb
    + line('M380 150v48', ink) + `<path d="M380 128l-11 26h22Z" fill="${ink}"/>` // north,
    + line('M371 118V92L389 118V92', ink, 'stroke-linejoin="miter"')); // under its N
}
const resources = [{ id: 'route', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'route.svg', width: 420, height: 440 }, placement: { span: 'side' },
  caption: t({ en: 'Night route, north up. Solid: straight across on the flood, 12 minutes. '
    + 'Dashed red: on the ebb, when the current runs out to sea (arrow), bowed upstream of the '
    + 'Coffin Rock shoal (dotted), 15 minutes.',
  es: 'Ruta nocturna, norte arriba. Continua: en línea recta con la llenante, 12 minutos. '
    + 'Roja discontinua: con la vaciante (flecha), aguas arriba del bajo Piedra Negra '
    + '(punteado), 15 minutos.' }),
  altText: t({ en: 'A plan of the crossing: two banks, a dotted shoal, and between two slips a '
    + 'straight black track and a dashed red track bowed away from the shoal; an arrow marked N '
    + 'points north, another points out to sea.',
  es: 'Plano del cruce: dos orillas, un bajo punteado y, entre dos rampas, una ruta negra recta '
    + 'y una ruta roja discontinua que se abre lejos del bajo; una flecha con una N señala el '
    + 'norte y otra, el mar.' }) },
{ id: 'crossing', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, // 159 × 50 mm
  bitmap: { fileId: 'crossing-1590.jpg', format: 'jpeg', width: 1590, height: 500 },
  altText: t({ en: 'A ferry with lit windows and a red port light crossing a dark estuary under '
    + 'a full moon, a cannery and its stack on the far bank.',
  es: 'Una barcaza con las ventanas encendidas y la luz roja de babor cruza de noche un estuario '
    + 'bajo la luna llena, con una planta en la otra orilla.' }) }];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Charis SIL': ['400', '400i', '700', '700i'], Chivo: ['400', '700', '900'],
  'Fragment Mono': ['400'] }; // every face, loaded before the first build (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([loadFonts(FONTS, markdown), loadSvg('route.svg', routeMap()),
  loadImage('crossing-1590.jpg', asset('crossing-1590.jpg'))]);
const $ = (id) => document.getElementById(id); // the proof desk of index.html
const source = $('source');
for (const el of document.querySelectorAll('#proof [data-en]')) el.textContent = el.dataset[LANG];
const build = (m) => buildWithFonts(() => buildDocument({ markdown: m, resources }, config()), m);
const proofAgain = async (md) => proofDesk(source.value = md, await build(md));
const first = await proofAgain(firstPass(markdown)); // first: the galley as it came in
const doc = await build(markdown); // last: the corrected galley, the pages below
showPages(doc, { title: say('title') });
$('again').onclick = () => proofAgain(source.value);
$('fixed').onclick = () => proofAgain(markdown);
selectSource(first[0].from, first[0].to); // the first fault, selected in the Markdown

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### データの囲みを分割しない

既定の`keepTogether`では、囲みはひとかたまりで2ページ目に移ります。1ページ目は33 mm足りずに終わり、記事は3ページ目まで続きます。

```diff
-  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
+  { id: 'facts', backgroundEnabled: false, marginBottom: pt(LEAD),
```

### 不具合が残るビルドを止める

ビルドスクリプトやテストでは、最終原稿に`proof()`を実行し、何か見つかったら例外を投げます。

```diff
 const doc = await build(markdown); // last: the corrected galley, the pages below
+if (proof(markdown, doc).length) throw new Error('The second pass still has faults.');
```

## よくあるつまずき

- **警告の多くはSandboxにしかない.** 不明なid、スタイル、ディレクティブ、欠けたフォント、ゆるい行を検査するのはSandboxで、エンジンではありません。CodePenのサンプルで得られるのはdoc.warningsとparseMarkdownWithIssuesだけです。不明なスタイルは何の知らせもなくフォールバックし、不明なディレクティブはテキストとして印字されるので、idを確認してください。
- **不明なidの:refは'?'を印字し、エンジンは警告しない.** どのリソースにもないidへの:refは「?」を印字して何も配置せず、警告するのはSandboxだけです。参照するidがすべて存在するか確認してください。
- **裸の$は数式を開く。\$と書く.** ドル記号はインライン数式を開くため、$40のような価格は数式の始まりになります。\$40と書いてください。
- **段落の頭の'1998. 'や'- 'はリストになる.** 数字、ピリオド、スペースで始まる段落や、ハイフンとスペースで始まる段落はリスト項目になります。数字の前にワードジョイナー（U+2060）を入れ、会話文はemダッシュで書いてください。
- **:::columnsは囲みの中でしか効かず、分割されない.** :::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **章扉の画像は確保する高さに数えられない.** postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **組版の警告: 閉じられていない数式の区切り記号** (`unclosedMath`). $でインライン数式が始まっていますが、閉じられていません。たいていは価格に付いたドル記号です。 解決策: ドル記号そのものは\$と書くか、同じ行で数式を閉じてください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#数式))
- **組版の警告: 閉じられていないコンテナー** (`unclosedContainer`). :::の囲いが閉じられていないため、コンテナーが文書の末尾まで続きます。 解決策: コンテナーの終わる位置に、:::だけを書いた行を加えてください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#コンテナー))
- **組版の警告: 段からはみ出す囲み** (`calloutOverflow`). どこでも分割できないボックス（段より高い図、表、:::columnsグループ、または大きすぎるsplitMinLines）が、はみ出した状態で配置されました。エンジンはこれをdoc.warningsに記録し、Sandboxが一覧に表示します。 解決策: ボックスを短くする、keepTogether: falseで分割を許す、splitMinLinesを下げる、または別のスパンを指定してください。 ([ドキュメント](https://postext.dev/ja/docs/configuration.md#calloutコンテナー))
- **組版の警告: 不明なリソース** (`unknownResourceId`). :refまたは::resourceが、どのリソースにもないidを指定しています。参照は「?」と印字され、何も配置されません。 解決策: idを修正する（二重引用符のみ使えます）か、リソースを追加してください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形))
- **組版の警告: 不明なディレクティブ** (`unknownDirective`). :::nameの行がPostextのディレクティブやコンテナーのどれにも当たらないため、テキストとして印字されます。 解決策: 対応している名前（pagebreak、columnbreak、numbering、space、toc、callout、paragraphs、part、columns、paper）と綴りを照らし合わせてください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#ディレクティブ))
- **組版の警告: ゆるい行** (`looseLine`). 両端そろえの行で、語間がしきい値を超えて広がっています。原因はたいてい長い単語、URL、または狭い行長です。 解決策: 正しい言語でハイフネーションを有効にする、行長を広げる、文を書き換える、またはその箇所を左そろえ（行末不ぞろい）で組んでください。 ([ドキュメント](https://postext.dev/ja/docs/justification.md#ゆるい行のデバッグ))

- データの囲みが1ページ目を最後の行まで埋めるよう、原稿の長さを調整してください。1.4.1では、段を締めくくる囲みが段末そろえのときに下へ動きます。最初の段落の最後の文を削ると、囲みの罫は運賃の段落の下2.4 mmではなく6.4 mmに来ます。
- Webアドレスとエスケープの周りでは、ソースのオフセットが後ろにずれます。1.4.1のハイフネーションは、アドレスのスラッシュの後にゼロ幅スペース（U+200B）を挿入し、`line.text`はそれを保持します。そのためアドレスを含む行は、ゼロ幅スペース1つにつき1字遅れて終わり、次の行も同じだけ遅れて始まります。1ページ目の*https://aldercounty*で終わる行は、*.example*の点まで選択します。エスケープした文字で始まる行の範囲はバックスラッシュの後から始まります。1ページ目の`$40`で始まる行がその例です。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: The night crossing at the head of page 1: Generated With Diffusion Models, オリジナル
- 画像: The route map, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- 書体: Charis SIL (OFL-1.1), Chivo (OFL-1.1), Fragment Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 014 · 狭い両端そろえの段で語間を均一にする](https://postext.dev/ja/cookbook/justification-lab.md): 語間を通常の0.8〜1.6倍に抑えたKnuth–Plassの改行を、貪欲法の対照版と並べます。対照版のゆるい行、孤立した行、ラントはVDTから読み取って印を付けます。 · 難易度 3 (上級) · 雑誌とジン
- [No. 027 · 新聞の第1面](https://postext.dev/ja/cookbook/newspaper-front-page.md): 地方週刊紙の第1面と2ページ目を罫で区切った2段で組み、ページ幅の大見出しは囲みとして組み、4段の短信欄は下端にフロートさせます。 · 難易度 3 (上級) · 新聞とニュースレター
- [No. 021 · 分かれる囲み、フロートする囲み、固定する囲み](https://postext.dev/ja/cookbook/boxes-split-float-pin.md): 手順の囲みが2つの段に分かれ、データシートが次のページの頭へ移り、バッジが地に固定される理科の実習ワークシート。 · 難易度 3 (上級) · ワークブックと練習問題, 教科書
