# Poemas Tang frente a su versión inglesa

> Cada poema Tang abre una página impar teñida y la versión de Giles, la par de enfrente: un estilo de título salta a página par y el otro a impar.

- Versión HTML: https://postext.dev/es/cookbook/tang-poems-facing-english
- Receta N.º 083 · Página y retícula · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Poesía
- Requiere postext ≥ 1.9.0, postext-pdf ≥ 1.9.0 · probada con 1.9.0, postext-pdf 1.9.0 el 2026-09-30
- Páginas: [1](https://postext.dev/cookbook/tang-poems-facing-english/es/p01.webp?v=b6395c45), [2](https://postext.dev/cookbook/tang-poems-facing-english/es/p02.webp?v=b6395c45), [3](https://postext.dev/cookbook/tang-poems-facing-english/es/p03.webp?v=b6395c45), [4](https://postext.dev/cookbook/tang-poems-facing-english/es/p04.webp?v=b6395c45), [5](https://postext.dev/cookbook/tang-poems-facing-english/es/p05.webp?v=b6395c45), [6](https://postext.dev/cookbook/tang-poems-facing-english/es/p06.webp?v=b6395c45), [7](https://postext.dev/cookbook/tang-poems-facing-english/es/p07.webp?v=b6395c45), [8](https://postext.dev/cookbook/tang-poems-facing-english/es/p08.webp?v=b6395c45), [9](https://postext.dev/cookbook/tang-poems-facing-english/es/p09.webp?v=b6395c45), [10](https://postext.dev/cookbook/tang-poems-facing-english/es/p10.webp?v=b6395c45)
- PDF: https://postext.dev/cookbook/tang-poems-facing-english/es/tang-poems-facing-english.pdf?v=b6395c45
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=tang-poems-facing-english&lang=es (.postext: https://postext.dev/cookbook/tang-poems-facing-english/es/tang-poems-facing-english.postext)
- Última actualización: 2026-09-29
- Otros idiomas: [en](https://postext.dev/en/cookbook/tang-poems-facing-english.md)

## Lo que vas a componer

Un pequeño libro bilingüe de poemas Tang, compuesto como las ediciones bilingües de los clásicos: el chino en la página impar, sobre una hoja teñida como papel viejo, y los versos rimados de Herbert A. Giles, de 1901, en la par de enfrente. Cuatro poemas de Li Bai, Du Fu y Wang Wei ocupan un pliego cada uno, con título en cada lengua y el nombre del poeta. El chino va en LXGW WenKai, una Kai dibujada a partir del pincel, a dos versos por renglón, de modo que cada renglón queda a la altura de los dos versos ingleses que lo traducen. Al pie de la página china van las notas, en la lengua del lector. [Traducción enfrentada, estrofa a estrofa](https://postext.dev/es/cookbook/bilingual-facing-verse.md) pone un poema español junto a su versión inglesa en una sola página; aquí cada lengua tiene su página.

**Esta receta responde a:**

- ¿Cómo compongo un poema y su traducción en páginas enfrentadas, con el original siempre en la impar?
- ¿Cómo compongo poesía: un verso por línea, espacio entre estrofas, sangría francesa en los versos que no caben y sin separación silábica?
- ¿Cómo hago notas al pie?
- ¿Cómo fuerzo un salto de página o de columna, y hago que cada capítulo empiece en página impar?

## La respuesta corta

```js
// script.js, líneas 39–63
const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line
const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
// A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm.
const titled = (size, title, poet, under = []) => ({
  enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [...under,
    { kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2,
      align: 'center', overflow: 'wrap', color: col('ink'),
      placement: at(23 - (size * 1.2 * 25.4) / 72), ...title },
    { kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'),
      placement: at(24), ...poet },
  ] },
});
// Each style breaks to its own side of the spread (gotcha: style-inherits-break).
const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
  advancedDesign: titled(19, { fontFamily: ROMAN, italic: true },
    { content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
      letterSpacing: pt(1.5), textTransform: 'uppercase' }) };
// The original on a tinted leaf: span 'page' lets the tint reach past the text column.
const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) },
    { content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) },
    [{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: 'fill' } } }]) };
```

## Ingredientes

**Enseña**

- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes): Un nivel de título empieza en página nueva: la siguiente, la siguiente impar o la siguiente par, con una página en blanco si hace falta.
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado): Variantes con nombre de un nivel de título, elegidas con {style="…"}: otra apertura, tipografía, cabeceras o página por capítulo.
- [Notas al pie](https://postext.dev/es/docs/document-format.md#notas-al-pie): Notas citadas con [^id] y compuestas al pie de la columna que las cita, o tras el capítulo, numeradas por capítulo o por todo el libro.

**También usa**

- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Retícula de caracteres](https://postext.dev/es/docs/configuration.md#retícula-de-caracteres)
- [Fuentes chinas, japonesas y coreanas](https://postext.dev/es/docs/configuration.md#fuentes-chinas-japonesas-y-coreanas)
- [Corte de líneas en chino](https://postext.dev/es/docs/configuration.md#tipografía-de-asia-oriental)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`cjk`](https://postext.dev/es/docs/configuration.md#tipografía-de-asia-oriental), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), `footnotes`, [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Un estilo de título para cada lado del pliego

El código está en [la respuesta corta](#la-respuesta-corta), más arriba. Cada poema son dos títulos de nivel 1: `# Night Thoughts {style="en" …}` con los versos de Giles y después `# 靜夜思 {style="zh" …}` con el poema. El estilo `en` salta a la siguiente página par; el `zh`, a la siguiente impar, la que queda enfrente. Mientras cada poema quepa en su página no entra ninguna página en blanco; una versión inglesa que se pasara de página empujaría su original a la impar siguiente, detrás de una par en blanco ([Saltar antes](/es/docs/configuration#saltar-antes)). Los dos diseños reservan seis líneas de la retícula, así que el inglés y el chino empiezan en la misma línea. El estilo chino lleva `span: 'page'` porque su primer elemento, una caja del tamaño de la página, es la hoja teñida, y un diseño que se queda en la columna se corta al pie de la columna ([Span y diseño avanzado](/es/docs/configuration#span-y-diseño-avanzado)).

![Página de apertura 2: Night Thoughts.](https://postext.dev/cookbook/tang-poems-facing-english/es/p02.webp?v=b6395c45)

*Página 2: el inglés empieza en la página par seis líneas más abajo, en la misma línea de la retícula en que empieza el chino en la página 3.*

### 2 · Un renglón chino por cada dos versos ingleses

```js
// script.js, líneas 67–79
// Each indent centres the longest line of the poems that use it on the 103.7 mm measure:
// 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's.
const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({
  id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left',
  firstLineIndent: pt(0), indent: mm(indent) }));
const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD),
  textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD
  // Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid.
  // A fixed left edge, and the note numbers after each row's 。: every character keeps its column.
  indent: pt((CHARS * BODY - 12 * SHI) / 2) };
// A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents
// his rhyming lines by become two em spaces behind a word joiner, which nothing trims.
const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003');
```

Los renglones chinos tienen el doble de interlineado que los versos ingleses, 32 pt frente a 16, y así un renglón de dos versos de cinco caracteres queda frente al pareado que Giles saca de él. En el poema de la luna de las [páginas 4 y 5](https://postext.dev/cookbook/tang-poems-facing-english/es/p04.webp?v=b6395c45) el mismo `:::space{lines=1}` va en los dos textos, y sus tres estrofas siguen a la misma altura en todo el pliego. El renglón chino entra 51 pt, lo que centra sus doce cuadratines en los 28 de la retícula, y cada llamada de nota va tras el 。 de su renglón, fuera de los doce: dentro del renglón empujaría un tercio de cuadratín a la derecha todos los caracteres que la siguen, que dejarían de caer sobre los del renglón de abajo. Las sangrías inglesas salen de medir el verso más largo de cada poema. Giles sangra un verso de cada dos; el Markdown conserva sus dos espacios, e `indentVerse` los cambia por espacios de cuadratín tras un unidor de palabras, porque un párrafo pierde los espacios con que empieza.

### 3 · Notas al pie de la página china

```js
// script.js, líneas 83–86
const footnotes = { placement: 'column', // the foot of the column, here the foot of the page
  fontSize: pt(8), lineHeight: pt(11), color: col('ink'), textAlign: 'left',
  spaceAbove: pt(LEAD), spaceBelowRule: pt(5), spaceBetween: pt(3),
  separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') } };
```

Las notas son notas al pie, una por renglón: `疑是地上霜。[^bed]` cierra el primer renglón del poema y `[^bed]: …` va tras él, con las palabras que glosa en negrita, porque la llamada ya no está a su lado. La numeración vuelve a empezar en cada título que abre página, así que cada poema cuenta sus notas desde 1 ([Notas al pie](/es/docs/document-format#notas-al-pie)). Las notas se componen con la fuente del cuerpo, y la de este libro es Noto Serif TC porque una nota mezcla caracteres y pinyin; las letras latinas de Noto Serif TC son las de Source Serif, y por eso las páginas inglesas van en Source Serif 4. Las notas se escriben en la lengua de la edición: con la cabecera de la página par y la nota sobre el texto, son lo que cambia en la edición española.

### 4 · Cabeceras en la lengua de cada página

```js
// script.js, líneas 120–131
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'opener', // every poem page opens with its title; a blank page stays bare
  fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }),
  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
    { letterSpacing: pt(1.2), textTransform: 'uppercase' }),
  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
    { fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }),
] };
```

Todas las páginas de este libro empiezan con un título, así que todas son aperturas, y las cabeceras llevan `pages: 'opener'`. Una página en blanco que añadiera un salto quedaría limpia, y la portada las quita con una cabecera vacía propia. La página par toma el título del libro del `subtitle` del frontmatter, que cada edición escribe en su lengua; la impar toma el nombre del poeta del atributo `name` del título chino ([Encabezados y pies](/es/docs/configuration#encabezados-y-pies)).

### 5 · Una portada sobre una luna

```js
// script.js, líneas 90–116
const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(size ? { size: { width: mm(size), height: mm(size) } } : {}) });
const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title
const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
  overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style });
const titlePage = {
  id: 'title', span: 'page', runningChapter: false,
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON),
      style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } },
    line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF,
      fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }),
    line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600,
      letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }),
    line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }),
    line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11),
      italic: true }),
    // A seal reading 明月, the bright moon of three of the four poems, set down its square.
    { kind: 'box', id: 'seal', placement: onPage(170, 13),
      style: { backgroundColor: col('seal'), borderRadius: mm(1) } },
    line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl',
      fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'),
      verticalAlign: 'middle' }),
  ] } },
};
```

La portada también es un estilo de título, con `runningChapter: false` para que no nombre ningún capítulo. La luna es una caja de diseño cuyo radio de esquina mide la mitad de su lado, y 唐詩 se centra sobre ella con una cuenta: el centro de la luna menos la mitad de la altura del título. El sello compone 明月 con `writingMode: 'vertical-rl'`, un carácter sobre el otro, como se tallan los sellos.

### 6 · Cada fuente china carga lo que compone

```js
// script.js, líneas 332–341
const grab = (re) => (markdown.match(re) ?? []).join('');
const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '0123456789'; // and note numbers
await loadFonts(FONTS, markdown);
await Promise.all([
  loadCjkFonts({ [SERIF]: ['400'] }, markdown), // the notes, and the heading blocks
  loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)),
  loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)),
  loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`),
  loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)),
]);
```

Fontsource parte cada fuente china en unos cien archivos por rangos de caracteres, y `loadCjkFonts` descarga solo los que necesita su texto ([Fuentes chinas, japonesas y coreanas](/es/docs/configuration#fuentes-chinas-japonesas-y-coreanas)). La Kai recibe los poemas y el sello; la Hei, los nombres de los poetas; la Song en negrita, los cuatro títulos y las palabras glosadas de las notas, y su peso más negro, los dos caracteres de la portada.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/tang-poems-facing-english

### script.js

```js
// ═══ Postext Cookbook · Nº 083 · Tang poems facing their English verse ═══════════════
// https://postext.dev/en/cookbook/tang-poems-facing-english
// Code: MIT · Text: Tang poems, zh.wikisource; H. A. Giles, 1901, Project Gutenberg (PD)
// Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4 (OFL) · Needs postext ≥ 1.9.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'tang-poems-facing-english';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Ink and seal red on a cream paper, and a pale moon for the title page.
const palette = {
  ink: '#1f1b18', // text: a warm near-black
  seal: '#ad3526', // the accent: vermilion seal paste, for the poets, the rules and the seal
  moon: '#eadcb4', // the title page's moon
  leaf: '#f2e8cf', // the recto's tint, under the Chinese
  muted: '#6d655c', // running heads and the colophon
  paper: '#faf6ed', // a cream paper
};
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: 'seal (defaults)', value: { hex: palette.seal, model: 'hex' } },
];
const SERIF = 'Noto Serif TC'; // 明: the Chinese titles and the notes
const KAI = 'LXGW WenKai TC'; // 楷: the poems
const HEI = 'Noto Sans TC'; // 黑: the poet lines and the recto's running head
const ROMAN = 'Source Serif 4'; // the English: the Latin that Noto Serif TC is built on
const LABEL = 'Source Sans 3'; // English labels and folios
const [BODY, LEAD, SHI] = [10.5, 16, 16]; // pt: 五号 on a 16 pt pitch; the poems' size
const [CHARS, LINES] = [28, 30]; // the character grid: 28 ems by 30 lines
const TRIM = { width: 148, height: 210 }; // mm: 25開, a Taiwan trade trim
const [INNER, OUTER] = [18, 21]; // mm: minimum sides; the grid centres its measure between them
const MEASURE = (CHARS * BODY * 25.4) / 72; // 103.7 mm: 28 ems of 五号
const EDGE = OUTER + (TRIM.width - INNER - OUTER - MEASURE) / 2; // 23.6 mm: the outer text edge

// #region answer: the English opens the next verso, the Chinese the recto that faces it
const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line
const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
// A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm.
const titled = (size, title, poet, under = []) => ({
  enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [...under,
    { kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2,
      align: 'center', overflow: 'wrap', color: col('ink'),
      placement: at(23 - (size * 1.2 * 25.4) / 72), ...title },
    { kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'),
      placement: at(24), ...poet },
  ] },
});
// Each style breaks to its own side of the spread (gotcha: style-inherits-break).
const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
  advancedDesign: titled(19, { fontFamily: ROMAN, italic: true },
    { content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
      letterSpacing: pt(1.5), textTransform: 'uppercase' }) };
// The original on a tinted leaf: span 'page' lets the tint reach past the text column.
const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) },
    { content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) },
    [{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: 'fill' } } }]) };
