# Cartilla de lectura con sílabas en chips

> Una unidad de cartilla dedicada a la eme, con cada sílaba en un chip del color de su vocal y seis palabras en una tabla, cada una bajo su dibujo.

- Versión HTML: https://postext.dev/es/cookbook/reading-primer-syllables
- Receta N.º 060 · Texto y tipografía · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Cuadernos y ejercicios
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [37](https://postext.dev/cookbook/reading-primer-syllables/es/p01.webp?v=14b7ab97), [38](https://postext.dev/cookbook/reading-primer-syllables/es/p02.webp?v=14b7ab97), [39](https://postext.dev/cookbook/reading-primer-syllables/es/p03.webp?v=14b7ab97)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/reading-primer-syllables.md)

## Lo que vas a componer

La unidad 7 de *Letra a letra*, una cartilla de lectura inventada: tres páginas dedicadas a la eme. La apertura compone una Mm de DynaPuff a 190 pt sobre una franja crema, junto a dos cosas cuyo nombre empieza por eme, una mariposa y una manzana. Las sílabas de la unidad van en chips del color de su vocal: rojo la a, naranja la e, turquesa la i, azul la o y morado la u. Las que llevan eme van rellenas y las demás, solo con el contorno, así que el niño ve qué mitad de *mesa* es de esta unidad. La página 38 reúne seis palabras con dibujo en una tabla de esquinas redondeadas y tres pautas para repasar. La 39 trae cuatro frases a 24 pt, un recuadro de sílabas para colorear y una nota para la familia.

**Esta receta responde a:**

- ¿Cómo compongo sílabas en chips de colores, un color por vocal, para una cartilla de lectura?
- ¿Cómo pongo imágenes o iconos dentro de las celdas de una tabla?
- ¿Cómo añado espacio vertical entre dos bloques, si las líneas en blanco no hacen nada?
- ¿Cómo hago una ficha de ejercicios: líneas para rellenar, cajas de respuesta, bancos de palabras, listas de comprobación?

## La respuesta corta

```js
// script.js, líneas 27–47
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
  ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
    { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
    { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
  ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
  { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
    borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, end) => `:chip[${s}]{style="${vowel(s)}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
  before + word.split('·').map((s) => chipOf(s, before ? '-frase' : '')).join(''));
```

## Ingredientes

**Enseña**

- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip): Cajas redondeadas alrededor de palabras que pasan de línea como una unidad y nunca se estiran: teclas, etiquetas, bancos de palabras, sílabas.
- [Imágenes en las celdas](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Un mapa de bits o un SVG dibujado dentro de una celda, sin numerar, con el texto de la celda debajo; la fila crece hasta que cabe.

**También usa**

- [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)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Estilos de tabla con nombre](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)

**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), [`chipStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-chip), [`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), [`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), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)

**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), [`setCellImage`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)

**Tipografías**

- Andika (OFL-1.1), DynaPuff (OFL-1.1), Playpen Sans (OFL-1.1)

## Elaboración

### 1 · Un estilo de chip por vocal y una función que escribe los chips

