# De una cadena Markdown a una página diseñada

> Una cadena Markdown con frontmatter se compone en páginas de revista a dos columnas y se pinta en canvas, con seis colores en lugar del azul por defecto.

- Versión HTML: https://postext.dev/es/cookbook/first-page-from-markdown
- Receta N.º 012 · Salida e integración · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Revistas y fanzines
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/first-page-from-markdown/es/p01.webp?v=b86cfd94), [2](https://postext.dev/cookbook/first-page-from-markdown/es/p02.webp?v=b86cfd94)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/first-page-from-markdown.md)

## Lo que vas a componer

Las primeras páginas de un reportaje de viajes en una pequeña revista, *Cuaderno de campo*. La página 1 es una apertura en página impar: una banda añil que ocupa el 58 % superior, un dibujo de salinas y dunas, el título en Young Serif de 54 puntos y, debajo, dos columnas justificadas que arrancan con una frase en negrita. La página 2, a la vuelta, sigue bajo una cabecera en mayúsculas espaciadas y termina con un signo de fin y un colofón. Todo el diseño cabe en una función de configuración y una paleta de seis colores. El titular sale del título de primer nivel, y la entradilla, la autora y la fecha, del frontmatter del Markdown, así que el mismo pen compone cualquier artículo cuyo título quepa en dos líneas; solo el dibujo es propio de este.

**Esta receta responde a:**

- ¿Cómo paso una cadena Markdown a páginas diseñadas, sin el azul por defecto en títulos, negritas y viñetas?
- ¿Cómo preparo una página de libro: formato, márgenes interior y exterior simétricos, dos columnas y medianil?
- ¿Cómo convierto una cadena Markdown en páginas compuestas y dibujo una en un canvas?

## La respuesta corta

```js
// script.js, líneas 187–224
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. The restated break
      // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
```

## Ingredientes

**Enseña**

- [Metadatos del documento](https://postext.dev/es/docs/document-format.md#frontmatter): Bloque frontmatter en YAML con título, subtítulo, autor y fecha; lo imprimen los marcadores de las cabeceras y las cubiertas, y da el título del PDF.
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores): Colores con nombre a los que cada ajuste se enlaza por id, de modo que todo el documento cambia de tono al modificar una muestra; los colores por defecto salen de main-color.
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies): Ranuras de cabecera y pie con marcadores ({pageNumber}, {chapterTitle}, {title}…) distintas en recto y verso, con los títulos largos recortados o partidos.

**También usa**

- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Fuentes antes de componer](https://postext.dev/es/docs/configuration.md#caché-de-medidas)
- [Formato de página](https://postext.dev/es/docs/configuration.md#tamaños-de-página-predefinidos)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Una o dos columnas](https://postext.dev/es/docs/configuration.md#tipos-de-disposición)
- [Tipografía del texto](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Muestras de color](https://postext.dev/es/docs/document-format.md#formato-en-línea)
- [Separación silábica e idioma del documento](https://postext.dev/es/docs/justification.md#idiomas-soportados)
- [Listas de viñetas y de comprobación](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Saltos de línea en los títulos](https://postext.dev/es/docs/document-format.md#saltos-de-línea-en-los-títulos)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`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), [`unorderedLists`](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

- Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)

## Elaboración

### 1 · Todas las fuentes, antes de la primera composición

```js
// script.js, líneas 290–294
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
```

Postext mide el texto con las fuentes que el navegador ya ha cargado y guarda las medidas en caché, así que una composición que se adelanta a las fuentes conserva sus cortes de línea equivocados. El kit carga estos archivos de Fontsource, los mismos que incrustaría un PDF. Después, `buildWithFonts` repasa las fuentes que usan las páginas. Si un bloque o un elemento de diseño está compuesto en una fuente que no se ha cargado, avisa en la consola, la carga y vuelve a componer; si Fontsource no tiene esa fuente, el pen se detiene con un error (consulta «Errores frecuentes»). Las negritas y cursivas de una fuente de texto se cargan sin aviso, y solo si la familia las incluye.

### 2 · Nombra los colores y apunta a los nombres

```js
// script.js, líneas 23–43
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads
  sand: '#e9dcc0', // the title, the byline and the dunes
  salt: '#f7f4ee', // the standfirst, the salt pans and the cairn
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the band and the folios too.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
  // point it at the accent. Header, footer and opener defaults do not follow it, so they
  // are restated below.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
```

Cada color de la configuración es `col('id')`, un identificador de la paleta con su valor hexadecimal al lado. `bodyText.boldColor`, `italicColor`, `referenceColor` y `unorderedLists.color` apuntan a `ink`, y `headings.color`, al acento. Los valores por defecto del motor para títulos, negritas, cursivas y viñetas están enlazados a `main-color`, y esta paleta da a esa entrada el color del acento, de modo que cualquiera de ellos que no fijes sale añil y no azul. En Postext 1.4.1, los elementos de cabeceras, pies y aperturas, y también `referenceColor`, se pintan con el valor hexadecimal, no con el identificador; por eso los objetos de diseño son funciones que `config()` llama en cada composición, y `col()` copia cada vez el valor vigente de `palette`. El signo de fin es una muestra de color en línea que nombra `band`, así que también cambia con la paleta. La cabecera por defecto no lee la paleta y se queda azul hasta que la sustituyas (paso 5).

### 3 · Un dibujo hecho con código

```js
// script.js, líneas 47–111
function landscape(w, h) { // in mm: the page width by the depth of the band
  let seed = 11; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
  const rand = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
  const f = (n) => n.toFixed(1);
  const fill = (id, opacity) => `fill="${palette[id]}" fill-opacity="${opacity}"`;
  const gy = h - 36; // the horizon: the flats take the bottom 36 mm of the band
  const k = w / 170; // the flats are laid out across a 170 mm width, then scaled to the page
  // A low dune: a smooth, jittered ridge from (x0, y0) up to (w, y1), filled down to the foot.
  const dune = (x0, y0, y1, jitter, opacity) => {
    const p = Array.from({ length: 9 }, (_, i) => [x0 + ((w - x0) * i) / 8,
      y0 + ((y1 - y0) * i) / 8 - (i && i < 8 ? rand() * jitter : 0)]);
    const ridge = p.slice(1, -1).map(([x, y], i) =>
      `Q${f(x)} ${f(y)} ${f((x + p[i + 2][0]) / 2)} ${f((y + p[i + 2][1]) / 2)}`).join('');
    return `<path d="M${f(x0)} ${f(y0)}${ridge}L${f(w)} ${f(y1)}V${h + 8}H${f(x0)}Z" `
      + `${fill('sand', opacity)}/>`;
  };
  // The article's forty pans in perspective, whiter towards the front, each a little askew.
  const at = (u, v) => `${f(k * (36 - 32 * v + (56 + 50 * v) * u))},${f(gy + 2 + 34 * v ** 1.4)}`;
  let pans = '';
  for (let row = 0; row < 5; row++) {
    for (let c = 0; c < 8; c++) {
      const j = () => 0.05 * rand();
      const [u0, u1] = [(c + 0.06 + j()) / 8, (c + 0.94 - j()) / 8];
      const [v0, v1] = [(row + 0.1 + j()) / 5, (row + 0.9 - j()) / 5];
      const s = 0.012 * (rand() - 0.5); // a slight twist
      const white = Math.min(1, 0.6 + (0.36 * (row * 8 + c)) / 39 + 0.04 * rand());
      pans += `<polygon points="${at(u0 + s, v0)} ${at(u1 + s, v0)} ${at(u1 - s, v1)} `
        + `${at(u0 - s, v1)}" ${fill('salt', white.toFixed(2))}/>`;
    }
  }
  // The dunes at Sorra: a long windward slope, a sharp crest, a shaded slip face falling away
  // to the right, and a cairn of white stones on the crest.
  const [cx, cy] = [0.8 * w, 0.6 * h];
  const up = `M${f(0.44 * w)} ${gy + 2}C${f(0.58 * w)} ${gy - 3} ${f(cx - 18)} ${f(cy + 3)} `
    + `${f(cx)} ${f(cy)}`;
  const foot = `L${f(0.56 * w)} ${h}Q${f(0.48 * w)} ${gy + 12} ${f(0.44 * w)} ${gy + 2}Z`;
  const brink = `C${f(cx + 2)} ${f(cy + 10)} ${f(cx + 7)} ${h - 10} ${f(cx + 14)} ${h}`;
  const whole = `${up}C${f(cx + 6)} ${f(cy + 3)} ${f(cx + 20)} ${f(cy + 14)} ${w} ${f(cy + 22)}`
    + `V${h}${foot}`;
  const sorra = `<path d="${whole}" fill="${palette.band}"/>` // opaque: hides the pans behind
    + `<path d="${whole}" ${fill('sand', 0.74)}/><path d="${up}${brink}${foot}" `
    + `${fill('sand', 0.88)}/>`;
  let cairn = '';
  let y = cy + 0.6;
  for (const [sw, sh] of [[6.4, 2.2], [5, 2], [3.8, 1.8], [2.6, 1.5]]) {
    cairn += `<ellipse cx="${f(cx + 0.8 * (rand() - 0.5))}" cy="${f(y - sh / 2)}" `
      + `rx="${sw / 2}" ry="${sh / 2}" ${fill('salt', 1)}/>`;
    y -= sh * 0.82;
  }
  // The carriers' trail: across the pans, then up the windward slope to the cairn.
  const trail = `M${f(0.13 * w)} ${h}C${f(0.22 * w)} ${h - 14} ${f(0.38 * w)} ${gy + 14} `
    + `${f(0.48 * w)} ${gy + 5}C${f(0.6 * w)} ${gy} ${f(cx - 18)} ${f(cy + 6)} `
    + `${f(cx - 1)} ${f(cy + 0.5)}`;
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" `
    + `viewBox="0 0 ${w} ${h}">${dune(0, gy, gy - 3, 5, 0.22)}`
    + `<rect y="${gy + 1}" width="${w}" height="35" ${fill('sand', 0.1)}/>${pans}${sorra}`
    + `${dune(0.66 * w, h + 4, gy + 18, 3, 1)}<path d="${trail}" fill="none" `
    + `stroke="${palette.ink}" stroke-opacity="0.55" stroke-width="0.7" `
    + `stroke-dasharray="1.4 1.2"/>${cairn}</svg>`;
}
```

Las salinas, las dunas y el mojón son una cadena SVG construida con la misma paleta y un generador aleatorio con semilla: cada ejecución dibuja el mismo paisaje y la captura solo cambia cuando cambia el código. Se dibuja para un marco de `PAGE.width` × `BAND` milímetros y el recurso declara ese mismo marco, a 10 px por milímetro, de modo que el dibujo llena exactamente la banda y, si la banda crece, el dibujo crece con ella. La apertura lo muestra con un elemento de imagen que nombra el recurso por su identificador.

### 4 · El frontmatter escribe la apertura

```js
// script.js, líneas 115–153
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// under the horizon (BAND − 36 mm), onto the pale pans: keep titles short or deepen BAND
const DECK_W = 104; // mm: the standfirst stops short of the dune's crest (0.8 × PAGE.width)
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
        placement: { anchor: { to: 'bleed', edge: 'top-left' },
          size: { width: 'fill', height: mm(BAND) } } },
      // The drawing is PAGE.width × BAND (see resources): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
      // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        overflow: 'wrap', placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