// #endregion

// #region verse: a Chinese row faces two lines of English on the grid both pages share
// Each indent centres the longest line of the poems that use it on the 103.7 mm measure:
// 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's.
const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({
  id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left',
  firstLineIndent: pt(0), indent: mm(indent) }));
const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD),
  textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD
  // Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid.
  // A fixed left edge, and the note numbers after each row's 。: every character keeps its column.
  indent: pt((CHARS * BODY - 12 * SHI) / 2) };
// A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents
// his rhyming lines by become two em spaces behind a word joiner, which nothing trims.
const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003');
// #endregion

// #region notes: glosses at the foot of the recto, in the edition's language
const footnotes = { placement: 'column', // the foot of the column, here the foot of the page
  fontSize: pt(8), lineHeight: pt(11), color: col('ink'), textAlign: 'left',
  spaceAbove: pt(LEAD), spaceBelowRule: pt(5), spaceBetween: pt(3),
  separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') } };
// #endregion

// #region title: 唐詩 on a pale moon, with a seal
const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(size ? { size: { width: mm(size), height: mm(size) } } : {}) });
const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title
const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
  overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style });
const titlePage = {
  id: 'title', span: 'page', runningChapter: false,
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON),
      style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } },
    line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF,
      fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }),
    line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600,
      letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }),
    line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }),
    line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11),
      italic: true }),
    // A seal reading 明月, the bright moon of three of the four poems, set down its square.
    { kind: 'box', id: 'seal', placement: onPage(170, 13),
      style: { backgroundColor: col('seal'), borderRadius: mm(1) } },
    line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl',
      fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'),
      verticalAlign: 'middle' }),
  ] } },
};
// #endregion

