# アンカー早見表：要素を連ねて組むポスター

> 要素を裁ち落とし、ページ、スロット、ほかの要素から吊るして組んだA3の講演会ポスター。2枚目では各要素を枠で囲み、13の要素にタグを付けます。

- HTML版: https://postext.dev/ja/cookbook/anchoring-cheat-sheet
- レシピ No. 029 · 見出しと章扉 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: 一枚物とエフェメラ
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.4.1 ／テスト日 2026-09-26
- ページ: [1](https://postext.dev/cookbook/anchoring-cheat-sheet/en/p01.webp?v=c981c0ac), [2](https://postext.dev/cookbook/anchoring-cheat-sheet/en/p02.webp?v=c981c0ac)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=anchoring-cheat-sheet&lang=en (.postext: https://postext.dev/cookbook/anchoring-cheat-sheet/en/anchoring-cheat-sheet.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/anchoring-cheat-sheet.md), [es](https://postext.dev/es/cookbook/anchoring-cheat-sheet.md), [ca](https://postext.dev/ca/cookbook/anchoring-cheat-sheet.md), [zh](https://postext.dev/zh/cookbook/anchoring-cheat-sheet.md), [ar](https://postext.dev/ar/cookbook/anchoring-cheat-sheet.md)

## かんたんな説明

日食を前にした天文講演会のポスターです。紙の端やほかの要素からの距離で、ひとつひとつの要素を置く方法を示します。

## できあがり

架空の天文クラブが、2027年8月2日の皆既日食を前に木曜日ごとに開く4回の講演会のポスターです。A3の紙の上3分の2を群青の空が占めます。右側では黒い月がレモン色の太陽を覆い、太陽は仕上がり線で切れています。コードで描いたコロナが両者の背後に広がります。120ptのFunnel Displayで組んだ題『The Shadow Returns』は、空の下端でリード文の上に立ちます。地平線の下では青い4つの日付が講演の見出しになり、細かな注記が青い帯の上に2段で続きます。各要素は裁ち落とし、ページ、スロット、ほかの要素のいずれかから吊るされ、オフセットはそこから測ります。2枚目は同じポスターを繰り返し、各要素の箱とヘッダー・フッターのコンテナーをピンクの枠で囲み、13の要素にそれぞれの配置を印字したタグを付けています。

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

- デザイン要素を座標ではなく、ページや裁ち落とし、ほかの要素を基準に置くにはどうしますか？
- すべてのページに透かし、背景の色、装飾画像を加えるには？

## 手短な答え

```js
// script.js, 行 57–73
const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge
const AIR = 14; // mm between the standfirst and the horizon
const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order
  // 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box.
  { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed
    placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) },
  ...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun
  // The headline stands on the horizon and grows upwards: 'above' puts an element's
  // bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker.
  text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR),
    size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm
  text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\
  text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)),
  ...programme(), // four columns chained off the horizon
] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() }
// {titleText} is the heading's text; each {attr.*} is written on its line in content.md.
```

## 材料

**学べること**

- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration.md#要素の配置): 座標ではなく、コンテナー、ページ、裁ち落とし、または別の要素（right-of、below、align-*）を基準に要素を配置します。

**ほかに使うもの**

- [ページデザインのテキスト・罫・ボックス](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/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#リソース)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)
- [裁ち落としとトンボ](https://postext.dev/ja/docs/configuration.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#計測キャッシュ), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Funnel Display (OFL-1.1), Funnel Sans (OFL-1.1), Martian Mono (OFL-1.1)

## 作り方

### 1 · 2つの要素を紙に固定し、残りをそこから吊るす

コードは前に掲げた[手短な答え](#手短な答え)です。`anchor.to`は要素を何から吊るすかを指定します。`'bleed'`と`'page'`は紙の枠（`page.cutLines`がオフのあいだは同じ箱）、`'container'`はスロット自身の箱、`'#id'`はほかの要素です（[要素の配置](/ja/docs/configuration#要素の配置)）。幅のない箱は枠の反対側の端まで伸びるので、空は裁ち落としを左右いっぱいに埋めます。空を基準に置いた要素は空の左端、つまり裁ち落としの左端から測るので、リード文は`x`に`INSET`の18mmを取ります。見出しまわりは空の下端から上へ積み上げます。`'align-bottom'`はリード文の箱の下端をその辺から`AIR`の14mm上に置き、`'above'`は各要素の下端を下の要素の上端に載せます。そのため題の行数が増えると、リード文を覆うのではなく見出し前置きが押し上げられます。デザインの下では、細かな注記が、いちばん下の要素である講演者の行から少なくとも3.2mm下にある最初の16ptのグリッド線から始まります。この3.2mmは見出しの下マージンの既定値です。

### 2 · 月とコロナを太陽から吊るす

```js
// script.js, 行 77–88
const SUN = 150; // mm across; the Moon is drawn the same size
const CORONA = 300; // mm, the square picture of the corona
const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side
const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left
const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement,
  size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } });
const eclipse = () => [
  { kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind
    placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) },
  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
  disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)),
];
```

ページ、つまり仕上がりの箱に固定した要素は太陽だけです。`'top-right'`の角から測ると、正の`x`は右端の外へ進むので、円盤の30mmが切れます。月とコロナは`'align-top'`で太陽から吊るしてあるので、太陽を動かせば両方がついてきます。オフセットは太陽の左上の角から測ります。月は右へ2mm、下へ2.5mmずれて左上に太陽の細い縁を残し、コロナは`HALO`の75mmだけ左上から始まって、300mm四方の画像を150mmの円盤の中心にそろえます。コロナは箱の大きさに合わせる画像要素で（[画像要素](/ja/docs/configuration#画像要素)）、一覧で円盤より前にあるため、円盤の背後に描かれます。

### 3 · 講演の夕べを連ねて段にする

```js
// script.js, 行 92–101
const GUT = 7; // mm between the evenings, and between the two columns of text under them
const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm
const DROP = 12; // mm from the horizon to the dates
const programme = () => [1, 2, 3, 4].flatMap((n) => [
  text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1
    ? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon
    : at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before
  text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))),
  text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))),
]);
```

日付、講演題目、講演者の各行には`COL`の固定の`size.width`があり、`'right-of'`と`GUT`で次の日付を1段右に置きます。幅がないと日付は数字に合わせて縮み（「8」は幅17mm）、「15」はその7mm後から始まり、各講演題目は1行のまま次の段に食い込みます。`COL`は版面の幅を4つに分け、下の2段組みの注記と同じ7mmの段間を取ります。これで注記の2段目が「22」の下にそろいます。講演題目はそれぞれの日付の`'below'`に、講演者の行は題目の下に吊るしてあるので、題目が3行目に折り返しても押し下げられるのはその講演者の行だけです。

### 4 · 帯をフッターに入れ、全ページに載せる

```js
// script.js, 行 105–116
const MARK = 10; // mm, the club's mark
const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin
const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it
const footer = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled
    placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) },
  // The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down.
  { kind: 'image', id: 'mark', resourceId: 'mark',
    placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) },
  text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)),
  text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)),
] };
```

この帯のように全ページに印刷するものは、ヘッダーかフッターのスロットに入れます。独自のヘッダーやフッターを持つ見出しスタイルは、その節のページでスロットを置き換えます。guidesスタイルのフッターはこの4つの要素を枠とタグ付きで繰り返すので、帯とマークは2枚とも印刷されます。フッターのコンテナーは地の余白、つまり版面から仕上がりまでです。`'bottom-left'`は仕上がりから上へ、`'top-right'`は版面から下へ数えるので、マークの−10と「Free entry」の行の+10は帯の中央の同じ10mmの帯域に収まります（[ヘッダーとフッター](/ja/docs/configuration#柱とノンブル)）。フッターは本文の後に描かれ、本文の場所を取りません。そこで`FOOT`で地の余白と帯の高さを両方決め、帯がその上の行にかからないようにします。

### 5 · 同じ設定から補助線を描く

```js
// script.js, 行 120–147
const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) };
const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm
const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`,
  `${edge} ${to}`, // the two words of anchor: { to, edge }
  ['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '),
].filter(Boolean).join(' · ');
// A border never changes an element's size (padding does), so each frame traces its box.
// An image or a rounded box gets a square frame of its size, hung from its top-left corner.
const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }]
  : el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }]
    : [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE,
      placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]);
