# Una pàgina de diari vertical, en pisos

> Dos fulls d'un diari xinès de 1912 en vertical: el text en dos pisos amb un filet entre ells, els telegrames en tres i el titular a tota l'alçada de tots dos.

- Versió HTML: https://postext.dev/ca/cookbook/vertical-newspaper-tiers
- Recepta Núm. 084 · Pàgina i retícula · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Diaris i butlletins
- Requereix postext ≥ 1.9.0, postext-pdf ≥ 1.9.0 · provada amb 1.9.0, postext-pdf 1.9.0 el 2026-09-30
- Pàgines: [一](https://postext.dev/cookbook/vertical-newspaper-tiers/es/p01.webp?v=0f45160f), [二](https://postext.dev/cookbook/vertical-newspaper-tiers/es/p02.webp?v=0f45160f)
- PDF: https://postext.dev/cookbook/vertical-newspaper-tiers/es/vertical-newspaper-tiers.pdf?v=0f45160f
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=vertical-newspaper-tiers&lang=es (.postext: https://postext.dev/cookbook/vertical-newspaper-tiers/es/vertical-newspaper-tiers.postext)
- Última actualització: 2026-09-29
- Altres idiomes: [en](https://postext.dev/en/cookbook/vertical-newspaper-tiers.md), [es](https://postext.dev/es/cookbook/vertical-newspaper-tiers.md), [zh](https://postext.dev/zh/cookbook/vertical-newspaper-tiers.md), [ar](https://postext.dev/ar/cookbook/vertical-newspaper-tiers.md)

## En poques paraules

Dues pàgines d'un diari xinès de 1912, escrit en columnes que es llegeixen de dalt a baix. La pàgina es divideix en franges apilades, separades per una línia, com a la premsa d'aleshores.

## Què compondràs

Dos fulls d'un diari xinès tal com es componia el 1912, el primer any de la República, quan la premsa encara es llegia de dalt a baix. Els textos són del *Shenbao* de Xangai: el banquet a Sun Yat-sen al Llac de l'Oest, on Chen Qimei es va aixecar per protestar per un discurs i es va tornar a aixecar per disculpar-se; les honres als soldats de Zhejiang morts en la presa de Nanquín; el monument amb què Hangzhou va rebre l'exèrcit en tornar. Quatre telegrames del primer número del butlletí del Govern Provisional omplen un requadre. El text va en dos pisos (欄) de 25 caràcters amb un filet entre ells, i els telegrames en tres. Una capçalera del diari en vermelló i el titular principal, a tota l'alçada de la pàgina, obren el primer full. [Portada de diari](https://postext.dev/ca/cookbook/newspaper-front-page.md) compon la mateixa pàgina en horitzontal.

**Aquesta recepta respon a:**

- Com componc una pàgina de diari xinès en vertical, en pisos separats per filets?
- Com poso el títol de capítol a tota l'amplada mentre el text continua a dues columnes a sota?
- Pot Postext compondre tres o més columnes de text, o fer que el text envolti una imatge?
- Com defineixo la caixa en caràcters, tants per línia i tantes línies per pàgina?

## La resposta curta

```js
// script.js, línies 40–58
// On a vertical page two columns are two tiers (欄), stacked and filled from the upper right:
// the gutter is the gap between them, the column rule a rule across the page.
const layout = {
  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
  gutterWidth: pt(2 * BODY),
  columnRule: { enabled: true, color: col('ink'), lineWidth: pt(0.75) },
};
// The grid counts characters down a tier and lines across the page: 25 × 29.
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 29 } };
// The body has two tiers at most. The telegrams take three inside a page-span box floated to
// the foot of the flow, the left of the sheet (gotcha: callout-columns), with the fences
//   :::callout{type="wires" span="page" placement="bottom" title="電報"} and :::columns{count=3}
const wires = { id: 'wires', backgroundEnabled: false,
  stripe: { enabled: true, side: 'top', width: pt(1.5), color: col('ink') }, // on its right
  padding: { top: pt(6), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: pt((HEIGHT - 3 * 18 * 9) / 2), // three tiers of 18 characters of 9 pt
  titleStyle: { fontFamily: HEI, fontSize: pt(12), fontWeight: 700, color: col('accent') },
  body: { fontFamily: SONG, fontSize: pt(9), lineHeight: pt(13.5), textAlign: 'justify',
    firstLineIndent: pt(0), boldFontWeight: 700 } };
```

## Ingredients

**Ensenya**

- [Una o dues columnes](https://postext.dev/ca/docs/configuration.md#tipus-de-disposició): Una sola columna o dues columnes iguals amb el seu espai entre columnes; dues columnes és el valor per defecte, així que en una novel·la cal demanar-ne una de sola.
- [Filet de columna](https://postext.dev/ca/docs/configuration.md#filet-de-columna): Un filet vertical a l'espai entre columnes, interromput per les bandes d'amplada completa, al canvas i al PDF.
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat): Un títol dibuixat com a composició lliure de textos, filets, caixes i imatges, que reserva l'alçada que necessita sobre el text.

**També fa servir**

- [Text vertical](https://postext.dev/ca/docs/configuration.md#escriptura-vertical)
- [Llibres enquadernats per la dreta](https://postext.dev/ca/docs/configuration.md#enquadernació)
- [Retícula de caràcters](https://postext.dev/ca/docs/configuration.md#retícula-de-caràcters)
- [Columnes dins d'un requadre](https://postext.dev/ca/docs/document-format.md#columns)
- [Requadres a tota l'amplada](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Requadres flotants](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Fonts xineses, japoneses i coreanes](https://postext.dev/ca/docs/configuration.md#fonts-xineses-japoneses-i-coreanes)
- [Amplada de la puntuació xinesa](https://postext.dev/ca/docs/configuration.md#amplades-de-la-puntuació)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Tall de línies en xinès](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Preliminars en romans](https://postext.dev/ca/docs/document-format.md#numbering)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`cjk`](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

- Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)

## Elaboració

### 1 · Converteix dues columnes en dos pisos

El codi és [la resposta curta](#la-resposta-curta) de més amunt. Amb `writingMode: 'vertical-rl'` la pàgina és una pàgina horitzontal girada un quart de volta, així que `layoutType: 'double'` apila dos pisos, i es llegeix primer el de dalt; el filet de columna passa a ser un filet que travessa la pàgina entre tots dos ([escriptura vertical](/ca/docs/configuration#escriptura-vertical)). La retícula de caràcters dona a cada pis 25 caràcters de cos 五號 (10,5 pt), el màxim d'una columna de diari, i a la pàgina 29 línies; arrodoneix l'espai entre columnes a dos quadratins i engrandeix els marges per deixar els pisos al mig ([retícula de caràcters](/ca/docs/configuration#retícula-de-caràcters)).

El text admet dos pisos com a màxim. Els telegrames van en tres dins d'un requadre: `span="page"` li dona l'alçada de la caixa de text, `placement="bottom"` el porta al peu del flux, que en una pàgina vertical és la vora esquerra, i `:::columns{count=3}` el talla en pisos. `columnGap` és el que deixen tres pisos de 18 caràcters de 9 pt en els 546 pt de la caixa de text, de manera que cada pis conté caràcters sencers.

![Página 二: 湯蟄仙之紀功碑.](https://postext.dev/cookbook/vertical-newspaper-tiers/es/p02.webp?v=0f45160f)

*Pàgina 2: els telegrames en tres pisos; la franja de la vora superior del requadre es dibuixa com un filet a la seva dreta.*

### 2 · Posa la capçalera del diari i el titular en un sol disseny

```js
// script.js, línies 72–99
// A page-span heading always opens a page (gotcha: page-span-heading-new-page), so the lead
// story's H1 carries the masthead in its own design, above its headline.
const MAST = 6.5 * LEAD; // the masthead's block, across the page
const block = (id, inset, style) => ({ kind: 'box', id, style,
  placement: at(inset, inset, { size: { width: pt(HEIGHT - 2 * inset),
    height: pt(MAST - 2 * inset) } }) });
const BAND = 13 * LEAD; // the whole design: masthead, headline, deck
const front = { enabled: true, minHeight: pt(BAND), slot: { elements: [
    block('field', 0, { backgroundColor: col('accent') }), // the name reversed out of it
    block('frame', 3.5, { borderColor: col('paper'), borderWidth: pt(0.75) }),
    text('name', '{title}', KAI, 700, 64, 'paper', { lineHeight: 1, letterSpacing: pt(6),
      placement: at(22, 13) }),
    text('kicker', '{subtitle}', HEI, 700, 12, 'paper', { placement: at(24, 82) }),
    text('sources', '錄{author}', HEI, 400, 8.5, 'paper', { placement: at(150, 84) }),
    text('issue', '{attr.issue}', HEI, 700, 12, 'paper', { placement: at(330, 33) }), // the year
    text('edition', '{attr.edition}', HEI, 400, 8.5, 'paper', { placement: at(330, 84) }),
    text('sheet', '第{pageNumber}張', HEI, 700, 10, 'accent', { placement: at(HEIGHT - 58, 80),
      box: { backgroundColor: col('paper'), padding: { top: pt(3), right: pt(3),
        bottom: pt(3), left: pt(3) } } }),
    // The headline down both tiers, centred; the dateline heads the deck's line.
    text('head', '{titleText}', SONG, 900, 44, 'ink', { align: 'center', lineHeight: 1.1,
      placement: at(0, MAST + 12, fill) }),
    text('deck', '{attr.deck}', KAI, 400, 15, 'ink', { align: 'center',
      placement: below('head', 6, fill) }),
    text('dateline', '{attr.dateline}', HEI, 700, 9, 'muted', { placement: below('head', 10) }),
    { kind: 'rule', id: 'foot', direction: 'horizontal', thickness: pt(0.75),
      color: col('ink'), placement: at(0, BAND - 3, fill) },
] } };
```

El titular de la notícia principal havia de travessar els dos pisos sota la capçalera del diari. Un títol amb `span: 'page'` es compon com a obertura al capdamunt d'una pàgina, així que un segon títol així, darrere de la capçalera, hauria començat una pàgina nova. Per això l'H1 de la pàgina és la notícia principal, i el seu estil de títol dibuixa la capçalera del diari damunt del titular: un bloc vermelló de 6,5 línies amb el nom del diari calat i, a sota, la línia del número: l'any de la República i el del cicle, i 今日出紙兩張, «avui, dos fulls»; després, el titular en Noto Serif TC 900 a 44 pt. Al flux, l'amplada del disseny és l'alçada de la caixa de text sobre el paper, així que `size: { width: 'fill' }` amb `align: 'center'` centra el titular a tota l'alçada dels dos pisos. El disseny fa 13 línies, un nombre enter de línies de la retícula, i el text que el segueix comença en una línia de la retícula.

![Página de apertura 一: 西湖公宴孫中山紀事.](https://postext.dev/cookbook/vertical-newspaper-tiers/es/p01.webp?v=0f45160f)

*Pàgina 1: el bloc de la capçalera del diari, el titular centrat a tota l'alçada dels dos pisos, el sumari al costat i la data al capdamunt de la seva línia.*

### 3 · Obre cada notícia amb un disseny de títol

```js
// script.js, línies 103–110
const storyHead = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // 4 lines
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at(0, 0, fill) },
  text('head', '{titleText}', SONG, 900, 20, 'ink', { placement: at(0, 6) }),
  text('deck', '{attr.deck}', KAI, 400, 11, 'ink', { // two characters down the tier
    placement: below('head', 4, { offset: { x: pt(2 * BODY), y: pt(4) } }) }),
  text('dateline', '{attr.dateline}', HEI, 700, 9, 'muted', { placement: below('deck', 4) }),
] } };
```

Un disseny de títol en columna viu dins del seu pis. El seu filet és `direction: 'horizontal'` al flux, així que sobre el paper baixa al llarg del pis i separa dues notícies. El titular comença al capdamunt del pis, el sumari dos caràcters més avall, i la data dona el lloc, el diari i el dia en què va sortir la crònica. `minHeight` manté cada capçal en quatre línies, i el text de cada notícia comença a la retícula.

### 4 · Posa capçalera al segon full i signa'l al peu

```js
// script.js, línies 114–123
// The header's frame is the margin above the tiers; the footer's, the margin below them.
const above = (y, extra = {}) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(0), y: mm(HEAD - y) }, ...extra });
const header = { elements: [
  text('title', '{title}', KAI, 700, 14, 'ink', { align: 'right', placement: above(13, fill) }),
  text('sheet', '第{pageNumber}張', HEI, 700, 9, 'ink', { placement: above(10.8) }),
  { kind: 'rule', id: 'rule', thickness: pt(1.5), color: col('ink'), placement: above(6, fill) },
].map((element) => ({ ...element, pages: 'body' })) }; // page 1 has the masthead instead
const footer = { elements: [text('colophon', '{attr.colophon}', HEI, 400, 7, 'muted', {
  pages: 'body', placement: at(0, 17, fill) })] }; // 6 mm under the tiers
```

En una pàgina vertical, les capçaleres continuen en horitzontal, com descriu clreq. El marc de la capçalera és el marge que queda damunt dels pisos; la retícula l'engrandeix, i `HEAD` en repeteix el compte perquè el filet quedi 6 mm per sobre del primer caràcter. `pages: 'body'` deixa la pàgina 1 a la capçalera del diari. El colofó és un atribut de l'H1, així que l'edició anglesa i l'espanyola imprimeixen cadascuna la seva línia sota les mateixes pàgines en xinès.

### 5 · Carrega cada veu amb el text que compon

```js
// script.js, línies 221–230
const pick = (pattern) => (markdown.match(pattern) ?? []).join('');
const attrs = (...keys) => pick(new RegExp(`[{ ](?:${keys.join('|')})="[^"]*"`, 'g'));
const voices = [ // family, weights, the text it sets, and whether it runs down a column
  [SONG, ['400'], markdown, true], // the text
  [SONG, ['700', '900'], pick(/^#+ [^{\n]*|\*\*[^*]+\*\*/gm), false], // heads, labels
  [HEI, ['400', '700'], pick(/^(?:subtitle|author): .*$|style="source"\}[^:]*/gm) // masthead,
    + attrs('dateline', 'issue', 'edition', 'title', 'colophon') + '錄第一二張', true], // datelines
  [KAI, ['400'], attrs('deck') + pick(/style="inscription"\}[^:]*/g), true], // decks, the stele
  [KAI, ['700'], pick(/^title: .*$/m), false], // the paper's name
];
```

Fontsource serveix cada pes d'aquestes famílies en un centenar de fitxers, i `loadCjkFonts` baixa els que toca un text. Noto Serif TC 400 rep la mostra sencera; els seus pesos 700 i 900, només els titulars i les etiquetes dels telegrames; Noto Sans TC, les línies de la capçalera del diari, les dates i el colofó; LXGW WenKai TC, els sumaris, la inscripció i el nom del diari. Si una família rep menys text del que compon, la captura falla (C12) i anomena els caràcters que ha hagut de prendre d'una altra.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/vertical-newspaper-tiers

### script.js

```js
// ═══ Postext Cookbook · Nº 084 · A vertical newspaper page in tiers ═══════════════
// https://postext.dev/en/cookbook/vertical-newspaper-tiers
// Code: MIT · Text: Shenbao, 1912 (PD); gazette, stele report: zh.wikisource (CC BY-SA 4.0)
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the colophon; the news is Chinese in both editions
const RECIPE = 'vertical-newspaper-tiers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: newsprint, ink and the vermilion of the masthead
const palette = {
  ink: '#1c1a17', // text, the rules between tiers
  accent: '#b0281c', // the one accent: the masthead block, the telegrams' flag
  rule: '#8f887c', // the hairline before each story
  muted: '#5e574e', // datelines, sources, the colophon
  paper: '#f7f2e6', // newsprint, and the masthead's type reversed out of the vermilion
};
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: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion

const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const BODY = 10.5; // pt: 五號, the body size of the papers of 1912
const LEAD = 15.75; // pt: the line pitch across the page, 1.5 em
const CHARS = 25; // characters down a tier: a newspaper column, 17 to 25
const HEIGHT = 2 * CHARS * BODY + 2 * BODY; // pt: two tiers and a gutter of two ems
const TRIM = { w: 184, h: 260 }; // mm, 16開
const MARGIN = { top: 30, bottom: 22, side: 11 }; // mm, minimums: the grid centres the tiers
// The grid grows the margins evenly, so the tiers start HEAD mm below the top edge.
const HEAD = MARGIN.top + (TRIM.h - MARGIN.top - MARGIN.bottom - (HEIGHT * 25.4) / 72) / 2;

// #region answer: two tiers down the page with a rule between them, three inside a box
// On a vertical page two columns are two tiers (欄), stacked and filled from the upper right:
// the gutter is the gap between them, the column rule a rule across the page.
const layout = {
  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
  gutterWidth: pt(2 * BODY),
  columnRule: { enabled: true, color: col('ink'), lineWidth: pt(0.75) },
};
// The grid counts characters down a tier and lines across the page: 25 × 29.
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 29 } };
// The body has two tiers at most. The telegrams take three inside a page-span box floated to
// the foot of the flow, the left of the sheet (gotcha: callout-columns), with the fences
//   :::callout{type="wires" span="page" placement="bottom" title="電報"} and :::columns{count=3}
const wires = { id: 'wires', backgroundEnabled: false,
  stripe: { enabled: true, side: 'top', width: pt(1.5), color: col('ink') }, // on its right
  padding: { top: pt(6), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: pt((HEIGHT - 3 * 18 * 9) / 2), // three tiers of 18 characters of 9 pt
  titleStyle: { fontFamily: HEI, fontSize: pt(12), fontWeight: 700, color: col('accent') },
  body: { fontFamily: SONG, fontSize: pt(9), lineHeight: pt(13.5), textAlign: 'justify',
    firstLineIndent: pt(0), boldFontWeight: 700 } };
// #endregion

// In the flow of a vertical page x runs down the sheet and y leftward from its right edge.
const at = (x, y, extra = {}) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(x), y: pt(y) }, ...extra });
const text = (id, content, family, weight, size, colour, extra = {}) => ({ kind: 'text', id,
  content, fontFamily: family, fontWeight: weight, fontSize: pt(size), lineHeight: 1.2,
  color: col(colour), align: 'left', overflow: 'wrap', ...extra });
const below = (id, y, extra = {}) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: pt(y) }, ...extra }); // y more points across the page, leftward
const fill = { size: { width: 'fill' } };

// #region front: the masthead and the lead headline, one design down the right of page 1
// A page-span heading always opens a page (gotcha: page-span-heading-new-page), so the lead
// story's H1 carries the masthead in its own design, above its headline.
const MAST = 6.5 * LEAD; // the masthead's block, across the page
const block = (id, inset, style) => ({ kind: 'box', id, style,
  placement: at(inset, inset, { size: { width: pt(HEIGHT - 2 * inset),
    height: pt(MAST - 2 * inset) } }) });
const BAND = 13 * LEAD; // the whole design: masthead, headline, deck
const front = { enabled: true, minHeight: pt(BAND), slot: { elements: [
    block('field', 0, { backgroundColor: col('accent') }), // the name reversed out of it
    block('frame', 3.5, { borderColor: col('paper'), borderWidth: pt(0.75) }),
    text('name', '{title}', KAI, 700, 64, 'paper', { lineHeight: 1, letterSpacing: pt(6),
      placement: at(22, 13) }),
    text('kicker', '{subtitle}', HEI, 700, 12, 'paper', { placement: at(24, 82) }),
    text('sources', '錄{author}', HEI, 400, 8.5, 'paper', { placement: at(150, 84) }),
    text('issue', '{attr.issue}', HEI, 700, 12, 'paper', { placement: at(330, 33) }), // the year
    text('edition', '{attr.edition}', HEI, 400, 8.5, 'paper', { placement: at(330, 84) }),
    text('sheet', '第{pageNumber}張', HEI, 700, 10, 'accent', { placement: at(HEIGHT - 58, 80),
      box: { backgroundColor: col('paper'), padding: { top: pt(3), right: pt(3),
        bottom: pt(3), left: pt(3) } } }),
    // The headline down both tiers, centred; the dateline heads the deck's line.
    text('head', '{titleText}', SONG, 900, 44, 'ink', { align: 'center', lineHeight: 1.1,
      placement: at(0, MAST + 12, fill) }),
    text('deck', '{attr.deck}', KAI, 400, 15, 'ink', { align: 'center',
      placement: below('head', 6, fill) }),
    text('dateline', '{attr.dateline}', HEI, 700, 9, 'muted', { placement: below('head', 10) }),
    { kind: 'rule', id: 'foot', direction: 'horizontal', thickness: pt(0.75),
      color: col('ink'), placement: at(0, BAND - 3, fill) },
] } };
// #endregion

// #region heads: each story opens with a hairline, the headline, a deck and a dateline
const storyHead = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // 4 lines
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at(0, 0, fill) },
  text('head', '{titleText}', SONG, 900, 20, 'ink', { placement: at(0, 6) }),
  text('deck', '{attr.deck}', KAI, 400, 11, 'ink', { // two characters down the tier
    placement: below('head', 4, { offset: { x: pt(2 * BODY), y: pt(4) } }) }),
  text('dateline', '{attr.dateline}', HEI, 700, 9, 'muted', { placement: below('deck', 4) }),
] } };
// #endregion

// #region pagehead: the second sheet's head and the colophon, horizontal on the sheet
// The header's frame is the margin above the tiers; the footer's, the margin below them.
const above = (y, extra = {}) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(0), y: mm(HEAD - y) }, ...extra });
const header = { elements: [
  text('title', '{title}', KAI, 700, 14, 'ink', { align: 'right', placement: above(13, fill) }),
  text('sheet', '第{pageNumber}張', HEI, 700, 9, 'ink', { placement: above(10.8) }),
  { kind: 'rule', id: 'rule', thickness: pt(1.5), color: col('ink'), placement: above(6, fill) },
].map((element) => ({ ...element, pages: 'body' })) }; // page 1 has the masthead instead
const footer = { elements: [text('colophon', '{attr.colophon}', HEI, 400, 7, 'muted', {
  pages: 'body', placement: at(0, 17, fill) })] }; // 6 mm under the tiers
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // Taiwan conventions: centred punctuation (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.side),
      right: mm(MARGIN.side), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' }, // 第一張, 第二張
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
  },
  headings: {
    fontFamily: SONG, fontWeight: 900, color: col('ink'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0) },
      { level: 2, fontSize: pt(20), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD / 2),
        marginBottom: pt(0), advancedDesign: storyHead },
    ],
  },
  headingStyles: [{ id: 'front', advancedDesign: front }],
  calloutStyles: [wires],
  paragraphStyles: [
    { id: 'source', fontFamily: HEI, fontSize: pt(8.5), color: col('muted'), textAlign: 'right',
      firstLineIndent: pt(0) }, // the telegrams' source, at the foot of its line
    { id: 'inscription', fontFamily: KAI, indent: em(2), firstLineIndent: em(2) }, // 低二格
  ],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "民元舊聞"
