# DIN 5008に従うビジネスレター

> A4で2ページのドイツ語の見積書です。件名をH1にし、そのデザインでレターヘッド、宛名、情報ブロックを書式Bの位置に固定します。

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

## かんたんな説明

ドイツの窓工房が出す2ページの見積書です。窓付き封筒に収まるよう、宛名などビジネスレターの各部をドイツの規格が定める位置に置く方法を示します。

## できあがり

ベルリンで二重窓を修復する架空の工房Fensterwerkstatt Tessinが、集合住宅の管理会社に見積書を送ります。ドイツ語でA4判2枚、DIN 5008の書式Bに従ってレイアウトします。上部を横切るベルリンブルーの帯に工房のロゴと名前が入ります。その下では、各部分が規格の定める位置に収まります。差出人行と宛名はDL封筒の窓から見える85 × 45 mmの欄の中に、情報ブロックは左端から125 mmの位置に、太字の件名は欄の2行下の空きのあとに置き、折り目マークとパンチマークは105 mm、148.5 mm、210 mmに付けます。会社情報は両方の用紙の地に3段で入ります。2枚目は「Seite 2 von 2」の横に整理番号をくり返し、コードで描いた署名で終わります。

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

- 手紙の宛名と情報ブロックを、DIN 5008のミリ単位の位置に固定するには？
- すべてのページに透かし、背景の色、装飾画像を加えるには？
- 章扉や白ページで柱を隠したり、空白の偶数ページを部の色で塗ったりするには？
- エピグラフ、献辞、署名、大きな引用符の付いたプルクオートを組むには？

## 手短な答え

```js
// script.js, 行 58–80
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });
```

## 材料

**学べること**

- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration.md#要素の配置): 座標ではなく、コンテナー、ページ、裁ち落とし、または別の要素（right-of、below、align-*）を基準に要素を配置します。
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン): テキスト、罫、ボックス、画像を自由に組み合わせて描く見出しです。本文の上に必要な高さを確保します。

**ほかに使うもの**

- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/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/justification.md#対応するロケール)
- [仕上がりサイズ](https://postext.dev/ja/docs/configuration.md#ページサイズのプリセット)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [PDFに埋め込むフォント](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#柱とノンブル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`unorderedLists`](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成)

**書体**

- Nunito Sans (OFL-1.1), Familjen Grotesk (OFL-1.1), Reddit Mono (OFL-1.1)

## 作り方

### 1 · 1ページ目をミリ単位で用紙に固定する

```js
// script.js, 行 58–80
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });
```

`at()`は要素の左上の角を`'page'`、つまり仕上がり枠に固定します（[要素の配置](/ja/docs/configuration#要素の配置)）。そのため`x`と`y`はどれも用紙の角からの距離になり、規格の数値をそのまま書けます。宛名は横25 mm、縦62.7 mm、情報ブロックの最初のラベルは125と50です。これらの要素がH1のデザインになります。見出しのテキストは件名（`{titleText}`）で、その属性に宛名と整理番号を入れます。こうして設定が便箋を、Markdownが手紙の中身を受け持ちます。100.6 mmにある件名がいちばん下の要素なので、見出しは件名の下端までの用紙を確保します。`marginBottom`で2行の空きを加え、あいさつ文は次のグリッド行、117 mmから始まります。

宛名は1つの属性で、行のあいだに`\n`を入れます。1.4.1では、デザインのテキストが属性の`\n`を改行に変えるのは、`paragraphIndent`が0より大きいときだけです。宛名では見えないほど小さい0.01 ptの字下げを設定しています。0にすると、バックスラッシュとnが宛名に印刷されます。`span: 'page'`を付けないとデザインは本文の段の中にとどまり、27 mmの天の余白で切り取られます。するとロゴと名前が消え、帯は下の5 mmしか残りません。

### 2 · 固定した1つのラベルから情報ブロックをつなげる

```js
// script.js, 行 84–95
const INFO = [['Ihr Zeichen', 'ihr-zeichen'], ['Ihre Nachricht vom', 'ihre-nachricht'],
  ['Unser Zeichen', 'unser-zeichen'], ['Name', 'name'], ['Telefon', 'telefon'],
  ['E-Mail', 'email'], ['Datum', 'datum']]; // [label, heading attribute]
const VALUE = 29; // mm from the labels to the values
const infoBlock = () => INFO.flatMap(([label, key], i) => [
  text(`label-${i}`, label, FACE.label, i === 0 ? at(DIN.info.x, DIN.info.y) : {
    anchor: { to: `#label-${i - 1}`, edge: 'below' }, // each label under the last
    offset: { y: mm(key === 'datum' ? ROW * PT : 0) } }), // a blank row before the date
  text(`value-${i}`, `{attr.${key}}`, FACE.value, { // a 12 pt row as well, on the label's baseline
    anchor: { to: `#label-${i}`, edge: 'align-top' }, offset: { x: mm(VALUE) },
    size: { width: mm(DIN.info.w - VALUE) } }),
]);
```

用紙に固定するのは最初のラベルだけです。ほかのラベルはどれも直前のラベルの`'below'`に、各値は自分のラベルに`'align-top'`と29 mmのオフセットで固定するので、`INFO`に行を足しても座標は要りません。デザインのテキストはベースラインを行ボックスの80%の位置に置きます。`lineHeight: 12 / 7`の7 ptのラベルも`12 / 9`の9 ptの値も12 ptの行になるので、4.23 mmごとのどの行でも同じベースラインに並びます。

### 3 · レターヘッドを同じデザインに描く

```js
// script.js, 行 99–120
const [BAND, TICK] = [32, 3]; // mm: the band's depth, each arm of the window's corner angles
const LOGO = { y: 7, h: 18 }; // mm: the mark, centred in the band
const CRAFTS = 3 * 7.5 * 1.45 * PT; // mm: three 7.5 pt lines at 1.45
const letterhead = () => [
  { kind: 'box', id: 'band', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'logo', resourceId: 'logo',
    placement: at(DIN.left, LOGO.y, { height: mm(LOGO.h) }) },
  text('name', 'Tessin', face('Familjen Grotesk', 30, 700, 'paper', { lineHeight: 1 }),
    { anchor: { to: '#logo', edge: 'right-of' }, offset: { x: mm(5), y: mm(1.2) } }),
  text('trade', 'FENSTERWERKSTATT · BERLIN', face('Reddit Mono', 7.5, 500, 'sky',
    { letterSpacing: pt(1.2) }), { anchor: { to: '#name', edge: 'below' },
    offset: { x: mm(0.4), y: mm(1.5) } }),
  text('crafts', 'Kastenfenster\nHolzfenster\nDenkmalpflege', face('Reddit Mono', 7.5,
    400, 'sky', { lineHeight: 1.45, align: 'right' }),
    at(210 - DIN.right - 45, LOGO.y + (LOGO.h - CRAFTS) / 2, { width: mm(45) })),
  ...[[0, 0], [1, 0], [0, 1], [1, 1]].flatMap(([right, low], i) => { // turned inwards
    const [x, y] = [DIN.field.x + right * DIN.field.w, DIN.field.y + low * DIN.field.h];
    return [rule(`across-${i}`, 'marks', at(x - right * TICK, y, { width: mm(TICK) })),
      rule(`down-${i}`, 'marks', at(x, y - low * TICK, { height: mm(TICK) }), 'vertical')];
  }),
];
```

帯は用紙の角に固定した囲みです。`width: 'fill'`で端から端まで伸ばし、32 mmの深さで27 mmの天の余白を5 mm越えます。ロゴは画像要素で、高さ18 mmでリソースを描き、幅は図から取ります（[画像要素](/ja/docs/configuration#画像要素)）。名前はロゴの`'right-of'`に、業種の行は名前の`'below'`に吊るします。3 mmの罫8本で、85 × 45 mmの宛名欄、つまり封筒の窓から見える部分の四隅に角マークを描きます。

### 4 · どの用紙にも共通するものはヘッダーとフッターに置く

```js
// script.js, 行 124–146
const header = { elements: [
  // In the H1's design they would stretch its reserve (gotcha: opener-reserves-anchored).
  ...[[DIN.folds[0], 5], [DIN.punch, 8], [DIN.folds[1], 5]].map(([y, length], i) =>
    rule(`mark-${i}`, 'marks', at(0, y, { width: mm(length) }))), // fold, punch, fold
  // Page 1 opens with the H1, which makes it an 'opener'; later sheets are 'body' pages.
  { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(4) }) },
  text('ref', 'Fensterwerkstatt Tessin · Unser Zeichen {attr.unser-zeichen} · {attr.datum}',
    FACE.small, at(DIN.left, 13.2), { pages: 'body' }),
  text('page', 'Seite {pageNumber} von {totalPages}', FACE.small,
    at(210 - DIN.right - 40, 13.2, { width: mm(40) }), { pages: 'body', align: 'right' }),
] };
const FOOT = 297 - DIN.bottom + 5; // mm: the hairline over the company data
const COMPANY = [ // three columns, 57 mm apart, inside the bottom margin
  'Fensterwerkstatt Tessin GmbH\nRennbahnstraße 48\n13086 Berlin\nTelefon 030 23125-400',
  'Geschäftsführer Martin Tessin\nAmtsgericht Charlottenburg\nHRB 000000 B\nUSt-IdNr. DE000000000',
  'Musterbank Berlin\nIBAN DE00 0000 0000 0000 0000 00\nBIC MUSTDEBBXXX',
];
const footer = { elements: [
  rule('foot-rule', 'rule', at(DIN.left, FOOT, { width: mm(TEXT) })),
  ...COMPANY.map((lines, i) => text(`company-${i}`, lines, FACE.small,
    at(DIN.left + 57 * i, FOOT + 2.5, { width: mm(52) }))),
] };
```

H1のデザインのうち件名の下に固定した要素は、見出しが確保する高さを増やします。210 mmの折り目マークをデザインに入れると、あいさつ文は222.8 mmまで下がり、手紙は3ページになります。ヘッダー枠はすべてのページに描かれ、領域を確保しないので、3つのマークはそこに置きます（[ヘッダーとフッター](/ja/docs/configuration#柱とノンブル)）。1ページ目はH1で始まるので`'opener'`ページになり、`pages: 'body'`で青い帯、整理番号の行、`Seite {pageNumber} von {totalPages}`を2枚目以降に限定します。整理番号の行は`{attr.unser-zeichen}`と`{attr.datum}`でH1の属性を読みます。

### 5 · 署名を本文の流れに置く

```js
// script.js, 行 150–159
const resourceTypes = [{ id: 'drawing', name: 'Zeichnung', shortLabel: 'Zeichnung',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }];
const drawing = (id, width, height, altText, placement) => ({ id, typeId: 'drawing',
  kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, altText, placement,
  createdAt: 0, updatedAt: 0 });