const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el),
  face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'),
    padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }),
  at(`#${el.id}`, edge, x, y));
const guides = (elements, tags) => [...elements.flatMap(frame),
  ...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))];
const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn
  placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) });
const TAGS = { // where each tag sits against its element: [edge, x, y, text]
  sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'],
  moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'],
  day1: ['above', 0, -6], day2: ['above'], who1: ['below'],
  mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2],
  header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })],
  footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })],
};
```

`guides()`はポスターの要素をたどります。テキストには箱に、四角い箱にはスタイルに枠線を付けます。どちらも何も動かしません。箱を大きくするのはパディングだけだからです。画像や円盤には、同じ大きさの四角い箱を左上の角から吊るします。これで月とコロナが吊るされている太陽の角も描かれます。各タグはその要素から吊るしたテキストで、`describe()`がその要素自身の`placement`から書き出すので、タグが表す設定と食い違うことはありません。2つ目の見出しに付けた`{style="guides"}`が、このデザインと独自のヘッダー・フッターを呼び込みます（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。幅のないタグが使えるのは、アンカーからコンテナーの右端までの範囲だけです。`#free`のタグがテキストより30mm左から始まるのはそのためです。内容の`continuation`にある`pageIndexOffset: 1`はポスターを偶数ページにするので、ビューアーは2枚を見開きで並べます。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 029 · Anchoring cheat sheet: a poster built from chained elements ═══
// https://postext.dev/en/cookbook/anchoring-cheat-sheet
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Funnel Display, Funnel Sans, Martian Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// An A3 poster whose elements hang from the bleed, the page, their slot or one another, never
// from coordinates; the second sheet is the same poster with each element framed and tagged.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#0b0b0c', // text, and the Moon
  sky: '#1b3bff', // the sky, the foot band and the dates
  sun: '#ffe53b', // the Sun and the kicker
  paper: '#ffffff', // type on the sky
  muted: '#5c5f66', // speakers and the colophon
  guide: '#ff2d9b', // frames and tags on the construction sheet
};
// col(id): a palette-linked colour that also carries its hex, because 1.4.1 paints design
// elements from the 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' } })),
  { id: 'main-color', name: 'sky (defaults)', value: { hex: palette.sky, model: 'hex' } },
];

