# Cinc obertures de capítol en un sol llibre

> 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.

- Versió HTML: https://postext.dev/ca/cookbook/five-chapter-openers
- Recepta Núm. 017 · Títols i obertures · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Qualsevol gènere
- Requereix postext ≥ 1.4.1 · provada amb 1.8.0 el 2026-09-28
- Pàgines: [1](https://postext.dev/cookbook/five-chapter-openers/es/p01.webp?v=9cbabe9a), [2](https://postext.dev/cookbook/five-chapter-openers/es/p02.webp?v=9cbabe9a), [3](https://postext.dev/cookbook/five-chapter-openers/es/p03.webp?v=9cbabe9a), [4](https://postext.dev/cookbook/five-chapter-openers/es/p04.webp?v=9cbabe9a), [5](https://postext.dev/cookbook/five-chapter-openers/es/p05.webp?v=9cbabe9a), [6](https://postext.dev/cookbook/five-chapter-openers/es/p06.webp?v=9cbabe9a), [7](https://postext.dev/cookbook/five-chapter-openers/es/p07.webp?v=9cbabe9a)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=five-chapter-openers&lang=es (.postext: https://postext.dev/cookbook/five-chapter-openers/es/five-chapter-openers.postext)
- Última actualització: 2026-09-28
- Altres idiomes: [en](https://postext.dev/en/cookbook/five-chapter-openers.md), [es](https://postext.dev/es/cookbook/five-chapter-openers.md), [zh](https://postext.dev/zh/cookbook/five-chapter-openers.md), [ar](https://postext.dev/ar/cookbook/five-chapter-openers.md)

## En poques paraules

Un llibre breu sobre com començar un capítol, on cada capítol arrenca amb un estil diferent. Ensenya a donar a un capítol el seu color, els seus marges i la seva maquetació amb un sol ajust.

## Què compondràs

*Cinc maneres de començar* és un llibre de set pàgines sobre obertures de capítol, de 180 × 240 mm, i cada capítol va compost amb l'obertura que descriu. El primer és de novel·la: avantítol espaiat, títol en cursiva fina i caplletra vermella de cinc línies, amb capçaleres a les pàgines següents. El segon tanca el títol i un floró en una cartel·la crema de dos filets blaus. El tercer apila el títol en Fraunces Black blanca sobre un camp magenta a sang, damunt d'una inicial monoespaiada calada en una caixa. El quart, el del manual, porta capçaleres en monoespaiada, una insígnia taronja amb el número al costat del títol i dues columnes de Geist. La coda baixa el títol a mitja pàgina, sobre una inicial elevada. El capítol u fa servir l'obertura pròpia del nivell 1; els altres quatre anomenen un estil a la línia del seu títol.

**Aquesta recepta respon a:**

- Com dono a cada capítol la seva pròpia obertura, el seu color o la seva foto, i ho trio des del Markdown?
- Com afegeixo a una obertura una línia d'autor, una entradeta o un primer paràgraf amb caplletra?
- Com poso el títol de capítol a tota l'amplada mentre el text continua a dues columnes a sota?
- Com faig una insígnia amb el número al costat del títol que s'eixampli quan el número creix (9 → 10)?

## La resposta curta

```js
// script.js, línies 16–44
// 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" lead="Long before there…"}
// A style overrides the level fields it names (here the opener) and, until the next level-1
// heading, the section fields it names; what it leaves out comes from the level and 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). Any headings object drops the default break to a
  // recto (gotcha: headings-drop-h1-break): restate the side, 'any' for short chapters.
  span: 'page',
  breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(LEAD), // one line of air between the opener and the text
  advancedDesign: literary }); // chapter one, unstyled: the book's own opener
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
  { id: 'stacked', advancedDesign: stacked, palette: { accent: palette.magenta } },
  { id: 'spec', advancedDesign: spec, palette: { accent: palette.signal },
    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,
    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()
```

## Ingredients

**Ensenya**

- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament): Variants amb nom d'un nivell de títol, triades amb {style="…"}: una altra obertura, tipografia, capçaleres o pàgina per capítol.
- [Geometria per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament): Un estil de títol que canvia marges i columnes a la seva secció, com un pròleg a una columna en un llibre a dues.
- [Caplletres a les obertures](https://postext.dev/ca/docs/configuration.md#elements-de-text): Una caplletra i paràgrafs sagnats dins d'un element de text de disseny, per a l'entradeta d'una obertura de capítol o d'article.

**També fa servir**

- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Colors per part](https://postext.dev/ca/docs/configuration.md#el-contenidor-part)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Bandes a sang i pestanyes](https://postext.dev/ca/docs/configuration.md#posicionament-delements)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)
- [Ancoratge d'elements de disseny](https://postext.dev/ca/docs/configuration.md#posicionament-delements)
- [Capçaleres i folis](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)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)

**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)

**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**

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

## Elaboració

### 1 · Posa al nivell l'obertura pròpia del llibre

```js
// script.js, línies 86–104
// The opening paragraph comes from lead="…" on the heading line: drop caps exist only in
// design text, so the lead is part of the opener (gap: body-drop-cap).
const lead = (placement, dropCap, extra = {}) => text('lead', '{attr.lead}', 'Lora',
  LEADIN.size, placement, { lineHeight: LEADIN.lead / LEADIN.size, dropCap, ...extra });
const CAPS = 0.7; // the height of a capital in Lora and in Fraunces, em
// A drop cap's foot stands on the last baseline it spans. Its default size makes the capital
// as tall as all those line boxes, so it rises above the first line (2.2 mm for five lines
// here); this size puts its top on the first line's capitals instead.
const dropSize = (lines) => pt(((lines - 1) * LEADIN.lead + CAPS * LEADIN.size) / CAPS);
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
  lead(at('#title', 'below', 0, 17, 'fill'), {
    lines: 5, fontSize: dropSize(5), fontFamily: 'Fraunces', fontWeight: 400,
    color: col('accent'), gap: mm(2.5) }),
]);
```

El capítol u no porta estil, així que pren el disseny del nivell 1, del qual parteix cada estil ([la resposta curta](#la-resposta-curta) mostra el nivell i els estils). Les caplletres només existeixen al text de disseny, de manera que el primer paràgraf del capítol surt de `lead="…"` a la línia del títol i `lead()` el compon a l'obertura; el cos del text continua a sota. El motor recolza la lletra a la cinquena línia de base, però amb el cos per defecte la majúscula mesura el mateix que les cinc caixes de línia juntes i sobresurt 2,2 mm per sobre de les majúscules de la primera línia. `dropSize()` calcula el cos a partir de l'alçada de les majúscules de Lora i Fraunces (0,7 em), i així la M queda enrasada per dalt amb elles.

### 2 · Emmarca el títol amb dues caixes

```js
// script.js, línies 108–128
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) },
  // The section's palette does not reach dropCap.color: name the blue (gotcha: drop-cap-palette).
  lead(at('container', 'top-left', 0, FRAME + 9, 'fill'), { lines: 3, fontSize: dropSize(3),
    fontFamily: 'Fraunces', fontWeight: 600, color: col('lapis'), gap: mm(2) }),
]);
```

L'ordre de l'array és l'ordre de pintat: primer les caixes i al damunt el text; el floró és un element d'imatge, que dibuixa un recurs SVG registrat que no es numera ni se cita mai. Un element d'imatge també serveix per donar a cada capítol la seva pròpia foto: des de postext 1.8 el seu `resourceId` admet marcadors, així que amb `resourceId: '{attr.foto}'` un mateix estil dibuixa la foto que anomena cada títol, `# El marco {style="framed" foto="lago"}`, com les receptes de [la fitxa de cuina](https://postext.dev/ca/cookbook/recipe-card.md) ([elements d'imatge](/ca/docs/configuration#elements-dimatge)). L'estil `framed` de la resposta curta fa servir `palette` per tornar blau `accent` a les pàgines d'aquest capítol, de manera que els filets, l'avantítol i el foli surten en blau, i els seus `margins` centren la caixa de text sota el marc. A la 1.4.1, el `color` d'una caplletra no segueix la paleta de la secció; per això la inicial porta el blau escrit amb `col('lapis')`.

### 3 · Apila el títol i posa la inicial en una caixa

```js
// script.js, línies 132–163
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 engine sets the letter, a drop cap on the third baseline; the box
// behind it is fitted by hand, in pt from the top of the lead's first line box.
const BASE = 0.8; // 1.4.1 sets a design text's baseline 0.8 down its line box; no option
// 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 = BASE * LEADIN.lead - CAPS * LEADIN.size; // the first line's capitals: box top
const foot = (BOXED - 1 + BASE) * LEADIN.lead; // the third baseline: the letter's foot
// The M's top sits as far under the box's top as its sides sit in, and the box runs as far
// under its foot, so the colour is as wide on all four sides.
const letter = (foot - capsTop) / (MONO.caps + MONO.side), 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') }, // behind the lead
    placement: { anchor: { to: '#lead', edge: 'align-top' }, offset: { y: pt(capsTop) },
      size: { width: pt(MONO.advance * letter), height: pt(foot + pad - capsTop) } } },
  // The lead: 8 mm under the field (FIELD is from the trim). Its drop cap's line box
  // counts in the opener's height, 3.5 mm under the lead: FIELD is set so it costs no line.
  lead(at('container', 'top-left', 0, FIELD - TOP + 8, 'fill'), { lines: BOXED,
    fontSize: pt(letter), fontFamily: 'Geist Mono', fontWeight: 700, color: col('paper'),
    gap: mm(2.2) }),
]);
```

Els talls del títol s'escriuen al Markdown: `# Salta \\ a la \\ vista.` parteix `{titleText}` a cada `\\`, mentre que les capçaleres i els marcadors del PDF llegeixen el títol en una sola línia ([salts de línia en els títols](/ca/docs/document-format#salts-de-línia-als-títols)). El camp penja de la vora superior de la zona de sang, a tota l'amplada i amb 130 mm d'alçada, i l'entradeta comença 8 mm per sota. La inicial en caixa es fa amb dos elements. La caplletra de l'entradeta compon la M en tres línies, i la caixa magenta que es pinta abans es calcula a partir de la caixa de línia de l'entradeta i de les mides de Geist Mono, de manera que el color que queda a sobre i a sota de la M mesura el mateix que el seu blanc lateral.

### 4 · Dona al manual la seva insígnia, dues columnes i les seves capçaleres

```js
// script.js, línies 184–211
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),
  ];
};
```

La insígnia és un element de text amb fons i farciment interior però sense amplada, així que la seva amplada segueix la de `{chapterNumber}`, i el títol es col·loca a la seva dreta amb `right-of`: al capítol 10 la insígnia creix una xifra, 5,5 mm, i el títol es desplaça amb ella. El seu farciment inferior és 1 mm menor que el superior perquè la caixa de línia deixa aquest mil·límetre de més sota les xifres, i així el 4 queda centrat. A l'estil `spec`, `layout` passa la secció a dues columnes i el `span: 'page'` del nivell estén l'obertura sobre totes dues. Els seus `margins` eixamplen la caixa de text a 144 mm, `bodyStyle` compon Geist en bandera sobre la retícula de base de 14,5 punts del llibre, `header` substitueix les capçaleres del llibre i un `footer` buit treu el foli.

### 5 · Deixa aire a la coda

```js
// script.js, línies 215–227
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 }),
  // One line: the letter stands on the first baseline and rises into the white above it.
  lead(at('#title', 'below', 0, 16, 'fill'), { lines: 1, fontFamily: 'Fraunces',
    fontWeight: 300, fontSize: pt(46), color: col('graphite'), gap: mm(1.2) },
  { fontSize: pt(QUIET.size), lineHeight: QUIET.lead / QUIET.size }),
]);
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'avantítol penja 92 mm per sota del començament de la caixa de text, i la inicial elevada és una caplletra d'una sola línia: el seu peu es recolza a la primera línia de base i, amb 46 punts, sobresurt cap al blanc de dalt en lloc d'enfonsar-se al paràgraf. A l'estil `quiet`, `margins` estrenyen la mesura a 106 mm, `bodyStyle` compon el text en bandera a 12,5/17,5 punts, `footer` canvia el foli de l'obertura per un de petit a la cantonada exterior del peu i `palette` torna gris l'avantítol. En aquesta pàgina no s'imprimeix cap número de capítol, així que `numbered: false` només es notaria en un capítol afegit després de la coda, que continuaria sent el cinquè.

## 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/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.4.1
// 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 {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @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" lead="Long before there…"}
// A style overrides the level fields it names (here the opener) and, until the next level-1
// heading, the section fields it names; what it leaves out comes from the level and 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). Any headings object drops the default break to a
  // recto (gotcha: headings-drop-h1-break): restate the side, 'any' for short chapters.
  span: 'page',
  breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(LEAD), // one line of air between the opener and the text
  advancedDesign: literary }); // chapter one, unstyled: the book's own opener
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
  { id: 'stacked', advancedDesign: stacked, palette: { accent: palette.magenta } },
  { id: 'spec', advancedDesign: spec, palette: { accent: palette.signal },
    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,
    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. It carries the hex too, because 1.4.1 paints design
// elements from the 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 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: the opening paragraph each opener sets itself
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) } }) });
// Design text wraps; left alone it ends in '…' (gotcha: overflow-ellipsis-default). A number
// lineHeight multiplies the size, never pt() (gotcha: design-lineheight-multiple).
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 } });

// #region literary: chapter one, the level's own opener: a five-line drop in rubric red
// The opening paragraph comes from lead="…" on the heading line: drop caps exist only in
// design text, so the lead is part of the opener (gap: body-drop-cap).
const lead = (placement, dropCap, extra = {}) => text('lead', '{attr.lead}', 'Lora',
  LEADIN.size, placement, { lineHeight: LEADIN.lead / LEADIN.size, dropCap, ...extra });
const CAPS = 0.7; // the height of a capital in Lora and in Fraunces, em
// A drop cap's foot stands on the last baseline it spans. Its default size makes the capital
// as tall as all those line boxes, so it rises above the first line (2.2 mm for five lines
// here); this size puts its top on the first line's capitals instead.
const dropSize = (lines) => pt(((lines - 1) * LEADIN.lead + CAPS * LEADIN.size) / CAPS);
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
  lead(at('#title', 'below', 0, 17, 'fill'), {
    lines: 5, fontSize: dropSize(5), fontFamily: 'Fraunces', fontWeight: 400,
    color: col('accent'), gap: mm(2.5) }),
]);
// #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) },
  // The section's palette does not reach dropCap.color: name the blue (gotcha: drop-cap-palette).
  lead(at('container', 'top-left', 0, FRAME + 9, 'fill'), { lines: 3, fontSize: dropSize(3),
    fontFamily: 'Fraunces', fontWeight: 600, color: col('lapis'), gap: mm(2) }),
]);
// #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 engine sets the letter, a drop cap on the third baseline; the box
// behind it is fitted by hand, in pt from the top of the lead's first line box.
const BASE = 0.8; // 1.4.1 sets a design text's baseline 0.8 down its line box; no option
// 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 = BASE * LEADIN.lead - CAPS * LEADIN.size; // the first line's capitals: box top
const foot = (BOXED - 1 + BASE) * LEADIN.lead; // the third baseline: the letter's foot
// The M's top sits as far under the box's top as its sides sit in, and the box runs as far
// under its foot, so the colour is as wide on all four sides.
const letter = (foot - capsTop) / (MONO.caps + MONO.side), 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') }, // behind the lead
    placement: { anchor: { to: '#lead', edge: 'align-top' }, offset: { y: pt(capsTop) },
      size: { width: pt(MONO.advance * letter), height: pt(foot + pad - capsTop) } } },
  // The lead: 8 mm under the field (FIELD is from the trim). Its drop cap's line box
  // counts in the opener's height, 3.5 mm under the lead: FIELD is set so it costs no line.
  lead(at('container', 'top-left', 0, FIELD - TOP + 8, 'fill'), { lines: BOXED,
    fontSize: pt(letter), fontFamily: 'Geist Mono', fontWeight: 700, color: col('paper'),
    gap: mm(2.2) }),
]);
// #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 }),
  // One line: the letter stands on the first baseline and rises into the white above it.
  lead(at('#title', 'below', 0, 16, 'fill'), { lines: 1, fontFamily: 'Fraunces',
    fontWeight: 300, fontSize: pt(46), color: col('graphite'), gap: mm(1.2) },
  { fontSize: pt(QUIET.size), lineHeight: QUIET.lead / QUIET.size }),
]);
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 config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation, by exact code (gotcha: hyphenation-locales)
  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: [{ 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: "Cinco maneras de empezar"
subtitle: "Notas sobre la apertura de capítulo"
---

# La voz {kicker="Capítulo uno" lead="Muchos libros empiezan en voz baja. Una novela abre cada capítulo con un número, a veces un título, un espacio en blanco y la primera frase, compuesta en la misma letra que el resto del texto. Esa disposición está pensada para pasar inadvertida. Avisa a quien acaba un capítulo de que empieza otro y le deja seguir leyendo sin detenerse a mirar la página."}

El único adorno que conserva la novela es la inicial. Viene del rubricador, el copista que repasaba el manuscrito terminado para añadir en tinta roja los títulos y las mayúsculas; *rúbrica* procede del latín *rubrica*, el almagre u ocre rojo. La costumbre pasó a la imprenta. Muchos de los primeros libros impresos tienen un cuadro en blanco al comienzo de cada capítulo, a veces con una diminuta letra de guía, para que un rubricador pintara después la inicial a mano. Algunos ejemplares de una misma edición se iluminaron con oro y colores y otros nunca se terminaron; en estos, el cuadro sigue en blanco y la letra de guía, a la vista.

La capitular se coloca a partir de las líneas que tiene al lado. Su parte superior se alinea con las mayúsculas de la primera línea, y su pie descansa sobre la línea de base del último renglón que abarca, de modo que la letra queda dentro de la caja de texto. Todas las líneas que desplaza empiezan a la misma distancia de su borde derecho. Si sube por encima de las mayúsculas, parece un recorte pegado; si baja, parece que se hunde en el párrafo.

Una capitular de cinco líneas es grande y suele reservarse para el primer capítulo de un libro largo. Lo habitual son dos o tres, y muchas novelas prescinden de la inicial y marcan el comienzo del capítulo solo con el blanco que dejan sobre la primera línea.

Una apertura sencilla también se rige por medidas fijas. El blanco que queda sobre el título mide lo mismo en todos los capítulos, de modo que los títulos quedan a la misma altura al pasar las páginas, tenga el título una palabra u ocho. El primer renglón del texto cae en la misma línea de la rejilla base que en el resto del libro, y así los renglones de las páginas enfrentadas coinciden cuando el libro está abierto. Si ese blanco varía unos milímetros de un capítulo a otro, se nota en cuanto se hojea el libro.

El número de capítulo puede ir en letra, como aquí, en cifras o, si se busca un aire antiguo, en números romanos; el libro elige una forma y la usa en todos los capítulos. Algunas novelas suprimen la palabra *capítulo* y dejan el número solo sobre el texto, cosa que conviene a los libros de capítulos cortos y numerosos. Otras dan a cada capítulo un título y ningún número. Entonces el título es el único elemento de la apertura, y se compone en un cuerpo mayor y con más blanco encima.

Tras la inicial, muchos libros componen en versalitas el resto de la primera línea o las primeras palabras, lo que suaviza el paso de la letra grande al cuerpo del texto; otros siguen en redonda. El primer párrafo no lleva sangría. La sangría separa un párrafo del anterior, y el primero de un capítulo no tiene ninguno delante.

Los capítulos de una novela suelen empezar en página nueva y, en un libro largo, a menudo en página impar, aunque la par anterior quede en blanco. En una novela de veinte capítulos eso puede sumar unas diez páginas en blanco. Los editores las aceptan porque así todas las aperturas caen del mismo lado, y quien busca un capítulo lo encuentra pasando las hojas con el pulgar.

La apertura no lleva cabecera. El título del capítulo ya está en la página y una cabecera encima lo repetiría, así que el número de página baja al pie, centrado bajo el texto. Es el llamado folio caído. En muchos libros ni siquiera aparece: la página cuenta en la numeración, pero no lleva número impreso.

En la segunda página del capítulo vuelven las cabeceras, el título del libro en la página par y el del capítulo en la impar, y los folios regresan a las esquinas exteriores. Desde ahí hasta el final del capítulo, el texto se mantiene en la misma rejilla. Un capítulo que termina en su primera página no llega a mostrar las cabeceras.

Este librito compone cada capítulo con la apertura que describe, y hasta aquí ha seguido las convenciones de la novela. Los cuatro capítulos siguientes se apartan de ellas: un título enmarcado para una edición de regalo, un título apilado sobre un campo de color para una revista, una apertura numerada y con filetes para un manual y, para una coda, un título bajo en una página casi vacía.

# El marco {style="framed" kicker="Capítulo segundo" lead="Siglos antes de que los libros tuvieran portada, el comienzo de un texto se marcaba en el propio manuscrito con una orla pintada en la primera hoja y una gran inicial de color."}

Los primeros impresores heredaron esa costumbre de los manuscritos que imitaban y la volvieron mecánica. Con florones, adornos fundidos en forma de flores, hojas y nudos, armaban orlas de cualquier tamaño pieza a pieza, como un albañil levanta un muro. Las iniciales eran grabados en madera, cada letra tallada en un taco de follaje. Algunos tacos tenían el centro vacío para encajar en él cualquier mayúscula de la caja; los impresores los llamaban factótum, del latín *fac totum*, «hazlo todo».

Hoy el marco aparece sobre todo en clásicos reeditados y ediciones de aniversario, libros que se compran para regalar. Aquí un filete grueso por fuera y otro fino 3 mm por dentro rodean el título como el paspartú rodea un grabado. La letra va centrada, sobre un solo adorno, y el color se reserva para el marco, el antetítulo y la inicial.

Bajo el marco, el texto lleva la letra, el cuerpo y la medida del resto del libro; solo cambian los márgenes, que centran la caja bajo el marco.

# Salta \\ a la \\ vista. {style="stacked" kicker="Capítulo tres · Manifiestos, revistas, carteles" lead="Manifiestos, revistas y carteles dedican la primera página al título, compuesto tan grande como permite el formato y a menudo sin ninguna imagen."}

Un título apilado tiene pocas palabras, una frase por línea, y los cortes los decide quien escribe. Si se deja que el título se parta donde se acaba la medida, puede separar un artículo de su sustantivo y dejar un «la» colgando al final de la línea; cortado donde haría una pausa quien lo dijera en voz alta, se lee como un titular.

Pide una letra muy negra y blanco alrededor. Aquí el campo de color le sirve de fondo y sale a sangre por arriba y por los lados. Bajo el campo, el texto vuelve al cuerpo de lectura, y solo su primera letra, calada en blanco sobre un cuadro magenta, repite el color.

# Numéralo todo {style="spec" kicker="Manuales · Normas · Hojas de datos"}

Casi nadie lee un manual de la primera página a la última. Se abre por la sección que hace falta, muchas veces con el aparato en la otra mano, y se cierra en cuanto se resuelve la duda. El número de capítulo es lo que el lector anota en un margen o cita en un correo, así que la apertura lo compone grande, en cifras de ancho fijo, arriba a la izquierda, donde se empieza a leer.

## Números antes que nombres

Los lectores citan las secciones por su número. Una referencia a la sección 4.2 sigue valiendo aunque se cambie el título de la sección, cosa que no garantiza una referencia a «la sección sobre las columnas». Los títulos se numeran hasta el segundo nivel, y el número de una sección retirada no se da nunca a otra nueva, para que una referencia antigua no lleve a un texto distinto.

La insignia que lleva el número de capítulo se ensancha con él. Los números de una y de dos cifras reciben el mismo relleno a cada lado, y el título se desplaza para dejar sitio, de modo que entre la insignia y el título hay la misma distancia en el capítulo 10 que en el 9. A este cuerpo, cada cifra más añade 5,5 mm a la insignia.

## Dos columnas, líneas cortas

El texto técnico suele ir en dos columnas porque una línea corta se recorre mejor con la vista. Aquí cada columna mide 68,5 mm, unos cuarenta caracteres de este texto. Quien busca un valor baja por una columna estrecha, y al final de cada línea el ojo encuentra el comienzo de la siguiente sin perderse. El texto va en bandera, así que cada espacio conserva su ancho natural y una cadena larga, como una referencia de pieza, nunca estira los espacios de la línea que la precede.

El título abarca las dos columnas. Es lo primero que hay que leer en la página, y metido solo en la primera columna parecería un párrafo más.

## Filetes y color

Los filetes dividen la página. Uno grueso sobre la caja de texto marca el comienzo del capítulo y otro fino separa el título del texto. La cabecera que hay encima da el capítulo y el número de página sobre el total, para que el lector sepa por dónde va.

El único color es el naranja de la insignia. Todo lo demás que hay que leer va en negro o en gris oscuro, de modo que una fotocopia en blanco y negro de la página no pierde información.

# Deja aire {style="quiet" kicker="Coda" lead="Hay libros que no necesitan ninguna señal. Un poemario o unas memorias pueden usar la apertura para que el lector baje el ritmo."}

Esta coda lo consigue con blanco: el título empieza a más de 90 mm del borde superior de la caja de texto, y la inicial sube por encima de la primera línea, hacia la página vacía.

:::paragraphs{style="colophon"}
*Cinco maneras de empezar* está compuesto en Lora, Fraunces, Geist y Geist Mono (SIL Open Font License). Texto y florón: 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 (gotcha: fonts-first)
  Lora: ['400', '400i'], Fraunces: ['300', '300i', '400', '600', '900'],
  Geist: ['400', '400i', '500', '600', '700'], 'Geist Mono': ['500', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('fleuron.svg', fleuron(palette.lapis)); // a picture's colours are fixed when drawn
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
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 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'`, tots els capítols comencen en pàgina senar i del segon al cinquè porten al davant una parella en blanc, així que el llibre passa de 7 a 11 pàgines.

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

### Deixa la caplletra en tres línies

`dropSize()` rep el nombre de línies, així que una caplletra de tres línies també queda enrasada amb les majúscules de la primera.

```diff
-    lines: 5, fontSize: dropSize(5), fontFamily: 'Fraunces', fontWeight: 400,
+    lines: 3, fontSize: dropSize(3), fontFamily: 'Fraunces', fontWeight: 400,
```

## Errors freqüents

- **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.
- **La paleta d'una secció o d'una part no recoloreja la caplletra.** A postext 1.4.1, la paleta d'un estil de títol, igual que la d'una part, recoloreja a les seves pàgines els colors enllaçats dels elements de disseny (textos, filets, caixes, fons i vores de caixa), però no dropCap.color: la caplletra conserva l'hex escrit a la configuració. Posa a la caplletra el color propi de la secció, o deixa dropCap.color sense fixar perquè prengui el color del seu element de text.
- **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.
- **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ó.
- **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.
- **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.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().
- **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.
- **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à.

- El text de disseny no es justifica mai, així que el primer paràgraf d'una obertura surt en bandera encara que el llibre vagi justificat. Dona-li un cos propi (aquí 12/17 punts enfront dels 10,8/14,5 del text) perquè la bandera sembli buscada.
- La inicial en caixa està ajustada a mà a Lora 12/17, a la M de Geist Mono i a la línia de base del text de disseny, que la 1.4.1 situa a 0,8 de la caixa de línia (`BASE`) sense cap opció per llegir-la. Si canvies la font, el cos o l'interlineat de l'entradeta, la lletra inicial, el nombre de línies o la versió del motor, torna a comprovar la caixa contra les majúscules de la primera línia.
- Una caplletra que ocupa totes les línies del seu paràgraf reserva igualment la seva caixa de línia completa: a la versió 1.4.1, l'alçada de l'obertura s'allarga 3,5 mm per sota de l'entradeta del capítol tres, i això pot baixar el text una línia de la retícula de base. Amb el camp a 130 mm d'alçada, aquest sobrant no afegeix cap línia de la retícula; si canvies l'alçada, revisa el blanc sota l'entradeta.
- Els títols no es giren mai ni van en vertical: el text de disseny és sempre horitzontal. Apila les paraules amb `\\`, com fa el capítol tres.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: Lora (OFL-1.1), Fraunces (OFL-1.1), Geist (OFL-1.1), Geist Mono (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [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
- [Núm. 066 · Capítols de conte amb vinyetes i sumaris](https://postext.dev/ca/cookbook/storybook-chapter-vignettes.md): 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. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
- [Núm. 065 · Clàssic de butxaca: capítols breus seguits](https://postext.dev/ca/cookbook/short-chapters-run-on.md): Dom Casmurro en butxaca: capítols breus seguits, amb títols dibuixats a la columna i sense línies ni títols solts als salts de pàgina. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