// #region heads: the book in English over the verso, the poet in Chinese over the recto
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'opener', // every poem page opens with its title; a blank page stays bare
  fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }),
  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
    { letterSpacing: pt(1.2), textTransform: 'uppercase' }),
  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
    { fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }),
] };
// #endregion

const paragraphStyles = [shi, ...verses,
  // Justified like the body, and divided by the patterns of the edition's language (bodyText).
  { id: 'prose', fontFamily: ROMAN, fontSize: pt(10), lineHeight: pt(LEAD),
    firstLineIndent: mm(4) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
    italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // the Chinese sets the rules; written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
    // 23 + 17.5 mm leave 169.5 mm for 30 lines of 16 pt (169.3 mm): a head deeper than the foot.
    margins: { top: mm(23), bottom: mm(17.5), left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } },
  bodyText: { fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: false,
    hyphenation: { enabled: true, locale: LANG } }, // no Chinese patterns: the Latin's language
  // The designs paint the titles; weight 400 keeps the heading blocks in a loaded face.
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  headings: { fontFamily: SERIF, fontWeight: 400, levels: [{ level: 1, marginTop: pt(0),
    marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } }] },
  headingStyles: [titlePage, english, chinese],
  paragraphStyles, footnotes, header, footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "唐詩"