const A3 = { width: 297, height: 420 }; // mm, the trim
const MARGIN = 18; // mm at the top and sides: the body's edges, and the header container's
const FOOT = 30; // mm, the bottom margin: the footer container and its band
const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below
// at(): a placement. Offsets are distances from the anchor point, never page coordinates.
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const wide = (w) => ({ width: mm(w) }); // fixed, so a text is as wide as its column
const square = (d) => ({ width: mm(d), height: mm(d) });
// Design text is centred and cut with '…' by default (gotcha: overflow-ellipsis-default).
const text = (id, content, face, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...face, placement });
const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size),
  fontWeight, color: col(colour), ...more });
const caps = (tracking) => ({ letterSpacing: pt(tracking), textTransform: 'uppercase' });
const FACE = { // lineHeight: a multiple, never pt() (gotcha: design-lineheight-multiple)
  kicker: face('Martian Mono', 12, 500, 'sun', caps(1.8)),
  title: face('Funnel Display', 120, 800, 'paper', { lineHeight: 0.86 }),
  deck: face('Funnel Sans', 22, 400, 'paper', { lineHeight: 1.22 }),
  day: face('Funnel Display', 84, 800, 'sky', { lineHeight: 0.9 }),
  talk: face('Funnel Sans', 19, 600, 'ink', { lineHeight: 1.14 }),
  who: face('Funnel Sans', 12.5, 400, 'muted', { lineHeight: 1.3, italic: true }),
  foot: face('Martian Mono', 9, 500, 'paper', caps(0.9)),
};

