# Early reader with syllable chips

> A Spanish primer unit for the letter M: syllables as chips in the colour of their vowel, and a grid of picture words with a drawing in each cell.

- HTML version: https://postext.dev/en/cookbook/reading-primer-syllables
- Recipe Nº 060 · Type & text · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Workbooks & exercises
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [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)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/reading-primer-syllables.md)

## What you'll build

Unit 7 of *Letra a letra*, an invented Spanish reading primer: three pages on the letter M. Spanish primers teach reading syllable by syllable, so the sample is in Spanish only. The opener sets a 190 pt DynaPuff Mm on a cream band beside two things whose Spanish names start with m, a butterfly (*mariposa*) and an apple (*manzana*). The unit's syllables are chips in the colour of their vowel: red a, orange e, teal i, blue o, purple u. Syllables with an m are filled and the rest are outlined, so a child sees which half of *mesa* belongs to this unit. Page 38 sets six picture words in a rounded grid, a drawing in each cell, then three rows of letters to trace. Page 39 has four sentences at 24 pt, a box of syllables to colour in and a note for the family.

**This recipe answers:**

- How do I set syllables as coloured inline chips, one colour per vowel, in an early-reading primer?
- How do I put pictures or icons inside table cells?
- How do I add extra vertical space between two blocks, when blank lines do nothing?
- How do I build a worksheet page: fill-in lines, answer boxes, word banks, checklists?

## The short answer

One chip style per vowel, and {ma·má} in the text written out as chips.

```js
// script.js, lines 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(''));
```

## Ingredients

**Teaches**

