# Cinque aperture di capitolo in un libro

> Un’apertura sul livello 1 e quattro stili di titolo nominati nel Markdown; ogni stile cambia il colore d’accento, e alcuni anche margini, colonne o folio.

- Versione HTML: https://postext.dev/it/cookbook/five-chapter-openers
- Ricetta Nº 017 · Titoli e aperture · Livello 3 (Avanzato) · Output: Canvas
- Generi: Qualsiasi genere
- Richiede postext ≥ 1.25.0 · provata con 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/five-chapter-openers/en/p01.webp?v=d582c085), [2](https://postext.dev/cookbook/five-chapter-openers/en/p02.webp?v=d582c085), [3](https://postext.dev/cookbook/five-chapter-openers/en/p03.webp?v=d582c085), [4](https://postext.dev/cookbook/five-chapter-openers/en/p04.webp?v=d582c085), [5](https://postext.dev/cookbook/five-chapter-openers/en/p05.webp?v=d582c085), [6](https://postext.dev/cookbook/five-chapter-openers/en/p06.webp?v=d582c085), [7](https://postext.dev/cookbook/five-chapter-openers/en/p07.webp?v=d582c085)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=five-chapter-openers&lang=en (.postext: https://postext.dev/cookbook/five-chapter-openers/en/five-chapter-openers.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [en](https://postext.dev/en/cookbook/five-chapter-openers.md), [es](https://postext.dev/es/cookbook/five-chapter-openers.md), [ca](https://postext.dev/ca/cookbook/five-chapter-openers.md), [de](https://postext.dev/de/cookbook/five-chapter-openers.md), [fr](https://postext.dev/fr/cookbook/five-chapter-openers.md), [pt](https://postext.dev/pt/cookbook/five-chapter-openers.md), [zh](https://postext.dev/zh/cookbook/five-chapter-openers.md), [ja](https://postext.dev/ja/cookbook/five-chapter-openers.md), [ar](https://postext.dev/ar/cookbook/five-chapter-openers.md)

## In breve

Un breve libro su come possono cominciare i capitoli, in cui ogni capitolo si apre in uno stile diverso. Mostra come dare a un capitolo colori, margini e impaginazione propri con una sola impostazione.

## Che cosa comporrai

*Five Ways to Begin* è un libro di sette pagine sulle aperture di capitolo, 180 × 240 mm, e ogni capitolo è composto nell’apertura che descrive. Il capitolo uno è quella di un romanzo: un occhiello spaziato, un titolo in corsivo chiaro e un’iniziale rossa di cinque righe, con le testatine nelle pagine che seguono. Il capitolo due mette il titolo e un fregio tipografico dentro un cartiglio color crema disegnato con due filetti blu. Il capitolo tre impila il titolo in Fraunces Black bianco su un campo magenta che esce al vivo dalla pagina, sopra un’iniziale monospaziata in negativo su un riquadro. Il capitolo quattro, il manuale, ha testatine monospaziate, un numero in badge arancione accanto al titolo e due colonne di Geist. La coda compone il titolo a metà di una pagina vuota, sopra un’iniziale rialzata. Il capitolo uno usa l’apertura propria del livello 1; gli altri quattro nominano uno stile di titolo nella riga del loro titolo.

**Questa ricetta risponde a:**

- Come do a ogni capitolo la sua apertura, il suo colore o la sua foto, scegliendoli dal Markdown?
- Come aggiungo a un’apertura una riga d’autore, un sommario o un attacco con capolettera?
- Come compongo il titolo di un capitolo a tutta larghezza mentre il testo scorre sotto su due colonne?
- Come costruisco un badge numerico accanto al titolo che si allarga quando il numero cresce (9 → 10)?

## La risposta breve

```js
// script.js, righe 16–52
// A heading line picks a style by id, and its attributes feed the opener's {attr.…} texts:
//   # The Frame {style="framed" kicker="Chapter the Second"}
// A style overrides the level fields it names (opener, drop cap) and, until the next level-1
// heading, the section fields it names; the rest comes from the level and the document.
const level1 = () => ({ level: 1,
  // span 'page' opens each chapter at the head of a new page and runs its opener across
  // every column (chapter four has two). By default an H1 waits for a recto, with a blank
  // page before it; parity 'any' lets these short chapters open on either side.
  span: 'page',
  breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), // each opener ends where its first paragraph starts
  advancedDesign: literary, // chapter one, unstyled: the book's own opener
  dropCap: { lines: 5, fontFamily: 'Fraunces', fontWeight: 400, color: col('accent'),
    gap: mm(2.5) } }); // a five-line drop in rubric red
const headingStyles = () => [ // factories, called by config() once every design exists
  // Each style brings its opener and swaps 'accent' for a colour of its own, on its pages.
  { id: 'framed', advancedDesign: framed, palette: { accent: palette.lapis },
    margins: { left: mm(EVEN), right: mm(EVEN) }, // a centred text block; 'accent' is blue:
    dropCap: { lines: 3, fontFamily: 'Fraunces', fontWeight: 600, color: col('accent'),
      gap: mm(2) } },
  { id: 'stacked', advancedDesign: stacked, palette: { accent: palette.magenta }, // white, on
    dropCap: { lines: BOXED, fontFamily: 'Geist Mono', fontWeight: 700, color: col('paper'),
      gap: mm(2.2) } }, // the square the opener draws for it
  { id: 'spec', advancedDesign: spec, palette: { accent: palette.signal }, dropCap: false,
    marginBottom: pt(0), // the hairline under the title is the separator
    margins: { left: mm(SPEC), right: mm(SPEC) }, // a 144 mm text block
    layout: { layoutType: 'double', gutterWidth: mm(7) },
    bodyStyle: { fontFamily: 'Geist', fontSize: pt(9.8), textAlign: 'left' }, // on the grid
    header: { elements: [...specHead('even'), ...specHead('odd')] },
    footer: { elements: [] } }, // no folio at the foot: the head gives the page
  { id: 'quiet', numbered: false, // a coda: it advances no chapter counter
    advancedDesign: quiet, palette: { accent: palette.graphite }, footer: tinyFolio,
    dropCap: { lines: 1, fontFamily: 'Fraunces', fontWeight: 300, fontSize: pt(46), // raised:
      color: col('accent'), gap: mm(1.2) }, // on the first baseline, rising into the white
    margins: { left: mm(QUIET.inner), right: mm(QUIET.outer) }, // a 106 mm measure
    bodyStyle: { fontSize: pt(QUIET.size), lineHeight: pt(QUIET.lead), textAlign: 'left' } },
]; // hook-up in config(): headings: { levels: [level1(), …] }, headingStyles()
```

## Ingredienti

**Insegna**

- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli): Varianti con nome di un livello di titolo, scelte con {style="…"}: un’apertura, una tipografia, testatine o un’impostazione di pagina diverse per capitolo.
- [Geometria della sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli): Uno stile di titolo che cambia margini e disposizione delle colonne per la sua sezione, come una prefazione a una colonna in un libro a due colonne.
- [Capilettera nei paragrafi del testo corrente](https://postext.dev/it/docs/configuration-text.md#capilettera): dropCap su un livello di titolo, uno stile di titolo o uno stile di paragrafo apre un paragrafo del testo corrente con la prima lettera in grande accanto alle prime righe, che si accorciano e si compongono con il testo; un’iniziale sporgente, una virgoletta d’apertura appesa o composta con la lettera, un attacco in maiuscoletto.

**Usa anche**

- [Testatine per sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Colori per parte](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-part)
- [Capitoli non numerati](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Fascia di capitolo a tutta larghezza](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Testo, filetti e riquadri nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Fasce al vivo e unghiature](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [A capo nei titoli](https://postext.dev/it/docs/document-format.md#a-capo-nei-titoli)
- [Ancoraggio degli elementi del progetto](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)
- [Titoli numerati](https://postext.dev/it/docs/configuration-text.md#sovrascritture-per-livello)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Spazio verticale esplicito](https://postext.dev/it/docs/document-format.md#space)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headingStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)

**Caratteri**

- Lora (OFL-1.1), Fraunces (OFL-1.1), Geist (OFL-1.1), Geist Mono (OFL-1.1)

## Preparazione

### 1 · Imposta sul livello l’apertura propria del libro

```js
// script.js, righe 97–105
// The first paragraph, in :::paragraphs{style="lead"}, starts 17 mm under the title.
const literary = design([
  text('kicker', '{attr.kicker}', 'Geist', 8, centred(22), { ...caps(1.7), align: 'center' }),
  text('title', '{titleText}', 'Fraunces', 32, at('#kicker', 'below', 0, 4, 'fill'),
    { fontWeight: 300, italic: true, lineHeight: 1.05, align: 'center' }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'),
    placement: at('#title', 'below', (MEASURE - 18) / 2, 6, 18) }, // an 18 mm rule, centred
  room('#title', 17),
]);
```

Il capitolo uno non ha stile, quindi prende il design proprio del livello 1, da cui parte ogni stile ([la risposta breve](https://postext.dev/it/cookbook/five-chapter-openers.md#la-risposta-breve) mostra il livello e gli stili). Il primo paragrafo del capitolo sta nel Markdown, in un gruppo `:::paragraphs{style="lead"}` un corpo più grande del testo (12/17 pt contro 10,8/14,5), e il `dropCap` del livello lo apre ([drop caps](https://postext.dev/it/docs/configuration-text.md#capilettera)): la M poggia sulla quinta linea di base e, senza `fontSize`, la sua sommità è alla stessa altezza delle maiuscole della prima riga, con entrambe le altezze delle maiuscole misurate da Fraunces e Lora. L’apertura finisce con un riquadro vuoto, `room()`, 17 mm sotto il titolo, dove comincia il paragrafo d’apertura; un `:::space` sotto di esso lascia una riga e fa partire il testo senza rientro.

### 2 · Incornicia il titolo in due riquadri

```js
// script.js, righe 109–127
const FRAME = 62; // mm: the outer box's height
const INSET = 3; // mm: the hairline box runs this far inside it
const FLEURON = 44; // mm: the ornament's width, centred under the title
const framed = design([ // array order is paint order: the boxes first, the type on top
  { kind: 'box', id: 'frame', placement: { ...at('container', 'top-left', 0, 0),
    size: { width: 'fill', height: mm(FRAME) } },
  style: { backgroundColor: col('tint'), borderColor: col('accent'), borderWidth: pt(1.6) } },
  { kind: 'box', id: 'hairline', placement: { ...at('#frame', 'align-top', INSET, INSET),
    size: { width: mm(MEASURE - 2 * INSET), height: mm(FRAME - 2 * INSET) } },
  style: { borderColor: col('accent'), borderWidth: pt(0.5) } },
  text('kicker', '{attr.kicker}', 'Geist', 7.5, centred(14), { ...caps(1.6), align: 'center' }),
  text('title', '{titleText}', 'Fraunces', 34, at('#kicker', 'below', 0, 3.5, 'fill'),
    { fontWeight: 600, lineHeight: 1.05, align: 'center' }),
  // An image element draws a registered resource, never numbered or captioned. Its resourceId
  // may be '{attr.photo}' (postext ≥ 1.8): then each heading names its own photo.
  { kind: 'image', id: 'fleuron', resourceId: 'fleuron',
    placement: at('#title', 'below', (MEASURE - FLEURON) / 2, 4.5, FLEURON) },
  room('#frame', 9), // the text starts 9 mm under the frame
]);
```

L’ordine dell’array è l’ordine di disegno, quindi prima vengono i riquadri e sopra il testo; il fregio è un elemento immagine, che disegna una risorsa SVG registrata mai numerata né citata. Un elemento immagine è anche il modo in cui ogni capitolo riceve una foto sua: da postext 1.8 il suo `resourceId` accetta segnaposto, quindi con `resourceId: '{attr.photo}'` un solo stile disegna la foto che ogni titolo nomina, `# The Frame {style="framed" photo="lake"}`, come fanno i piatti della [scheda ricetta](https://postext.dev/it/cookbook/recipe-card.md) ([image elements](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)). Lo stile `framed` nella risposta breve imposta `palette` per rendere blu `accent` nelle pagine di questo capitolo, così i filetti, l’occhiello e il folio in basso si stampano in blu, e i suoi `margins` centrano la gabbia del testo sotto la cornice. Il `dropCap` dello stile nomina `col('accent')`, che la palette rende anch’esso blu, così la L di tre righe si accorda con la cornice.

### 3 · Impila il titolo, chiudi l’iniziale in un riquadro

```js
// script.js, righe 131–160
const FIELD = 130; // mm from the trim: the foot of the colour field
const S_BEARING = 1.2; // mm: the white left of the title's S (0.0365 em at 94 pt)
// The boxed initial: the style's drop cap sets the white letter; the opener draws the square,
// fitted by hand in pt. The text starts on the grid line 8 mm under the field (from the trim).
const TEXT_TOP = Math.ceil(((FIELD - TOP + 8) * 72) / 25.4 / LEAD) * LEAD; // pt
const BASE = 0.8; // a line's baseline stands 0.8 down its line box
const CAPS = 0.7; // the height of a capital in Lora, em
// Geist Mono, em: cap height, the advance every capital shares, the white either side of an M
const MONO = { caps: 0.71, advance: 0.6, side: 0.044 };
const BOXED = 3; // lines the initial spans
const capsTop = TEXT_TOP + BASE * LEADIN.lead - CAPS * LEADIN.size; // the first line's capitals
const foot = TEXT_TOP + (BOXED - 1 + BASE) * LEADIN.lead; // the third baseline: the letter's foot
// The M runs from the first line's capitals to its foot; the square runs past both as far as
// the M's sides sit in, so the colour is as wide on all four sides.
const letter = (foot - capsTop) / MONO.caps, pad = MONO.side * letter;
const stacked = design([
  { kind: 'box', id: 'field', style: { backgroundColor: col('accent') },
    placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(FIELD) } } },
  text('kicker', '{attr.kicker}', 'Geist', 8, at('container', 'top-left', 0, 4),
    { ...caps(1.8), color: col('paper') }),
  // {titleText} keeps the \\ breaks from the Markdown, so each line ends at the writer's \\.
  // Moved left by the S's side bearing, so its curve meets the text edge.
  text('title', '{titleText}', 'Fraunces', 94,
    at('container', 'top-left', -S_BEARING, 15, 'fill'),
    { fontWeight: 900, lineHeight: 0.84, color: col('paper') }),
  { kind: 'box', id: 'initial', style: { backgroundColor: col('accent') }, reserve: false,
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(capsTop - pad) },
      size: { width: pt(MONO.advance * letter), height: pt(foot - capsTop + 2 * pad) } } },
  room('container', (TEXT_TOP * 25.4) / 72, 'top-left'), // the opener ends where the text starts
]);
```

La pila è scritta nel Markdown: `# Say it \\ out \\ loud.` spezza `{titleText}` a ogni `\\`, mentre le testatine e la struttura del PDF leggono il titolo su una riga ([line breaks in titles](https://postext.dev/it/docs/document-format.md#a-capo-nei-titoli)). Il campo pende dalla testa dell’abbondanza, a tutta larghezza e profondo 130 mm, e il paragrafo d’apertura comincia sulla prima riga della griglia 8 mm sotto (`TEXT_TOP`). L’iniziale in riquadro è fatta di due pezzi. Il `dropCap` dello stile compone la M su tre righe del paragrafo, in bianco, e l’apertura disegna sotto di essa un riquadro magenta, collocato a partire da `TEXT_TOP`, dall’interlinea del paragrafo e dalle metriche di Geist Mono, così il colore sopra e sotto la M è largo quanto i suoi spazi laterali. Il riquadro non occupa spazio nell’apertura (`reserve: false`); un riquadro `room` vuoto chiude l’apertura dove comincia il paragrafo.

### 4 · Dai al manuale un badge, due colonne e testatine proprie

```js
// script.js, righe 181–208
const SPEC = 18; // mm: both side margins, for a 144 mm text block in two columns
const PAD = 1.6; // mm of colour over the badge's figures
const spec = design([
  { kind: 'rule', id: 'top-rule', direction: 'horizontal', thickness: pt(2.4), color: col('ink'),
    placement: at('container', 'top-left', 0, 0, 'fill') },
  text('kicker', '{attr.kicker}', 'Geist Mono', 7.5, at('container', 'top-left', 0, 3.2),
    { ...caps(1.2, 500), color: col('graphite') }),
  // The badge's width follows its text, with padding on both sides, so 9 → 10 widens it. Its
  // line box holds 1 mm more under the figures than over them, so the foot pads 1 mm less.
  text('badge', '{chapterNumber}', 'Geist Mono', 26, at('container', 'top-left', 0, 12),
    { fontWeight: 700, lineHeight: 1, box: { backgroundColor: col('accent'), padding: {
      top: mm(PAD), right: mm(2.4), bottom: mm(PAD - 1), left: mm(2.4) } } }),
  // Same size and line height as the badge, dropped by its top padding, so the baselines meet.
  text('title', '{titleText}', 'Geist', 26, at('#badge', 'right-of', 4, PAD),
    { fontWeight: 700, lineHeight: 1 }),
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('ink'),
    placement: at('container', 'top-left', 0, 31, 'fill') }, // room for a one-line title
]);
const specHead = (parity) => { // mono labels over the opener's rule, on every page of the section
  const [edge, s] = parity === 'even' ? ['top-left', 1] : ['top-right', -1];
  const mono = { ...caps(1.1, 500), color: col('graphite'), parity, pages: 'all' };
  return [
    text(`sp-page-${parity}`, '{pageNumber} / {totalPages}', 'Geist Mono', 7,
      at('container', edge, 0, HEAD_Y), mono),
    text(`sp-chap-${parity}`, '§ {chapterNumber} · {chapterTitle}', 'Geist Mono', 7,
      at('container', edge, s * 16, HEAD_Y), mono),
  ];
};
```

Il badge è un elemento di testo con un fondo e una spaziatura interna ma senza larghezza, quindi la sua larghezza segue `{chapterNumber}`, e il titolo è collocato `right-of` rispetto a esso: al capitolo 10 il badge cresce di una cifra, 5,5 mm, e il titolo si sposta con lui. La spaziatura inferiore è 1 mm più piccola di quella superiore perché il riquadro della riga contiene altrettanto spazio in più sotto le cifre, così il 4 resta centrato. Nello stile `spec`, `layout` compone la sezione su due colonne e lo `span: 'page'` del livello stende l’apertura su entrambe. I suoi `margins` allargano la gabbia del testo a 144 mm, `bodyStyle` compone Geist a bandiera sulla griglia da 14,5 pt del libro, `header` sostituisce le testatine del libro e un `footer` vuoto toglie il folio.

### 5 · Lascia spazio alla coda

```js
// script.js, righe 212–221
const quiet = design([ // the kicker is in the accent, which this style turns graphite
  text('kicker', '{attr.kicker}', 'Geist', 7.5, at('container', 'top-left', 0, 92),
    caps(1.6, 500)),
  text('title', '{titleText}', 'Fraunces', 26, at('#kicker', 'below', 0, 3),
    { fontWeight: 300, lineHeight: 1.1 }),
  room('#title', 3), // and the initial's rise, in whole lines: the text 16 mm under the title
]);
const tiny = (parity, edge) => text(`tiny-${parity}`, '{pageNumber}', 'Geist', 6.5,
  at('container', edge, 0, -12), { fontWeight: 500, color: col('muted'), parity });
const tinyFolio = { elements: [tiny('even', 'bottom-left'), tiny('odd', 'bottom-right')] };
```

L’occhiello pende 92 mm sotto la testa della gabbia del testo, e l’iniziale rialzata è il capolettera di una riga dello stile `quiet`: il suo piede poggia sulla prima linea di base e, a 46 pt, sale nel bianco sopra invece di affondare nel paragrafo. Il paragrafo lascia libera quella salita sopra di sé in righe intere, quindi il `room` dell’apertura sotto il titolo è di soli 3 mm. Nello stile `quiet`, `margins` restringono la giustezza a 106 mm, `bodyStyle` compone il testo a bandiera a 12,5/17,5 pt, `footer` sostituisce il folio in basso con uno piccolo nell’angolo esterno e `palette` rende grigio l’occhiello. Niente in questa pagina stampa un numero di capitolo, quindi `numbered: false` si vedrebbe solo in un capitolo aggiunto dopo la coda, che sarebbe comunque il quinto.

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: https://github.com/drnachio/postext/tree/main/cookbook/five-chapter-openers

### script.js

```js
// ═══ Postext Cookbook · Nº 017 · Five chapter openers in one book ═════════════════
// https://postext.dev/en/cookbook/five-chapter-openers
// Code: MIT · Text: original (CC BY 4.0) · Ornament: generated in code (CC BY 4.0)
// Fonts: Lora, Fraunces, Geist, Geist Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A small book about openers, each chapter set in the one it describes. Every heading style
// brings its opener and palette; three move the margins, two bring their own type and folios.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'five-chapter-openers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: one opener per chapter: the level's own, then heading styles by name
// A heading line picks a style by id, and its attributes feed the opener's {attr.…} texts:
//   # The Frame {style="framed" kicker="Chapter the Second"}
// A style overrides the level fields it names (opener, drop cap) and, until the next level-1
// heading, the section fields it names; the rest comes from the level and the document.
const level1 = () => ({ level: 1,
  // span 'page' opens each chapter at the head of a new page and runs its opener across
  // every column (chapter four has two). By default an H1 waits for a recto, with a blank
  // page before it; parity 'any' lets these short chapters open on either side.
  span: 'page',
  breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), // each opener ends where its first paragraph starts
  advancedDesign: literary, // chapter one, unstyled: the book's own opener
  dropCap: { lines: 5, fontFamily: 'Fraunces', fontWeight: 400, color: col('accent'),
    gap: mm(2.5) } }); // a five-line drop in rubric red
const headingStyles = () => [ // factories, called by config() once every design exists
  // Each style brings its opener and swaps 'accent' for a colour of its own, on its pages.
  { id: 'framed', advancedDesign: framed, palette: { accent: palette.lapis },
    margins: { left: mm(EVEN), right: mm(EVEN) }, // a centred text block; 'accent' is blue:
    dropCap: { lines: 3, fontFamily: 'Fraunces', fontWeight: 600, color: col('accent'),
      gap: mm(2) } },
  { id: 'stacked', advancedDesign: stacked, palette: { accent: palette.magenta }, // white, on
    dropCap: { lines: BOXED, fontFamily: 'Geist Mono', fontWeight: 700, color: col('paper'),
      gap: mm(2.2) } }, // the square the opener draws for it
  { id: 'spec', advancedDesign: spec, palette: { accent: palette.signal }, dropCap: false,
    marginBottom: pt(0), // the hairline under the title is the separator
    margins: { left: mm(SPEC), right: mm(SPEC) }, // a 144 mm text block
    layout: { layoutType: 'double', gutterWidth: mm(7) },
    bodyStyle: { fontFamily: 'Geist', fontSize: pt(9.8), textAlign: 'left' }, // on the grid
    header: { elements: [...specHead('even'), ...specHead('odd')] },
    footer: { elements: [] } }, // no folio at the foot: the head gives the page
  { id: 'quiet', numbered: false, // a coda: it advances no chapter counter
    advancedDesign: quiet, palette: { accent: palette.graphite }, footer: tinyFolio,
    dropCap: { lines: 1, fontFamily: 'Fraunces', fontWeight: 300, fontSize: pt(46), // raised:
      color: col('accent'), gap: mm(1.2) }, // on the first baseline, rising into the white
    margins: { left: mm(QUIET.inner), right: mm(QUIET.outer) }, // a 106 mm measure
    bodyStyle: { fontSize: pt(QUIET.size), lineHeight: pt(QUIET.lead), textAlign: 'left' } },
]; // hook-up in config(): headings: { levels: [level1(), …] }, headingStyles()
// #endregion

const palette = { // every colour in the config links to one of these by id
  ink: '#16181c', // text: a cool near-black
  accent: '#8c1c13', // the one accent of a section; the book's own is a rubric red
  tint: '#f6f0e3', // the frame's cream
  muted: '#6b6760', // running heads and the colophon
  paper: '#ffffff', // type on colour
  lapis: '#24427a', magenta: '#a3155e', signal: '#ff5a1f', graphite: '#4a4f57',
};
// col(id): a palette-linked colour, so a style's palette can swap 'accent' on its own pages.
// The hex is the value the entry holds in the book's palette.
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 engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
const TRIM = { w: 180, h: 240 }; // mm
const TOP = 22, INNER = 20, OUTER = 36; // margins, mm: a wide outer margin keeps 124 mm lines
const MEASURE = TRIM.w - INNER - OUTER; // mm
const EVEN = (TRIM.w - MEASURE) / 2; // mm: equal side margins, for the framed chapter
const LEAD = 14.5; // body leading, pt: the baseline grid
const LEADIN = { size: 12, lead: 17 }; // pt: a chapter's opening paragraph, a size larger
const QUIET = { size: 12.5, lead: 17.5, inner: 30, outer: 44 }; // the coda: pt, and mm margins

// Design-slot shorthands. at(): a placement from an anchor's edge, x and y in mm.
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width !== undefined && { size: { width: width === 'fill' ? 'fill' : mm(width) } }) });
// Every design text wraps: an opener's would anyway, a running head's would end in '…'.
// A number lineHeight multiplies the size.
const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id,
  content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'left', overflow: 'wrap',
  placement, ...extra });
const caps = (tracking, weight = 600) => ({ fontWeight: weight, letterSpacing: pt(tracking),
  textTransform: 'uppercase', color: col('accent') });
const centred = (y) => at('container', 'top', 0, y, 'fill'); // a full-width line, centred
// No minHeight: the text starts under the lowest element of each opener.
const design = (elements) => ({ enabled: true, slot: { elements } });
// An empty box reserves room: the text starts y mm under the element it hangs from.
const room = (anchor, y, edge = 'below') => ({ kind: 'box', id: 'room', style: {},
  placement: { ...at(anchor, edge, 0, 0, 'fill'), size: { width: 'fill', height: mm(y) } } });

// #region literary: chapter one, the level's own opener: a five-line drop in rubric red
// The first paragraph, in :::paragraphs{style="lead"}, starts 17 mm under the title.
const literary = design([
  text('kicker', '{attr.kicker}', 'Geist', 8, centred(22), { ...caps(1.7), align: 'center' }),
  text('title', '{titleText}', 'Fraunces', 32, at('#kicker', 'below', 0, 4, 'fill'),
    { fontWeight: 300, italic: true, lineHeight: 1.05, align: 'center' }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'),
    placement: at('#title', 'below', (MEASURE - 18) / 2, 6, 18) }, // an 18 mm rule, centred
  room('#title', 17),
]);
// #endregion

// #region framed: a cartouche of two boxes around the title, an ornament, a three-line initial
const FRAME = 62; // mm: the outer box's height
const INSET = 3; // mm: the hairline box runs this far inside it
const FLEURON = 44; // mm: the ornament's width, centred under the title
const framed = design([ // array order is paint order: the boxes first, the type on top
  { kind: 'box', id: 'frame', placement: { ...at('container', 'top-left', 0, 0),
    size: { width: 'fill', height: mm(FRAME) } },
  style: { backgroundColor: col('tint'), borderColor: col('accent'), borderWidth: pt(1.6) } },
  { kind: 'box', id: 'hairline', placement: { ...at('#frame', 'align-top', INSET, INSET),
    size: { width: mm(MEASURE - 2 * INSET), height: mm(FRAME - 2 * INSET) } },
  style: { borderColor: col('accent'), borderWidth: pt(0.5) } },
  text('kicker', '{attr.kicker}', 'Geist', 7.5, centred(14), { ...caps(1.6), align: 'center' }),
  text('title', '{titleText}', 'Fraunces', 34, at('#kicker', 'below', 0, 3.5, 'fill'),
    { fontWeight: 600, lineHeight: 1.05, align: 'center' }),
  // An image element draws a registered resource, never numbered or captioned. Its resourceId
  // may be '{attr.photo}' (postext ≥ 1.8): then each heading names its own photo.
  { kind: 'image', id: 'fleuron', resourceId: 'fleuron',
    placement: at('#title', 'below', (MEASURE - FLEURON) / 2, 4.5, FLEURON) },
  room('#frame', 9), // the text starts 9 mm under the frame
]);
// #endregion

// #region stacked: a colour field, the title's words stacked, a boxed initial
const FIELD = 130; // mm from the trim: the foot of the colour field
const S_BEARING = 1.2; // mm: the white left of the title's S (0.0365 em at 94 pt)
// The boxed initial: the style's drop cap sets the white letter; the opener draws the square,
// fitted by hand in pt. The text starts on the grid line 8 mm under the field (from the trim).
const TEXT_TOP = Math.ceil(((FIELD - TOP + 8) * 72) / 25.4 / LEAD) * LEAD; // pt
const BASE = 0.8; // a line's baseline stands 0.8 down its line box
const CAPS = 0.7; // the height of a capital in Lora, em
// Geist Mono, em: cap height, the advance every capital shares, the white either side of an M
const MONO = { caps: 0.71, advance: 0.6, side: 0.044 };
const BOXED = 3; // lines the initial spans
const capsTop = TEXT_TOP + BASE * LEADIN.lead - CAPS * LEADIN.size; // the first line's capitals
const foot = TEXT_TOP + (BOXED - 1 + BASE) * LEADIN.lead; // the third baseline: the letter's foot
// The M runs from the first line's capitals to its foot; the square runs past both as far as
// the M's sides sit in, so the colour is as wide on all four sides.
const letter = (foot - capsTop) / MONO.caps, pad = MONO.side * letter;
const stacked = design([
  { kind: 'box', id: 'field', style: { backgroundColor: col('accent') },
    placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(FIELD) } } },
  text('kicker', '{attr.kicker}', 'Geist', 8, at('container', 'top-left', 0, 4),
    { ...caps(1.8), color: col('paper') }),
  // {titleText} keeps the \\ breaks from the Markdown, so each line ends at the writer's \\.
  // Moved left by the S's side bearing, so its curve meets the text edge.
  text('title', '{titleText}', 'Fraunces', 94,
    at('container', 'top-left', -S_BEARING, 15, 'fill'),
    { fontWeight: 900, lineHeight: 0.84, color: col('paper') }),
  { kind: 'box', id: 'initial', style: { backgroundColor: col('accent') }, reserve: false,
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(capsTop - pad) },
      size: { width: pt(MONO.advance * letter), height: pt(foot - capsTop + 2 * pad) } } },
  room('container', (TEXT_TOP * 25.4) / 72, 'top-left'), // the opener ends where the text starts
]);
// #endregion

// The book's running heads and folios, anchored to the text block, whatever its margins.
const HEAD_Y = 14; // mm from the trim to the running heads
const label = (id, content, parity, edge, x, extra = {}) => text(id, content, 'Geist', 7.5,
  at('container', edge, x, HEAD_Y), { ...caps(1.4, 500), color: col('muted'), parity,
    pages: 'body', ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0.4) };
// Title on the verso, chapter on the recto, folios at the outer corners.
const header = { elements: [
  label('verso-folio', '{pageNumber}', 'even', 'top-left', 0, folio),
  label('verso-title', '{title}', 'even', 'top-left', 9),
  label('recto-title', '{chapterTitle}', 'odd', 'top-right', -9),
  label('recto-folio', '{pageNumber}', 'odd', 'top-right', 0, folio),
] };
// Openers carry a drop folio instead, centred under the text block, in the section's accent.
const footer = { elements: [text('drop-folio', '{pageNumber}', 'Geist', 7.5,
  at('container', 'top', 0, 9), { ...folio, color: col('accent'), pages: 'opener' })] };

// #region spec: a ruled title, a number badge that widens with the number, two columns
const SPEC = 18; // mm: both side margins, for a 144 mm text block in two columns
const PAD = 1.6; // mm of colour over the badge's figures
const spec = design([
  { kind: 'rule', id: 'top-rule', direction: 'horizontal', thickness: pt(2.4), color: col('ink'),
    placement: at('container', 'top-left', 0, 0, 'fill') },
  text('kicker', '{attr.kicker}', 'Geist Mono', 7.5, at('container', 'top-left', 0, 3.2),
    { ...caps(1.2, 500), color: col('graphite') }),
  // The badge's width follows its text, with padding on both sides, so 9 → 10 widens it. Its
  // line box holds 1 mm more under the figures than over them, so the foot pads 1 mm less.
  text('badge', '{chapterNumber}', 'Geist Mono', 26, at('container', 'top-left', 0, 12),
    { fontWeight: 700, lineHeight: 1, box: { backgroundColor: col('accent'), padding: {
      top: mm(PAD), right: mm(2.4), bottom: mm(PAD - 1), left: mm(2.4) } } }),
  // Same size and line height as the badge, dropped by its top padding, so the baselines meet.
  text('title', '{titleText}', 'Geist', 26, at('#badge', 'right-of', 4, PAD),
    { fontWeight: 700, lineHeight: 1 }),
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('ink'),
    placement: at('container', 'top-left', 0, 31, 'fill') }, // room for a one-line title
]);
const specHead = (parity) => { // mono labels over the opener's rule, on every page of the section
  const [edge, s] = parity === 'even' ? ['top-left', 1] : ['top-right', -1];
  const mono = { ...caps(1.1, 500), color: col('graphite'), parity, pages: 'all' };
  return [
    text(`sp-page-${parity}`, '{pageNumber} / {totalPages}', 'Geist Mono', 7,
      at('container', edge, 0, HEAD_Y), mono),
    text(`sp-chap-${parity}`, '§ {chapterNumber} · {chapterTitle}', 'Geist Mono', 7,
      at('container', edge, s * 16, HEAD_Y), mono),
  ];
};
// #endregion

// #region quiet: the coda: a title far down the page, a raised initial, a folio at the side
const quiet = design([ // the kicker is in the accent, which this style turns graphite
  text('kicker', '{attr.kicker}', 'Geist', 7.5, at('container', 'top-left', 0, 92),
    caps(1.6, 500)),
  text('title', '{titleText}', 'Fraunces', 26, at('#kicker', 'below', 0, 3),
    { fontWeight: 300, lineHeight: 1.1 }),
  room('#title', 3), // and the initial's rise, in whole lines: the text 16 mm under the title
]);
const tiny = (parity, edge) => text(`tiny-${parity}`, '{pageNumber}', 'Geist', 6.5,
  at('container', edge, 0, -12), { fontWeight: 500, color: col('muted'), parity });
const tinyFolio = { elements: [tiny('even', 'bottom-left'), tiny('odd', 'bottom-right')] };
// #endregion

const flush = { textAlign: 'left', firstLineIndent: pt(0) };
const config = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the hyphenation patterns of each edition
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, // 150 dpi: a screen edition
    margins: { top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Lora', fontSize: pt(10.8), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    minWordSpacing: 0.8, maxWordSpacing: 1.6 }, // an even texture on 124 mm lines
  headings: { fontFamily: 'Geist', color: col('ink'), levels: [level1(),
    { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), fontWeight: 700, // the manual's
      numberingTemplate: '{1}.{2}', marginTop: pt(LEAD), marginBottom: pt(0) }] },
  headingStyles: headingStyles(),
  paragraphStyles: [
    // A chapter's opening paragraph, a size larger and ragged (the coda's in its own size);
    // a :::space under it in the Markdown leaves a line and starts the text flush.
    { id: 'lead', fontSize: pt(LEADIN.size), lineHeight: pt(LEADIN.lead), ...flush },
    { id: 'coda-lead', fontSize: pt(QUIET.size), lineHeight: pt(QUIET.lead), ...flush },
    { id: 'colophon', fontFamily: 'Geist', fontSize: pt(7), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(2 * LEAD) }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Five Ways to Begin"
subtitle: "Notes on the chapter opener"
---

# The Voice {kicker="Chapter One"}

:::paragraphs{style="lead"}
Most books begin quietly. A novel opens each chapter with a number, sometimes a title, some white space and then the first sentence, set in the same face as the rest of the text. The arrangement is meant to go unnoticed. It tells a reader who has finished one chapter that the next one has started, and lets them go on reading without stopping to look at the page.
:::

:::space

The one ornament the novel has kept is the initial. It goes back to the rubricator, the scribe who went through a finished manuscript adding the headings and the capital letters in red ink; the word *rubric* comes from *rubrica*, the Latin for red ochre. The custom survived into print. Many early printed books leave a blank square where a chapter begins, sometimes with a small guide letter printed in it, so that a rubricator could paint the initial by hand once the sheets came off the press. Some copies were finished in gold and colour and others never were; in those the squares are still empty but for the guide letter.

A drop cap is positioned from the lines beside it. Its top aligns with the capitals of the first line, and its foot stands on the baseline of the last line it spans, so the letter sits inside the text block. The lines it displaces all start the same distance from its right edge. Set above the capitals, the letter looks pasted on; set below them, it seems to sink into the paragraph.

Five lines is a deep drop, usually kept for the first chapter of a long book. Two or three lines are more common, and many novels have no initial at all and mark the start of a chapter with the white space above the first line.

A plain opener still follows exact measurements. The white space above the title, which printers call the sinkage, is the same depth on every opener, so the titles sit at one height as the pages turn, whether a title is one word long or eight. The first line of text falls on the same grid line as in the rest of the book, so that the lines on facing pages align when the book lies open. A sinkage that changes by a few millimetres from one chapter to the next shows as soon as you flick through the book.

The chapter number can be spelled out, as it is here, printed in figures or, for an older look, set in roman numerals, and a book keeps to the form it chooses. Some novels leave out the word *Chapter* and print the number alone above the text, which suits a book of many short chapters. Others give each chapter a title and no number. The title is then the only element of the opener, so it is set a size larger, with more space above it.

After the initial, many books set the rest of the first line, or the first phrase, in small capitals, which eases the step down from the large letter to the size of the text; others continue in roman. The first paragraph is not indented. An indent separates a paragraph from the one before it, and the first paragraph of a chapter has none before it.

Chapters of a novel usually start on a new page, and in a long book often on a right-hand page, even when that leaves the page before it blank. In a novel of twenty chapters this can add about ten blank pages. Publishers accept them because every opener then falls on the same side of the book, and a reader looking for a chapter can find it by riffling the pages.

The opener has no running head. The chapter title is already on the page and a head above it would repeat it, so the page number moves to the foot of the page instead, centred under the text. Printers call this a drop folio. Many books leave it out as well, and the opener is then counted but carries no printed number, a blind folio.

On the chapter’s second page the running heads come back, the book’s title on the left-hand page and the chapter’s on the right, and the folios return to the outer corners. From there to the end of the chapter the text keeps to the same grid. A chapter that ends on its first page never shows the running heads at all.

This book sets each chapter in the opener it describes, and so far it has followed the conventions of the novel. The next four chapters depart from them: a framed title for a gift edition, a title stacked on a colour field for a magazine, a numbered and ruled opener for a manual, and a title set low on an empty page for a coda.

# The Frame {style="framed" kicker="Chapter the Second"}

:::paragraphs{style="lead"}
Long before books had title pages, the start of a text was marked in the manuscript itself, with a painted border round the first leaf and a large coloured initial, often under a band of leaves or knotwork.
:::

:::space

The first printers took the practice over from the manuscripts they imitated and made it mechanical. They kept cases of cast ornaments in the shape of flowers, leaves and knots, which the trade calls fleurons, and built borders of any size from them, piece by piece, as a mason builds a wall. Initials were woodcuts, each letter cut into a block of foliage. A thrifty shop kept a few blocks with an empty centre, into which any capital from the case could be set; printers called such a block a factotum, from the Latin for “do everything”.

A frame now appears mostly on reissued classics and anniversary editions, books bought to be given. Here a heavy rule outside and a hairline 3 mm inside it hold the title the way a mount holds a print. The type inside the frame is centred, with a single ornament below it. Colour goes on the frame, the kicker and the initial, and the text stays black.

Below the frame the text has the face, size and measure of the rest of the book. Only the margins change, so that the text block is centred under the frame.

# Say it \\ out \\ loud. {style="stacked" kicker="Chapter Three · Magazines, posters, manifestos"}

:::paragraphs{style="lead"}
Magazines, posters and manifestos give their first page to the title, set as large as the page allows and often with no picture at all.
:::

:::space

A stacked title has few words, one phrase to each line, and the writer decides where the lines break. Left to wrap wherever the measure runs out, a title can split an article from its noun and end a line on *the*; broken where a speaker would pause, it reads as a headline.

It needs a heavy face and empty space around it. Here the colour field gives it a background that bleeds off the top and sides of the page. Below the field the text returns to reading size, and only its first letter, reversed out in white from a magenta square, repeats the colour.

# Number Everything {style="spec" kicker="Manuals · Standards · Datasheets"}

Few people read a manual from the first page to the last. They open it at the section they need, often with the machine it describes in the other hand, and close it once the question is answered. The chapter number is what they write in a margin or quote in an email, so the opener sets it large, in figures of equal width, at the top left, where the eye starts.

## Numbers before names

Readers cite a section by its number. A reference to section 4.2 still works after the section’s title has been reworded, which a reference to “the section on columns” may not. Headings are numbered down to the second level, and the number of a withdrawn section is never given to a new one, so an old reference cannot lead to different text.

The badge that carries the chapter number widens with it. One- and two-figure numbers get the same padding on either side, and the title moves along to make room, so chapter 10 has the same gap between badge and title as chapter 9. At this size each extra figure adds 5.5 mm.

## Two columns, short lines

Technical text is often set in two columns because a short line is easier to scan. Here each column is 68.5 mm wide, about forty characters of this text. A reader looking for one value can run down a narrow column, and at the end of each line the eye finds the start of the next without losing its place. The text is ragged right, so every word space keeps its natural width, and a long string such as a part number never stretches the spaces of the line before it.

The title runs across both columns. It has to be read before anything else on the page, and in the first column alone it would look like one more paragraph.

## Rules and colour

The rules divide the page. A heavy rule across the top of the text block marks the start of the chapter, and a hairline separates the title from the text. Both run the full width of the text block, across the two columns. The running head above them gives the chapter and the page number over the total, so a reader can see how far into the document they are without turning to the contents.

The only colour is the orange of the badge, and the figure printed on it is black. Everything else a reader has to read is black or dark grey, so a black-and-white photocopy of the page loses no information.

# Leave Room {style="quiet" kicker="Coda"}

:::paragraphs{style="coda-lead"}
Some books need no signal at all. A collection of poems or a memoir can use its openers to slow the reader down.
:::

:::space

This coda does it with white space: its title starts more than 90 mm down the text block, and its initial rises above the first line into the empty page.

:::paragraphs{style="colophon"}
*Five Ways to Begin* is set in Lora, Fraunces, Geist and Geist Mono (SIL Open Font License). Text and ornament: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the fleuron, a printer's flower drawn in paths in the frame's blue
function fleuron(ink) { // two scrolls with a leaf above and below, a rosette between them
  const scroll = 'M51 14.2C42 9.5 31 8.6 22 11.4C16 13.2 11.6 13 9.6 10.2C8.2 7.8 10.2 5.3 13 5.8'
    + 'C11 6.8 10.6 8.8 12 9.9C14.6 11.4 18 10.2 21.6 9.1C30.6 6.4 42 7.6 51 12.4Z'
    + 'M36 11.4C33.4 15.6 28.6 18.4 23.4 18.8C26.4 15 31 12.4 36 11.4Z' // the leaf below
    + 'M31 8.3C29.6 5.2 26.4 3.4 23 3.5C25.2 5.8 27.8 7.6 31 8.3Z'; // the leaf above
  const petal = 'M60 13.4C58.2 10.6 58.2 7.2 60 4.4C61.8 7.2 61.8 10.6 60 13.4Z';
  const turn = (a) => `transform="rotate(${a} 60 13.4)"`;
  const rosette = [0, 90, 180, 270].map((a) => `<path d="${petal}" ${turn(a)}/>`).join('')
    + [45, 135, 225, 315].map((a) => `<circle cx="60" cy="9.6" r="1.1" ${turn(a)}/>`).join('');
  return '<svg xmlns="http://www.w3.org/2000/svg" width="720" height="162" viewBox="0 0 120 27">'
    + `<g fill="${ink}"><path d="${scroll}"/>` // the right half is the left one mirrored
    + `<path d="${scroll}" transform="translate(120 0) scale(-1 1)"/>${rosette}`
    + '<circle cx="4.5" cy="10.5" r="1.5"/><circle cx="115.5" cy="10.5" r="1.5"/></g></svg>';
}
// #endregion
const resources = [{ id: 'fleuron', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'fleuron.svg', width: 720, height: 162 },
  altText: t({ en: 'A printer’s flower: a four-petalled rosette between two leafy scrolls.',
    es: 'Un florón de imprenta: una roseta de cuatro pétalos entre dos volutas con hojas.' }) }];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display, label and figure faces
  Lora: ['400', '400i'], Fraunces: ['300', '300i', '400', '600', '900'],
  Geist: ['400', '400i', '500', '600', '700'], 'Geist Mono': ['500', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('fleuron.svg', fleuron(palette.lapis)); // a picture's colours are fixed when drawn
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Five chapter openers', es: 'Cinco aperturas de capítulo' }) });

// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

## Varianti

### Manda ogni capitolo su un recto

Con `'odd'` ogni capitolo si apre su una pagina destra e i capitoli dal due al cinque ricevono ognuno un verso bianco prima, così il libro passa da 7 a 11 pagine.

```diff
-  breakBefore: { enabled: true, parity: 'any' },
+  breakBefore: { enabled: true, parity: 'odd' },
```

### Un’iniziale di tre righe

Senza `fontSize` il corpo segue il numero di righe, quindi un’iniziale di tre righe si allinea ancora alle maiuscole della prima riga.

```diff
-  dropCap: { lines: 5, fontFamily: 'Fraunces', fontWeight: 400, color: col('accent'),
+  dropCap: { lines: 3, fontFamily: 'Fraunces', fontWeight: 400, color: col('accent'),
```

### Maiuscoletto dopo l’iniziale

`leadIn` compone in maiuscoletto le prime parole dopo la lettera, come fanno molti romanzi; `words: 'line'` prende l’intera prima riga.

```diff
-    gap: mm(2.5) } });
+    gap: mm(2.5), leadIn: { words: 'line' } } });
```

## Errori frequenti

- **Valori degli attributi: niente { o }; virgolette semplici per un valore con ".** Il valore di un attributo finisce alla graffa di chiusura, quindi non può contenere { o }. Un valore che contiene una virgoletta doppia va tra virgolette semplici; il simbolo del dollaro va bene.

- I paragrafi d’apertura sono composti a bandiera, come nell’apertura di una rivista; un paragrafo giustificato accanto a un’iniziale di cinque righe può allargare molto gli spazi in una riga di poche parole.
- Il riquadro magenta è adattato a mano al Lora 12/17, alla M di Geist Mono e alla linea di base della riga, a 0,8 dell’altezza del suo riquadro di riga (`BASE`). Se cambi il carattere, il corpo o l’interlinea del paragrafo, la lettera, il numero di righe o la profondità del campo, ricontrolla il riquadro rispetto alle maiuscole della prima riga.
- I titoli non ruotano e non scorrono in verticale: il testo di design è sempre orizzontale. Impila invece le parole con `\\`, come fa il capitolo tre.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caratteri: Lora (OFL-1.1), Fraunces (OFL-1.1), Geist (OFL-1.1), Geist Mono (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 003 · Apertura di capitolo su una fascia al vivo](https://postext.dev/it/cookbook/chapter-opener-bleed-band.md): Un’apertura advancedDesign sul titolo di livello 1: una fascia al vivo, il numero del capitolo sul suo piede, occhiello e sommario dagli attributi del titolo. · Livello 3 (Avanzato) · Libri di testo
- [Nº 066 · Capitoli di un libro illustrato con vignette e sommari](https://postext.dev/it/cookbook/storybook-chapter-vignettes.md): Un solo stile di titolo apre ogni capitolo; ogni titolo nomina negli attributi la sua vignetta e il suo sommario in corsivo, che l’apertura dipinge. · Livello 2 (Intermedio) · Narrativa, teatro e prosa letteraria
- [Nº 065 · Classico tascabile: capitoli brevi di seguito](https://postext.dev/it/cookbook/short-chapters-run-on.md): Dom Casmurro come libro tascabile: capitoli brevi di seguito sotto titoli disegnati nella colonna, e nessun cambio di pagina lascia sola una riga o un titolo. · Livello 2 (Intermedio) · Narrativa, teatro e prosa letteraria
