# Web Workerでレイアウトするライブエディター

> Markdownの横に文庫判のページ。blobから起動したワーカーが専用のフォントで章を組み、キーを打つたびに進行中のビルドを取り消します。

- HTML版: https://postext.dev/ja/cookbook/web-worker-live-editor
- レシピ No. 056 · 出力と組み込み · 難易度 3 (上級) · 出力: Canvas, ライブ操作
- ジャンル: 小説・戯曲・文芸
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.9.2 ／テスト日 2026-10-01
- ページ: [1](https://postext.dev/cookbook/web-worker-live-editor/en/p01.webp?v=be69bc7a), [2](https://postext.dev/cookbook/web-worker-live-editor/en/p02.webp?v=be69bc7a), [3](https://postext.dev/cookbook/web-worker-live-editor/en/p03.webp?v=be69bc7a), [4](https://postext.dev/cookbook/web-worker-live-editor/en/p04.webp?v=be69bc7a), [5](https://postext.dev/cookbook/web-worker-live-editor/en/p05.webp?v=be69bc7a)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=web-worker-live-editor&lang=en (.postext: https://postext.dev/cookbook/web-worker-live-editor/en/web-worker-live-editor.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/web-worker-live-editor.md), [es](https://postext.dev/es/cookbook/web-worker-live-editor.md), [ca](https://postext.dev/ca/cookbook/web-worker-live-editor.md), [zh](https://postext.dev/zh/cookbook/web-worker-live-editor.md), [ar](https://postext.dev/ar/cookbook/web-worker-live-editor.md)

## かんたんな説明

『タイム・マシン』の小さな文庫版を編集するライブエディターです。入力するたびにページが裏で組み直されるので、画面は反応し続けます。

## できあがり

H. G. ウェルズの『タイム・マシン』の第I章を110 × 147 mmの文庫版に組み、その横にMarkdownを置きます。扉には、拡散モデルで生成した、4つの小さな目盛り盤をもつ真鍮の計器の写真があり、コードで描いたオックスブラッドの地に載って、その下にCinzelの題が入ります。章の本文はローマ数字と真鍮色の罫の下、9行目から始まり、Baskervvilleの9.5/13 ptで、スモールキャピタルの柱の下を流れます。キーを打つたびにWeb Workerが章全体を組み直し、右のCanvasはキャレットのあるページに切り替わります。エディターの下では、メインスレッドが毎フレーム回す針があり、そのスレッドがふさがっている間は止まります。ステータス行には、ビルドの時間、最も長かったフレーム、新しいキー入力によって取り消されたビルドの数が出ます。右上のメニューで、比較のためにビルドをメインスレッドへ移せます。

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

- 長いテキストをレイアウトする間もエディターの反応を保つには？Web Workerでのフォントと取り消しは？
- 行の分割が変わったりPDFで単語が重なったりするのはなぜですか？フォントを正しく読み込むには？
- サーバーやCLI（Node）でページやPDFを生成できますか？

## 手短な答え

```js
// script.js, 行 137–165
async function startLayoutWorker(faces) {
  // In 1.4.1, createLayoutWorker() on its own starts esm.sh's worker file, which the browser
  // refuses to run from another origin; a same-origin blob that imports it is allowed.
  // An import map does not reach the worker: if the page pins postext@x.y.z, pin this URL too.
  const entry = new Blob([`import 'https://esm.sh/postext/worker/entry';`],
    { type: 'text/javascript' });
  const layout = createLayoutWorker({
    worker: new Worker(URL.createObjectURL(entry), { type: 'module' }) });
  // The worker measures with its own FontFaceSet, not the page's. Without the bytes of every
  // face it measures in a fallback font, and 1.4.1 raises no error. Weights are strings.
  const payloads = await Promise.all(faces.map(async ({ family, weight, style, url }) => {
    // Check the status: a 404 page sent as a font only logs a warning inside the worker.
    const response = await fetch(url);
    if (!response.ok) throw new Error(`${family} ${weight} ${style}: HTTP ${response.status}`);
    return { family, weight, style, buffer: await response.arrayBuffer() };
  }));
  await layout.registerFonts(payloads); // the buffers move to the worker, not copied
  let inFlight = null;
  return async function typeset(content) {
    inFlight?.abort(); // cancel the build that the previous keystroke asked for
    const build = (inFlight = new AbortController());
    try {
      return await layout.build(content, config(), { signal: build.signal });
    } catch (error) {
      if (error.name === 'AbortError') return null; // superseded: a newer build is on its way
      throw error;
    }
  };
}
```

## 材料

**学べること**

- [Web Workerでのレイアウト](https://postext.dev/ja/docs/configuration.md#web-workerでレイアウトを実行する): 登録したフォントと協調的なキャンセルを使い、メインスレッドの外でレイアウトを実行します。長い本でもページが固まりません。
- [レイアウト前のフォント読み込み](https://postext.dev/ja/docs/configuration.md#計測キャッシュ): レイアウトはブラウザーが読み込んだ書体で計測します。そのため先にすべての書体を読み込み、遅れて届いたものがあれば計測キャッシュを消去します。
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する): buildDocumentはMarkdownと設定をページにレイアウトし、renderPageToCanvasは任意のページを任意の倍率で描画します。

**ほかに使うもの**

- [仕上がりサイズ](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/configuration.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/configuration.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/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/document-format.md#space)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)

**設定の一覧**

- [`bodyText`](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#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`createLayoutWorker`](https://postext.dev/ja/docs/configuration.md#web-workerでレイアウトを実行する), [`createMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Baskervville (OFL-1.1), Baskervville SC (OFL-1.1), Cinzel (OFL-1.1)

## 作り方

### 1 · ワーカーをblobから起動する

この手順のコードは上の[手短な答え](#手短な答え)にあります。1.4.1では、オプションなしで`createLayoutWorker()`を呼ぶと、モジュールの横にあるワーカーファイルを起動します。モジュールをesm.shから読み込むと、そのファイルはesm.shのオリジンにあり、`Worker`コンストラクターがSecurityErrorを投げます。ページが作るblob URLはページのオリジンをもつので、手短な答えでは、esm.shから`postext/worker/entry`をインポートする1行のblobでモジュールワーカーを起動し、そのワーカーを`createLayoutWorker({ worker })`に渡します。ハンドルの`build()`は`buildDocument()`と同じコンテンツと設定を受け取り、同じ種類の文書を返します（[Web Workerでレイアウトを実行する](/ja/docs/configuration#web-workerでレイアウトを実行する)）。

> Postextにサーバー側で動くモードはありません。文字をCanvasで測るので、レイアウトはブラウザーかワーカーで行います。バッチ処理なら、PuppeteerでヘッドレスChromeにページを開き、そこからページやPDFを取り出してください。ディスク上の`.postext`プロジェクトなら、postext-portスキルの`render.mjs`がNodeでエンジンを動かし、Canvasの代わりにfontkitで、プロジェクト自身のフォントから文字を測ります（[スクリプト](/ja/docs/skill#スクリプト)）。

### 2 · すべての書体を2回読み込む

```js
// script.js, 行 315–327
const FONTS = { // every face the pages use: the page loads them, and so must the worker
  Baskervville: ['400', '400i'], // text, folios, subtitle, colophon
  'Baskervville SC': ['500'], // running heads, the author
  Cinzel: ['700'], // title, numeral, chapter title
};
// The worker gets the same Fontsource files the page loads: identical metrics on both threads.
const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const [id, weight, style] = [family.toLowerCase().replace(/ /g, '-'), parseInt(spec, 10),
    spec.endsWith('i') ? 'italic' : 'normal'];
  const file = `${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
  return { family, weight: String(weight), style,
    url: `https://cdn.jsdelivr.net/npm/@fontsource/${file}` };
}));
```

ワーカーは自分専用の`FontFaceSet`をもち、ページが読み込んだ書体を見られません。そこで手短な答えでは同じFontsourceのファイルを取得し、そのバイト列を`registerFonts()`に渡します。ウェイトはそれぞれ文字列（`'400'`）で指定します。この呼び出しを省くと、1.4.1のワーカーは警告なしにフォールバックのフォントで測ります。この章は190行ではなく175行で組まれ、1行目は「of」ではなく「him)」で終わります。ダウンロードに失敗したフォントファイルも同じように見逃されます。ローマン体のBaskervvilleの代わりに404のページを受け取っても、`registerFonts()`はそのまま完了し、章は186行で組まれ、警告はワーカーのコンソールにしか出ません。そのため手短な答えでは、バイト列を送る前に各レスポンスのステータスを確かめ、書体名とHTTPステータスを付けて例外を投げます。ページ側でも`loadFonts()`で同じ書体を読み込みます。Canvasはページのフォントで描画するからです。

### 3 · キーを打つたびにビルドし、メインスレッドで描画する

```js
// script.js, 行 350–396
let [doc, shown, builds, cancelled] = [null, 0, 0, 0];
// For the comparison, the main thread keeps a measurement cache as the worker does, so its
// pages match the worker's: in 1.4.1 a build with a cache can break lines differently.
const mainCache = createMeasurementCache();
function paint(n = shown) { // the canvas is sized to its box, in device pixels
  shown = Math.max(0, Math.min(doc.pages.length - 1, n));
  const vdtPage = doc.pages[shown]; // a laid-out page, not the page config above
  const height = ($('proof').clientHeight || 640) * Math.min(devicePixelRatio || 1, 2);
  renderPageToCanvas(vdtPage, doc, $('proof'), { scale: height / vdtPage.height });
  $('proof').setAttribute('aria-label', `Page ${vdtPage.pageLabel}`);
  $('folio').value = `${shown + 1} / ${doc.pages.length}`;
}
function follow() { // turn to the page that holds the caret: every block keeps its source offset
  const caret = $('source').selectionStart;
  paint(doc.pages.findLastIndex((vdtPage) => vdtPage.columns.some((column) =>
    column.blocks.some((block) => block.sourceStart <= caret))));
}
async function refresh() {
  const [ticket, onMain, started] = [++builds, $('thread').value === 'main', performance.now()];
  frames.worst = 0;
  const content = { markdown: $('source').value, resources };
  const next = onMain ? buildDocument(content, config(), mainCache) : await typeset(content);
  await new Promise(requestAnimationFrame); // the first frame after the build shows any stall
  if (!next || ticket !== builds) { cancelled++; return; } // a newer build has been asked for
  doc = next;
  if (document.activeElement === $('source')) follow(); else paint();
  showPages(doc, { title: 'The Time Machine · chapter I, set in a Web Worker' });
  $('clock').value = `${onMain ? 'Main thread' : 'Worker'} · ${doc.pages.length} pages in `
    + `${Math.round(performance.now() - started)} ms · longest frame ${Math.round(frames.worst)}`
    + ` ms · ${builds} ${builds === 1 ? 'build' : 'builds'}, ${cancelled} cancelled`;
}
$('source').addEventListener('input', refresh);
for (const type of ['click', 'keyup']) $('source').addEventListener(type, () => doc && follow());
$('thread').addEventListener('change', refresh);
$('prev').addEventListener('click', () => paint(shown - 1));
$('next').addEventListener('click', () => paint(shown + 1));
// A dial the main thread turns on every frame: it stops while that thread is busy.
const frames = { last: 0, worst: 0 };
requestAnimationFrame(function turn() { // the clock, not the frame's timestamp: a late frame
  const now = performance.now(); // keeps the time it was due, which hides the stall
  frames.worst = Math.max(frames.worst, now - (frames.last || now));
  frames.last = now;
  const deg = ((now * 0.06) % 360).toFixed(1); // a turn in 6 s
  $('hand').setAttribute('transform', `rotate(${deg})`);
  requestAnimationFrame(turn);
});
await refresh();
```

`typeset()`は次のビルドを依頼する前に、進行中のビルドを中止します。中止されたビルドのPromiseはすぐに`AbortError`で拒否され、`typeset()`はそれを`null`に変えます。ワーカーの中では、キューで待っているビルドはただちに捨てられ、実行中のビルドは現在の配置パスの終わりで止まります。ビルドが取り消しを確かめるのはパスの合間だけだからです。`refresh()`はビルドに番号を振り、いちばん新しいものだけを描画するので、遅れて届いたワーカーのビルドがメインスレッドのビルドの上に描かれることはありません。文書はメインスレッドに戻り、そこで`renderPageToCanvas()`が、メインスレッドに登録した画像を使って描画します。ワーカーが必要とするのは各画像の大きさだけで、それはリソースの項目がもっています（1100 × 840）。どのブロックもMarkdown内のオフセット（`sourceStart`）を保持しており、`follow()`は、キャレットの位置かそれより前で始まるブロックを含む最後のページを表示します。

> キャプチャーを作ったマシンで、カードのもの以外に12回ページを読み込んで計測しました。ステータス行は各ビルドを、依頼からページが届いた後の最初のフレームまでで計ります。ワーカーの最初のビルドは66〜141 ms、以降は10〜36 msです。ワーカーは存続する間ずっと1つの計測キャッシュを保つからです。その間、最も長いフレームは8〜18 msにとどまります。メインスレッドでは、最初のビルドが計器の針を54〜74 ms止めます。以降のビルドは専用のキャッシュを使って5〜17 msかかり、最も長いフレームを最大25 msまで延ばします。60 Hzでおよそ1.5フレームです。この章の最初のビルドや、章の多い本のビルドのように1フレーム（60 Hzで16.7 ms）より長いビルドは、メインスレッドではエディターを固まらせますが、ワーカーでは固まらせません。

### 4 · 見出しスタイルとしての扉

```js
// script.js, 行 56–78
// # The Time Machine {style="title"}: numbered false, so the Introduction is still chapter I.
const PLATE = TRIM.width * (840 / 1100); // mm: the plate's depth at full width (84 mm)
const DIAL = { x: 20, y: 7, size: 70 }; // mm: the dial's photograph, centred on the plate
const titlePage = {
  id: 'title', numbered: false,
  span: 'page', // kept in the column, the design is clipped to it: the plate's top, the author
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'plate', resourceId: 'plate',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'image', id: 'dial', resourceId: 'dial', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, offset: { x: mm(DIAL.x), y: mm(DIAL.y) },
      size: { width: mm(DIAL.size), height: mm(DIAL.size) } } },
    text('title', '{titleText}', DISPLAY, 28, { fontWeight: 700, lineHeight: 1.04,
      overflow: 'wrap' }, // two lines, not one and '…' (gotcha: overflow-ellipsis-default)
    onPage(PLATE + 11)),
    text('subtitle', '{subtitle}', TEXT, 12, { italic: true, color: col('oxblood') },
      onPage(PLATE + 34.5)),
    brassRule('rule', onPage(PLATE + 43), 12),
    text('author', '{author}', LABEL, 10, { fontWeight: 500, letterSpacing: pt(2) },
      onPage(PLATE + 46.5)),
  ] } },
};
```

`# The Time Machine {style="title"}`の行は、1ページ目を`title`見出しスタイルで組みます。このスタイルの`header`と`footer`は空なので、ページには柱もノンブルも入りません。`numbered: false`で題は章の数に入らず、Introductionは第I章のままです。図版は裁ち落としの左上の角に固定し、著者名は段の地より下に組みます。`span: 'page'`があるので、デザインはその両方の場所に描けます。これがないと1.4.1はデザインを段で切り取り、図版は上の15 mmを失い、著者名は描かれません。