// #region answer: two elements pinned to frames of the sheet, every other one to an element
const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge
const AIR = 14; // mm between the standfirst and the horizon
const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order
  // 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box.
  { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed
    placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) },
  ...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun
  // The headline stands on the horizon and grows upwards: 'above' puts an element's
  // bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker.
  text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR),
    size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm
  text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\
  text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)),
  ...programme(), // four columns chained off the horizon
] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() }
// {titleText} is the heading's text; each {attr.*} is written on its line in content.md.
// #endregion

// #region eclipse: the Sun pinned to the trim's corner, the Moon and the corona to the Sun
const SUN = 150; // mm across; the Moon is drawn the same size
const CORONA = 300; // mm, the square picture of the corona
const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side
const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left
const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement,
  size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } });
const eclipse = () => [
  { kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind
    placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) },
  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
  disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)),
];
// #endregion

// #region programme: four evenings in fixed-width columns, chained right-of and below
const GUT = 7; // mm between the evenings, and between the two columns of text under them
const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm
const DROP = 12; // mm from the horizon to the dates
const programme = () => [1, 2, 3, 4].flatMap((n) => [
  text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1
    ? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon
    : at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before
  text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))),
  text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))),
]);
// #endregion

// #region footer: a band and the club's mark in the footer slot, which prints on every page
const MARK = 10; // mm, the club's mark
const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin
const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it
const footer = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled
    placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) },
  // The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down.
  { kind: 'image', id: 'mark', resourceId: 'mark',
    placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) },
  text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)),
  text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)),
] };
// #endregion

// #region guides: the same slots, each element framed and tagged with its own placement
const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) };
const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm
const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`,
  `${edge} ${to}`, // the two words of anchor: { to, edge }
  ['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '),
].filter(Boolean).join(' · ');
// A border never changes an element's size (padding does), so each frame traces its box.
// An image or a rounded box gets a square frame of its size, hung from its top-left corner.
const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }]
  : el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }]
    : [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE,
      placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]);
const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el),
  face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'),
    padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }),
  at(`#${el.id}`, edge, x, y));
const guides = (elements, tags) => [...elements.flatMap(frame),
  ...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))];
const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn
  placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) });
const TAGS = { // where each tag sits against its element: [edge, x, y, text]
  sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'],
  moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'],
  day1: ['above', 0, -6], day2: ['above'], who1: ['below'],
  mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2],
  header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })],
  footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })],
};
// #endregion

const LEAD = 16; // body leading in pt
const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette,
  page: { width: mm(A3.width), height: mm(A3.height), dpi: 150, // 150 dpi is for the screen
    cutLines: { enabled: BLEED > 0, bleed: mm(BLEED) },
    margins: { top: mm(MARGIN), bottom: mm(FOOT), left: mm(MARGIN), right: mm(MARGIN) } },
  layout: { layoutType: 'double', gutterWidth: mm(GUT) }, // the text columns under the evenings
  bodyText: { fontFamily: 'Funnel Sans', fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0),
    paragraphSpacing: true },
  // The hidden title is still measured, so it needs a face FONTS loads.
  headings: { fontFamily: 'Funnel Display', fontWeight: 800,
    levels: [{ level: 1, span: 'page', advancedDesign: poster(),
      breakBefore: { enabled: true, parity: 'any' } }] }, // gotcha: headings-drop-h1-break
  // {style="guides"}: the same design framed and tagged, and a header and footer of its own.
  headingStyles: [{ id: 'guides',
    advancedDesign: { enabled: true, slot: { elements: guides(poster().slot.elements, TAGS) } },
    header: { elements: guides([container('header')], TAGS) },
    footer: { elements: guides([...footer.elements, container('footer')], TAGS) } }],
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Martian Mono', fontSize: pt(8),
    lineHeight: pt(LEAD * 0.75), color: col('muted') }],
  header: { elements: [] }, // the poster has none; the guides page draws the empty container
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# The\\Shadow\\Returns {kicker="April 2027 · Thursdays at 19:30" deck="Four talks to get ready for the total eclipse of 2 August 2027, when the Moon’s shadow crosses the Strait of Gibraltar." d1="8" t1="Why the Moon fits the Sun" s1="Marta Iribarren, astronomer" d2="15" t2="Where the shadow will fall" s2="Óscar Beltrán, meteorologist" d3="22" t3="What the corona tells us" s3="Lucía Ferrándiz, physicist" d4="29" t4="Looking at the Sun without harm" s4="Tomás Rey, optometrist" club="Umbra Circle · amateur astronomers since 1987" free="Free entry · no booking"}