```

El título de primer nivel abarca las dos columnas, y su apertura es una ranura de elementos: una caja añil a sangre, el dibujo y cuatro textos encadenados uno debajo de otro. Un título más largo empuja hacia abajo la entradilla y la firma en vez de montarse sobre ellas, pero a partir de la tercera línea la firma cae sobre las salinas claras. `{titleText}` es el texto del título; `{subtitle}`, `{author}` y `{publishDate}` salen del frontmatter. La `\\` del título corta la línea donde la pongas ([saltos de línea en los títulos](/es/docs/document-format#saltos-de-línea-en-los-títulos)). `minHeight` se cuenta desde el margen superior, así que la altura reservada acaba 5 mm por debajo de la banda.

### 5 · Cabeceras en lugar del azul por defecto

```js
// script.js, líneas 157–176
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
```

Si omites `header`, vuelve la cabecera por defecto del motor: Open Sans sobre un filete de lado a lado, en todas las páginas. Aquí cada folio se ancla a la página física y se filtra por paridad, así que siempre queda en el borde exterior. El texto que lo acompaña se ancla al folio, a `GAP` milímetros, con `right-of` o `left-of`, y un folio de tres o cuatro cifras lo desplaza en vez de chocar con él. `pages: 'body'` deja los cuatro elementos fuera de la apertura. El pie de página lleva un único folio, el de la apertura, y como `footer` está definido, las demás páginas pierden el folio centrado por defecto.

### 6 · Compón con una configuración nueva y pinta

```js
// script.js, líneas 299–307
await loadFonts(FONTS, markdown);
await loadSvg('landscape.svg', landscape(PAGE.width, BAND));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada' }),
});
```

`config()` es una función porque el motor guarda en caché cada configuración resuelta según la identidad del objeto, así que un ajuste editado solo surte efecto si cada composición recibe un objeto nuevo. `showPages` coloca las páginas como irían en un libro encuadernado, con la página 1 sola a la derecha, y pinta cada una con `renderPageToCanvas`.

## 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/first-page-from-markdown

### script.js

```js
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════
// https://postext.dev/en/cookbook/first-page-from-markdown
// Code: MIT · Text: original (CC BY 4.0) · Drawing: generated in code (CC BY 4.0)
// Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.4.1
//
// This pen sets a Markdown string with a frontmatter block on two magazine pages and paints
// them on canvases. The whole design is in config(); swap config() for {} in the build
// below to see the engine's defaults.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'first-page-from-markdown';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every setting that shapes the pages is in this part, from PAGE down to config().
const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side
const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on