subtitle: "民國元年報章選錄"
author: "上海《申報》及《臨時政府公報》"
---

# 西湖公宴孫中山紀事 {style="front" deck="孫君演說錢幣革命　陳其美誤會起立質問" dateline="杭州　《申報》十二月十一日" issue="中華民國元年　歲次壬子" edition="今日出紙兩張" colophon="Compuesto en Noto Serif TC, Noto Sans TC y LXGW WenKai TC (SIL OFL) · Texto: zh.wikisource, rev. 9458640 y 2490036 (CC BY-SA 4.0)"}

孫中山先生於本月八號蒞杭情形，已紀昨報。茲悉九號上午九時，孫先生命駕出城，致祭光復諸先烈，順道游覽三潭印月諸名勝。十句半鐘蒞公園，當由朱都督陪侍招待，並介紹軍政各界領袖行相見禮。十一句四十分，入席公宴。

入席後，經孫君首先演說，其宗旨注重整理財政，發達工商，而以錢幣革命為入手辦法。演畢，復經隨員前滬軍都督陳其美引申其辭；次朱都督表明贊同孫君政策；次第六師長呂公望演說，合國民全力扶持中央，群策群力，以固疆隅。詎陳其美誤會為一人布政，必令全國人民服從，起立質問。經講武堂長童伯吹諸君說明呂師長立言大意，以資駁復，陳即起立道歉而罷。