[La respuesta corta](#la-respuesta-corta) saca los 21 estilos de chip de un mismo objeto, `syllable`. Hay uno relleno por vocal para las sílabas con eme, otro con contorno por vocal para las demás, un gemelo `-frase` de cada uno de esos diez y `pinta`, el contorno en tinta que el niño colorea. La función `syllables()` convierte `{mi·mo·sa}` en tres `:chip[…]` sin nada entre ellos, así que las sílabas de una palabra se tocan y el borde blanco marca la junta. Donde hay un espacio entre palabras, el chip guarda como mínimo su `gap`: con 0,5 em, las palabras de una fila de 24 pt quedan a 4,2 mm, y el espacio solo dejaría 2,3 mm. Dentro de una frase, ese `gap` casi duplicaría el espacio delante de la palabra nueva; por eso, cuando la palabra va detrás de otra, lleva los gemelos `-frase`, con `gap` 0, y el espacio se queda en 2,3 mm.

### 2 · Un interlineado que deja sitio a los chips

```js
// script.js, líneas 145–155
  // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
  // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
  paragraphStyles: [
    { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
      color: col('muted') }, // the instruction under each activity
    { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
    { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
    { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
    { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
      color: col('muted'), marginTop: pt(GRID) },
  ],
```

El relleno vertical y el borde de un chip se pintan fuera de su línea y no suman interlineado, así que un chip más alto que el interlineado choca con los chips de la línea de abajo. Un chip de sílaba a 24 pt mide 10,4 mm de alto; con 30 pt de interlineado (10,6 mm), los chips de dos frases quedarían a 0,1 mm. Por eso las líneas que lee el niño llevan 45 pt y las filas de sílabas 60 pt, tres y cuatro líneas de la rejilla base de 15 pt en la que van las consignas.

### 3 · Un dibujo en cada celda

```js
// script.js, líneas 94–106
const PICTURE_WORDS = ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'];
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
  headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
  bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
  .map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the drawing above the word, 0.72 of the cell's width
  const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
  grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});
```

`setCellImage` pone un dibujo encima del texto de una celda con una fracción de su ancho interior, aquí 0,72 (36,5 mm), y la fila crece para que quepa. Bajo cada dibujo va la palabra en chips de sílabas, que una celda admite como cualquier párrafo. El estilo de tabla `dibujos` redondea el marco y sus rellenos a 5 mm y separa las baldosas crema con filetes blancos de 4 pt.

### 4 · Una línea de espacio antes del recuadro

```js
// script.js, líneas 158–170
  calloutStyles: [
    { id: 'colorea', background: col('cream'), borderRadius: mm(4),
      marginTop: pt(0), // the :::space before the box is the whole gap above it
      padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
      body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
    { id: 'familia', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
      padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
        ...tag },
      body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
  ],
```

Entre la última frase y el recuadro para colorear, el Markdown lleva `:::space{lines=1}`, una línea de 15 pt (5,3 mm). El `marginTop` del recuadro es 0, así que esa línea es todo el espacio; sin ella, el recuadro empezaría justo donde acaba la línea de la última frase. Un `:::space` que abre un recuadro se descarta, de modo que el recuadro empieza con su enunciado y los chips de la muestra de colores, y su propio `:::space` va detrás, entre la muestra y la tarea de colorear.

### 5 · La apertura de la letra

```js
// script.js, líneas 59–77
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// Images reserve no height in an opener (gotcha: opener-image-no-reserve): by itself the
// opener ends at the foot of the word manzana, 88.7 mm down, and the first title starts on
// the wave's edge. 16 grid lines of minHeight start it on the grid, 11.4 mm below the wave.
const opener = { enabled: true, minHeight: pt(16 * GRID),
  slot: { elements: [
    picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
    words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
      box: { backgroundColor: col('ink'), borderRadius: mm(3),
        padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
    words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
      { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
    words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
    words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
    picture('butterfly', 'mariposa', at(146, 12, { width: mm(40) })),
    picture('apple', 'manzana', at(151, 53, { width: mm(30) })),
    ...[['mariposa', 45.5], ['manzana', 84.5]].map(([name, y]) => words(name, name, LABEL, 10,
      600, 'muted', at(146, y, { width: mm(40) }), { align: 'center' })), // under each picture
  ] } };
```

La Mm es el texto del título (`{titleText}`), y el número de la unidad, la serie y el nombre de la letra son atributos de esa misma línea. La franja, la mariposa y la manzana son imágenes, y una apertura no reserva altura para las imágenes. Sin `minHeight`, la apertura acaba al pie de la palabra *manzana*, a 88,7 mm del borde superior de la página, y el título de la primera actividad empieza a 99,3 mm, 2,6 mm por encima del punto más bajo de la onda (101,9 mm). Con un `minHeight` de 16 líneas de la rejilla base, la apertura termina a 102,7 mm, y las dos líneas de `marginTop` del título lo bajan a 113,3 mm, sobre la rejilla y 11,4 mm por debajo de la onda.

### 6 · Iconos junto a los títulos de actividad

```js
// script.js, líneas 81–90
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
  lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
  picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(ICON), height: mm(ICON) } }),
  words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
    edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
  { verticalAlign: 'middle' }), // centred on the icon
] } } });
```

Cada título de actividad nombra su estilo, como en `## Lee las frases {style="lee"}`, y el estilo dibuja un icono con el título centrado a su lado. Siguen siendo títulos de nivel 2, así que el espacio lo fija el nivel `H2`: dos líneas de la rejilla base de `lineHeight` para el título y otras dos de `marginTop` encima.

## 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/reading-primer-syllables

### script.js

```js
// ═══ Postext Cookbook · Nº 060 · Early reader with syllable chips ════════════════
// https://postext.dev/en/cookbook/reading-primer-syllables
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Andika, DynaPuff, Playpen Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, setCellImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'reading-primer-syllables';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#242832', paper: '#ffffff', muted: '#5f6470', // text; instructions
  cream: '#f6f0e3', rule: '#d3c9b6', sun: '#f2bd24', // the band and tiles; guides; drawings
  // The vowel code: a syllable takes its vowel's colour. White on these fills measures
  // 3.2–4.9:1, above WCAG's 3:1 for large text: the vowel chips are bold, 20 pt or more.
  a: '#d9482b', e: '#dd740c', i: '#1f9a8f', o: '#3f6fd8', u: '#9152cf' };
// Design elements read the hex and ignore the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults are linked to 'main-color': point it at the ink.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'DynaPuff', 'Playpen Sans']; // Andika: one-storey a
const [TOP, SIDE, GRID] = [18, 20, 15]; // mm, mm, pt: every activity starts on the 15 pt grid

// #region answer: one chip style per vowel, and {ma·má} in the text written out as chips
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
  ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
    { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
    { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
  ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
  { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
    borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, end) => `:chip[${s}]{style="${vowel(s)}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
  before + word.split('·').map((s) => chipOf(s, before ? '-frase' : '')).join(''));
// #endregion

const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const words = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text',
  id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color),
  align: 'left', overflow: 'wrap', placement, ...extra });