// #region palette: six named colours; every colour in the config links to one of them
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads
  sand: '#e9dcc0', // the title, the byline and the dunes
  salt: '#f7f4ee', // the standfirst, the salt pans and the cairn
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the band and the folios too.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
  // point it at the accent. Header, footer and opener defaults do not follow it, so they
  // are restated below.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
// #endregion

// #region art: salt pans and the dunes at Sorra, drawn in code for a page × band frame
function landscape(w, h) { // in mm: the page width by the depth of the band
  let seed = 11; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
  const rand = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
  const f = (n) => n.toFixed(1);
  const fill = (id, opacity) => `fill="${palette[id]}" fill-opacity="${opacity}"`;
  const gy = h - 36; // the horizon: the flats take the bottom 36 mm of the band
  const k = w / 170; // the flats are laid out across a 170 mm width, then scaled to the page
  // A low dune: a smooth, jittered ridge from (x0, y0) up to (w, y1), filled down to the foot.
  const dune = (x0, y0, y1, jitter, opacity) => {
    const p = Array.from({ length: 9 }, (_, i) => [x0 + ((w - x0) * i) / 8,
      y0 + ((y1 - y0) * i) / 8 - (i && i < 8 ? rand() * jitter : 0)]);
    const ridge = p.slice(1, -1).map(([x, y], i) =>
      `Q${f(x)} ${f(y)} ${f((x + p[i + 2][0]) / 2)} ${f((y + p[i + 2][1]) / 2)}`).join('');
    return `<path d="M${f(x0)} ${f(y0)}${ridge}L${f(w)} ${f(y1)}V${h + 8}H${f(x0)}Z" `
      + `${fill('sand', opacity)}/>`;
  };
  // The article's forty pans in perspective, whiter towards the front, each a little askew.
  const at = (u, v) => `${f(k * (36 - 32 * v + (56 + 50 * v) * u))},${f(gy + 2 + 34 * v ** 1.4)}`;
  let pans = '';
  for (let row = 0; row < 5; row++) {
    for (let c = 0; c < 8; c++) {
      const j = () => 0.05 * rand();
      const [u0, u1] = [(c + 0.06 + j()) / 8, (c + 0.94 - j()) / 8];
      const [v0, v1] = [(row + 0.1 + j()) / 5, (row + 0.9 - j()) / 5];
      const s = 0.012 * (rand() - 0.5); // a slight twist
      const white = Math.min(1, 0.6 + (0.36 * (row * 8 + c)) / 39 + 0.04 * rand());
      pans += `<polygon points="${at(u0 + s, v0)} ${at(u1 + s, v0)} ${at(u1 - s, v1)} `
        + `${at(u0 - s, v1)}" ${fill('salt', white.toFixed(2))}/>`;
    }
  }
  // The dunes at Sorra: a long windward slope, a sharp crest, a shaded slip face falling away
  // to the right, and a cairn of white stones on the crest.
  const [cx, cy] = [0.8 * w, 0.6 * h];
  const up = `M${f(0.44 * w)} ${gy + 2}C${f(0.58 * w)} ${gy - 3} ${f(cx - 18)} ${f(cy + 3)} `
    + `${f(cx)} ${f(cy)}`;
  const foot = `L${f(0.56 * w)} ${h}Q${f(0.48 * w)} ${gy + 12} ${f(0.44 * w)} ${gy + 2}Z`;
  const brink = `C${f(cx + 2)} ${f(cy + 10)} ${f(cx + 7)} ${h - 10} ${f(cx + 14)} ${h}`;
  const whole = `${up}C${f(cx + 6)} ${f(cy + 3)} ${f(cx + 20)} ${f(cy + 14)} ${w} ${f(cy + 22)}`
    + `V${h}${foot}`;
  const sorra = `<path d="${whole}" fill="${palette.band}"/>` // opaque: hides the pans behind
    + `<path d="${whole}" ${fill('sand', 0.74)}/><path d="${up}${brink}${foot}" `
    + `${fill('sand', 0.88)}/>`;
  let cairn = '';
  let y = cy + 0.6;
  for (const [sw, sh] of [[6.4, 2.2], [5, 2], [3.8, 1.8], [2.6, 1.5]]) {
    cairn += `<ellipse cx="${f(cx + 0.8 * (rand() - 0.5))}" cy="${f(y - sh / 2)}" `
      + `rx="${sw / 2}" ry="${sh / 2}" ${fill('salt', 1)}/>`;
    y -= sh * 0.82;
  }
  // The carriers' trail: across the pans, then up the windward slope to the cairn.
  const trail = `M${f(0.13 * w)} ${h}C${f(0.22 * w)} ${h - 14} ${f(0.38 * w)} ${gy + 14} `
    + `${f(0.48 * w)} ${gy + 5}C${f(0.6 * w)} ${gy} ${f(cx - 18)} ${f(cy + 6)} `
    + `${f(cx - 1)} ${f(cy + 0.5)}`;
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" `
    + `viewBox="0 0 ${w} ${h}">${dune(0, gy, gy - 3, 5, 0.22)}`
    + `<rect y="${gy + 1}" width="${w}" height="35" ${fill('sand', 0.1)}/>${pans}${sorra}`
    + `${dune(0.66 * w, h + 4, gy + 18, 3, 1)}<path d="${trail}" fill="none" `
    + `stroke="${palette.ink}" stroke-opacity="0.55" stroke-width="0.7" `
    + `stroke-dasharray="1.4 1.2"/>${cairn}</svg>`;
}
// #endregion