const resources = [ // an image element draws the logo; nothing cites it
  drawing('logo', 200, 280, 'Zeichen der Fensterwerkstatt Tessin, ein Kastenfenster'),
  drawing('signature', 760, 328, 'Unterschrift von Martin Tessin',
    { position: 'here', width: 0.297 }), // 49 × 21.1 mm: under 4 lines, no grid gap below
];
```

`::resource{id="signature"}`で「Mit freundlichen Grüßen」の下に図を置き、`width: 0.297`で幅49 mm、深さ21.1 mmにします。15 ptの行4本にわずかに足りない深さです。本文の流れの中でリソースに続くテキストは、本文のグリッドに戻ります。`0.25`では図の深さが17.8 mmで、その下にグリッドが加えた3.4 mmのために氏名が署名から離れていました。このリソース種別は`captionPrefix`が空で、1.4.1は接頭辞とキャプションがどちらも空ならキャプションを組まないため、署名の下にはラベルも番号も出ません（[リソースの種類](/ja/docs/configuration#リソースの種類)）。ロゴも同じ種別のリソースで、画像要素だけが描きます。`:ref`も`::resource`も名指ししないので、本文の流れには入りません。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/din-business-letter

### script.js

```js
// ═══ Postext Cookbook · Nº 058 · Business letter to DIN 5008 ═════════════════════
// https://postext.dev/en/cookbook/din-business-letter
// Code: MIT · Text: original, in German (CC BY 4.0) · Logo, signature: drawn in code (CC BY 4.0)
// Fonts: Nunito Sans, Familjen Grotesk, Reddit Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A German quotation on A4 whose H1 design pins the letterhead and the address to DIN 5008.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'din-business-letter';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2429', brand: '#1b4a73', // text, a blue-grey near-black; Berlin blue, for the band
  sky: '#b4cbe0', paper: '#ffffff', // the trade and the crafts on the band; the logo and name
  muted: '#5b6670', pen: '#26408f', // labels, return line, company data; the signature's ink
  marks: '#98a3ad', rule: '#c9d1d8', // fold marks, window corners; the hairline in the footer
};
// col(id) links a palette entry and keeps its hex for designs (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// main-color is the engine's default accent: the dashes of the list take it.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.brand })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

