# Capítols de conte amb vinyetes i sumaris

> Un sol estil de títol obre tots els capítols; cada títol anomena en atributs la seva vinyeta i el seu sumari en cursiva, que pinta l'obertura.

- Versió HTML: https://postext.dev/ca/cookbook/storybook-chapter-vignettes
- Recepta Núm. 066 · Títols i obertures · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Narrativa, teatre i prosa literària
- Requereix postext ≥ 1.8.0 · provada amb 1.8.0 el 2026-09-28
- Pàgines: [6](https://postext.dev/cookbook/storybook-chapter-vignettes/es/p01.webp?v=b34a59f6), [7](https://postext.dev/cookbook/storybook-chapter-vignettes/es/p02.webp?v=b34a59f6), [8](https://postext.dev/cookbook/storybook-chapter-vignettes/es/p03.webp?v=b34a59f6), [9](https://postext.dev/cookbook/storybook-chapter-vignettes/es/p04.webp?v=b34a59f6), [10](https://postext.dev/cookbook/storybook-chapter-vignettes/es/p05.webp?v=b34a59f6)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=storybook-chapter-vignettes&lang=es (.postext: https://postext.dev/cookbook/storybook-chapter-vignettes/es/storybook-chapter-vignettes.postext)
- Última actualització: 2026-09-28
- Altres idiomes: [en](https://postext.dev/en/cookbook/storybook-chapter-vignettes.md), [es](https://postext.dev/es/cookbook/storybook-chapter-vignettes.md), [zh](https://postext.dev/zh/cookbook/storybook-chapter-vignettes.md), [ar](https://postext.dev/ar/cookbook/storybook-chapter-vignettes.md)

## En poques paraules

Les primeres pàgines d'una edició infantil italiana de Pinotxo. Cada capítol comença amb una il·lustració pròpia en aquarel·la i un breu resum del que hi passa.

## Què compondràs

Les primeres pàgines d'una edició infantil italiana de *Le avventure di Pinocchio*, de Collodi, compostes en Averia Serif Libre de 13/19 pt, en format de 190 × 240 mm. Davant del capítol I, a la pàgina parella, va una portada il·lustrada: una aquarel·la dels turons toscans, on un carro estirat per un ase porta llenya al taller d'un fuster, per sobre del títol en Fredericka the Great. Cada capítol comença sota una vinyeta en aquarel·la que es fon amb el paper (mestre Ciliegia amb la destral enlaire sobre el tronc que parla al capítol I, Geppetto amb la seva perruca groga rebent el tronc al II) i continua amb la línia del capítol i el sumari de Collodi en cursiva centrada. Les quatre imatges es van generar amb models de difusió a partir d'unes mateixes indicacions d'estil i la paleta de la pàgina. Només porten capçalera les pàgines 8 i 9, totes dues sense obertura. Els folis van en discos color fusta, i el capítol I acaba amb dues cireres sense peu ni número.

**Aquesta recepta respon a:**

- Com encapçalo cada capítol d'un llibre infantil amb una vinyeta diferent i un sumari en cursiva?
- Com componc il·lustracions sense numerar: ornaments, vinyetes, logotips?
- Com afegeixo a una obertura una línia d'autor, una entradeta o un primer paràgraf amb caplletra?
- Com amago les capçaleres a les obertures i a les pàgines en blanc, o pinto una pàgina parella en blanc amb el color de la part?

## La resposta curta

```js
// script.js, línies 45–67
// # Capitolo I {style="capitolo" vignetta="ceppo" summary="Come andò che Maestro Ciliegia…"}
// resourceId takes placeholders like a design text: '{attr.vignetta}' draws the resource the
// heading names, so every chapter shares one style.
const VIGNETTE = { width: 84, height: 56 }; // mm, centred at the head of the text block
const SUMMARY = 124; // mm: the summary's measure, narrower than the text's
const opener = { enabled: true,
  // A floor for the height the opener reserves, in whole lines, so the text under chapter I's
  // two-line summary starts on the same grid line as the text under chapter II's three lines.
  minHeight: pt(14 * LEAD),
  slot: { elements: [
    { kind: 'image', id: 'vignette', resourceId: '{attr.vignetta}',
      placement: at('container', 'top', 0, 0,
        { width: mm(VIGNETTE.width), height: mm(VIGNETTE.height) }) },
    // Placed from the container, not from #vignette: images do not count in the height an
    // opener reserves (gotcha: opener-image-no-reserve), the texts under them do.
    text('chapter', '{titleText}', DISPLAY, 24, at('container', 'top-left', trail(2.4),
      VIGNETTE.height + 6, { width: mm(MEASURE) }), { ...caps(2.4), lineHeight: 1 }),
    text('summary', '{attr.summary}', TEXT, 12.5, at('#chapter', 'below',
      (MEASURE - SUMMARY) / 2 - trail(2.4), 5, { width: mm(SUMMARY) }),
    { italic: true, lineHeight: 1.38 }), // a multiple (gotcha: design-lineheight-multiple)
  ] },
};
const chapter = { id: 'capitolo', advancedDesign: opener };
```

## Ingredients

**Ensenya**

- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat): Un títol dibuixat com a composició lliure de textos, filets, caixes i imatges, que reserva l'alçada que necessita sobre el text.
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge): Logotips, fotos i ornaments col·locats a capçaleres, peus, obertures i pàgines de part, amb la proporció conservada.
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus): Ranures de capçalera i peu amb marcadors ({pageNumber}, {chapterTitle}, {title}…) diferents en recto i verso, amb els títols llargs retallats o partits.