// #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// under the horizon (BAND − 36 mm), onto the pale pans: keep titles short or deepen BAND
const DECK_W = 104; // mm: the standfirst stops short of the dune's crest (0.8 × PAGE.width)
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
        placement: { anchor: { to: 'bleed', edge: 'top-left' },
          size: { width: 'fill', height: mm(BAND) } } },
      // The drawing is PAGE.width × BAND (see resources): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
      // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        overflow: 'wrap', placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
// #endregion

// #region running-heads: folio and title on body pages, a drop folio under the opener
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
// #endregion

// The colophon: small sans under a 0.5 pt hairline, with no box around it.
const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
  padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } });

// #region answer: one config factory in place of the default skin: page, type, colour, slots
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. The restated break
      // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// content.<lang>.md, inlined by the Cookbook: every frontmatter value is quoted, since an ISO
// date or a number would print empty (gotcha: quote-frontmatter). The end mark is an inline
// swatch that names `band`, so it follows the palette too.
const markdown = String.raw`---
title: "La ruta de la sal"
subtitle: "Cuatro días a pie por la senda de los porteadores, de las salinas de Arvela al mercado de Castrel."
author: "Lena Varga"
publishDate: "mayo de 2026"
---

# La ruta \\ de la sal

**Los porteadores salían del dique de Arvela.** Con la marea baja, las marismas se extienden tres kilómetros al pie del dique, una llanura gris surcada de caños, y desde lo alto se ven las salinas antes que el mar: cuarenta balsas de salmuera dispuestas como un tablero de ajedrez, cada una un poco más blanca que la anterior.

Durante seis siglos, la sal de Arvela salió de la costa a cuestas de hombres y mujeres, que cargaban al amanecer treinta kilos por cesta y caminaban tierra adentro por una senda que nadie llegó a empedrar: cruzaban las marismas, las dunas de Sorra y el pinar, y subían hasta el mercado de Castrel, a cuatro días de marcha. El tren acabó con el oficio en 1911, pero los caminantes han mantenido abierta la senda.

La recorrí en abril con Tomás Reis, cuya abuela cargó sal de niña y que lleva veinte años señalando la ruta con mojones de piedras blancas: el invierno los derriba y él los vuelve a levantar cada mayo.

—Busca la *sal* en el suelo —me dijo la primera mañana—. Donde se derramó, no ha vuelto a crecer nada.

El segundo día, ya en las dunas, la senda desaparece durante horas, y de pronto asoma en la arena una veta pálida de unos centímetros de ancho, como trazada con tiza. Es sal caída de las cestas durante seis siglos, una costra que el viento descubre cada primavera. Tomás camina a su lado y nunca la pisa.

Esa noche dormimos en una hondonada entre las dunas que los porteadores llamaban la Cocina, porque allí no llega nunca el viento. Tomás no encendió fuego. Cenó pan y pescado seco y habló de su abuela, que distinguía la sal de Arvela de la de las salinas del sur con probar un solo grano y que, según él, jamás perdió la senda en la niebla.

## La carga de retorno

Las cestas volvían a la costa tan llenas como habían salido. Un libro de cuentas de 1887, que se conserva en el archivo parroquial de Sorra, abre con las cargas de regreso de una primavera:

- **Grano** de las granjas de la sierra, sobre todo centeno y cebada.
- **Resina y brea** de pino, para calafatear cada año las barcas de Arvela.
- **Lana y cueros** de la feria de mayo de Castrel.
- **Clavos y anzuelos** de la herrería de Orsa.

Hasta que llegó el tren, una cesta de sal se cambiaba por una cesta de grano. Un buen porteador hacía once viajes de ida y vuelta al año, ochenta y ocho días de camino.

En el libro hay tres letras. La primera, una caligrafía esmerada, anota cada carga al medio kilo; la segunda, desde 1893, no escribe más que cifras; la tercera, una letra infantil, aparece en los inviernos y escribe siempre «Castrell».

El libro se interrumpe en el otoño de 1910, a media página, con una carga de cebada que nunca se anotó como entregada. En Sorra nadie sabe por qué. Tomás cree que el porteador se marchó a tender la vía del tren, que pagaba en moneda.

## Hacia los pinos

El tercer día es el más largo. La senda entra en el pinar por Orsa y sube durante ocho horas entre troncos que huelen a resina, junto a las casas de posta en ruinas donde los porteadores dormían sobre paja y pagaban la cama con sal. Cada casa tiene un mojón junto a la puerta, y cada mojón, una piedra blanca que puso Tomás.

La mejor conservada está en los Nueve Pozos, a una hora por debajo del collado. El dintel de la puerta tiene hileras de muescas cortas, una por cada noche que durmió allí un porteador, y en algunos tramos están tan juntas que se han gastado hasta formar un solo surco. Tomás intentó contarlas un invierno y lo dejó al llegar a cuatro mil.

La cuarta tarde, la senda deja atrás los pinos y sube a una loma pelada, y abajo aparece Castrel: un pueblo gris en torno a una plaza que todavía se llama el Mercado de la Sal, aunque hace cien años que nadie vende sal allí. Como hacían los porteadores, Tomás saca un puñado de sal del bolsillo, lo deja en el borde de piedra de la fuente y bebe.

Luego da media vuelta y echa a andar de nuevo hacia el mar. Los porteadores nunca dormían en Castrel, dice, porque las posadas del pueblo cobraban en moneda. :swatch{color="band"}

:::callout{type="colophon"}
Obra de ficción: Arvela, Sorra, Orsa y Castrel son lugares imaginarios. Compuesto en Newsreader, Young Serif e Inter Tight (SIL Open Font License) · Texto e ilustración: originales, CC BY 4.0.
:::
`;
// The drawing is a resource, drawn for a PAGE.width × BAND mm frame at 10 px per mm: the
// opener's image element points at it by id.
const resources = [
  { id: 'landscape', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'landscape.svg', width: PAGE.width * 10, height: BAND * 10 } },
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// #region fonts: every face the design uses, loaded first (gotcha: fonts-first)
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: fonts, the drawing, one buildDocument call with a fresh config, then paint
await loadFonts(FONTS, markdown);
await loadSvg('landscape.svg', landscape(PAGE.width, BAND));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada' }),
});
// #endregion

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

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