// DIN 5008, form B: every position in mm from the sheet's top-left corner.
const DIN = {
  left: 25, right: 20, top: 27, bottom: 30, // the margins of the text
  field: { x: 20, y: 45, w: 85, h: 45 }, // the address field, behind the envelope window
  notes: 17.7, // the field's top zone, for the return line; the address zone takes the rest
  info: { x: 125, y: 50, w: 75 }, // the information block
  folds: [105, 210], punch: 148.5, // fold marks for a DL envelope, and the punch mark
};
const TEXT = 210 - DIN.left - DIN.right; // 165 mm of text width
const WINDOW = DIN.field.w - 2 * (DIN.left - DIN.field.x); // 75 mm: text inset 5 mm each side
const PT = 25.4 / 72; // mm per point
const [LEAD, ROW] = [15, 12]; // pt: the body's leading, a row of the information block

// Design text is centred and cut with '…' by default (gotcha: overflow-ellipsis-default).
const text = (id, content, face, placement, more) => ({ kind: 'text', id, content,
  align: 'left', overflow: 'wrap', ...face, placement, ...more }); // more: pages, align
const rule = (id, colour, placement, direction = 'horizontal') => ({ kind: 'rule', id,
  direction, thickness: pt(0.5), color: col(colour), placement });
const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size),
  fontWeight, color: col(colour), ...more });
const FACE = { // lineHeight is a multiple, never pt() (gotcha: design-lineheight-multiple)
  label: face('Reddit Mono', 7, 400, 'muted', { lineHeight: ROW / 7 }),
  value: face('Nunito Sans', 9, 400, 'ink', { lineHeight: ROW / 9 }),
  from: face('Reddit Mono', 6.5, 400, 'muted', { lineHeight: 1.2 }),
  address: face('Nunito Sans', 10, 400, 'ink', // six lines fill the address zone
    { lineHeight: (DIN.field.h - DIN.notes) / 6 / PT / 10 }),
  subject: face('Familjen Grotesk', 11, 700, 'ink', { lineHeight: LEAD / 11 }),
  small: face('Reddit Mono', 7, 400, 'muted', { lineHeight: 1.45 }),
};
const FROM = 'Fensterwerkstatt Tessin · Rennbahnstr. 48 · 13086 Berlin'; // 72 mm: fits

// #region answer: the subject line's design pins page 1 to the sheet at form B's positions
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });
// #endregion

// #region info: the information block, labels from the stationery, values from the letter
const INFO = [['Ihr Zeichen', 'ihr-zeichen'], ['Ihre Nachricht vom', 'ihre-nachricht'],
  ['Unser Zeichen', 'unser-zeichen'], ['Name', 'name'], ['Telefon', 'telefon'],
  ['E-Mail', 'email'], ['Datum', 'datum']]; // [label, heading attribute]
const VALUE = 29; // mm from the labels to the values
const infoBlock = () => INFO.flatMap(([label, key], i) => [
  text(`label-${i}`, label, FACE.label, i === 0 ? at(DIN.info.x, DIN.info.y) : {
    anchor: { to: `#label-${i - 1}`, edge: 'below' }, // each label under the last
    offset: { y: mm(key === 'datum' ? ROW * PT : 0) } }), // a blank row before the date
  text(`value-${i}`, `{attr.${key}}`, FACE.value, { // a 12 pt row as well, on the label's baseline
    anchor: { to: `#label-${i}`, edge: 'align-top' }, offset: { x: mm(VALUE) },
    size: { width: mm(DIN.info.w - VALUE) } }),
]);
// #endregion