### 5 · ページの役割で決める柱

```js
// script.js, 行 82–98
// pages: 'body' keeps the heads off the title page and the opener; parity puts the book's
// title on the verso and the chapter on the recto, folios on the outer edge.
const HEAD_Y = 8.2; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontSize: pt(8), color: col('oxblood'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } } });
const smallCaps = { fontFamily: LABEL, fontWeight: 500, letterSpacing: pt(1.2) };
const folio = { fontFamily: TEXT, color: col('ink') }; // the heads' size: the same baseline
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-chapter', '{chapterTitle}', 'odd', 'top', SHIFT, smallCaps),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [{ ...text('drop-folio', '{pageNumber}', TEXT, 8,
  { color: col('muted') }, inColumn(6.5)), pages: 'opener' }] };
```

`pages: 'body'`で柱は扉と章扉に入らず、`parity`で偶数ページに書名、奇数ページに章名を置き、ノンブルは小口側に置きます。章扉には代わりに、`pages: 'opener'`をもつフッター要素から地側のノンブルが入ります。ノンブルも柱もすべて8 ptで、天から8.2 mm下に下がるので、ノンブルの数字は柱のベースラインに立ちます。

### 6 · 章扉

```js
// script.js, 行 102–113
const chapter = { level: 1, numberingTemplate: '{1:I}', // {number} prints 'I'
  breakBefore: { enabled: true, parity: 'any' }, // restated (gotcha: headings-drop-h1-break)
  marginTop: pt(0), marginBottom: pt(0),
  advancedDesign: { enabled: true, minHeight: line(8), slot: { elements: [
    text('numeral', '{number}', DISPLAY, 24, { fontWeight: 700, color: col('oxblood') },
      inColumn(5)),
    brassRule('rule', inColumn(18.5), 10),
    text('chapter', '{titleText}', DISPLAY, 12, { fontWeight: 700, letterSpacing: pt(1.8),
      textTransform: 'uppercase', overflow: 'wrap' }, inColumn(22.5)), // a longer title wraps
  ] } } };
const colophon = { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'),
  textAlign: 'center', firstLineIndent: pt(0) };
```