## Variantes

### Mira lo que sustituye la configuración

Compón con una configuración vacía y el mismo Markdown sale en una página de 17 × 24 cm, en EB Garamond de 8 puntos, con títulos azules en Open Sans y una cabecera azul sobre un filete; el kit carga esas fuentes por ti, con un aviso en la consola.

```diff
-  () => buildDocument({ markdown, resources }, config()), markdown);
+  () => buildDocument({ markdown, resources }, {}), markdown);
```

### Cambia el color de toda la página

Cambia el acento y la siguiente ejecución pinta con el nuevo color la banda, los folios, los ladillos y el signo de fin, porque `col()` copia el nuevo valor junto al identificador cada vez que se llama a `config()` y la función del paisaje lee `palette` cada vez que dibuja.

```diff
-  band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads
+  band: '#3f5b3a', // the one accent (a pine green): the band, the folios, the subheads
```

### Pinta una página en tu propio canvas

`renderPageToCanvas` da al canvas el tamaño de la página en píxeles a la resolución del documento (300 ppp por defecto) multiplicado por `scale`, así que `scale: 0.5` pinta a unos 150 ppp, y el CSS decide lo grande que se ve en pantalla.

```diff
-showPages(doc, {
-  title: t({ en: 'From a Markdown string to a designed page',
-    es: 'De una cadena Markdown a una página diseñada' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });
```