讌畢，由都督陪侍孫君合攝一影，並參觀藏書樓各機關。至五句鐘，排隊入城，赴國民公所團體宴會。並定十號晨赴江干察看路線，即在之江大學午餐；午後乘江墅車赴拱埠參觀商場；十一號出遊天竺、靈隱諸名勝；十三號專車回滬云。

## 浙都督追祭攻寧陣亡將士 {deck="斷橋沿堤盡懸五色旗　湖中船隻均懸白旗" dateline="杭州　《申報》十二月四日"}

浙江朱都督於十二月二號在西湖新公園追祭攻寧陣亡將士。天甫黎明，各軍隊已紛紛排隊前往錢塘門外，沿途均懸五色旗；至斷橋，則五色大旗及十八星白旛沿堤皆是。傾城人士絡繹於途，湖中船隻均懸追祭白旗一面。

十一時祭台招魂開祭，由楊雪門君贊禮啟幕。朱都督就主祭位，呂師長、屈民政司、張財政司、沈教育司、朱提法司（請孫君代表）以及旅長、團長、杭縣汪知事、省會警察局張雨蕉等均就陪祭位。旋由呂師長公望出席演說：「今日追祭諸先烈的榮耀，都是從去年戰死中來的。現在俄庫密約，正是我們國兒立功的時候。」勸勵現在將士一番，隨宣布浙軍攻寧戰況情形，並報告陣亡將士姓名、籍貫、人數。即上香、獻酌、獻帛，由魏在田諸君次第讀都督暨呂師長等祭文，全場行禮，奏樂退位。