Every lecture begins at 19:30 and lasts about an hour, questions included. The Umbra Circle hall is at 14 Lighthouse Road; the doors open at 19:00, entry is free and there is no booking, so the 120 seats go to whoever comes first.

On 29 April every listener takes home a pair of eclipse glasses made to ISO 12312-2: wear them through each partial phase, and take them off only while the Sun is completely covered. On eclipse morning the Circle sets up filtered telescopes on the harbour wall.

# The\\Shadow\\Returns {style="guides" kicker="April 2027 · Thursdays at 19:30" deck="Four talks to get ready for the total eclipse of 2 August 2027, when the Moon’s shadow crosses the Strait of Gibraltar." d1="8" t1="Why the Moon fits the Sun" s1="Marta Iribarren, astronomer" d2="15" t2="Where the shadow will fall" s2="Óscar Beltrán, meteorologist" d3="22" t3="What the corona tells us" s3="Lucía Ferrándiz, physicist" d4="29" t4="Looking at the Sun without harm" s4="Tomás Rey, optometrist" club="Umbra Circle · amateur astronomers since 1987" free="Free entry · no booking"}

**How to read this sheet.** Each pink frame is an element’s box as laid out, and a tag gives an element’s id, the edge it is pinned by, what it is pinned to and any offset in millimetres. Above the footer only two elements hang from the sheet itself: the sky from the bleed and the Sun from the page, which is the trim. Every other one hangs from an element, so a longer title lifts the kicker, and the four evenings move with the horizon.

The frames in the top and bottom margins are the header and footer containers. The footer container runs from the text down to the trim, so its bottom anchors count up from the trim and its top anchors down from the text.