subtitle: "Poemas Tang"
---

# 唐詩 {style="title" en="Poemas Tang" poets="Li Bai · Du Fu · Wang Wei" english="con la versión inglesa en verso de Herbert A. Giles"}

# Night Thoughts {style="en" kicker="Li Bai · 701–762"}

:::paragraphs{style="verse"}
I wake, and moonbeams play around my bed,

  Glittering like hoar-frost to my wondering eyes;

Up towards the glorious moon I raise my head,

  Then lay me down,—and thoughts of home arise.
:::

# 靜夜思 {style="zh" poet="〔唐〕李白" name="李白"}

:::paragraphs{style="shi"}
牀前明月光，疑是地上霜。[^bed]

舉頭望明月，低頭思故鄉。[^moon]
:::

[^bed]: **牀** chuáng, hoy escrito 床: la cama. Algunos comentaristas leen aquí el brocal de un pozo, y otros un asiento plegable sacado al patio; Giles se queda con la cama.

[^moon]: **明月** míngyuè, la luna clara. Las obras reunidas de Li Bai dicen 看月光 en el primer verso y aquí 山月, la luna sobre los montes; la edición de 1705 de toda la poesía Tang dice también 山月, pero conserva 明月光. Esta página sigue la antología de 1763, el texto que los niños chinos aprenden de memoria. **鄉** xiāng rima con 光 guāng y 霜 shuāng, al final de los dos primeros versos; Giles rima su cuarteta en versos alternos.