## Errores frecuentes

- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **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.
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **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.
- **fontFamily es un nombre de familia, nunca una pila CSS.** Una pila como 'Lora, serif' se lee como una única familia que no existe, así que el texto se mide sin aviso con una fuente de reserva y el canvas, el HTML y el PDF no coinciden. Escribe una sola familia.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.

- Young Serif tiene un solo peso. Los títulos van en negrita por defecto, así que aquí `headings.fontWeight: 400` es obligatorio: sin él, el kit busca una Young Serif negrita que Fontsource no tiene y el pen se detiene con un error.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 004 · Reportaje de revista: de la foto de apertura al signo final](https://postext.dev/es/cookbook/magazine-feature-opener.md): Una apertura advancedDesign pone una foto a sangre y saca del título antetítulo, titular, entradilla y firma; siguen recuadros flotantes y un chip de cierre. · Nivel 3 (Avanzado) · Revistas y fanzines
- [N.º 005 · Cabeceras según la paridad en un libro de ensayos](https://postext.dev/es/cookbook/running-heads-by-parity.md): Cabeceras por paridad y tipo de página: el título del libro en las pares, el del ensayo cortado en las impares y solo un folio al pie en las aperturas. · Nivel 2 (Intermedio) · Narrativa, teatro y prosa literaria
- [N.º 003 · Apertura de capítulo sobre banda a sangre](https://postext.dev/es/cookbook/chapter-opener-bleed-band.md): Apertura advancedDesign del título de nivel 1: banda a sangre con el número de capítulo sobre su filete, y antetítulo y entradilla tomados de sus atributos. · Nivel 3 (Avanzado) · Libros de texto