// #region letterhead: a band of Berlin blue, the logo and the name, the window's corners
const [BAND, TICK] = [32, 3]; // mm: the band's depth, each arm of the window's corner angles
const LOGO = { y: 7, h: 18 }; // mm: the mark, centred in the band
const CRAFTS = 3 * 7.5 * 1.45 * PT; // mm: three 7.5 pt lines at 1.45
const letterhead = () => [
  { kind: 'box', id: 'band', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'logo', resourceId: 'logo',
    placement: at(DIN.left, LOGO.y, { height: mm(LOGO.h) }) },
  text('name', 'Tessin', face('Familjen Grotesk', 30, 700, 'paper', { lineHeight: 1 }),
    { anchor: { to: '#logo', edge: 'right-of' }, offset: { x: mm(5), y: mm(1.2) } }),
  text('trade', 'FENSTERWERKSTATT · BERLIN', face('Reddit Mono', 7.5, 500, 'sky',
    { letterSpacing: pt(1.2) }), { anchor: { to: '#name', edge: 'below' },
    offset: { x: mm(0.4), y: mm(1.5) } }),
  text('crafts', 'Kastenfenster\nHolzfenster\nDenkmalpflege', face('Reddit Mono', 7.5,
    400, 'sky', { lineHeight: 1.45, align: 'right' }),
    at(210 - DIN.right - 45, LOGO.y + (LOGO.h - CRAFTS) / 2, { width: mm(45) })),
  ...[[0, 0], [1, 0], [0, 1], [1, 1]].flatMap(([right, low], i) => { // turned inwards
    const [x, y] = [DIN.field.x + right * DIN.field.w, DIN.field.y + low * DIN.field.h];
    return [rule(`across-${i}`, 'marks', at(x - right * TICK, y, { width: mm(TICK) })),
      rule(`down-${i}`, 'marks', at(x, y - low * TICK, { height: mm(TICK) }), 'vertical')];
  }),
];
// #endregion

// #region furniture: marks on every sheet, 'Seite 2 von 2' on the next, the company data
const header = { elements: [
  // In the H1's design they would stretch its reserve (gotcha: opener-reserves-anchored).
  ...[[DIN.folds[0], 5], [DIN.punch, 8], [DIN.folds[1], 5]].map(([y, length], i) =>
    rule(`mark-${i}`, 'marks', at(0, y, { width: mm(length) }))), // fold, punch, fold
  // Page 1 opens with the H1, which makes it an 'opener'; later sheets are 'body' pages.
  { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(4) }) },
  text('ref', 'Fensterwerkstatt Tessin · Unser Zeichen {attr.unser-zeichen} · {attr.datum}',
    FACE.small, at(DIN.left, 13.2), { pages: 'body' }),
  text('page', 'Seite {pageNumber} von {totalPages}', FACE.small,
    at(210 - DIN.right - 40, 13.2, { width: mm(40) }), { pages: 'body', align: 'right' }),
] };
const FOOT = 297 - DIN.bottom + 5; // mm: the hairline over the company data
const COMPANY = [ // three columns, 57 mm apart, inside the bottom margin
  'Fensterwerkstatt Tessin GmbH\nRennbahnstraße 48\n13086 Berlin\nTelefon 030 23125-400',
  'Geschäftsführer Martin Tessin\nAmtsgericht Charlottenburg\nHRB 000000 B\nUSt-IdNr. DE000000000',
  'Musterbank Berlin\nIBAN DE00 0000 0000 0000 0000 00\nBIC MUSTDEBBXXX',
];
const footer = { elements: [
  rule('foot-rule', 'rule', at(DIN.left, FOOT, { width: mm(TEXT) })),
  ...COMPANY.map((lines, i) => text(`company-${i}`, lines, FACE.small,
    at(DIN.left + 57 * i, FOOT + 2.5, { width: mm(52) }))),
] };
// #endregion

// #region signature: a drawing set in the flow by ::resource, with no label or number
const resourceTypes = [{ id: 'drawing', name: 'Zeichnung', shortLabel: 'Zeichnung',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }];
const drawing = (id, width, height, altText, placement) => ({ id, typeId: 'drawing',
  kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, altText, placement,
  createdAt: 0, updatedAt: 0 });