午後各學校、各團體、各軍隊致祭。禮畢，由都督領首，全場追祭人員均至孤山忠墳行禮，將所佩之花均散於墳上而回。西賓亦有數人在場觀禮。馨香俎豆，諸烈為不朽矣。

:::callout{type="wires" span="page" placement="bottom" title="電報"}
:::columns{count=3}
**南京去電**　津浦路站電局速送廣東北伐軍司令姚雨平、協統林震鑒：聞我軍昨夜得勝，追敵數十里，足見士卒用命。深堪嘉許。總統孫文。勘。

**南京去電**　四川資州軍政府署鑒：劉光漢被拘，希派人護送來寧，勿苛待。總統府。宥。

**南京去電**　上海陳都督其美鑒：趙珊林為吾黨舊同志，去歲新軍反正之役，頗為出力。今聞因事繫獄，請念前功，即予省釋。孫文。宥。

**武昌來電**　南京孫大總統、上海伍外交總長鑒：停戰期限將滿，和議尚未告成。聞滿清已簡放張勳為南京總督，揆此情形，顯係滿清不願意共和，徒廢時期，以疲我軍士。此停戰期滿，彼方若不決定退位，共同組織共和民國，再議展期，決不承認。曲實在彼，即前次所提待遇從優之條件，一律取銷。鄂中全體軍士均已預備作戰，誓不願與滿清共和，再不可聽其狡展，致遏我軍義勇之氣。請大總統、外交總長將種種情形通告各國是幸。元洪。廿六號。
:::