# Drinking Alone under the Moon {style="en" kicker="Li Bai · 701–762"}

:::paragraphs{style="verse-long"}
An arbour of flowers and a kettle of wine:

Alas! in the bowers no companion is mine.

Then the moon sheds her rays on my goblet and me,

And my shadow betrays we’re a party of three.

:::space{lines=1}

Though the moon cannot swallow her share of the grog,

And my shadow must follow wherever I jog,—

Yet their friendship I’ll borrow and gaily carouse,

And laugh away sorrow while spring-time allows.

:::space{lines=1}

See the moon,—how she glances response to my song;

See my shadow,—it dances so lightly along!

While sober I feel you are both my good friends;

When drunken I reel, our companionship ends.

But we’ll soon have a greeting without a good-bye,

At our next merry meeting away in the sky.
:::

# 月下獨酌 {style="zh" poet="〔唐〕李白" name="李白"}

:::paragraphs{style="shi"}
花間一壺酒，獨酌無相親。

舉杯邀明月，對影成三人。[^three]

:::space{lines=1}

月既不解飲，影徒隨我身。

暫伴月將影，行樂須及春。[^jiang]

:::space{lines=1}

我歌月徘徊，我舞影零亂。

醒時同交歡，醉後各分散。

永結無情遊，相期邈雲漢。[^wuqing]
:::