**També fa servir**

- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Capítols que obren en pàgina senar](https://postext.dev/ca/docs/configuration.md#saltar-abans)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Partició de mots i llengua del document](https://postext.dev/ca/docs/justification.md#llengües-compatibles)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)

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

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

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

- Averia Serif Libre (OFL-1.1), Fredericka the Great (OFL-1.1), Quicksand (OFL-1.1)

## Elaboració

### 1 · Un sol estil de títol, la vinyeta a cada títol

El codi d'aquest pas és a [la resposta curta](#la-resposta-curta). El `resourceId` d'un element d'imatge admet els mateixos marcadors que un text de disseny, així que `'{attr.vignetta}'` dibuixa el recurs que anomena el títol, igual que `{attr.summary}` imprimeix el seu sumari. Els dos capítols comparteixen l'estil `capitolo` i anomenen la seva imatge a la línia de títol: `# Capitolo II {style="capitolo" vignetta="polenta" summary="…"}`. Un tercer capítol necessita un recurs i un títol més, no un altre estil ([elements d'imatge](/ca/docs/configuration#elements-dimatge)). La línia del capítol es col·loca des del cap del contenidor, 6 mm per sota de la caixa de la vinyeta, en lloc d'ancorar-se a `#vignette`. Una imatge no reserva alçada, de manera que els textos ancorats a ella es mesuren com si el dibuix no hi fos i, sense `minHeight`, el text del capítol I començaria a 62 mm de la vora superior de la pàgina, dins del dibuix. Amb `minHeight` com a mínim de 14 línies, el text del capítol I comença a la mateixa línia que el del II, el sumari del qual ocupa una línia més; sense aquest mínim, el del capítol I comença 6,7 mm més amunt ([span i disseny avançat](/ca/docs/configuration#span-i-disseny-avançat)).

### 2 · Carrega les il·lustracions com a recursos de mapa de bits

```js
// script.js, línies 126–137
const PICTURES = { // id: [file, width, height, alt text in the book's language]
  frontespizio: ['frontespizio-1400.jpg', 1400, 1105, 'Colline toscane con cipressi e un borgo; '
    + 'un contadino guida un asinello con un carretto di legna verso la bottega di un falegname.'],
  ceppo: ['ceppo-1200.jpg', 1200, 800, 'Maestro Ciliegia, con la scure alzata sopra un pezzo di '
    + 'legno sul banco, si guarda intorno spaventato.'],
  polenta: ['polenta-1200.jpg', 1200, 800, 'Geppetto, con la parrucca gialla, riceve il pezzo di '
    + 'legno da Maestro Ciliegia; accanto, un paiolo di polenta fumante.'],
  ciliegie: ['ciliegie-600.jpg', 600, 626, 'Due ciliegie rosse su un picciolo, con una foglia.'],
};
const resources = Object.entries(PICTURES).map(([id, [fileId, w, h, altText]]) => ({
  id, typeId: 'fregio', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText,
  bitmap: { fileId, format: 'jpeg', width: w, height: h } }));
```

Cada imatge és un JPEG de la carpeta `assets/` de la recepta, declarat amb la seva mida en píxels: la composició només llegeix aquestes mides, i el llenç pinta el que `loadImage()` hagi registrat amb cada identificador de fitxer quan dibuixa la pàgina. Les vinyetes i les cireres es van generar sobre blanc i després es van multiplicar pel color del paper, `#fffaf0`, de manera que els seus marges surten del color de la pàgina i les imatges no tenen una vora visible. La portada està retallada a 190:150, la forma del seu camp, perquè la imatge ajustada l'ompli sense una franja de paper. El paisatge i les vinyetes els pinten elements d'imatge dels dissenys; les cireres, la línia `::resource{id="ciliegie"}` del Markdown.

### 3 · Fes de la portada un estil de títol

```js
// script.js, línies 71–89
const SKY = 150; // mm from the top edge: the foot of the painted field
// # Pinocchio {style="frontespizio" kicker="Le avventure di"}; subtitle and author come from
// the frontmatter. Every element hangs from the page, so the field runs off three edges.
const frontispiece = {
  id: 'frontespizio',
  span: 'page', // or the field is cut at the top of the text block (gotcha: opener-clipped-at-top)
  footer: { elements: [] }, // no folio; the running heads already skip openers
  advancedDesign: { enabled: true, slot: { elements: [ // array order is paint order
    { kind: 'image', id: 'landscape', resourceId: 'frontespizio', // cut to the field's 190:150
      placement: at('page', 'top-left', 0, 0, { width: 'fill', height: mm(SKY) }) },
    text('kicker', '{attr.kicker}', LABEL, 11, at('page', 'top', trail(2.6), SKY + 11),
      { ...caps(2.6), fontWeight: 700 }),
    text('title', '{titleText}', DISPLAY, 70, at('page', 'top', 0, SKY + 17),
      { lineHeight: 1, color: col('bark') }),
    text('subtitle', '{subtitle}', TEXT, 15, at('page', 'top', 0, SKY + 44), { italic: true }),
    text('author', '{author}', LABEL, 9, at('page', 'top', trail(2), SKY + 58),
      { ...caps(2), fontWeight: 600 }),
  ] } },
};
```

La portada és el primer títol del Markdown, `# Pinocchio {style="frontespizio" kicker="Le avventure di"}`, i el seu estil la pinta sencera: el paisatge, de 150 mm d'alçada des de la vora superior de la pàgina, i després l'avanttítol de l'atribut, el títol i, des de les metadades del principi del fitxer, el subtítol i l'autor. L'estil porta `span: 'page'` encara que el llibre tingui una sola columna, perquè un disseny que es queda a la columna es retalla al cap de la caixa de text, i els 22 mm superiors de la il·lustració sortirien del color del paper. El `footer` buit treu el disc del foli que porten les obertures de capítol, i `pages: 'body'` ja deixa les capçaleres fora d'aquesta pàgina ([estils d'encapçalament](/ca/docs/configuration#estils-dencapçalament)).

### 4 · Capçaleres a les pàgines de text, folis en discos

```js
// script.js, línies 93–114
// The excerpt opens on book page 6, a verso, so the title page faces chapter I.
const continuation = { pageIndexOffset: 5, pageNumbering: { startAt: 6 } };
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre sits off the page's centre
const head = (id, content, parity, x) => text(id, content, LABEL, 8,
  at('page', 'top', x + trail(1.8), 12), { ...caps(1.8), fontWeight: 600, parity, pages: 'body' });
const DISC = 7.4; // mm: the folio's disc
// A fixed square with a radius of half its side is a circle; the figures sit in its middle.
// lineHeight 1.17: the baseline falls at 0.8 of the line, 0.94 em down, so Quicksand's
// figures (0.70 em tall) leave 0.23 em above and below them, and 'middle' centres that line.
const disc = (id, parity, pages, edge, x) => text(id, '{pageNumber}', LABEL, 8.5,
  at('page', edge, x, -(BOTTOM - DISC) / 2, { width: mm(DISC), height: mm(DISC) }),
  { fontWeight: 700, color: col('paper'), lineHeight: 1.17, verticalAlign: 'middle', parity,
    pages, box: { backgroundColor: col('wood'), borderRadius: mm(DISC / 2) } });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the book on the left-hand page
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // the chapter on the right
] };
const footer = { elements: [
  disc('verso-folio', 'even', 'body', 'bottom-left', OUTER),
  disc('recto-folio', 'odd', 'body', 'bottom-right', -OUTER),
  disc('opener-folio', 'all', 'opener', 'bottom', 0), // a chapter opens on either side
] };
```

`continuation` numera l'extracte des de la pàgina 6 del llibre, que és parella, així que la portada queda davant del capítol I i el primer foli imprès és el 7. `pages: 'body'` deixa les capçaleres fora de la portada i de les dues obertures, de manera que només surten a les pàgines 8 i 9: el títol del llibre a la parella i el del capítol a la senar ([elements de text](/ca/docs/configuration#elements-de-text)). El foli és un text de disseny dins d'un quadrat fix de 7,4 mm les cantonades del qual tenen un radi de mig costat, és a dir, un cercle; `verticalAlign: 'middle'` i l'alçada de línia que es calcula al comentari hi centren les xifres. Les xifres, color paper, tenen un contrast de 4,98:1 sobre la fusta. `trail()` desplaça cada rètol en majúscules espaiades a la dreta la meitat del seu espaiat, perquè la 1.4.1 centra una línia espaiada com si l'espai que segueix l'última lletra en formés part.

### 5 · Tanca el capítol amb un cul de llàntia sense número

```js
// script.js, línies 118–122
// No caption prefix and no caption, so the cherries that close chapter I print with no
// label or number. The pictures the design slots paint are of this type too.
const resourceTypes = [{ id: 'fregio', name: 'Fregio', namePlural: 'Fregi', shortLabel: 'Fregio',
  captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.16, align: 'center' } }];
```

Un tipus de recurs imprimeix el seu `captionPrefix` i el seu número davant del peu. Amb el prefix buit i sense peu, sota les cireres no surt res; amb `captionPrefix: 'Fregio'`, el mateix recurs porta a sota *Fregio 1.* i creix 10 mm ([tipus de recurs](/ca/docs/configuration#tipus-de-recurs)). El `defaultPlacement` del tipus col·loca cada recurs on hi ha la seva línia `::resource`, centrat i amb un sisè de la mesura d'amplada, així que al Markdown n'hi ha prou amb `::resource{id="ciliegie"}` després de l'últim paràgraf del capítol. Les altres imatges comparteixen el tipus, però cap `:ref` ni `::resource` no les anomena, així que mai no es col·loquen ni es compten.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/storybook-chapter-vignettes

### script.js

```js
// ═══ Postext Cookbook · Nº 066 · Storybook chapters with vignettes and summaries ═══
// https://postext.dev/en/cookbook/storybook-chapter-vignettes
// Code: MIT · Text: C. Collodi, 1883 (PD, Gutenberg #52484) · Pictures: diffusion models
// Fonts: Averia Serif Libre, Fredericka the Great, Quicksand (SIL OFL 1.1) · Needs postext ≥ 1.8.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document (the sample is Italian in both)
const RECIPE = 'storybook-chapter-vignettes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config comes from here; the pictures were painted to it
  ink: '#2b2118', // text: a brown near-black
  paper: '#fffaf0', // a warm book paper, which the vignettes' margins were tinted to
  bark: '#6b4224', // chapter lines, running heads, the title
  wood: '#9a5f30', // the folio discs
};
// Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The defaults linked to main-color, bold among them, print in bark instead of blue.
  { id: 'main-color', name: 'bark (defaults)', value: { hex: palette.bark, model: 'hex' } },
];
// Text and summaries; the book's title and the chapter lines; running heads and folios.
const [TEXT, DISPLAY, LABEL] = ['Averia Serif Libre', 'Fredericka the Great', 'Quicksand'];
const TRIM = { width: 190, height: 240 }; // mm
const [TOP, INNER, OUTER] = [22, 22, 20]; // mm; mirrored, so the spine margin swaps sides
const MEASURE = TRIM.width - INNER - OUTER; // 148 mm
const [BODY, LEAD, LINES] = [13, 19, 29]; // pt, pt, and whole lines in the text block
const BOTTOM = TRIM.height - TOP - (LINES * LEAD * 25.4) / 72; // mm: 23.6
// Design-slot shorthands: a placement from an anchor's edge (mm), and a design text.
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id,
  content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'center',
  overflow: 'wrap', // wrap, never '…' (gotcha: overflow-ellipsis-default)
  placement, ...extra });
const caps = (tracking) => ({ textTransform: 'uppercase', letterSpacing: pt(tracking),
  color: col('bark') });
// Half a tracking (mm): 1.4.1 tracks after a centred line's last letter, shifting it left.
const trail = (tracking) => (tracking * 25.4) / 72 / 2;

// #region answer: one opener for every chapter: the vignette the heading names, then its summary
// # Capitolo I {style="capitolo" vignetta="ceppo" summary="Come andò che Maestro Ciliegia…"}
// resourceId takes placeholders like a design text: '{attr.vignetta}' draws the resource the
// heading names, so every chapter shares one style.
const VIGNETTE = { width: 84, height: 56 }; // mm, centred at the head of the text block
const SUMMARY = 124; // mm: the summary's measure, narrower than the text's
const opener = { enabled: true,
  // A floor for the height the opener reserves, in whole lines, so the text under chapter I's
  // two-line summary starts on the same grid line as the text under chapter II's three lines.
  minHeight: pt(14 * LEAD),
  slot: { elements: [
    { kind: 'image', id: 'vignette', resourceId: '{attr.vignetta}',
      placement: at('container', 'top', 0, 0,
        { width: mm(VIGNETTE.width), height: mm(VIGNETTE.height) }) },
    // Placed from the container, not from #vignette: images do not count in the height an
    // opener reserves (gotcha: opener-image-no-reserve), the texts under them do.
    text('chapter', '{titleText}', DISPLAY, 24, at('container', 'top-left', trail(2.4),
      VIGNETTE.height + 6, { width: mm(MEASURE) }), { ...caps(2.4), lineHeight: 1 }),
    text('summary', '{attr.summary}', TEXT, 12.5, at('#chapter', 'below',
      (MEASURE - SUMMARY) / 2 - trail(2.4), 5, { width: mm(SUMMARY) }),
    { italic: true, lineHeight: 1.38 }), // a multiple (gotcha: design-lineheight-multiple)
  ] },
};
const chapter = { id: 'capitolo', advancedDesign: opener };
// #endregion

// #region frontispiece: the title page as a heading style: a painted field, then the title
const SKY = 150; // mm from the top edge: the foot of the painted field
// # Pinocchio {style="frontespizio" kicker="Le avventure di"}; subtitle and author come from
// the frontmatter. Every element hangs from the page, so the field runs off three edges.
const frontispiece = {
  id: 'frontespizio',
  span: 'page', // or the field is cut at the top of the text block (gotcha: opener-clipped-at-top)
  footer: { elements: [] }, // no folio; the running heads already skip openers
  advancedDesign: { enabled: true, slot: { elements: [ // array order is paint order
    { kind: 'image', id: 'landscape', resourceId: 'frontespizio', // cut to the field's 190:150
      placement: at('page', 'top-left', 0, 0, { width: 'fill', height: mm(SKY) }) },
    text('kicker', '{attr.kicker}', LABEL, 11, at('page', 'top', trail(2.6), SKY + 11),
      { ...caps(2.6), fontWeight: 700 }),
    text('title', '{titleText}', DISPLAY, 70, at('page', 'top', 0, SKY + 17),
      { lineHeight: 1, color: col('bark') }),
    text('subtitle', '{subtitle}', TEXT, 15, at('page', 'top', 0, SKY + 44), { italic: true }),
    text('author', '{author}', LABEL, 9, at('page', 'top', trail(2), SKY + 58),
      { ...caps(2), fontWeight: 600 }),
  ] } },
};
// #endregion

// #region folios: running heads on body pages only, folios in wood-coloured discs
// The excerpt opens on book page 6, a verso, so the title page faces chapter I.
const continuation = { pageIndexOffset: 5, pageNumbering: { startAt: 6 } };
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre sits off the page's centre
const head = (id, content, parity, x) => text(id, content, LABEL, 8,
  at('page', 'top', x + trail(1.8), 12), { ...caps(1.8), fontWeight: 600, parity, pages: 'body' });
const DISC = 7.4; // mm: the folio's disc
// A fixed square with a radius of half its side is a circle; the figures sit in its middle.
// lineHeight 1.17: the baseline falls at 0.8 of the line, 0.94 em down, so Quicksand's
// figures (0.70 em tall) leave 0.23 em above and below them, and 'middle' centres that line.
const disc = (id, parity, pages, edge, x) => text(id, '{pageNumber}', LABEL, 8.5,
  at('page', edge, x, -(BOTTOM - DISC) / 2, { width: mm(DISC), height: mm(DISC) }),
  { fontWeight: 700, color: col('paper'), lineHeight: 1.17, verticalAlign: 'middle', parity,
    pages, box: { backgroundColor: col('wood'), borderRadius: mm(DISC / 2) } });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the book on the left-hand page
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // the chapter on the right
] };
const footer = { elements: [
  disc('verso-folio', 'even', 'body', 'bottom-left', OUTER),
  disc('recto-folio', 'odd', 'body', 'bottom-right', -OUTER),
  disc('opener-folio', 'all', 'opener', 'bottom', 0), // a chapter opens on either side
] };
// #endregion

// #region tailpiece: an unnumbered resource type, set where ::resource stands
// No caption prefix and no caption, so the cherries that close chapter I print with no
// label or number. The pictures the design slots paint are of this type too.
const resourceTypes = [{ id: 'fregio', name: 'Fregio', namePlural: 'Fregi', shortLabel: 'Fregio',
  captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.16, align: 'center' } }];
// #endregion

// #region pictures: four paintings, declared at their pixel size under the file ids they load as
const PICTURES = { // id: [file, width, height, alt text in the book's language]
  frontespizio: ['frontespizio-1400.jpg', 1400, 1105, 'Colline toscane con cipressi e un borgo; '
    + 'un contadino guida un asinello con un carretto di legna verso la bottega di un falegname.'],
  ceppo: ['ceppo-1200.jpg', 1200, 800, 'Maestro Ciliegia, con la scure alzata sopra un pezzo di '
    + 'legno sul banco, si guarda intorno spaventato.'],
  polenta: ['polenta-1200.jpg', 1200, 800, 'Geppetto, con la parrucca gialla, riceve il pezzo di '
    + 'legno da Maestro Ciliegia; accanto, un paiolo di polenta fumante.'],
  ciliegie: ['ciliegie-600.jpg', 600, 626, 'Due ciliegie rosse su un picciolo, con una foglia.'],
};
const resources = Object.entries(PICTURES).map(([id, [fileId, w, h, altText]]) => ({
  id, typeId: 'fregio', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText,
  bitmap: { fileId, format: 'jpeg', width: w, height: h } }));
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'it', // Italian hyphenation, by its exact code (gotcha: hyphenation-locales)
  resourceTypes, colorPalette, header, footer, layout: { layoutType: 'single' },
  page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    italicColor: col('ink'), // *ohi* in ink, not in the defaults' bark
    // The book cites nothing, but a :ref added later prints in ink: main-color does not reach
    // this colour, which stays #295AA3 (gotcha: palette-skips-designs).
    referenceColor: col('ink'),
    firstLineIndent: mm(5), indentAfterHeading: false,
    maxWordSpacing: 1.45, // the default 2 lets 14 lines stretch past 1.45×; 3 still do, to 1.58×
  },
  headings: {
    fontFamily: DISPLAY, fontWeight: 400, // the designs paint the titles: no Open Sans to load
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break): every
    // chapter on a new page, on either side, so chapter II opens on a verso.
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }],
  },
  headingStyles: [frontispiece, chapter],
  paragraphStyles: [{ id: 'colofon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11),
    color: col('bark'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Le avventure di Pinocchio"
subtitle: "Storia di un burattino"
author: "Carlo Collodi"
---

# Pinocchio {style="frontespizio" kicker="Le avventure di"}

# Capitolo I {style="capitolo" vignetta="ceppo" summary="Come andò che Maestro Ciliegia, falegname, trovò un pezzo di legno che piangeva e rideva come un bambino."}

— C’era una volta…

— Un re! — diranno subito i miei piccoli lettori.

— No, ragazzi, avete sbagliato. C’era una volta un pezzo di legno.

Non era un legno di lusso, ma un semplice pezzo da catasta, di quelli che d’inverno si mettono nelle stufe e nei caminetti per accendere il fuoco e per riscaldare le stanze.

Non so come andasse, ma il fatto gli è che un bel giorno questo pezzo di legno capitò nella bottega di un vecchio falegname, il quale aveva nome mastr’Antonio, se non che tutti lo chiamavano maestro Ciliegia, per via della punta del suo naso, che era sempre lustra e paonazza, come una ciliegia matura.

Appena maestro Ciliegia ebbe visto quel pezzo di legno, si rallegrò tutto; e dandosi una fregatina di mani per la contentezza, borbottò a mezza voce:

— Questo legno è capitato a tempo; voglio servirmene per fare una gamba di tavolino. —

Detto fatto, prese subito l’ascia arrotata per cominciare a levargli la scorza e a digrossarlo; ma quando fu lì per lasciare andare la prima asciata, rimase col braccio sospeso in aria, perchè sentì una vocina sottile sottile, che disse raccomandandosi:

— Non mi picchiar tanto forte! —

Figuratevi come rimase quel buon vecchio di maestro Ciliegia!

Girò gli occhi smarriti intorno alla stanza per vedere di dove mai poteva essere uscita quella vocina, e non vide nessuno! Guardò sotto il banco, e nessuno: guardò dentro un armadio che stava sempre chiuso, e nessuno; guardò nel corbello dei trucioli e della segatura, e nessuno; aprì l’uscio di bottega per dare un’occhiata anche sulla strada, e nessuno. O dunque?…

— Ho capito; — disse allora ridendo e grattandosi la parrucca — si vede che quella vocina me la son figurata io. Rimettiamoci a lavorare. —

E ripresa l’ascia in mano, tirò giù un solennissimo colpo sul pezzo di legno.

— Ohi! tu m’hai fatto male! — gridò rammaricandosi la solita vocina.

Questa volta maestro Ciliegia restò di stucco, cogli occhi fuori del capo per la paura, colla bocca spalancata e colla lingua giù ciondoloni fino al mento, come un mascherone da fontana.

Appena riebbe l’uso della parola, cominciò a dire tremando e balbettando dallo spavento:

— Ma di dove sarà uscita questa vocina che ha detto ohi?… Eppure qui non c’è anima viva. Che sia per caso questo pezzo di legno che abbia imparato a piangere e a lamentarsi come un bambino? Io non lo posso credere. Questo legno eccolo qui; è un pezzo di legno da caminetto, come tutti gli altri, e a buttarlo sul fuoco, c’è da far bollire una pentola di fagioli… O dunque? Che ci sia nascosto dentro qualcuno? Se c’è nascosto qualcuno, tanto peggio per lui. Ora l’accomodo io! —

E così dicendo, agguantò con tutt’e due le mani quel povero pezzo di legno, e si pose a sbatacchiarlo senza carità contro le pareti della stanza.

Poi si messe in ascolto, per sentire se c’era qualche vocina che si lamentasse. Aspettò due minuti, e nulla; cinque minuti, e nulla; dieci minuti, e nulla!

— Ho capito — disse allora sforzandosi di ridere e arruffandosi la parrucca — si vede che quella vocina che ha detto *ohi*, me la son figurata io! Rimettiamoci a lavorare. —

E perchè gli era entrato addosso una gran paura, si provò a canterellare per farsi un po’ di coraggio.

Intanto, posata da una parte l’ascia, prese in mano la pialla, per piallare e tirare a pulimento il pezzo di legno; ma nel mentre che lo piallava in su e in giù, sentì la solita vocina che gli disse ridendo:

— Smetti! tu mi fai il pizzicorino sul corpo! —

Questa volta il povero maestro Ciliegia cadde giù come fulminato. Quando riaprì gli occhi, si trovò seduto per terra.

Il suo viso pareva trasfigurito, e perfino la punta del naso, di paonazza come era quasi sempre, gli era diventata turchina dalla gran paura.

::resource{id="ciliegie"}

# Capitolo II {style="capitolo" vignetta="polenta" summary="Maestro Ciliegia regala il pezzo di legno al suo amico Geppetto, il quale lo prende per fabbricarsi un burattino maraviglioso, che sappia ballare, tirar di scherma e fare i salti mortali."}

In quel punto fu bussato alla porta.

— Passate pure, — disse il falegname, senza aver la forza di rizzarsi in piedi.

Allora entrò in bottega un vecchietto tutto arzillo, il quale aveva nome Geppetto; ma i ragazzi del vicinato, quando lo volevano far montare su tutte le furie, lo chiamavano col soprannome di Polendina, a motivo della sua parrucca gialla, che somigliava moltissimo alla polendina di granturco.

Geppetto era bizzosissimo. Guai a chiamarlo Polendina! Diventava subito una bestia, e non c’era più verso di tenerlo.

:::paragraphs{style="colofon"}
Composto in Averia Serif Libre, Fredericka the Great e Quicksand (SIL Open Font License). Testo di Carlo Collodi (1883) secondo l’edizione Bemporad del 1902, Project Gutenberg n. 52484. Illustrazioni generate con modelli di diffusione.
:::
`; // content.<lang>.md: Collodi's Italian in both editions


// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Averia Serif Libre': ['400', '400i'], 'Fredericka the Great': ['400'],
  Quicksand: ['400', '600', '700'] }; // every face, loaded before layout (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
// A design slot or ::resource names a resource, the resource a file id: load each file.
await Promise.all(Object.values(PICTURES).map(([file]) => loadImage(file, asset(file))));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: 'Le avventure di Pinocchio' });

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

## Variants

### Obre cada capítol en pàgina senar

Amb `'odd'`, el capítol II passa a la pàgina 11 i la 10 queda en blanc; la portada desactiva el seu propi salt, perquè hereta el del nivell i, si no, passaria a la pàgina 7 després d'una pàgina 6 en blanc.

```diff
@@ l'estil de la portada @@
   id: 'frontespizio',
+  breakBefore: { enabled: false },
@@ headings, a config() @@
-    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }],
+    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'odd' } }],
```

## Errors freqüents

- **Les imatges d'una obertura no compten per a l'alçada que reserva.** A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text.
- **Una obertura que es queda a la seva columna es talla al capdamunt de la caixa de text.** A postext 1.4.1, un títol amb disseny avançat que es queda a la seva columna es retalla per la vora superior de la columna: una caixa o una imatge ancorades a la pàgina o a la sang es pinten als marges laterals, però no al superior, i cap avís no ho diu. Dona span: 'page' a aquest títol, encara que el llibre tingui una sola columna: llavors el seu disseny es pinta sencer, com a banda d'obertura de la pàgina.
- **Valors d'atribut: sense { ni }; cometes simples si porten ".** Un valor d'atribut acaba a la clau de tancament, així que no pot contenir { ni }. Un valor que porti cometes dobles va entre cometes simples; el signe de dòlar no dona problemes.
- **Posa entre cometes cada valor del frontmatter.** YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984".
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- Al Markdown, un espai de no separació (U+00A0) uneix cada guió llarg de diàleg de Collodi a la seva paraula, perquè un guió de tancament no obri mai línia; dos més mantenen junts *di legno* i *in piedi* al final dels seus paràgrafs. A la 1.4.1, si el paràgraf porta negreta o cursiva, la línia encara es pot partir en un espai de no separació. L'únic paràgraf amb aquests espais i una paraula en cursiva, el de l'*ohi* de la pàgina 9, en té quatre, i a la captura tots cauen a mitja línia o al final del paràgraf.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Le avventure di Pinocchio (1883): chapter I and the opening of chapter II with Collodi’s chapter summaries, from the 1902 Bemporad edition, with typographic apostrophes, dashes and ellipses; chapter I’s summary has the comma after “falegname” that the edition’s table of contents prints and its chapter head leaves out: Carlo Collodi ([font](https://www.gutenberg.org/ebooks/52484)), domini públic
- Imatges: The frontispiece: Tuscan hills, a hill village and a donkey cart of firewood on its way to the carpenter’s workshop: Generated With Diffusion Models, original
- Imatges: Chapter I’s vignette: Maestro Ciliegia, axe raised over the talking log: Generated With Diffusion Models, original
- Imatges: Chapter II’s vignette: Geppetto in his yellow wig receives the log, beside a pot of polenta: Generated With Diffusion Models, original
- Imatges: The cherry tailpiece: Generated With Diffusion Models, original
- Tipus de lletra: Averia Serif Libre (OFL-1.1), Fredericka the Great (OFL-1.1), Quicksand (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 017 · Cinc obertures de capítol en un sol llibre](https://postext.dev/ca/cookbook/five-chapter-openers.md): Una obertura al nivell 1 i quatre estils de títol anomenats al Markdown; cadascun canvia el color d'accent, i alguns, els marges, les columnes o els folis. · Nivell 3 (Avançat) · Qualsevol gènere
- [Núm. 016 · Castellà justificat en una novel·la de butxaca](https://postext.dev/ca/cookbook/spanish-pocket-novel.md): El començament de Marianela en edició de butxaca: partició de mots del castellà, espais de menys d'1,7×, caplletra alçada i el plànol com a Figura 1. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
- [Núm. 003 · Obertura de capítol sobre banda a sang](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md): Obertura advancedDesign del títol de nivell 1: banda a sang amb el número de capítol sobre el filet, i avanttítol i entradeta presos dels seus atributs. · Nivell 3 (Avançat) · Llibres de text