- [Inline chips](https://postext.dev/en/docs/configuration.md#chip-styles): Rounded boxes around words that wrap as one unit and never stretch: keycaps, tags, word banks, syllables.
- [Pictures in table cells](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement): A bitmap or SVG drawn inside a cell, unnumbered, with the cell text under it and the row growing to fit.

**Also uses**

- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Anchoring design elements](https://postext.dev/en/docs/configuration.md#element-placement)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Named table styles](https://postext.dev/en/docs/configuration.md#named-table-styles)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Explicit vertical space](https://postext.dev/en/docs/document-format.md#space)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Figures exactly here](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Column balancing](https://postext.dev/en/docs/configuration.md#column-balancing)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`chipStyles`](https://postext.dev/en/docs/configuration.md#chip-styles), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyles`](https://postext.dev/en/docs/configuration.md#named-table-styles)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap), [`setCellImage`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)

**Typefaces**

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

## Method

### 1 · A chip style per vowel and a function that writes the chips

[The short answer](#the-short-answer) builds 21 chip styles by spreading one `syllable` object. There is a filled style per vowel for syllables with an m, an outlined one per vowel for the rest, a `-frase` twin of each of those ten, and `pinta`, the ink outline a child colours in. `syllables()` turns `{mi·mo·sa}` into three `:chip[…]` with nothing between them, so the syllables of a word touch and the white border draws the seam. Across a word space a chip keeps at least its `gap`, and 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 that gap would nearly double the space before the new word, so a word that follows another word gets the `-frase` twins, whose `gap` of 0 leaves the 2.3 mm space as it is.

### 2 · Leading that clears the chips

```js
// script.js, lines 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) },
  ],
```

A chip's vertical padding and border paint outside its line and add no leading, so a chip taller than the line pitch runs into the chips of the line below. A 24 pt syllable chip is 10.4 mm tall; at 30 pt leading (10.6 mm), the chips of two sentences would stand 0.1 mm apart. The lines a child reads get 45 pt and the rows of syllables 60 pt, three and four lines of the instructions' 15 pt grid.

### 3 · A drawing in every cell

```js
// script.js, lines 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` sets a drawing above a cell's text at a fraction of the cell's inner width, 0.72 here (36.5 mm), and the row grows to hold it. Under each drawing is the word in syllable chips. A table cell takes chip markup like any paragraph. The `dibujos` table style rounds the frame and its fills at 5 mm and parts the cream tiles with 4 pt white rules.

### 4 · One line of space before the box

```js
// script.js, lines 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) } },
  ],
```

The Markdown puts `:::space{lines=1}` between the last sentence and the colouring box, one 15 pt line (5.3 mm). The box's `marginTop` is 0, so that line is the whole gap; without it the box would start where the last sentence's line ends. A `:::space` that opens a box is dropped, so the box starts with its prompt line and the chips of the colour key, and its own `:::space` comes after them, between the key and the colouring task.

### 5 · The letter's opener

```js
// script.js, lines 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
  ] } };
```

The Mm is the heading's text (`{titleText}`); the unit number, the series and the letter's name are attributes on the same heading line. The band, the butterfly and the apple are images, and an opener reserves no height for images. Without `minHeight` the opener ends at the foot of the word *manzana*, 88.7 mm from the top of the page, and the first activity title starts at 99.3 mm, 2.6 mm above the lowest point of the wave (101.9 mm). A `minHeight` of 16 grid lines ends the opener at 102.7 mm, and the title's two grid lines of `marginTop` start it at 113.3 mm, on the grid and 11.4 mm below the wave.

### 6 · Icons beside the activity titles

```js
// script.js, lines 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
] } } });
```

Each activity title names its heading style, as in `## Lee las frases {style="lee"}`, and the style draws an icon with the title centred beside it. The titles are still level-2 headings, so the `H2` level spaces them: two grid lines of `lineHeight` for the title and two more of `marginTop` above it.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: 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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Round the syllables into pills

A radius over half the chip's height is clamped to that half, so a large value draws round ends. A one-letter syllable comes out nearly a circle, and the seams inside a word become notches.

```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) };
```

## Pitfalls

- **Chips taller than the line pitch touch the next line.** A chip whose box is taller than the line pitch touches the chips on the next line (the chipOverlap warning). Reduce its vertical padding, border or size, or open the leading.
- **Text inside an SVG <img> cannot use web fonts.** An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption.
- **:::space is dropped at the top of a box or column.** :::space is dropped at the top of a column, a box or a :::columns group, even when it is the box's only content, so an answer box made of space collapses. Open the box with a title or a prompt line, then add the space.
- **An opener's images never count towards the height it reserves.** In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().
- **Layout warning: Chips touch the next line** (`chipOverlap`). A chip style's box is taller than the line pitch, so chips on consecutive lines touch. Fix: Reduce the chip's vertical padding, outline or size, or increase the leading. ([Documentation](https://postext.dev/en/docs/configuration.md#chip-styles))

Give `minHeight` whole grid lines. At 85 mm, 0.33 mm past the 16th line, the first title started 0.33 mm off the grid. Its heading block then ran on to the next grid line, 15.5 mm tall instead of 10.6, and everything under it on page 37 moved down 5.3 mm.

With column balancing on, a `minHeight` of 16 lines left the activities of page 37 where 17 lines put them: the page had one grid line to spare, and balancing put it above the first activity title. The config turns balancing off (`headings.balancing`), so the spare line stays at the foot of the page.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Type: Andika (OFL-1.1), DynaPuff (OFL-1.1), Playpen Sans (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 022 · Worksheet with answer boxes and a word bank](https://postext.dev/en/cookbook/worksheet-answer-boxes.md): A four-page science worksheet: white answer boxes in pale green cards, 2 mm under each question and off the grid, with word banks and blanks made of chips. · Level 2 (Intermediate) · Workbooks & exercises
- [Nº 049 · Exam paper with an answer sheet](https://postext.dev/en/cookbook/exam-paper.md): A four-page history exam on US Letter, with bubbles and marks drawn as chips, questions numbered 1, a), i) and answer lines ruled by a table. · Level 2 (Intermediate) · Workbooks & exercises
- [Nº 048 · Code listings and keycaps without code blocks](https://postext.dev/en/cookbook/code-listings-and-keycaps.md): A shell guide whose fenced code becomes dark listing boxes before the build, with bold and italic runs as syntax colours and keys set as keycap chips. · Level 2 (Intermediate) · Manuals, guides & reference