[^three]: **三人** sān rén, tres personas: el poeta, la luna y su sombra.

[^jiang]: **將** jiāng: aquí une 月 y 影, la luna y la sombra.

[^wuqing]: **無情遊** wúqíng yóu: una amistad con compañeros que no sienten nada, la luna y la sombra; el poeta se ata a ella para siempre. **雲漢** yúnhàn: la Vía Láctea, donde los tres volverán a encontrarse, el «away in the sky» de Giles. La rima cambia en el noveno verso, donde empieza la tercera estrofa de Giles.

# Stop-short {style="en" kicker="Du Fu · 712–770"}

:::paragraphs{style="verse"}
White gleam the gulls across the darkling tide,

  On the green hills the red flowers seem to burn;

Alas! I see another spring has died….

  When will it come—the day of my return?
:::

# 絕句 {style="zh" poet="〔唐〕杜甫" name="杜甫"}

:::paragraphs{style="shi"}
江碧鳥逾白，山青花欲燃。[^yu]

今春看又過，何日是歸年。[^guinian]
:::

[^yu]: **逾** yú, todavía más: contra el verde del río, los pájaros parecen aún más blancos. **欲燃** yù rán, a punto de arder: el rojo de las flores. En Giles, «seem to burn».

[^guinian]: **歸年** guī nián, el año del regreso. Du Fu escribió esta cuarteta, la segunda de una pareja, en la primavera de 764 en Chengdu, adonde lo había llevado la guerra del norte.

# The Bamboo Lodge {style="en" kicker="Wang Wei · 701–761"}

:::paragraphs{style="verse-short"}
Beneath the bamboo grove, alone,

  I seize my lute and sit and croon;

No ear to hear me, save mine own:

  No eye to see me—save the moon.
:::

# 竹里館 {style="zh" poet="〔唐〕王維" name="王維"}

:::paragraphs{style="shi"}
獨坐幽篁裏，彈琴復長嘯。[^huang]

深林人不知，明月來相照。
:::

[^huang]: **幽篁** yōuhuáng: un bambudal hondo y oscuro. **長嘯** cháng xiào: un silbido largo con los labios fruncidos, arte de ermitaños; el poeta de Giles canturrea («croon»). El Pabellón de los Bambúes era uno de los veinte lugares de la finca de Wang Wei en Wangchuan, al sudeste de la capital Tang, y el poeta dedicó a cada uno una cuarteta de 輞川集, la colección de Wangchuan.

# Nota sobre el texto {style="en" kicker="Sobre esta edición"}

:::paragraphs{style="prose"}
Los poemas chinos siguen los *Trescientos poemas Tang*, la antología que Sun Zhu reunió en 1763 con el nombre de Hengtang Tuishi, salvo la cuarteta de Du Fu, que no figura en ella y se da como la imprime el *Quan Tang shi* de 1705. Los cuatro proceden de Wikisource, en caracteres tradicionales y con las formas antiguas que conservan allí algunos caracteres.