デザインの`{number}`は、`numberingTemplate: '{1:I}'`が与える形、つまりローマ数字のIで章番号を印刷します。`minHeight`は少なくとも8行を確保するので、題が1行か2行のうちは本文は9行目から始まり、長い題でより多くの高さが要るときはさらに下から始まります。`headings`オブジェクトを指定するとH1の初期設定の改ページが外れるので、`breakBefore`を改めて指定しています。`parity: 'any'`で章は2ページ目から始まり、章の2ページ目と向かい合います。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/web-worker-live-editor

### style.css

```css
/* The editor: the Markdown on the left, the page it sets on the right, on a walnut desk. */
#editor {
  --desk: #1c1814; --well: #120f0c; --text: #e4d9c1; --dim: #a39680; --brass: #c9a75e;
  display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto 1fr auto;
  gap: 0 30px; box-sizing: border-box; width: min(1152px, 100% - 32px); margin: 24px auto 0;
  padding: 0 30px; background: var(--desk); border-radius: 14px;
  box-shadow: 0 0 0 1px #2c261f, 0 30px 60px -30px rgb(0 0 0 / .9);
  font: 500 12px/1.4 system-ui, sans-serif; color: var(--dim);
}
#editor > header, #editor > footer {
  grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; min-height: 58px;
  letter-spacing: .12em; text-transform: uppercase;
}
#editor > header { justify-content: space-between; }
#editor select {
  margin-left: 8px; padding: 5px 8px; border: 1px solid #3a3229; border-radius: 6px;
  background: var(--well); color: var(--text); font: inherit; letter-spacing: .06em;
}
#source {
  box-sizing: border-box; width: 100%; height: 100%; min-height: 360px; resize: none;
  padding: 20px 22px; border: 0; border-radius: 8px; outline: 1px solid #2c261f;
  background: var(--well); color: var(--text); caret-color: var(--brass);
  font: 13.5px/1.7 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  scrollbar-width: thin; scrollbar-color: #3a3229 transparent;
}
#source:focus { outline-color: var(--brass); }
#source::selection { background: rgb(201 167 94 / .35); }
#editor figure { margin: 0; display: flex; flex-direction: column; align-items: center; }
/* The page at 110 × 147 mm, as tall as the desk allows. */
#proof {
  display: block; height: min(700px, 100vh - 170px); aspect-ratio: 110 / 147; background: #f2ead8;
  box-shadow: 0 1px 2px rgb(0 0 0 / .6), 0 24px 44px -18px rgb(0 0 0 / .9);
}
#editor figcaption { display: flex; align-items: center; gap: 14px; margin-top: 12px; }
#editor figcaption button {
  width: 30px; height: 30px; border: 1px solid #3a3229; border-radius: 50%; padding: 0;
  background: none; color: var(--text); font: 16px/1 system-ui, sans-serif; cursor: pointer;
}
#editor figcaption button:hover { border-color: var(--brass); color: var(--brass); }
#folio { min-width: 56px; text-align: center; font-variant-numeric: tabular-nums; }
#editor > footer { color: var(--text); font-variant-numeric: tabular-nums; }
/* The heartbeat: a hand the main thread turns every frame. */
#beat { width: 24px; height: 24px; flex: none; }
#beat circle { fill: none; stroke: var(--brass); stroke-width: 1.5; }
#beat path { stroke: var(--brass); stroke-width: 2; stroke-linecap: round; }
@media (max-width: 760px) {
  #editor { grid-template-columns: 1fr; padding: 0 16px; gap: 0; }
  #editor > header { flex-direction: column; align-items: flex-start; padding: 14px 0; }
  #source { height: 42vh; }
  #editor figure { margin-top: 20px; }
  #proof { height: auto; width: min(420px, 100%); }
  #editor > footer { flex-wrap: wrap; padding: 12px 0; }
}
```