:::paragraphs{style="source"}
以上錄《臨時政府公報》第一號，一月二十九日
:::
:::

## 湯蟄仙之紀功碑 {deck="仿法國凱旋門例　西湖公園之麓建凱旋碑" dateline="杭州　《申報》七月十七日"}

浙軍光復南京，功勳卓著，天保城一役戰績尤多。此次凱旋回杭，浙中父老除開會歡迎外，復仿法國凱旋門例，在西湖公園之麓建築凱旋碑，以誌不朽。適前都督湯蟄仙氏歸自海外，撰作序銘，刊於碑陰。豐功大文，誠足並垂百世也。原文錄下：

:::paragraphs{style="inscription"}
當武漢首義，江寧未附，浙與蘇、鎮、滬諸軍約從會攻，始收其地，建臨時政府。其後乃有媾和之議。方是時，緣江諸鎮新復，漢陽復陷，金陵扼塞東南，危所繫，一不舉則亂猶未撥。故共和之業，基於武昌，成於江寧，卒乃定於統一。數月之間，易號改朔，伊古以來未有也。而論者以謂江寧之役，蓋浙軍勞尤多云。

時壽潛承乏於浙，以今之第五軍軍長朱君瑞率師與諸軍會。朱君果立功，名顯榮於時。中華民國元年五月，悉所部八千人還。今浙江都督蔣君尊簋命參謀副長夏君超度地為壇，大集諸將，飲至獻捷，勞軍行賞。有司、羣僚、耆老、諸生、商賈、百工，各以其屬齎牛酒致頌，來觀禮者至萬人，歌呼駿奔，闐衢溢巷，號曰歡迎凱旋，斯不亦盛哉！因伐石刊辭，將耀來葉，而以文請。壽潛既退在閭里，謹述邦人之意，為銘曰：