Los versos ingleses son de Herbert A. Giles, tal como los publicó en *A History of Chinese Literature* (1901), donde los poemas no llevan título; los títulos ingleses son de esta edición. Giles escribía Li Po y Tu Fu donde hoy se escribe Li Bai y Du Fu, y llamaba *stop-short* al *jueju*, la cuarteta, de ahí el título inglés del poema de Du Fu. Se respetan sus rimas, sus sangrías y su puntuación; el poema de la luna, que su libro imprimió en hemistiquios, va aquí en versos enteros.

Cada poema chino va a dos versos por renglón, de modo que cada renglón queda frente a los dos versos ingleses que lo traducen. El blanco entre las estrofas del poema de la luna es del editor y está en las dos páginas.
:::

:::paragraphs{style="colophon"}
Compuesto en Noto Serif TC, LXGW WenKai TC y Noto Sans TC para el chino, y en Source Serif 4 y Source Sans 3 para el inglés, todas con la licencia SIL Open Font License · Texto chino: Wikisource · Versos ingleses: H. A. Giles, *A History of Chinese Literature*, 1901, libro electrónico 43711 del Proyecto Gutenberg
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Noto Serif TC': ['400', '700', '900'], // SERIF: the notes; the titles; 唐詩
  'LXGW WenKai TC': ['400'], // KAI: the poems and the seal
  'Noto Sans TC': ['400'], // HEI: the poet lines, the recto's head
  'Source Serif 4': ['400', '400i'], // ROMAN: the verse, the titles, the note on the text
  'Source Sans 3': ['400', '400i', '600'], // LABEL: kickers, the verso's head, folios, colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each Chinese face loads the files of the characters it sets