### script.js

```js
// ═══ Postext Cookbook · Nº 056 · Live editor with layout in a Web Worker ═══════════
// https://postext.dev/en/cookbook/web-worker-live-editor
// Code: MIT · Text: H. G. Wells, The Time Machine, 1895 (PD, Gutenberg #35) · Dial: generated
// Fonts: Baskervville, Baskervville SC, Cinzel (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, createMeasurementCache, renderPageToCanvas, clearMeasurementCache,
  registerResourceImage,
} from 'https://esm.sh/postext';
import { createLayoutWorker } from 'https://esm.sh/postext/worker';

const LANG = 'en'; // @lang: the language of the sample document (this recipe is English only)
const RECIPE = 'web-worker-live-editor';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231f1a', // the text: a warm near-black
  oxblood: '#7a1f1f', // the accent: running heads, the numeral, the subtitle, the plate's cloth
  brass: '#a88a4a', // rules (never text: 2.7:1 on the paper)
  gilt: '#d8bd7c', // the plate's frame, on the oxblood
  muted: '#6b5d4b', // the drop folio and the colophon (5.3:1 on the paper)
  paper: '#f2ead8', // a cream pocket-book paper
};
// Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color'. The editor takes any Markdown, and without this
  // entry a list typed into it gets the default blue markers.
  { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const [TEXT, LABEL, DISPLAY] = ['Baskervville', 'Baskervville SC', 'Cinzel'];

// The page: 110 × 147 mm, a text block of 25 whole lines.
const TRIM = { width: 110, height: 147 }; // mm: a Victorian pocket size, close to A6
const [BODY, LEAD, LINES] = [9.5, 13, 25]; // pt, pt, lines: the text block is LINES leads deep
const [TOP, INNER, OUTER] = [15, 11, 9]; // mm; mirrored, so INNER is the spine side
const MM_PER_PT = 25.4 / 72;
const MEASURE = TRIM.width - INNER - OUTER; // 90 mm: about 60 characters of Baskervville
const line = (n) => pt(n * LEAD); // n grid lines
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};

const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // centred
const inColumn = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const text = (id, content, fontFamily, fontSize, style, placement) => ({ kind: 'text', id,
  content, fontFamily, fontSize: pt(fontSize), color: col('ink'), align: 'center', ...style,
  placement: { ...placement, size: { width: mm(MEASURE) } } });
const brassRule = (id, y, width) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(0.75), color: col('brass'), placement: { ...y, size: { width: mm(width) } } });

// #region title: page 1 is a heading style of its own: the plate, the title and no heads
// # The Time Machine {style="title"}: numbered false, so the Introduction is still chapter I.
const PLATE = TRIM.width * (840 / 1100); // mm: the plate's depth at full width (84 mm)
const DIAL = { x: 20, y: 7, size: 70 }; // mm: the dial's photograph, centred on the plate
const titlePage = {
  id: 'title', numbered: false,
  span: 'page', // kept in the column, the design is clipped to it: the plate's top, the author
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'plate', resourceId: 'plate',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'image', id: 'dial', resourceId: 'dial', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, offset: { x: mm(DIAL.x), y: mm(DIAL.y) },
      size: { width: mm(DIAL.size), height: mm(DIAL.size) } } },
    text('title', '{titleText}', DISPLAY, 28, { fontWeight: 700, lineHeight: 1.04,
      overflow: 'wrap' }, // two lines, not one and '…' (gotcha: overflow-ellipsis-default)
    onPage(PLATE + 11)),
    text('subtitle', '{subtitle}', TEXT, 12, { italic: true, color: col('oxblood') },
      onPage(PLATE + 34.5)),
    brassRule('rule', onPage(PLATE + 43), 12),
    text('author', '{author}', LABEL, 10, { fontWeight: 500, letterSpacing: pt(2) },
      onPage(PLATE + 46.5)),
  ] } },
};
// #endregion

// #region heads: running heads on body pages only, a drop folio on the opener
// pages: 'body' keeps the heads off the title page and the opener; parity puts the book's
// title on the verso and the chapter on the recto, folios on the outer edge.
const HEAD_Y = 8.2; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontSize: pt(8), color: col('oxblood'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } } });
const smallCaps = { fontFamily: LABEL, fontWeight: 500, letterSpacing: pt(1.2) };
const folio = { fontFamily: TEXT, color: col('ink') }; // the heads' size: the same baseline
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-chapter', '{chapterTitle}', 'odd', 'top', SHIFT, smallCaps),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [{ ...text('drop-folio', '{pageNumber}', TEXT, 8,
  { color: col('muted') }, inColumn(6.5)), pages: 'opener' }] };
// #endregion

// #region opener: the chapter sinks eight lines under its roman numeral and a brass rule
const chapter = { level: 1, numberingTemplate: '{1:I}', // {number} prints 'I'
  breakBefore: { enabled: true, parity: 'any' }, // restated (gotcha: headings-drop-h1-break)
  marginTop: pt(0), marginBottom: pt(0),
  advancedDesign: { enabled: true, minHeight: line(8), slot: { elements: [
    text('numeral', '{number}', DISPLAY, 24, { fontWeight: 700, color: col('oxblood') },
      inColumn(5)),
    brassRule('rule', inColumn(18.5), 10),
    text('chapter', '{titleText}', DISPLAY, 12, { fontWeight: 700, letterSpacing: pt(1.8),
      textTransform: 'uppercase', overflow: 'wrap' }, inColumn(22.5)), // a longer title wraps
  ] } } };
const colophon = { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'),
  textAlign: 'center', firstLineIndent: pt(0) };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page,
  layout: { layoutType: 'single' }, // one column: the default is two
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
    indentAfterHeading: false,
    // Copy-fitted: at these spacings chapter I sets 25 lines on every full page, with no
    // hyphen inside a hyphenated word ('af-/ter-dinner') on the pages the Cookbook shows.
    minWordSpacing: 0.66, maxWordSpacing: 1.9,
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('ink'), levels: [chapter] },
  headingStyles: [titlePage],
  paragraphStyles: [colophon],
  header,
  footer,
});

// #region answer: layout in a module worker started from a blob, with its own fonts
async function startLayoutWorker(faces) {
  // In 1.4.1, createLayoutWorker() on its own starts esm.sh's worker file, which the browser
  // refuses to run from another origin; a same-origin blob that imports it is allowed.
  // An import map does not reach the worker: if the page pins postext@x.y.z, pin this URL too.
  const entry = new Blob([`import 'https://esm.sh/postext/worker/entry';`],
    { type: 'text/javascript' });
  const layout = createLayoutWorker({
    worker: new Worker(URL.createObjectURL(entry), { type: 'module' }) });
  // The worker measures with its own FontFaceSet, not the page's. Without the bytes of every
  // face it measures in a fallback font, and 1.4.1 raises no error. Weights are strings.
  const payloads = await Promise.all(faces.map(async ({ family, weight, style, url }) => {
    // Check the status: a 404 page sent as a font only logs a warning inside the worker.
    const response = await fetch(url);
    if (!response.ok) throw new Error(`${family} ${weight} ${style}: HTTP ${response.status}`);
    return { family, weight, style, buffer: await response.arrayBuffer() };
  }));
  await layout.registerFonts(payloads); // the buffers move to the worker, not copied
  let inFlight = null;
  return async function typeset(content) {
    inFlight?.abort(); // cancel the build that the previous keystroke asked for
    const build = (inFlight = new AbortController());
    try {
      return await layout.build(content, config(), { signal: build.signal });
    } catch (error) {
      if (error.name === 'AbortError') return null; // superseded: a newer build is on its way
      throw error;
    }
  };
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Time Machine"
subtitle: "An Invention"
author: "H. G. Wells"
---

# The Time Machine {style="title"}

# Introduction

The Time Traveller (for so it will be convenient to speak of him) was expounding a recondite matter to us. His grey eyes shone and twinkled, and his usually pale face was flushed and animated. The fire burnt brightly, and the soft radiance of the incandescent lights in the lilies of silver caught the bubbles that flashed and passed in our glasses. Our chairs, being his patents, embraced and caressed us rather than submitted to be sat upon, and there was that luxurious after-dinner atmosphere, when thought runs gracefully free of the trammels of precision. And he put it to us in this way—marking the points with a lean forefinger—as we sat and lazily admired his earnestness over this new paradox (as we thought it) and his fecundity.

“You must follow me carefully. I shall have to controvert one or two ideas that are almost universally accepted. The geometry, for instance, they taught you at school is founded on a misconception.”

“Is not that rather a large thing to expect us to begin upon?” said Filby, an argumentative person with red hair.

“I do not mean to ask you to accept anything without reasonable ground for it. You will soon admit as much as I need from you. You know of course that a mathematical line, a line of thickness *nil*, has no real existence. They taught you that? Neither has a mathematical plane. These things are mere abstractions.”

“That is all right,” said the Psychologist.

“Nor, having only length, breadth, and thickness, can a cube have a real existence.”

“There I object,” said Filby. “Of course a solid body may exist. All real things—”

“So most people think. But wait a moment. Can an *instantaneous* cube exist?”

“Don’t follow you,” said Filby.

“Can a cube that does not last for any time at all, have a real existence?”

Filby became pensive. “Clearly,” the Time Traveller proceeded, “any real body must have extension in *four* directions: it must have Length, Breadth, Thickness, and—Duration. But through a natural infirmity of the flesh, which I will explain to you in a moment, we incline to overlook this fact. There are really four dimensions, three which we call the three planes of Space, and a fourth, Time. There is, however, a tendency to draw an unreal distinction between the former three dimensions and the latter, because it happens that our consciousness moves intermittently in one direction along the latter from the beginning to the end of our lives.”

“That,” said a very young man, making spasmodic efforts to relight his cigar over the lamp; “that … very clear indeed.”

“Now, it is very remarkable that this is so extensively overlooked,” continued the Time Traveller, with a slight accession of cheerfulness. “Really this is what is meant by the Fourth Dimension, though some people who talk about the Fourth Dimension do not know they mean it. It is only another way of looking at Time. *There is no difference between Time and any of the three dimensions of Space except that our consciousness moves along it*. But some foolish people have got hold of the wrong side of that idea. You have all heard what they have to say about this Fourth Dimension?”

“*I* have not,” said the Provincial Mayor.

“It is simply this. That Space, as our mathematicians have it, is spoken of as having three dimensions, which one may call Length, Breadth, and Thickness, and is always definable by reference to three planes, each at right angles to the others. But some philosophical people have been asking why *three* dimensions particularly—why not another direction at right angles to the other three?—and have even tried to construct a Four-Dimensional geometry. Professor Simon Newcomb was expounding this to the New York Mathematical Society only a month or so ago. You know how on a flat surface, which has only two dimensions, we can represent a figure of a three-dimensional solid, and similarly they think that by models of three dimensions they could represent one of four—if they could master the perspective of the thing. See?”

“I think so,” murmured the Provincial Mayor; and, knitting his brows, he lapsed into an introspective state, his lips moving as one who repeats mystic words. “Yes, I think I see it now,” he said after some time, brightening in a quite transitory manner.

“Well, I do not mind telling you I have been at work upon this geometry of Four Dimensions for some time. Some of my results are curious. For instance, here is a portrait of a man at eight years old, another at fifteen, another at seventeen, another at twenty-three, and so on. All these are evidently sections, as it were, Three-Dimensional representations of his Four-Dimensioned being, which is a fixed and unalterable thing.”

“Scientific people,” proceeded the Time Traveller, after the pause required for the proper assimilation of this, “know very well that Time is only a kind of Space. Here is a popular scientific diagram, a weather record. This line I trace with my finger shows the movement of the barometer. Yesterday it was so high, yesterday night it fell, then this morning it rose again, and so gently upward to here. Surely the mercury did not trace this line in any of the dimensions of Space generally recognised? But certainly it traced such a line, and that line, therefore, we must conclude, was along the Time-Dimension.”

“But,” said the Medical Man, staring hard at a coal in the fire, “if Time is really only a fourth dimension of Space, why is it, and why has it always been, regarded as something different? And why cannot we move about in Time as we move about in the other dimensions of Space?”

The Time Traveller smiled. “Are you so sure we can move freely in Space? Right and left we can go, backward and forward freely enough, and men always have done so. I admit we move freely in two dimensions. But how about up and down? Gravitation limits us there.”

“Not exactly,” said the Medical Man. “There are balloons.”

“But before the balloons, save for spasmodic jumping and the inequalities of the surface, man had no freedom of vertical movement.”

“Still they could move a little up and down,” said the Medical Man.

“Easier, far easier down than up.”

“And you cannot move at all in Time, you cannot get away from the present moment.”

“My dear sir, that is just where you are wrong. That is just where the whole world has gone wrong. We are always getting away from the present moment. Our mental existences, which are immaterial and have no dimensions, are passing along the Time-Dimension with a uniform velocity from the cradle to the grave. Just as we should travel *down* if we began our existence fifty miles above the earth’s surface.”

“But the great difficulty is this,” interrupted the Psychologist. “You *can* move about in all directions of Space, but you cannot move about in Time.”

“That is the germ of my great discovery. But you are wrong to say that we cannot move about in Time. For instance, if I am recalling an incident very vividly I go back to the instant of its occurrence: I become absent-minded, as you say. I jump back for a moment. Of course we have no means of staying back for any length of Time, any more than a savage or an animal has of staying six feet above the ground. But a civilised man is better off than the savage in this respect. He can go up against gravitation in a balloon, and why should he not hope that ultimately he may be able to stop or accelerate his drift along the Time-Dimension, or even turn about and travel the other way?”

“Oh, *this*,” began Filby, “is all—”

“Why not?” said the Time Traveller.

“It’s against reason,” said Filby.

“What reason?” said the Time Traveller.

“You can show black is white by argument,” said Filby, “but you will never convince me.”

“Possibly not,” said the Time Traveller. “But now you begin to see the object of my investigations into the geometry of Four Dimensions. Long ago I had a vague inkling of a machine—”

“To travel through Time!” exclaimed the Very Young Man.

“That shall travel indifferently in any direction of Space and Time, as the driver determines.”

Filby contented himself with laughter.

“But I have experimental verification,” said the Time Traveller.

“It would be remarkably convenient for the historian,” the Psychologist suggested. “One might travel back and verify the accepted account of the Battle of Hastings, for instance!”

“Don’t you think you would attract attention?” said the Medical Man. “Our ancestors had no great tolerance for anachronisms.”

“One might get one’s Greek from the very lips of Homer and Plato,” the Very Young Man thought.

“In which case they would certainly plough you for the Little-go. The German scholars have improved Greek so much.”

“Then there is the future,” said the Very Young Man. “Just think! One might invest all one’s money, leave it to accumulate at interest, and hurry on ahead!”

“To discover a society,” said I, “erected on a strictly communistic basis.”

“Of all the wild extravagant theories!” began the Psychologist.

“Yes, so it seemed to me, and so I never talked of it until—”

“Experimental verification!” cried I. “You are going to verify *that*?”

“The experiment!” cried Filby, who was getting brain-weary.

“Let’s see your experiment anyhow,” said the Psychologist, “though it’s all humbug, you know.”

The Time Traveller smiled round at us. Then, still smiling faintly, and with his hands deep in his trousers pockets, he walked slowly out of the room, and we heard his slippers shuffling down the long passage to his laboratory.

The Psychologist looked at us. “I wonder what he’s got?”

“Some sleight-of-hand trick or other,” said the Medical Man, and Filby tried to tell us about a conjuror he had seen at Burslem, but before he had finished his preface the Time Traveller came back, and Filby’s anecdote collapsed.

:::space{lines=2}

:::paragraphs{style="colophon"}
Set in Baskervville, Baskervville SC and Cinzel (SIL Open Font License). Text: H. G. Wells, *The Time Machine* (1895), chapter I, from Project Gutenberg eBook 35.
:::
`; // content.en.md, inlined by the Cookbook
// The sizes are all the worker needs. The dial is a JPEG in assets/, cut square, declared at its
// pixels; outside the bezel it fades into the oxblood, so it lies on the drawn cloth unseen.
const resources = [{ id: 'plate', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plate.svg', width: 1100, height: 840 },
  altText: 'Oxblood cloth framed by a double gilt rule.' },
{ id: 'dial', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'dial-1120.jpg', format: 'jpeg', width: 1120, height: 1120 },
  altText: 'A brass dial with four small dials on its face, on the oxblood cloth.' }];