昭洪捷兮奠南疆，一禹域兮除穢荒。矯多士兮莫不揚，思禦侮兮在四方。樹隆碣兮示弗忘。
:::
`; // content.<lang>.md: the same Chinese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint; the Chinese ones by files (gotcha: cjk-fonts-slices).
const FONTS = { 'Noto Serif TC': ['400', '700', '900'], 'Noto Sans TC': ['400', '700'],
  'LXGW WenKai TC': ['400', '700'] };
// #region voices: each face loads the files of the text it sets
const pick = (pattern) => (markdown.match(pattern) ?? []).join('');
const attrs = (...keys) => pick(new RegExp(`[{ ](?:${keys.join('|')})="[^"]*"`, 'g'));
const voices = [ // family, weights, the text it sets, and whether it runs down a column
  [SONG, ['400'], markdown, true], // the text
  [SONG, ['700', '900'], pick(/^#+ [^{\n]*|\*\*[^*]+\*\*/gm), false], // heads, labels
  [HEI, ['400', '700'], pick(/^(?:subtitle|author): .*$|style="source"\}[^:]*/gm) // masthead,
    + attrs('dateline', 'issue', 'edition', 'title', 'colophon') + '錄第一二張', true], // datelines
  [KAI, ['400'], attrs('deck') + pick(/style="inscription"\}[^:]*/g), true], // decks, the stele
  [KAI, ['700'], pick(/^title: .*$/m), false], // the paper's name
];
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
for (const [family, weights, text, vertical] of voices) {
  await loadCjkFonts({ [family]: weights }, text, { vertical });
}
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'A vertical newspaper page in tiers',
  es: 'Una página de periódico vertical, en pisos' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${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 · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook ─
// Fontsource ships a CJK family as about a hundred files per weight, each
// declared in its stylesheet with the unicode-range it covers. The screen
// loads the files the sample touches; the PDF gets the same files for the
// characters its pages set in each face, and embeds each as a subset.
// A book bound on the right (vertical text) is shown with its spreads
// mirrored: page 1 alone on the left of the spine, then [3 | 2].

/** The files of a Fontsource face, read from its stylesheet: { url, range,
 *  ranges }, the last declared first (the order the browser tries them in). */
function cjkSlices(family, weight, style) {
  cjkSlices.cache ??= new Map();
  const id = fontsourceId(family);
  const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  if (!cjkSlices.cache.has(css)) {
    cjkSlices.cache.set(css, fetch(css)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`);
        return res.text();
      })
      .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => {
        const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF';
        const ranges = range.split(',').map((part) => {
          const [lo, hi = lo] = part.trim().slice(2).split('-');
          return [parseInt(lo, 16), parseInt(hi, 16)];
        });
        return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges };
      }).reverse()));
  }
  return cjkSlices.cache.get(css);
}

/** The file of `slices` that holds code point `cp`, if any. */
function cjkSliceFor(slices, cp) {
  return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
}

/** Whether Fontsource serves `family` as a Chinese, Japanese or Korean
 *  family (its subsets name the script). Fails when the API does not
 *  answer: a CJK face taken for a Latin one would paint in a system face. */
async function isCjkFamily(family) {
  const meta = await fontsourceMeta(family);
  if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`);
  return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset));
}

/** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the
 *  whole FONTS object may be passed, its other families are left to
 *  loadFonts. Adds one FontFace per file of each CJK face with its
 *  unicodeRange, then loads the files `text` touches. `text` is what the
 *  faces set: the sample for the text face; a book in several voices calls
 *  it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)),
 *  so the heading and quotation faces fetch and check only their own
 *  characters. Fails when a character of `text` is in no file of a face.
 *  List every weight the pages use: a weight left to buildWithFonts gets
 *  the latin file only. With { vertical: true } it also loads each
 *  family's vertical forms (brackets, quotes, pause marks) for the canvas,
 *  which needs loadVerticalAlternates imported from postext. Resolves to
 *  the number of files loaded. */