const picture = (id, resourceId, placement) => ({ kind: 'image', id, resourceId, placement });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.5) };

// #region opener: the letter's page: a cream band, a giant Mm and two things that start with m
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// Images reserve no height in an opener (gotcha: opener-image-no-reserve): by itself the
// opener ends at the foot of the word manzana, 88.7 mm down, and the first title starts on
// the wave's edge. 16 grid lines of minHeight start it on the grid, 11.4 mm below the wave.
const opener = { enabled: true, minHeight: pt(16 * GRID),
  slot: { elements: [
    picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
    words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
      box: { backgroundColor: col('ink'), borderRadius: mm(3),
        padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
    words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
      { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
    words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
    words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
    picture('butterfly', 'mariposa', at(146, 12, { width: mm(40) })),
    picture('apple', 'manzana', at(151, 53, { width: mm(30) })),
    ...[['mariposa', 45.5], ['manzana', 84.5]].map(([name, y]) => words(name, name, LABEL, 10,
      600, 'muted', at(146, y, { width: mm(40) }), { align: 'center' })), // under each picture
  ] } };
// #endregion

// #region activities: each activity heading draws its icon beside the title
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
  lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
  picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(ICON), height: mm(ICON) } }),
  words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
    edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
  { verticalAlign: 'middle' }), // centred on the icon
] } } });
// #endregion

// #region pictures: six picture words in a rounded grid, each drawing inside its cell
const PICTURE_WORDS = ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'];
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
  headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
  bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
  .map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the drawing above the word, 0.72 of the cell's width
  const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
  grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});
// #endregion