const resources = [ // an image element draws the logo; nothing cites it
  drawing('logo', 200, 280, 'Zeichen der Fensterwerkstatt Tessin, ein Kastenfenster'),
  drawing('signature', 760, 328, 'Unterschrift von Martin Tessin',
    { position: 'here', width: 0.297 }), // 49 × 21.1 mm: under 4 lines, no grid gap below
];
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: 'de', resourceTypes, colorPalette, // German hyphenation (gotcha: hyphenation-locales)
  page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, margins: {
    top: mm(DIN.top), bottom: mm(DIN.bottom), left: mm(DIN.left), right: mm(DIN.right) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Nunito Sans', fontSize: pt(10.5), lineHeight: pt(LEAD), // justified
    color: col('ink'), boldColor: col('ink'), firstLineIndent: pt(0), // and hyphenated by default
    paragraphSpacing: true }, // DIN 5008: a blank line between paragraphs, no indent
  headings: headings(), header, footer,
  unorderedLists: { bulletChar: '–' }, // in main-color
  paragraphStyles: [
    { id: 'stack', marginBottom: pt(LEAD) }, // name and role, the enclosures: no blank lines
    { id: 'colophon', fontFamily: 'Reddit Mono', fontSize: pt(6.5), lineHeight: pt(9),
      color: col('muted') }, // a blank line under the enclosures, from the stack's margin
  ],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# Angebot 2026-117: Instandsetzung der 24 Kastenfenster im Vorderhaus Christburger Straße 17 {to="Hausverwaltung Brenner & Kolb GmbH\nFrau Dr. Ines Kolb\nWinsstraße 11\n10405 Berlin" ihr-zeichen="Ko/CS17-F" ihre-nachricht="03.09.2026" unser-zeichen="MT 2026-117" name="Martin Tessin" telefon="030 23125-418" email="mt@tessin.example" datum="22.09.2026"}

Sehr geehrte Frau Dr. Kolb,

vielen Dank für Ihre Anfrage und für den Ortstermin am 10. September. Wir haben dabei alle 24 Kastenfenster des Vorderhauses aufgenommen, je sechs im ersten bis vierten Obergeschoss, drei zur Straße und drei zum Hof. Die Fenster stammen aus der Bauzeit um 1896. Ihre Rahmen aus Kiefernkernholz sind bis auf wenige Stellen gesund, und in 19 Fenstern sitzt noch das mundgeblasene Zylinderglas. Eine Instandsetzung kostet deshalb deutlich weniger als ein Nachbau, und die Straßenfassade behält ihre Fenster.

Die Schäden liegen vor allem an den Außenflügeln der Hofseite. Dort sind bei elf der zwölf Fenster die Wetterschenkel und die unteren Rahmenecken morsch. Der Kitt ist an fast allen Außenflügeln versprödet und stellenweise herausgebrochen, sodass bei Schlagregen Wasser in die Glasfalze läuft. Die Anstriche aus mehreren Jahrzehnten lösen sich großflächig; nach unserer Probe enthalten die beiden untersten Schichten Blei.

Unser Angebot umfasst die folgenden Leistungen, die im beiliegenden Leistungsverzeichnis einzeln aufgeführt sind:

- Ausbau in vier Etappen zu je sechs Fenstern, erst die Außen-, dann die Innenflügel, sodass jede Wohnung während der Arbeiten geschlossen bleibt
- Entlackung in unserer Werkstatt mit Infrarotwärme statt Lösemitteln, Entsorgung der bleihaltigen Altanstriche als Sonderabfall
- Holzreparatur mit Vierungen aus Altholz, neue Wetterschenkel an 22 Außenflügeln
- Ersatz von 14 gesprungenen oder fehlenden Scheiben durch Restaurierungsglas
- Neuverkittung aller Scheiben mit Leinölkitt, Anstrich mit Leinölfarbe in drei Schichten
- Wiedereinbau, Gängigmachen der Beschläge, eine eingefräste Dichtung in jedem Innenflügel

Für alle 24 Fenster berechnen wir 52.800,00 Euro netto, also 2.200,00 Euro je Fenster. Mit 19 % Umsatzsteuer von 10.032,00 Euro beträgt die Angebotssumme 62.832,00 Euro; das Angebot gilt bis zum 31. Oktober 2026. Die Flügel bauen wir von innen aus; ein Gerüst ist nicht nötig, und Arbeiten an der Fassade sind nicht enthalten.