async function loadCjkFonts(faces, text, { vertical = false } = {}) {
  kitStatus('Loading fonts…');
  let loaded = 0;
  try {
    if (vertical && typeof loadVerticalAlternates !== 'function') {
      throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext');
    }
    for (const [family, specs] of Object.entries(faces)) {
      if (!(await isCjkFamily(family))) continue;
      const twin = [];
      for (const spec of new Set(specs)) {
        const weight = parseInt(spec, 10);
        const style = spec.endsWith('i') ? 'italic' : 'normal';
        const slices = await cjkSlices(family, weight, style);
        const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0)));
        if (missing.length) {
          throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: `
            + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`);
        }
        for (const slice of slices) {
          document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`,
            { weight: String(weight), style, unicodeRange: slice.range }));
          twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range });
        }
        const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`;
        loaded += (await document.fonts.load(font, text)).length;
        if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`);
      }
      // The same files under a twin name with the `vert` feature on: the
      // canvas paints the punctuation of vertical lines with it.
      if (vertical && twin.length) await loadVerticalAlternates(family, twin);
    }
  } catch (error) {
    kitFail(error);
    throw error;
  }
  return loaded;
}

/** The PDF font provider for recipes with CJK faces: a family whose
 *  Fontsource subsets are Chinese, Japanese or Korean gets the files that
 *  hold the characters its pages set (`request.codePoints`); any other
 *  family goes to fontsourceProvider (the "pdf" block). */
async function cjkPdfProvider(family, weight, style, request) {
  if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style);
  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.styles.includes('italic') ? 'normal' : style;
  const slices = await cjkSlices(family, w, s);
  const picked = new Set();
  for (const cp of request?.codePoints ?? []) {
    const slice = cjkSliceFor(slices, cp);
    if (slice) picked.add(slice);
  }
  if (!picked.size) picked.add(slices[0]);
  return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => {
    const res = await fetch(slice.url);
    if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
}

/** showPages for a book bound on either edge. A right-bound book (the
 *  document says so: doc.binding is 'right' for page.binding 'right' and
 *  for vertical text) lies on the desk as it opens: page 1 alone on the
 *  left of the spine, then [3 | 2], the spine shade on each page's inner
 *  edge. `binding` ('left' | 'right') overrides the document's. */
function showBook(docs, { binding, ...options } = {}) {
  const count = showPages(docs, options);
  const right = (binding ?? [docs].flat()[0]?.binding) === 'right';
  if (!document.getElementById('pt-kit-cjk')) {
    // The pages keep direction ltr: a canvas draws text in the direction its
    // element inherits, and under rtl each run would end where the engine
    // starts it, its brackets mirrored.
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk">
      .pt-spread[dir="rtl"] canvas { direction: ltr; }
      .pt-spread[dir="rtl"] 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); }
    </style>`);
  }
  // Each pair stays [verso, recto] in the page; right to left, the verso
  // sits on the right. Phones stack the pages in reading order either way.
  for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr';
  document.getElementById('pages').dataset.binding = right ? 'right' : 'left';
  return count;
}

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

## Variants

### Dona quatre pisos als telegrames

Quatre pisos de 14 caràcters caben en la mateixa alçada, i el requadre s'estreny fins a les línies que ocupen.

```diff
-  columnGap: pt((HEIGHT - 3 * 18 * 9) / 2), // three tiers of 18 characters of 9 pt
+  columnGap: pt((HEIGHT - 4 * 14 * 9) / 3), // four tiers of 14 characters of 9 pt
```

Al Markdown, `:::columns{count=4}`.

### Compon les notícies en un sol pis

Un pis de 52 caràcters ocupa tota l'alçada de la caixa de text: 193 mm de paper per cada línia. Cada capçal de notícia ocupa ara quatre línies de la pàgina sencera, i els dos fulls passen a ser tres.

```diff
-  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
+  layoutType: 'single', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
```

Amb `charsPerLine: 2 * CHARS + 2` a la retícula.

## Errors freqüents