const grab = (re) => (markdown.match(re) ?? []).join('');
const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '0123456789'; // and note numbers
await loadFonts(FONTS, markdown);
await Promise.all([
  loadCjkFonts({ [SERIF]: ['400'] }, markdown), // the notes, and the heading blocks
  loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)),
  loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)),
  loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`),
  loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)),
]);
// #endregion
const doc = await buildWithFonts(
  () => buildDocument({ markdown: indentVerse(markdown) }, config()), markdown);
showPages(doc, { title: t({ en: 'Tang poems facing their English verse',
  es: 'Poemas Tang frente a su versión inglesa' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Pon el original en la página par

Las ediciones Loeb y muchos libros bilingües de chino e inglés ponen el original a la izquierda: intercambia las paridades de los dos estilos de título y de las dos cabeceras que nombran el libro y el poeta, y escribe en el Markdown cada título y poema chinos antes que su versión inglesa.

```diff
-const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
+const english = { id: 'en', breakBefore: { enabled: true, parity: 'odd' }, // a recto
-const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'even' },
-  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
+  head('verso-book', '{subtitle}', 'odd', 'top-right', -(EDGE + 8),
-  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
+  head('recto-poet', '{attr.name}', 'even', 'top-left', EDGE + 8,
```

### Pon las notas justo debajo del poema

Las antologías chinas anotadas (注釋本) imprimen las notas tras cada poema. Las notas de fin de capítulo lo hacen aquí, porque cada poema es un capítulo.

```diff
-const footnotes = { placement: 'column', // the foot of the column, here the foot of the page
+const footnotes = { placement: 'chapterEnd', chapterEndAlign: 'text',
```

## Errores frecuentes

- **Un estilo de título hereda el salto de página de su nivel.** Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }.
- **La página 1 es impar: planifica con números físicos.** La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue.
- **Las fuentes chinas se cargan por fragmentos, con el bloque cjk.** Fontsource sirve una familia china, japonesa o coreana en un centenar de archivos por peso, cada uno con un intervalo de caracteres. loadFonts solo descarga el archivo latin, así que en pantalla los caracteres chinos salen de una fuente del sistema y se miden mal, y fontsourceProvider entrega al PDF ese archivo latin, que los imprime como cajas vacías. Añade el bloque cjk del kit, llama a loadCjkFonts(FONTS, markdown) después de loadFonts (una vez por voz, con el texto que compone, si el libro usa varias fuentes chinas) y pasa a renderToPdf fontProvider: cjkPdfProvider: ambos toman los archivos que contienen los caracteres del texto.
- **Etiqueta el documento zh-Hans o zh-Hant, no con LANG.** Las ediciones de una receta son en y es, pero una muestra china es china en las dos: `locale: LANG` la etiquetaría como inglés o español, separaría sus palabras latinas, llamaría Figure o Figura a sus figuras y daría al PDF un idioma equivocado. Escribe tú la etiqueta: 'zh-Hans' (convenciones de la China continental: corte GB, puntuación Kaiming) o 'zh-Hant' (Taiwán: puntuación de ancho completo centrada); 'zh-HK' para Hong Kong. Un 'zh' a secas se lee como chino simplificado continental.
- **Una fuente china compone comillas y apóstrofos a ancho completo, también en texto latino.** Noto Serif TC y las demás fuentes de chino tradicional dibujan ‘ ’ “ ” con un cuadratín de ancho, como las usa el texto chino, y ese es el signo que recibe una palabra latina compuesta con la fuente: Li Bai’s sale con un hueco ancho tras el apóstrofo y una cita inglesa abre y cierra con un cuadratín en blanco. Una fuente china tampoco tiene cursiva, y la inclinación simulada en pantalla sale recta en el PDF. El texto que mezcla chino y una lengua latina en una sola fuente (las notas al pie, que usan la fuente del cuerpo, o las glosas con pinyin) se lee mejor sin apóstrofos, comillas inglesas ni cursivas; « » y los signos de Latin-1 conservan su ancho. Cuando el texto latino va aparte, dale una fuente latina con un estilo de párrafo.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: Tres poemas Tang en caracteres tradicionales, 靜夜思 y 月下獨酌 de Li Bai y 竹里館 de Wang Wei, como los dan los Trescientos poemas Tang (1763): Li Bai, Wang Wei ([fuente](https://zh.wikisource.org/wiki/唐詩三百首)), dominio público
- Texto: El 絕句 de Du Fu, el segundo de sus 絕句二首, como lo imprime el Quan Tang shi, la poesía completa de los Tang (1705, juan 228): Du Fu ([fuente](https://zh.wikisource.org/wiki/全唐詩/卷228)), dominio público
- Texto: Las traducciones inglesas en verso, de A History of Chinese Literature (1901), libro electrónico 43711 del Proyecto Gutenberg: Herbert A. Giles ([fuente](https://www.gutenberg.org/ebooks/43711)), dominio público
- Texto: Las notas, los títulos ingleses, la nota sobre el texto y el colofón: Postext Cookbook, original
- Tipografías: Noto Serif TC (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1), Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1)
- Código: MIT · Contenido de ejemplo: MIT

## Relacionadas

- [N.º 043 · Traducción enfrentada, estrofa a estrofa](https://postext.dev/es/cookbook/bilingual-facing-verse.md): Cada poema es un recuadro sin marco con dos columnas: un corte fijo abre la inglesa en su título y :::space deja cada estrofa a la altura de su original. · Nivel 2 (Intermedio) · Poesía
- [N.º 034 · Fichas de catálogo frente a sus láminas](https://postext.dev/es/cookbook/catalogue-facing-plates.md): Cada ficha abre página par y cita su lámina al empezar el comentario; la lámina flota a la impar de enfrente con 221 mm de alto y el ancho que da su proporción. · Nivel 3 (Avanzado) · Catálogos
- [N.º 015 · Poemas compuestos verso a verso](https://postext.dev/es/cookbook/poetry-collection.md): Cada verso es un párrafo, y el que no cabe sigue con 4 em de sangría francesa. Los espacios eme guardan las sangrías de 1918; :::space separa las estrofas. · Nivel 2 (Intermedio) · Poesía