Die Arbeiten würden wir in der Zeit von Mitte Januar bis Mitte April 2027 ausführen. Die Außenflügel einer Etappe bleiben zwei Wochen in der Werkstatt, die Innenflügel eine. Die Mieterinnen und Mieter informieren wir zwei Wochen vor jeder Etappe mit einem Aushang und einem Brief, der den Tag des Ausbaus nennt. Nach jeder abgeschlossenen Etappe berechnen wir ein Viertel der Angebotssumme, zahlbar innerhalb von 14 Tagen ohne Abzug.

Zur Eigentümerversammlung am 14. Oktober bringe ich gern einen instand gesetzten Außenflügel aus unserer Werkstatt mit, an dem die Eigentümer Glas, Kitt und Anstrich sehen können. Bei Fragen zum Leistungsverzeichnis erreichen Sie mich werktags von 7 bis 16 Uhr unter der Durchwahl 418.

Mit freundlichen Grüßen

::resource{id="signature"}

:::paragraphs{style="stack"}
Martin Tessin

Geschäftsführer
:::

:::paragraphs{style="stack"}
**Anlagen**

Leistungsverzeichnis, 6 Seiten

Fotodokumentation der 24 Fenster

Farbbefund der Außenflügel
:::

:::paragraphs{style="colophon"}
Gesetzt in Nunito Sans, Familjen Grotesk und Reddit Mono (SIL OFL) · Firma, Personen und Bankdaten sind erfunden.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the logo and the signature, drawn in code
function mulberry32(seed) { // a seeded generator: the same signature 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 logoSvg(stroke) { // a Berlin box window: a cross frame, six panes and the sill
  const line = (d, width) => `<path d="${d}" fill="none" stroke="${stroke}" `
    + `stroke-width="${width}"/>`;
  return '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280" viewBox="0 0 40 56">'
    + line('M1.5 1.5H38.5V51.5H1.5ZM1.5 17H38.5M20 1.5V51.5', 3) + line('M1.5 34.5H38.5', 1.4)
    + line('M0 54.8H40', 2.2) + '</svg>';
}
function catmull(points) { // a smooth path through the points, as cubic Béziers
  const f = (v) => v.toFixed(1);
  let d = `M${f(points[0][0])} ${f(points[0][1])}`;
  for (let i = 0; i < points.length - 1; i++) {
    const [p0, p1, p2, p3] = [points[i - 1] ?? points[i], points[i], points[i + 1],
      points[i + 2] ?? points[i + 1]];
    const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
    const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
    d += `C${f(c1[0])} ${f(c1[1])} ${f(c2[0])} ${f(c2[1])} ${f(p2[0])} ${f(p2[1])}`;
  }
  return d;
}
function signatureSvg() { // 'M. Tessin' in a quick, forward-leaning hand
  const rand = mulberry32(5008);
  const SLANT = 0.32; // the lean: x moves right by a third of the height above the baseline
  const hand = (points) => points.map(([x, y]) => { // shake each point a little, then lean it
    const [jx, jy] = [x + (rand() - 0.5) * 1.4, y + (rand() - 0.5) * 1.4];
    return [jx + SLANT * (60 - jy), jy];
  });
  const strokes = [ // x, y on a 190 × 82 sheet, baseline at 60
    [[3, 63], [7, 44], [11, 18], [14, 9], [17, 22], [19, 44], [21, 60], [23, 44], [27, 22],
      [30, 17], [32, 30], [33, 48], [35, 61], [40, 63], [45, 57]], // M
    [[48.5, 61], [49.5, 60]], // the full stop
    [[50, 25], [55, 20], [78, 15], [102, 12], [128, 9]], // the bar of the T
    [[80, 13], [79, 30], [76, 48], [73, 61], [77, 66], [84, 61], [89, 52], [91, 46], [88, 43],
      [85, 48], [87, 57], [93, 62], [97, 56], [100, 47], [102, 44], [104, 51], [102, 58],
      [99, 61], [103, 62], [108, 58], [111, 48], [113, 44], [115, 51], [113, 58], [110, 61],
      [114, 62], [120, 58], [123, 52], [125, 46], [126, 58], [130, 63], [134, 57], [137, 47],
      [140, 48], [141, 62], [144, 52], [148, 46], [152, 48], [153, 61], [159, 64], [168, 57]],
    [[127, 37], [128.5, 36]], // the dot on the i
    [[168, 57], [174, 50], [172, 58], [156, 70], [120, 76], [80, 76], [50, 72], [36, 68]],
  ];
  const pen = `fill="none" stroke="${palette.pen}" stroke-width="1.9" stroke-linecap="round" `
    + 'stroke-linejoin="round"';
  // The viewBox starts 3 units above the sheet, which lowers the ink towards the typed name.
  return '<svg xmlns="http://www.w3.org/2000/svg" width="760" height="328" viewBox="0 -3 190 82">'
    + strokes.map((points) => `<path d="${catmull(hand(points))}" ${pen}/>`).join('') + '</svg>';
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { 'Nunito Sans': ['400', '700'], 'Familjen Grotesk': ['700'],
  'Reddit Mono': ['400', '500'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('logo.svg', logoSvg(palette.paper));
await loadSvg('signature.svg', signatureSvg());
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Business letter to DIN 5008', es: 'Carta comercial DIN 5008' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

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

## よくあるつまずき

- **章扉はページに固定した要素のうち最も下のものまで高さを確保する.** 詳細デザインの章扉は、最も下にある要素までの高さを確保します。見出しより下にあるページや裁ち落としに固定した要素もこれに含まれるため、ページの下端に置いた装飾があると本文が次のページへ押し出されます。こうした装飾は見出しより上に置くか、ヘッダーやフッターのスロットに移すか、minHeightで確保する高さを指定してください。
- **属性内の\nが改行になるのはparagraphIndent > 0のときだけ.** デザインのテキスト要素で、属性値に書いた\nが改行になるのは、paragraphIndentが0より大きいか、ドロップキャップを設定したときだけです。それ以外では1行のままになります。paragraphIndentをごくわずかな値（0.01 pt）にするか、1行ごとに属性を分けてください。
- **ヘッダーとフッターの要素は本文の上に描かれる.** ヘッダーとフッターの要素はページの上に重ねて描かれ、本文の領域はそのための場所を空けません。要素は余白の中に収めてください。その場所を確保するのは余白です。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **デザインのテキストの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から書き直してください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **PDFはすべてのファミリーのすべてのウェイトとスタイルを要求する.** renderToPdfは、ブロックが使う可能性のあるすべてのファミリーについて、実際には印字されないものも含め、ボールド、イタリック、ボールドイタリックのフォントをフォントプロバイダーに求めます。1つでも拒否されると書き出しが止まります。プロバイダーは、そのファミリーが持つ最も近いウェイトを返し、イタリックがなければ立体にフォールバックする必要があります。

- 属性中の`\n`についての注意書きは、`paragraphIndent`が0ならテキストは1行のままだと説明しています。1.4.1では宛名は75 mmの幅で折り返し、改行すべき箇所に`\n`の2文字が印刷されます。
- `{totalPages}`は文書全体のページを数えるので、1つのMarkdownファイルに手紙を2通入れると、1通目の2枚目は「Seite 2 von 4」になります。手紙は1通ずつ組んでください。

## クレジット

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

## 関連レシピ

- [No. 029 · アンカー早見表：要素を連ねて組むポスター](https://postext.dev/ja/cookbook/anchoring-cheat-sheet.md): 要素を裁ち落とし、ページ、スロット、ほかの要素から吊るして組んだA3の講演会ポスター。2枚目では各要素を枠で囲み、13の要素にタグを付けます。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 063 · ギョーシェ模様で縁取った修了証](https://postext.dev/ja/cookbook/certificate-single-page.md): ひとつの見出しスタイルで描く修了証。ヘッダーにギョーシェの枠、フッターに印章と署名欄、章扉の位置に受講者の名前を置きます。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 005 · エッセイ集で奇数・偶数ページごとに柱を変える](https://postext.dev/ja/cookbook/running-heads-by-parity.md): ページの奇偶と役割で絞り込むヘッダー要素。偶数ページに書名、奇数ページに省略したエッセイの表題、余白にノンブルのタブ、章扉には地のノンブル。 · 難易度 2 (中級) · 小説・戯曲・文芸