- **Un títol a tota l'amplada sempre obre una pàgina nova.** Un títol el nivell o l'estil del qual fixa span: 'page' es compon com a obertura al capdamunt d'una pàgina, tingui o no breakBefore. Col·locat després d'un altre text, o d'una altra obertura a la mateixa pàgina, passa a la pàgina següent i deixa buida la resta de l'actual. Un titular que travessa les dues columnes (o els dos pisos d'una pàgina vertical) sota la capçalera del diari va al disseny de la mateixa obertura, al costat de la capçalera, o dins d'un requadre :::callout{span="page"} com a títol sense span, que és com newspaper-front-page compon els seus titulars.
- **:::columns només funciona dins d'un requadre i no es parteix.** :::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un.
- **Les fonts xineses es carreguen per fragments, amb el bloc cjk.** Fontsource serveix una família xinesa, japonesa o coreana en un centenar de fitxers per pes, cadascun amb un interval de caràcters. loadFonts només baixa el fitxer latin, així que a la pantalla els caràcters xinesos surten d'una font del sistema i es mesuren malament, i fontsourceProvider lliura al PDF aquest fitxer latin, que els imprimeix com a caixes buides. Afegeix el bloc cjk del kit, crida loadCjkFonts(FONTS, markdown) després de loadFonts (una vegada per veu, amb el text que compon, si el llibre fa servir diverses fonts xineses) i passa a renderToPdf fontProvider: cjkPdfProvider: tots dos agafen els fitxers que contenen els caràcters del text.
- **Etiqueta el document zh-Hans o zh-Hant, no amb LANG.** Les edicions d'una recepta són en i es, però una mostra xinesa és xinesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, separaria les seves paraules llatines, anomenaria Figure o Figura les seves figures i donaria al PDF una llengua equivocada. Escriu tu l'etiqueta: 'zh-Hans' (convencions de la Xina continental: tall GB, puntuació Kaiming) o 'zh-Hant' (Taiwan: puntuació d'amplada completa centrada); 'zh-HK' per a Hong Kong. Un 'zh' tot sol es llegeix com a xinès simplificat continental.
- **Un llibre enquadernat per la dreta mostra els seus plecs amb showBook.** En un llibre enquadernat per la dreta (text àrab, hebreu o persa, xinès vertical, o page.binding 'right') la pàgina 1 continua sent senar, però queda a l'esquerra del llom, i els plecs es llegeixen [3 | 2]. showPages compon tots els llibres com si s'enquadernessin per l'esquerra; showBook, del bloc book (el bloc cjk porta la mateixa funció), llegeix doc.binding i reflecteix els plecs. capture.hero continua anomenant un plec en ordre de lectura, [parella, senar]: [2, 3].
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().

- La Noto Serif TC de Fontsource declara 爲, la forma que imprimia el *Shenbao*, en el rang d'un dels seus fitxers, però aquest fitxer no té el glif: el llenç el va dibuixar amb una font del sistema i el PDF se'n va quedar sense (C25). Les cròniques porten 為.
- Les transcripcions de Wikisource porten errates que la pàgina corregeix: 己紀 per 已紀 i, dues vegades, 倍侍 per 陪侍 a la crònica del banquet; a la de les honres, 勸勵一番現在將土, amb 土 per 士 i el complement darrere de 一番, que aquí es compon 勸勵現在將士一番, «va arengar els oficials i soldats presents». La pàgina fa servir a més la forma normal de quatre variants: 寧 per 寗, 總 per 縂 (tres vegades) i 決 per 决 (dues) als telegrames, i 刊 per 刋 a la inscripció.
- LXGW WenKai TC, tal com la serveix Fontsource, dibuixa 為 com 爲, així que la inscripció, en Kai, mostra la forma antiga que al text en Song li falta.
- Els tres pisos del requadre són terços de la caixa de text i no coincideixen amb els dos pisos del text. Un diari compon aquestes seccions amb la seva pròpia retícula; el filet de la vora dreta del requadre separa les dues.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Shenbao (Shanghai), 11 December 1912: the banquet for Sun Yat-sen at West Lake, Hangzhou: Shenbao; transcription by Wikisource editors ([font](https://zh.wikisource.org/w/index.php?title=%E8%A5%BF%E6%B9%96%E5%85%AC%E5%AE%B4%E5%AD%AB%E4%B8%AD%E5%B1%B1%E7%B4%80%E4%BA%8B&oldid=9458634)), domini públic
- Text: Shenbao (Shanghai), 4 December 1912: the memorial for the Zhejiang soldiers killed at Nanjing: Shenbao; transcription by Wikisource editors ([font](https://zh.wikisource.org/w/index.php?title=%E6%B5%99%E9%83%BD%E7%9D%A3%E8%BF%BD%E7%A5%AD%E6%94%BB%E5%AF%A7%E9%99%A3%E4%BA%A1%E5%B0%87%E5%A3%AB&oldid=9458637)), domini públic
- Text: Shenbao (Shanghai), 17 July 1912: the victory monument at West Lake, with Tang Shouqian's inscription: Shenbao, Tang Shouqian; transcription and punctuation by Wikisource editors, revised for this recipe ([font](https://zh.wikisource.org/w/index.php?title=%E6%B9%AF%E8%9F%84%E4%BB%99%E4%B9%8B%E7%B4%80%E5%8A%9F%E7%A2%91&oldid=9458640)), CC-BY-SA-4.0
- Text: Four telegrams from Linshi zhengfu gongbao (Provisional Government Gazette) no. 1, 29 January 1912: Provisional Government, Nanjing; transcription and punctuation by Wikisource editors ([font](https://zh.wikisource.org/w/index.php?title=%E8%87%A8%E6%99%82%E6%94%BF%E5%BA%9C%E5%85%AC%E5%A0%B1_%28%E6%B0%91%E5%9C%8B%E5%85%83%E5%B9%B41%E6%9C%8829%E6%97%A5%29&oldid=2490036)), CC-BY-SA-4.0
- Text: Punctuation of the first two reports, the masthead, decks and datelines: Postext Cookbook, CC-BY-4.0
- Tipus de lletra: Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 027 · Portada de diari](https://postext.dev/ca/cookbook/newspaper-front-page.md): Portada i pàgina 2 d'un setmanari local a dues columnes amb filet, titulars a tota l'amplada en requadres i una franja de breus a quatre columnes al peu. · Nivell 3 (Avançat) · Diaris i butlletins
- [Núm. 081 · Dates i sigles dretes en text vertical](https://postext.dev/ca/cookbook/chinese-dates-upright.md): Dos documents de 1912 compostos en vertical i enquadernats per la dreta: els nombres de dues xifres es posen drets sols; :tcy i :upright marquen la resta. · Nivell 2 (Intermedi) · Llibres de text, Fulls solts i efímers
- [Núm. 075 · Una novel·la xinesa vertical, enquadernada per la dreta](https://postext.dev/ca/cookbook/vertical-novel-right-bound.md): El capítol 1 de 三國演義 en una pàgina 25開 de Taiwan: 40 caràcters per 16 columnes, un 回目 entre filets, capçaleres al tall i folis xinesos. · Nivell 3 (Avançat) · Narrativa, teatre i prosa literària