// #region art: the title page's plate: oxblood cloth and a gilt frame, drawn in code
// In tenths of a millimetre: 110 × 84 mm. The dial on it is a photograph (DIAL, below).
function plate() {
  const P = palette;
  const corner = (x, y) => `<path d="M${x} ${y - 11}L${x + 11} ${y}L${x} ${y + 11}`
    + `L${x - 11} ${y}Z" fill="${P.gilt}"/>`;
  const frame = (inset, width) => `<rect x="${inset}" y="${inset}" width="${1100 - 2 * inset}" `
    + `height="${840 - 2 * inset}" fill="none" stroke="${P.gilt}" stroke-width="${width}"/>`;
  return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 840">'
    + `<rect width="1100" height="840" fill="${P.oxblood}"/>${frame(46, 5)}${frame(62, 2)}`
    + [[62, 62], [1038, 62], [62, 778], [1038, 778]].map(([x, y]) => corner(x, y)).join('')
    + '</svg>';
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// #region fonts: one list of faces for both threads: the page loads them, the worker gets bytes
const FONTS = { // every face the pages use: the page loads them, and so must the worker
  Baskervville: ['400', '400i'], // text, folios, subtitle, colophon
  'Baskervville SC': ['500'], // running heads, the author
  Cinzel: ['700'], // title, numeral, chapter title
};
// The worker gets the same Fontsource files the page loads: identical metrics on both threads.
const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const [id, weight, style] = [family.toLowerCase().replace(/ /g, '-'), parseInt(spec, 10),
    spec.endsWith('i') ? 'italic' : 'normal'];
  const file = `${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
  return { family, weight: String(weight), style,
    url: `https://cdn.jsdelivr.net/npm/@fontsource/${file}` };
}));
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// The worker and the page each load the faces: the worker to measure, the page to paint.
const [typeset] = await Promise.all([startLayoutWorker(faces), loadFonts(FONTS, markdown)]);
await loadSvg('plate.svg', plate()); // images stay on the main thread: the worker never paints
await loadImage('dial-1120.jpg', asset('dial-1120.jpg'));