:::paragraphs{style="colophon"}
Set in Funnel Display, Funnel Sans and Martian Mono (SIL OFL) · Text and drawings: original, CC BY 4.0 · The Umbra Circle and its speakers are fictional.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the corona and the club's mark, drawn in code, and their resources
function mulberry32(seed) { // a seeded generator: the same corona on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function coronaSvg() { // a glow, then 14 streamers, 70 polar plumes and 160 fine rays
  const rand = mulberry32(20270802);
  const R = (500 * SUN) / CORONA; // the Sun's radius in the 1000-unit square
  const TILT = Math.PI / 4; // the Sun's equator: long streamers go up-left, clear of the kicker
  const TYPE = [2.2, 3.05]; // radians from the centre where the type is: rays there stop at 9 mm
  const p = (r, a) => `${(500 + r * Math.cos(a)).toFixed(1)} ${(500 + r * Math.sin(a)).toFixed(1)}`;
  const ray = (a, full, half, opacity) => { // a petal from the limb, tapered to a point
    const turn = ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
    const length = turn > TYPE[0] && turn < TYPE[1] ? Math.min(full, R * 0.12) : full;
    const bend = (rand() - 0.5) * 0.06;
    return `<path d="M${p(R * 0.97, a - half)}C${p(R + length * 0.35, a - half * 1.1)} `
      + `${p(R + length * 0.7, a + bend - half * 0.25)} ${p(R + length, a + bend)}`
      + `C${p(R + length * 0.7, a + bend + half * 0.25)} ${p(R + length * 0.35, a + half * 1.1)} `
      + `${p(R * 0.97, a + half)}Z" fill="${palette.paper}" fill-opacity="${opacity.toFixed(3)}"/>`;
  };
  const glow = Array.from({ length: 28 }, (_, i) => `<circle cx="500" cy="500" `
    + `r="${(R * (1.01 + i * 0.022)).toFixed(1)}" fill="${palette.paper}" fill-opacity="0.022"/>`)
    .reverse().join('');
  const out = [];
  for (let i = 0; i < 14; i++) { // helmet streamers, two fans across the equator
    const a = TILT + (i % 2) * Math.PI + (rand() - 0.5) * 1.1;
    out.push(ray(a, R * (0.55 + 0.45 * rand()), 0.1 + 0.14 * rand(), 0.06 + 0.06 * rand()));
  }
  for (let i = 0; i < 70; i++) { // polar plumes, shorter and thinner than the streamers
    const a = TILT + Math.PI / 2 + (i % 2) * Math.PI + (rand() - 0.5) * 1.3;
    out.push(ray(a, R * (0.18 + 0.3 * rand()), 0.008 + 0.012 * rand(), 0.1 + 0.12 * rand()));
  }
  for (let i = 0; i < 160; i++) { // fine rays all round
    const a = (i / 160) * 2 * Math.PI + (rand() - 0.5) * 0.04;
    out.push(ray(a, R * (0.12 + 0.3 * rand()), 0.006 + 0.014 * rand(), 0.05 + 0.07 * rand()));
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1500" `
    + `viewBox="0 0 1000 1000">${glow}${out.join('')}</svg>`;
}
function markSvg() { // the Umbra Circle's mark: a ring round an eclipsed Sun
  return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 100 100">`
    + `<circle cx="50" cy="50" r="45" fill="none" stroke="${palette.paper}" stroke-width="6"/>`
    + `<circle cx="50" cy="50" r="27" fill="${palette.sun}"/>`
    + `<circle cx="56" cy="45" r="27" fill="${palette.sky}"/></svg>`;
}
const svg = (id, size, altText) => ({ id, typeId: 'figure', kind: 'svg', altText,
  svg: { fileId: `${id}.svg`, width: size, height: size }, createdAt: 0, updatedAt: 0 });
// Image elements draw resources. Nothing cites them, so neither is placed or numbered as a figure.
const resources = [
  svg('corona', 1500, t({ en: 'The solar corona round the eclipsed Sun',
    es: 'La corona solar alrededor del Sol eclipsado' })),
  svg('mark', 200, t({ en: 'The Umbra Circle’s mark', es: 'El emblema del Círculo Umbra' })),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = {
  'Funnel Display': ['800'],
  'Funnel Sans': ['400', '400i', '600', '700'],
  'Martian Mono': ['400', '500'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('corona.svg', coronaSvg());
await loadSvg('mark.svg', markSvg());
await loadFonts(FONTS, markdown);
// pageIndexOffset 1 makes the poster a verso, so the viewer sets the two sheets side by side.
const content = { markdown, resources, continuation: { pageIndexOffset: 1 } };
const doc = await buildWithFonts(() => buildDocument(content, config()), markdown);
showPages(doc, { title: t({ en: 'Anchoring cheat sheet', es: 'Chuleta de anclajes' }) });

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

## アレンジ

### 3mmの裁ち落としとトンボを付ける

`BLEED`を3mmにするとトンボが有効になり、空と下端の帯は吊るされている裁ち落としの中へ3mm押し出されます。`INSET`があるので文字は余白の位置にとどまります。太陽、月、コロナはページから吊るされており、1.4.1はデザイン要素を裁ち落としで切り抜かないため、白い余白を越えて紙の端まで伸びます。

```diff
-const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below
+const BLEED = 3; // mm; 3 for the printer, which switches on page.cutLines below
```

### 地平線を上げる

`SKY`から20mm引くと空の下端が上がり、それに連れてリード文、題、見出し前置き、講演プログラムの12のテキストも上がり、細かな注記は1本上のグリッド線から始まります。太陽はページから吊るしているので、2つ目の変更で同じく20mm持ち上げます。そうしないと「Shadow」の「w」が三日月に触れてしまいます。

```diff
-const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
+const SKY = 252; // mm from the trim's top edge to the horizon, the sky's lower edge
-  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
+  disc('sun', 'sun', at('page', 'top-right', 30, 5)), // 30 mm of it past the right edge
```

## よくあるつまずき

- **ヘッダーとフッターの要素は本文の上に描かれる.** ヘッダーとフッターの要素はページの上に重ねて描かれ、本文の領域はそのための場所を空けません。要素は余白の中に収めてください。その場所を確保するのは余白です。
- **属性値には{も}も書けない。"を含む値は一重引用符で囲む.** 属性値は閉じ波かっこで終わるため、{や}を含められません。二重引用符を含む値は一重引用符で囲みます。ドル記号は問題ありません。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、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 }を書き直してください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **組版の警告: 循環するアンカー参照** (`designCyclicAnchor`). デザイン要素が互いにループ状にアンカーしているため、どの要素も配置できません。 解決策: 連鎖の中の1つの要素を、コンテナー、ページ、または裁ち落としにアンカーしてください。 ([ドキュメント](https://postext.dev/ja/docs/configuration.md#要素の配置))
- **組版の警告: 参照先のないアンカー** (`designDanglingAnchor`). デザイン要素が、そのスロットのどの要素も持たない#idにアンカーしています。 解決策: idを修正するか、参照先の要素を追加してください。 ([ドキュメント](https://postext.dev/ja/docs/configuration.md#要素の配置))

- CodePenのサンプルでは、1.4.1は循環したアンカーも存在しない先を指すアンカーも報告しません。Sandboxの**検査**パネルは、ヘッダー、フッター、部扉、見出しレベルにあるものは一覧にしますが、`guides`のような見出しスタイルの中のものは載せません。存在しない`#id`に固定した要素はコンテナーを基準に置かれます。`'below'`のような要素の辺はオフセット込みでコンテナーの左上の角に、`'top-right'`のようなコンテナーの辺はその角に戻ります。循環している場合は、1つの要素が同じように戻り、ほかの要素はそこから連なります。
- 1.4.1では`'align-left'`と`'align-top'`は同じ点に置かれます。どちらも要素の左上の角をアンカーの左上の角に合わせます。
- 枠線は箱の辺を中心に描かれるため、1ptのピンクの枠はなぞる箱の外へ0.5ptはみ出します。設定のドキュメントには線は内側に描かれるとありますが、実際はそうなりません。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: Funnel Display (OFL-1.1), Funnel Sans (OFL-1.1), Martian Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 058 · DIN 5008に従うビジネスレター](https://postext.dev/ja/cookbook/din-business-letter.md): A4で2ページのドイツ語の見積書です。件名をH1にし、そのデザインでレターヘッド、宛名、情報ブロックを書式Bの位置に固定します。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 063 · ギョーシェ模様で縁取った修了証](https://postext.dev/ja/cookbook/certificate-single-page.md): ひとつの見出しスタイルで描く修了証。ヘッダーにギョーシェの枠、フッターに印章と署名欄、章扉の位置に受講者の名前を置きます。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 023 · 雑誌の表紙とセクション別の目次](https://postext.dev/ja/cookbook/magazine-cover-and-contents.md): 写真の表紙では表紙コピーが題字から吊り下がり、目次はセクションごとに色の行を立てて生成されます。各セクションは、ページを開かない部です。 · 難易度 3 (上級) · 雑誌とジン