// The type of every drawing and the table: its empty captionPrefix prints no caption line.
const sheet = { id: 'lamina', name: 'Lámina', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
const here = (id, rest) => ({ id, typeId: 'lamina', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, ...rest });

const FOLIO = 9; // mm: the folio's disc
const folio = (parity, edge, x, textEdge, textX) => [
  words(`n-${parity}`, '{pageNumber}', DISPLAY, 12, 700, 'paper',
    at(x, -10, { width: mm(FOLIO), height: mm(FOLIO) }, edge),
    { align: 'center', verticalAlign: 'middle', parity,
      box: { backgroundColor: col('ink'), borderRadius: mm(FOLIO / 2) } }),
  words(`s-${parity}`, '{title} · {attr.unit}: {attr.name}', LABEL, 8, 600, 'muted',
    at(textX, -10, { width: mm(100), height: mm(FOLIO) }, textEdge),
    { parity, verticalAlign: 'middle', align: textEdge.endsWith('left') ? 'left' : 'right' }),
];

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, chipStyles, tableStyles, resourceTypes: [sheet],
  page: { width: mm(210), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } }, // equal sides: nothing to mirror
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(GRID), color: col('ink'),
    referenceColor: col('ink'), // the palette never reaches it (gotcha: palette-skips-designs)
    textAlign: 'left', firstLineIndent: pt(0) }, // ragged: 1.4.1 hyphenates no ragged text
  headings: { fontFamily: LABEL, // the titles print through designs, but are measured in it
    balancing: { enabled: false }, // or page 37's spare grid line goes above its first title
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // A unit opens on a recto. span 'page' paints the band into the top margin, where a
      // design kept in the column is cut off at the column's top edge.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener }, H2] },
  headingStyles: [activity('oye', 'oreja'), activity('lee', 'libro'), activity('mira', 'ojo'),
    activity('repasa', 'lapiz')],
  // #region reading: the child's lines on multiples of the 15 pt grid, the instructions at 10 pt
  // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
  // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
  paragraphStyles: [
    { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
      color: col('muted') }, // the instruction under each activity
    { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
    { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
    { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
    { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
      color: col('muted'), marginTop: pt(GRID) },
  ],
  // #endregion
  // #region boxes: an activity on cream, the note for the family under a rule
  calloutStyles: [
    { id: 'colorea', background: col('cream'), borderRadius: mm(4),
      marginTop: pt(0), // the :::space before the box is the whole gap above it
      padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
      body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
    { id: 'familia', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
      padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
        ...tag },
      body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
  ],
  // #endregion
  header: { elements: [] },
  footer: { elements: [ // the folio on the outer edge, the series beside it
    ...folio('odd', 'bottom-right', -SIDE, 'bottom-right', -(SIDE + FOLIO + 3)),
    ...folio('even', 'bottom-left', SIDE, 'bottom-left', SIDE + FOLIO + 3)] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Letra a letra"
---

# Mm {kicker="Letra a letra · Cartilla de lectura 1" unit="Unidad 7" name="la eme"}

## Escucha y repite {style="oye"}

:::paragraphs{style="consigna"}
Lee las sílabas en voz alta, en orden y después salteadas. Abajo, la eme va al final.
:::

:::paragraphs{style="fila"}
{ma} {me} {mi} {mo} {mu}

{am} {em} {im} {om} {um}
:::

## Lee las palabras {style="lee"}

:::paragraphs{style="consigna"}
Cada sílaba lleva el color de su vocal. Las que tienen eme van rellenas.
:::

:::paragraphs{style="palabras"}
{ma·má} {a·mo} {Me·mo} {e·me}

{mí·o} {mi·mo} {ma·mi} {mí·a}
:::

## Mira y lee {style="mira"}

:::paragraphs{style="consigna"}
Di el nombre de cada dibujo y léelo por sílabas.
:::

::resource{id="dibujos"}

## Repasa {style="repasa"}

:::paragraphs{style="consigna"}
Empieza en el punto y sigue la flecha. Luego repasa las sílabas.
:::

::resource{id="trazos"}

## Lee las frases {style="lee"}

:::paragraphs{style="consigna"}
Lee despacio. La primera vez que sale una palabra nueva, va partida en sílabas.
:::

:::paragraphs{style="lectura"}
Mi mamá me {mi·ma}.

Mamá me {a·ma}.

Yo amo a mi mamá.

Memo mima a {Mi·mí}.
:::

:::space{lines=1}

:::callout{type="colorea" title="Lee y colorea"}
Estos son los colores de las vocales:

:::paragraphs{style="palabras"}
:chip[a]{style="a"} :chip[e]{style="e"} :chip[i]{style="i"} :chip[o]{style="o"} :chip[u]{style="u"}
:::

:::space{lines=1}

Colorea cada sílaba con el color de su vocal.

:::paragraphs{style="palabras"}
:chip[mo]{style="pinta"} :chip[ma]{style="pinta"} :chip[mu]{style="pinta"} :chip[me]{style="pinta"} :chip[mi]{style="pinta"} :chip[ma]{style="pinta"}

:chip[mi]{style="pinta"} :chip[me]{style="pinta"} :chip[mo]{style="pinta"} :chip[mu]{style="pinta"} :chip[ma]{style="pinta"} :chip[mo]{style="pinta"}
:::
:::

:::callout{type="familia" title="Para la familia"}
Lean juntos las sílabas de la página 37: primero en orden y después salteadas. Si su hijo o su hija duda entre *ma* y *me*, no le dé la respuesta: señale la vocal y pregúntele de qué color es. Diez minutos al día bastan; es mejor parar cuando todavía tiene ganas de seguir.
:::

:::paragraphs{style="colofon"}
*Letra a letra* es una cartilla inventada para el Recetario de Postext. Texto y dibujos: CC BY 4.0.

Compuesta en Andika, DynaPuff y Playpen Sans (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the drawings, in the palette's colours
// No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts),
// so the tracing letters are strokes and every label is set by the page.
const P = palette;
const n = (v) => +v.toFixed(2);
const mix = (a, b, t) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
  * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t).toString(16).padStart(2, '0')).join('')}`;
const [SKIN, BROWN, WOOD] = [mix(P.e, P.paper, 0.72), mix(P.e, P.ink, 0.5), mix(P.e, P.ink, 0.35)];
const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const OUT = ` stroke="${P.ink}" stroke-width=".7" stroke-linejoin="round"`;
const shape = (d, fill, extra = OUT) => `<path d="${d}" fill="${fill}"${extra}/>`;
const line = (d, color, w, extra = '') => `<path d="${d}" fill="none" stroke="${color}" `
  + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
const dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const box = (x, y, w, h, r, fill, extra = OUT) => `<rect x="${x}" y="${y}" width="${w}" `
  + `height="${h}" rx="${r}" fill="${fill}"${extra}/>`;
const mirror = (w, body) => `${body}<g transform="translate(${w} 0) scale(-1 1)">${body}</g>`;
const g = (transform, body) => `<g transform="${transform}">${body}</g>`;

const apple = () => shape('M20 11.5C15 7.5 4.5 8.5 4.5 20.5C4.5 30.5 11.5 37.5 16.5 37.5C18 37.5 '
  + '19 36.6 20 36.6C21 36.6 22 37.5 23.5 37.5C28.5 37.5 35.5 30.5 35.5 20.5C35.5 8.5 25 7.5 20 '
  + '11.5Z', P.a) + `<ellipse cx="11.5" cy="19" rx="2.4" ry="4.4" fill="${P.paper}" `
  + 'fill-opacity=".4" transform="rotate(18 11.5 19)"/>'
  + line('M20 12C19.6 8.6 20.6 5.6 22.6 3.4', BROWN, 1.6)
  + shape('M21.6 7.8C23.4 3.4 28.6 2 32.6 3.2C31 7.6 26.2 9.8 21.6 7.8Z', P.i)
  + line('M22.8 7.3C25.8 6 28.6 4.8 31.2 3.8', P.paper, 0.5, ' stroke-opacity=".6"');
const butterfly = () => mirror(44, shape('M21 16C14 4 3 2.5 3 11C3 18 10 21 21 19Z', P.u)
  + shape('M21 19C12 20 6 26 9 31C12 35 18 30 21.5 22Z', mix(P.u, P.paper, 0.35))
  + dot(9.5, 11, 2.6, P.sun, OUT) + dot(12.5, 27.5, 1.6, P.sun, OUT)
  + line('M21.3 10C19.5 6 17.5 4.4 15.5 3.8', P.ink, 0.7) + dot(15.3, 3.7, 0.9, P.ink))
  + `<ellipse cx="22" cy="20" rx="1.9" ry="9.2" fill="${P.ink}"/>` + dot(22, 10.4, 2.1, P.ink);
const [STEM, LEAF] = [mix(P.i, P.ink, 0.25), mix(mix(P.i, P.muted, 0.5), P.paper, 0.15)];
// A mimosa leaf: pairs of leaflets swept towards the tip, shorter as they near it.
const frond = ([x1, y1, x2, y2]) => {
  const [dx, dy, deg] = [x2 - x1, y2 - y1, Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI];
  let out = line(`M${x1} ${y1}L${x2} ${y2}`, LEAF, 0.4);
  for (let t = 0.1; t < 0.97; t += 0.08) {
    const k = 1.1 * (1 - t * 0.5); // half the leaflet's length
    for (const turn of [55, -55]) {
      const a = (deg + turn) * Math.PI / 180;
      const [cx, cy] = [n(x1 + dx * t + Math.cos(a) * k), n(y1 + dy * t + Math.sin(a) * k)];
      out += `<ellipse cx="${cx}" cy="${cy}" rx="${n(k)}" ry="${n(0.38 * (1 - t * 0.3))}" `
        + `fill="${LEAF}" transform="rotate(${n(deg + turn)} ${cx} ${cy})"/>`;
    }
  }
  return out;
};
const drawings = {
  banda: () => svgDoc(210, BAND, shape(`M0 0H210V${BAND - 6}C190 ${BAND - 1} 172 ${BAND - 9} 150 `
    + `${BAND - 6}S102 ${BAND + 1} 76 ${BAND - 5} 28 ${BAND - 12} 0 ${BAND - 6}Z`, P.cream, '')),
  manzana: () => svgDoc(40, 40, apple()),
  mariposa: () => svgDoc(44, 36, butterfly()),
  mano: () => svgDoc(40, 30, g('rotate(-38 8.2 19.5)', box(6, 14, 4.4, 11, 2.2, SKIN))
    + [[12.2, 5, 14], [16.6, 3.4, 15.6], [21, 4.6, 14.4], [25.4, 7.4, 11.6]].map(([x, y, h]) =>
      box(x, y, 4.2, h, 2.1, SKIN)).join('') + box(11.6, 13, 17.8, 13.4, 5, SKIN)
    + line('M15 21.4C17.4 22.6 21 22.8 24 21.8', mix(SKIN, P.ink, 0.35), 0.6)
    + box(12.6, 25.6, 15.8, 4, 1.2, P.o)),
  mapa: () => svgDoc(40, 30, shape('M5 6L15 4L15 26L5 28Z', P.paper)
    + shape('M15 4L25 6L25 28L15 26Z', P.cream) + shape('M25 6L35 4L35 26L25 28Z', P.paper)
    + shape('M8 12C11 8 17 9 20 12S28 10 31 14C33 18 29 22 24 21S14 24 10 21C7 19 6 15 8 12Z',
      mix(P.i, P.paper, 0.5), '') + line('M6 25C12 21 18 26 24 23S31 19 34.4 21', P.o, 1)
    + line('M10 14C14 18 19 12 23 16S27 19 29 15', P.a, 0.9, ' stroke-dasharray="1.3 1.2"')
    + line('M27.8 13.2L31 16.4M31 13.2L27.8 16.4', P.a, 1.2)),
  mesa: () => svgDoc(40, 30, box(7, 15.6, 3, 12.4, 0.6, WOOD) + box(30, 15.6, 3, 12.4, 0.6, WOOD)
    + box(4, 12, 32, 3.6, 1.2, WOOD) + g('translate(9 1.2) scale(.27)', apple())
    + shape('M22.4 6.4H29.4L28.4 12H23.4Z', P.i) + line('M29 7.8C31.4 7.8 31.4 10.8 28.6 10.6',
      P.ink, 0.7)),
  mimosa: () => svgDoc(40, 30, line('M6 28.5C12 22 19 14 33 4', STEM, 1.1)
    + [[11.5, 22.5, 21, 27.3], [18.5, 15.5, 29, 19.5], [25.5, 9.8, 35.5, 12.5]].map(frond).join('')
    + [[12.5, 22, 6, 16.5], [18, 16, 13, 9.5], [24, 10.8, 20.5, 4.5]].map(([x1, y1, x2, y2]) =>
      line(`M${x1} ${y1}L${x2} ${y2}`, STEM, 0.6)).join('')
    + [[5.5, 17], [8.5, 20.5], [12, 10.5], [15.5, 13], [19.5, 5], [23, 7.5], [32, 4.5], [28.5, 7]]
      .flatMap(([x, y]) => [[0, 0], [2.1, 0.7], [-1.3, 1.7], [0.8, -1.9], [-1.9, -0.6]]
        .map(([dx, dy]) => dot(x + dx, y + dy, 1.25, P.sun, ` stroke="${mix(P.sun, P.e, 0.6)}" `
          + 'stroke-width=".35"'))).join('')),
  mono: () => svgDoc(40, 30, g('translate(20 15) scale(1.15) translate(-20 -15)', [9, 31]
    .map((x) => dot(x, 15, 4.2, BROWN, OUT)
    + dot(x, 15, 2.2, SKIN)).join('') + dot(20, 15, 10.5, BROWN, OUT)
    + shape('M20 11C17 7 11 8.5 11.5 14C12 20 15.5 24.5 20 24.5C24.5 24.5 28 20 28.5 14C29 8.5 '
      + '23 7 20 11Z', SKIN) + [16.5, 23.5].map((x) => dot(x, 14, 1.3, P.ink)
      + dot(x + 0.4, 13.6, 0.4, P.paper)).join('') + dot(19, 18.2, 0.5, P.ink)
    + dot(21, 18.2, 0.5, P.ink) + line('M16.5 20.2Q20 23.4 23.5 20.2', P.ink, 0.8))),
  muneca: () => svgDoc(40, 30, g('translate(20 15.4) scale(1.15) translate(-20 -17.5)', [13, 27]
    .map((x) => dot(x, 9, 3, BROWN, OUT)).join('')
    + mirror(40, shape('M9.6 6.2L12 7.6L9.8 9.4Z', P.a)) + line('M17 18.5L13.4 22.6', SKIN, 1.8)
    + line('M23 18.5L26.6 22.6', SKIN, 1.8) + box(16.6, 26, 2.4, 3, 0.6, SKIN)
    + box(21, 26, 2.4, 3, 0.6, SKIN) + shape('M20 15.6L12 27.2H28Z', P.u)
    + shape('M17.6 16.4H22.4L20 18.8Z', P.paper) + dot(20, 10, 6, SKIN, OUT)
    + shape('M14 9.4C14 3.8 26 3.8 26 9.4C23.2 7.4 16.8 7.4 14 9.4Z', BROWN)
    + dot(17.8, 10.6, 0.7, P.ink) + dot(22.2, 10.6, 0.7, P.ink)
    + dot(16.4, 12.4, 1, mix(P.a, P.paper, 0.55)) + dot(23.6, 12.4, 1, mix(P.a, P.paper, 0.55))
    + line('M18.8 13.2Q20 14.2 21.2 13.2', P.ink, 0.5))),
};
// The activity icons: a white glyph on an ink disc.
const icon = (glyph) => svgDoc(20, 20, dot(10, 10, 10, P.ink) + glyph);
Object.assign(drawings, {
  oreja: () => icon(line('M7.5 8.5C7.5 5 10 3.5 12.5 3.5C15.5 3.5 16.5 6 16.5 8C16.5 11 13.5 '
    + '11.5 13 14C12.5 16.5 10 17 8.5 15.5M10 8.5C10 7 11 6 12.5 6C13.8 6 14.3 7.2 14 8.3',
  P.paper, 1.5)),
  ojo: () => icon(line('M3.5 10Q10 3.5 16.5 10Q10 16.5 3.5 10Z', P.paper, 1.5)
    + dot(10, 10, 2.4, P.paper)),
  libro: () => icon(line('M10 6.5C7.5 5 5.5 5 4 5.8V14.8C5.5 14 7.5 14 10 15.5C12.5 14 14.5 14 '
    + '16 14.8V5.8C14.5 5 12.5 5 10 6.5ZM10 6.5V15.5', P.paper, 1.3)),
  lapiz: () => icon(line('M5.5 14.5L6.5 11L13.5 4L16 6.5L9 13.5ZM12 5.5L14.5 8', P.paper, 1.3)),
});
// Tracing: rows on writing guides, a model letter in ink, then dashed ones to go over.
const TRACE = { w: 170, first: 26, pitch: 20.5 }; // mm
const guides = (b) => line(`M0 ${b - 16}H${TRACE.w}M0 ${b}H${TRACE.w}`, P.rule, 0.4)
  + line(`M0 ${b - 8}H${TRACE.w}`, P.rule, 0.35, ' stroke-dasharray="1 1"');
// Letters as strokes: x-height 8, capitals 16, from the baseline b; [path, width].
const glyphs = {
  m: (x, b) => [`M${x} ${b - 8}V${b}M${x} ${b - 5.4}C${x} ${b - 7.4} ${x + 1.4} ${b - 8} `
    + `${x + 2.7} ${b - 8}S${x + 5.3} ${b - 7.2} ${x + 5.3} ${b - 5.4}V${b}M${x + 5.3} ${b - 5.4}`
    + `C${x + 5.3} ${b - 7.4} ${x + 6.7} ${b - 8} ${x + 8} ${b - 8}S${x + 10.6} ${b - 7.2} `
    + `${x + 10.6} ${b - 5.4}V${b}`, 10.6],
  M: (x, b) => [`M${x} ${b}V${b - 16}L${x + 6} ${b - 6}L${x + 12} ${b - 16}V${b}`, 12],
  a: (x, b) => [`M${x + 6.4} ${b - 4}A3.2 4 0 0 0 ${x} ${b - 4}A3.2 4 0 0 0 ${x + 6.4} ${b - 4}`
    + `M${x + 6.4} ${b - 8}V${b}`, 6.4],
  e: (x, b) => [`M${x + 0.2} ${b - 4}H${x + 6.8}C${x + 6.8} ${b - 6.4} ${x + 5.2} ${b - 8} `
    + `${x + 3.5} ${b - 8}S${x + 0.2} ${b - 6.2} ${x + 0.2} ${b - 4}S${x + 1.8} ${b} ${x + 3.7} `
    + `${b}C${x + 5} ${b} ${x + 6} ${b - 0.5} ${x + 6.7} ${b - 1.3}`, 6.8],
  i: (x, b) => [`M${x + 0.6} ${b - 8}V${b}M${x + 0.6} ${b - 11.4}V${b - 11}`, 1.2],
  o: (x, b) => [`M${x + 6.8} ${b - 4}A3.4 4 0 0 0 ${x} ${b - 4}A3.4 4 0 0 0 ${x + 6.8} ${b - 4}`,
    6.8],
  u: (x, b) => [`M${x} ${b - 8}V${b - 2.8}C${x} ${b - 1} ${x + 1.3} ${b} ${x + 3} ${b}S${x + 6.2} `
    + `${b - 1} ${x + 6.2} ${b - 2.8}M${x + 6.2} ${b - 8}V${b}`, 6.2],
};
const KERN = 1.8; // mm between letters
const write = (word, x, b) => word.split('').reduce(([d, at], ch) => {
  const [path, w] = glyphs[ch](at, b);
  return [d + path, at + w + KERN];
}, ['', x]); // [path, x after the last letter + KERN]
const dashed = ([d]) => line(d, P.muted, 1, ' stroke-dasharray="1.1 1"');
// The model: ink strokes, a teal dot where the pencil starts and an arrow beside the stem
// pointing the way the first stroke goes (dir 1 down, −1 up).
const model = (letter, b, [x, y], dir) => line(write(letter, 4, b)[0], P.ink, 1.3)
  + dot(x, y, 1.2, P.i) + line(`M${x - 2.4} ${y + dir * 1.5}V${y + dir * 6}`, P.i, 0.6)
  + shape(`M${x - 3.5} ${y + dir * 5.2}H${x - 1.3}L${x - 2.4} ${y + dir * 7.2}Z`, P.i, '');
const repeat = (letter, b) => Array.from({ length: 7 }, (_, k) =>
  dashed(write(letter, TRACE.first + k * TRACE.pitch, b))).join('');
// The five syllables, each centred in a fifth of the row.
const syllableRow = (b) => ['ma', 'me', 'mi', 'mo', 'mu'].map((syll, k) => {
  const w = write(syll, 0, b)[1] - KERN;
  return dashed(write(syll, (k + 0.5) * (TRACE.w / 5) - w / 2, b));
}).join('');
drawings.trazos = () => svgDoc(TRACE.w, 67, guides(17) + model('m', 17, [4, 9], 1)
  + repeat('m', 17) + guides(41) + model('M', 41, [4, 41], -1) + repeat('M', 41)
  + guides(65) + syllableRow(65));
const ALT = { banda: 'Franja de color crema con el borde ondulado',
  manzana: 'Una manzana roja con una hoja', mariposa: 'Una mariposa morada con manchas amarillas',
  mano: 'Una mano abierta', mapa: 'Un mapa plegado con un río y un camino hasta una cruz',
  mesa: 'Una mesa con una manzana y una taza', mono: 'La cara de un mono',
  mimosa: 'Una rama de mimosa con flores amarillas y hojas plumosas',
  muneca: 'Una muñeca con coletas y vestido morado',
  oreja: 'Una oreja', ojo: 'Un ojo', libro: 'Un libro abierto', lapiz: 'Un lápiz',
  trazos: 'Tres pautas: la eme minúscula y la mayúscula, cada una con un modelo y siete de puntos '
    + 'para repasar, y las sílabas ma, me, mi, mo y mu de puntos' };
// Each drawing is a resource that the opener, a heading style, a table cell or the text names
// by id. Sizes in px, 10 to the millimetre (the viewBox's own).
const size = (svg) => svg.match(/width="(\d+)" height="(\d+)"/).slice(1).map(Number);
const artwork = Object.entries(drawings).map(([id, draw]) => {
  const [width, height] = size(draw());
  return { id, typeId: 'lamina', kind: 'svg', altText: ALT[id], createdAt: 0, updatedAt: 0,
    svg: { fileId: `${id}.svg`, width, height }, ...(id === 'trazos' && here(id)) };
});
// #endregion

const resources = [...artwork, here('dibujos', { kind: 'table',
  table: { styleId: 'dibujos', model: grid } })];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Andika: ['400', '700'], DynaPuff: ['700'], 'Playpen Sans': ['400', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const text = syllables(markdown);
await Promise.all([loadFonts(FONTS, text), // every face before the build (gotcha: fonts-first)
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]);
// Page 1 is page 37 of the book: a recto, as a unit opener is.
const continuation = { pageIndexOffset: 36, pageNumbering: { startAt: 37 } };
const doc = await buildWithFonts(() => buildDocument({ markdown: text, resources, continuation },
  config()), text);
showPages(doc, { title: 'Letra a letra · La eme' });

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

### Convierte las sílabas en píldoras

Un radio mayor que la mitad de la altura del chip se recorta a esa mitad, así que un valor grande dibuja extremos redondos. Una sílaba de una sola letra sale casi redonda, y las juntas dentro de una palabra se convierten en muescas.

```diff
-  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
+  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5), borderRadius: em(1) };
```

## Errores frecuentes

- **Los chips más altos que el interlineado tocan la línea siguiente.** Un chip cuya caja es más alta que el interlineado toca los chips de la línea siguiente (el aviso chipOverlap). Reduce su relleno vertical, su borde o su cuerpo, o abre el interlineado.
- **El texto dentro de un SVG <img> no puede usar fuentes web.** Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie.
- **:::space se descarta al principio de un recuadro o columna.** :::space se descarta al principio de una columna, de un recuadro o de un grupo :::columns, aunque sea el único contenido de la caja, así que una caja de respuesta hecha solo de espacio se queda sin altura. Abre la caja con un título o una línea de enunciado y añade después el espacio.
- **Las imágenes de una apertura no cuentan para la altura que reserva.** En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto.
- **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.
- **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.
- **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.
- **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().
- **Aviso de maquetación: Los chips tocan la línea siguiente** (`chipOverlap`). La caja de un estilo de chip es más alta que el interlineado, así que los chips de líneas seguidas se tocan. Solución: Reduce el relleno vertical, el contorno o el cuerpo del chip, o aumenta el interlineado. ([Documentación](https://postext.dev/es/docs/configuration.md#estilos-de-chip))

Da a `minHeight` líneas enteras de la rejilla base. Con 85 mm, 0,33 mm más allá de la línea 16, el primer título empezó 0,33 mm fuera de la rejilla. Su bloque se alargó entonces hasta la línea siguiente, 15,5 mm en lugar de 10,6, y todo lo que va debajo en la página 37 bajó 5,3 mm.

Con el equilibrado de columnas activo, un `minHeight` de 16 líneas dejaba las actividades de la página 37 donde las dejaban 17: a la página le sobraba una línea de la rejilla base, y el equilibrado la ponía encima del título de la primera actividad. La configuración lo desactiva (`headings.balancing`), así que la línea sobrante se queda al pie de la página.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Andika (OFL-1.1), DynaPuff (OFL-1.1), Playpen Sans (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 022 · Ficha con cajas de respuesta y banco de palabras](https://postext.dev/es/cookbook/worksheet-answer-boxes.md): Una ficha de ciencias de cuatro páginas: cajas de respuesta blancas en tarjetas verde claro, a 2 mm de cada pregunta, y chips para bancos de palabras y huecos. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
- [N.º 049 · Examen con hoja de respuestas](https://postext.dev/es/cookbook/exam-paper.md): Un examen de historia de cuatro páginas, con burbujas y puntuaciones hechas con chips, preguntas numeradas 1, a), i) y renglones trazados con una tabla. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
- [N.º 048 · Listados de código y teclas sin bloques de código](https://postext.dev/es/cookbook/code-listings-and-keycaps.md): Guía de la terminal cuyos bloques de código pasan a ser recuadros oscuros antes de componer, con la negrita y la cursiva como colores y las teclas como chips. · Nivel 2 (Intermedio) · Manuales, guías y obras de consulta