document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editor">
  <header><span>time-machine.md · chapter I</span><label>Lay out in <select id="thread">
    <option value="worker">a Web Worker</option><option value="main">the main thread</option>
  </select></label></header>
  <textarea id="source" spellcheck="false" aria-label="Markdown source"></textarea>
  <figure><canvas id="proof" role="img"></canvas><figcaption><button id="prev"
    aria-label="Previous page">‹</button><output id="folio"></output><button id="next"
    aria-label="Next page">›</button></figcaption></figure>
  <footer><svg id="beat" viewBox="-12 -12 24 24" aria-hidden="true"><circle r="11"/>
    <path id="hand" d="M0 2V-9"/></svg><output id="clock"></output></footer></section>`);
const $ = (id) => document.getElementById(id);
$('source').value = markdown;

// #region editor: each keystroke sets the chapter again; the main thread only paints
let [doc, shown, builds, cancelled] = [null, 0, 0, 0];
// For the comparison, the main thread keeps a measurement cache as the worker does, so its
// pages match the worker's: in 1.4.1 a build with a cache can break lines differently.
const mainCache = createMeasurementCache();
function paint(n = shown) { // the canvas is sized to its box, in device pixels
  shown = Math.max(0, Math.min(doc.pages.length - 1, n));
  const vdtPage = doc.pages[shown]; // a laid-out page, not the page config above
  const height = ($('proof').clientHeight || 640) * Math.min(devicePixelRatio || 1, 2);
  renderPageToCanvas(vdtPage, doc, $('proof'), { scale: height / vdtPage.height });
  $('proof').setAttribute('aria-label', `Page ${vdtPage.pageLabel}`);
  $('folio').value = `${shown + 1} / ${doc.pages.length}`;
}
function follow() { // turn to the page that holds the caret: every block keeps its source offset
  const caret = $('source').selectionStart;
  paint(doc.pages.findLastIndex((vdtPage) => vdtPage.columns.some((column) =>
    column.blocks.some((block) => block.sourceStart <= caret))));
}
async function refresh() {
  const [ticket, onMain, started] = [++builds, $('thread').value === 'main', performance.now()];
  frames.worst = 0;
  const content = { markdown: $('source').value, resources };
  const next = onMain ? buildDocument(content, config(), mainCache) : await typeset(content);
  await new Promise(requestAnimationFrame); // the first frame after the build shows any stall
  if (!next || ticket !== builds) { cancelled++; return; } // a newer build has been asked for
  doc = next;
  if (document.activeElement === $('source')) follow(); else paint();
  showPages(doc, { title: 'The Time Machine · chapter I, set in a Web Worker' });
  $('clock').value = `${onMain ? 'Main thread' : 'Worker'} · ${doc.pages.length} pages in `
    + `${Math.round(performance.now() - started)} ms · longest frame ${Math.round(frames.worst)}`
    + ` ms · ${builds} ${builds === 1 ? 'build' : 'builds'}, ${cancelled} cancelled`;
}
$('source').addEventListener('input', refresh);
for (const type of ['click', 'keyup']) $('source').addEventListener(type, () => doc && follow());
$('thread').addEventListener('change', refresh);
$('prev').addEventListener('click', () => paint(shown - 1));
$('next').addEventListener('click', () => paint(shown + 1));
// A dial the main thread turns on every frame: it stops while that thread is busy.
const frames = { last: 0, worst: 0 };
requestAnimationFrame(function turn() { // the clock, not the frame's timestamp: a late frame
  const now = performance.now(); // keeps the time it was due, which hides the stall
  frames.worst = Math.max(frames.worst, now - (frames.last || now));
  frames.last = now;
  const deg = ((now * 0.06) % 360).toFixed(1); // a turn in 6 s
  $('hand').setAttribute('transform', `rotate(${deg})`);
  requestAnimationFrame(turn);
});
await refresh();
// #endregion

// ─── 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 ───────────────────────────────────────────────────────────────────────
```

## よくあるつまずき

- **createLayoutWorker()はCDNからワーカーを起動できない.** オプションなしで呼ぶと、createLayoutWorker()はモジュールの隣にあるワーカーファイルを起動します。esm.shから読み込んだ場合、そのファイルは別のオリジンにあり、WorkerコンストラクターがSecurityError（'cannot be accessed from origin'）を投げます。https://esm.sh/postext/worker/entryをimportする同一オリジンのblobからモジュールワーカーを起動し、createLayoutWorker({ worker })として渡してください。postextを自分のオリジンから配信するバンドラーを使うなら、blobは不要です。
- **レイアウトのワーカーは自分のフォントで計測する.** ワーカーは独自のFontFaceSetを持つため、ページが読み込んだフォントはワーカーからは見えません。最初のビルドの前に、registerFonts()ですべてのフォントのバイト列を送ってください。ウェイトは文字列（'400'）で指定します。送らないと、ワーカーはエラーも警告もなく代替フォントで計測し、改行位置とページ数が変わります。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。

- postext 1.4.1では、計測キャッシュのあるビルドとないビルドで改行が変わることがあり、ワーカーはつねにキャッシュをもちます。このレシピの90 mmの行長ではどちらでも章は190行ですが、余白を13 mmと10 mm（行長87 mm）にして語間を初期値にすると、キャッシュなしでは196行、ありでは199行になります。CodePenのサンプルはメインスレッドにも専用のキャッシュ（`createMeasurementCache()`）を与えているので、行長を変えても2つのスレッドは同じページを組み続けます。
- ページのインポートマップはワーカーには届きません。ページ用に`https://esm.sh/postext@1.4.1`を固定するなら、blobの`import`も同じバージョンに固定してください。そうしないと2つのスレッドが別々のリリースを動かすことがあります。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: The Time Machine, chapter I: H. G. Wells ([出典](https://www.gutenberg.org/ebooks/35)), パブリックドメイン
- 画像: The title page’s brass dial, a photograph: Generated With Diffusion Models, オリジナル
- 画像: The title page’s oxblood cloth and gilt frame, drawn in code in the page’s palette: Ignacio Ferro, MIT
- 書体: Baskervville (OFL-1.1), Baskervville SC (OFL-1.1), Cinzel (OFL-1.1)
- コード: MIT · サンプルの内容: MIT

## 関連レシピ

- [No. 011 · 1つの原稿から印刷版と画面版を作る](https://postext.dev/ja/cookbook/print-and-screen-editions.md): 2つの版は1つの設定から生まれます。htmlViewer.overridesが画面用のダークなデザインを持ち、applyHtmlViewerOverridesがHTMLを生成するたびにそれをマージします。 · 難易度 3 (上級) · 雑誌とジン
- [No. 026 · 組版前にすべてのフォントを読み込む書体見本](https://postext.dev/ja/cookbook/fonts-before-layout.md): 4ページの書体見本。1回目の組版でページが使う書体の名前がすべてそろい、サンプルはそれを読み込み、幅のキャッシュを消してから組み直します。 · 難易度 2 (中級) · カタログ
- [No. 038 · 並製本の小説：下げた章頭と改丁](https://postext.dev/ja/cookbook/trade-paperback-novel.md): 『The Awakening』を140 × 216 mmの並製本に組みます。柱のない彩色の表紙と奥付に続き、各章は奇数ページから、イタリックの章番号の下へ下げて始まります。 · 難易度 2 (中級) · 小説・戯曲・文芸
