# Espanhol justificado num romance de bolso

> O início de Marianela em edição de bolso: hifenização espanhola, espaços abaixo de 1,7×, sem linhas curtas, capitular elevada e o mapa como Figura 1.

- Versão HTML: https://postext.dev/pt/cookbook/spanish-pocket-novel
- Receita Nº 016 · Tipo e texto · Nível 2 (Intermediário) · Saídas: Canvas
- Gêneros: Ficção, teatro e prosa literária
- Requer postext ≥ 1.12.2 · testada com 1.19.1 em 2026-10-06
- Páginas: [8](https://postext.dev/cookbook/spanish-pocket-novel/es/p01.webp?v=d23e4327), [9](https://postext.dev/cookbook/spanish-pocket-novel/es/p02.webp?v=d23e4327), [10](https://postext.dev/cookbook/spanish-pocket-novel/es/p03.webp?v=d23e4327), [11](https://postext.dev/cookbook/spanish-pocket-novel/es/p04.webp?v=d23e4327), [12](https://postext.dev/cookbook/spanish-pocket-novel/es/p05.webp?v=d23e4327)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=spanish-pocket-novel&lang=es (.postext: https://postext.dev/cookbook/spanish-pocket-novel/es/spanish-pocket-novel.postext)
- Última atualização: 2026-10-02
- Outros idiomas: [en](https://postext.dev/en/cookbook/spanish-pocket-novel.md), [es](https://postext.dev/es/cookbook/spanish-pocket-novel.md), [ca](https://postext.dev/ca/cookbook/spanish-pocket-novel.md), [zh](https://postext.dev/zh/cookbook/spanish-pocket-novel.md), [ja](https://postext.dev/ja/cookbook/spanish-pocket-novel.md), [ar](https://postext.dev/ar/cookbook/spanish-pocket-novel.md)

## Em poucas palavras

As primeiras páginas de um romance espanhol de 1878 num livro de bolso. Mostra como manter iguais os espaços entre as palavras, dividindo as palavras nas sílabas certas do espanhol.

## O que você vai compor

O início de *Marianela* (1878), de Galdós, numa edição de bolso de 11,5 × 18 cm, com um mapa conjectural das minas de Socartes de frente para o capítulo I. Acima do título do capítulo, em Libre Bodoni itálico, *capítulo primero* vem por extenso em versaletes; o texto, em Gentium Book Plus, começa a um quarto da altura da página com uma capitular elevada cor de sangue de boi. As palavras quebram nas sílabas do espanhol (*hie-rro*, *esta-blecimiento*) e os espaços entre palavras ficam abaixo de 1,7 vez a largura normal; nenhum parágrafo termina com o resto de uma palavra hifenizada. O diálogo é composto com *rayas*, os travessões que também abrem e fecham os *incisos* (apartes) do narrador. A senha do viajante vem entre «comillas latinas», e entre aspas duplas inglesas onde a nota da edição a cita dentro de outra citação. O mapa é a *Figura 1*, citada no texto como [fig. 1]. Cabeços em Marcellus SC dão o autor nas páginas pares e o título nas ímpares.

**Esta receita responde a:**

- Como justifico e hifenizo espanhol, com travessões de diálogo, aspas angulares e “Figura” em vez de “Figure”?
- Como evito viúvas, órfãs e últimas linhas de uma só palavra, e mantenho cada título junto do seu texto?
- Como escrevo travessões de diálogo, anos no início do parágrafo, preços e símbolos literais sem que o Markdown os interprete errado?
- Como faço os rótulos “Figura” e “Tabela” saírem no idioma do meu documento?
- Como acrescento a uma abertura uma linha de autor, uma linha fina ou um primeiro parágrafo com capitular?

## A resposta curta

```js
// script.js, linhas 45–60
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
```

## Ingredientes

**Ensina**

- [Hifenização e idioma do documento](https://postext.dev/pt/docs/justification.md#idiomas-compatíveis): Hifenização com padrões do TeX em oito idiomas, escolhida pelo locale do documento, que também define os textos de continuação das tabelas e o idioma do PDF com tags.
- [Quebra de linha ótima (Knuth–Plass)](https://postext.dev/pt/docs/justification.md#knuth-plass-o-parágrafo-inteiro-de-uma-vez): Quebra cada parágrafo como um todo para igualar o espaçamento entre palavras, dentro dos limites definidos, em vez de preencher linha por linha.
- [Capitulares nas aberturas](https://postext.dev/pt/docs/configuration.md#elementos-de-texto): Uma capitular e parágrafos com recuo dentro de um elemento de texto do design, para o texto de entrada de uma abertura de capítulo ou de artigo.

**Também usa**

- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Viúvas, órfãs e linhas curtas](https://postext.dev/pt/docs/configuration.md#órfãs-viúvas-linhas-curtas-e-regras-de-manter-junto)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Capítulos que abrem em página ímpar](https://postext.dev/pt/docs/configuration.md#quebra-antes)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Legendas numeradas](https://postext.dev/pt/docs/document-format.md#numeração-pela-primeira-referência)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Espaço vertical explícito](https://postext.dev/pt/docs/document-format.md#space)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Escapes e caracteres literais](https://postext.dev/pt/docs/document-format.md#convenções-de-escrita)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Cabeços por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headingStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration.md#hifenização), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`defaultResourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

- Gentium Book Plus (OFL-1.1), Libre Bodoni (OFL-1.1), Marcellus SC (OFL-1.1)

## Preparo

### 1 · Sílabas espanholas, espaços contidos

O código é [a resposta curta](https://postext.dev/pt/cookbook/spanish-pocket-novel.md#a-resposta-curta) lá em cima. `locale: 'es'` escolhe os padrões de hifenização do espanhol, e só esse código exato faz isso: com `'es-ES'`, estas páginas quebram *cabal-los*, *hi-erro* e *establec-imiento* pelas regras americanas, e os espaços de uma linha se abrem até 2,13× ([idiomas suportados](https://postext.dev/pt/docs/justification.md#idiomas-compatíveis)). A justificação, o Knuth–Plass e as regras de viúvas e órfãs vêm ligados por padrão. A medida de 86 mm também precisa conter os espaços: com o `maxWordSpacing: 2` padrão, oito linhas passam de 1,6× e a mais aberta chega a 1,89×; com 1,7, a mais aberta fica em 1,65×, porque o quebrador hifeniza *hie-rro* e *ca-lles* em vez disso ([limites de espaçamento entre palavras](https://postext.dev/pt/docs/justification.md#limites-do-espaçamento-entre-palavras)). Acima do limite, uma linha custa muito mais, mas continua sendo permitida, então numa medida mais estreita e sem quebra melhor ela ainda pode passar. `runtMinCharacters: 26` pega os restos hifenizados que o padrão de 20 larguras de espaço deixa passar: *siem- / pre adelante.* vira *Adelante, / siempre adelante.*, e *So- / cartes [fig. 1].* vira *de / Socartes [fig. 1].* ([linhas curtas](https://postext.dev/pt/docs/configuration.md#órfãs-viúvas-linhas-curtas-e-regras-de-manter-junto)).

### 2 · Uma página de bolso feita de linhas inteiras

```js
// script.js, linhas 33–41
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
```

A margem inferior é calculada a partir do refile, da margem superior e de `LINES`, de modo que a mancha comporta exatamente 30 linhas de 13,8 pt e toda página cheia termina na mesma linha de base, como as [páginas 10 e 11](https://postext.dev/cookbook/spanish-pocket-novel/es/p03.webp?v=d23e4327). Com `mirror: true`, a margem interna de 15 mm fica junto à lombada e a externa de 14 mm junto ao corte da frente nas duas páginas da página dupla ([margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)), e a medida de 86 mm comporta cerca de 57 caracteres de Gentium Book Plus em 10 pt.

### 3 · O capítulo por extenso e uma capitular elevada

```js
// script.js, linhas 64–97
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
    // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
      placement: centred(TITLE_Y) },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
      lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
      color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
      dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
        color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
  ] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own: the text goes on
// on the line under the lead.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener };
```

Os modelos de numeração só imprimem algarismos (1, 01, I, i, A, a), nunca palavras, por isso *capítulo primero* vem do atributo `ordinal` do próprio título ([atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)). O Postext só desenha uma capitular em texto de design, que é composto em bandeira, e só quando esse texto tem `overflow: 'wrap'`. Para esconder a borda irregular, a capitular recebe uma única linha: `lines: 1` a eleva ao lado da linha guardada no atributo `lead`, e `INITIAL_GAP` foi ajustado até essa linha terminar rente, a 0,1 mm do fim da medida. O parágrafo continua no Markdown sem recuo ([elementos de texto](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)). O `marginBottom: 0` do nível de capítulo substitui a margem padrão, que acrescentaria uma linha em branco, e assim o texto continua na linha logo abaixo da linha de abertura.

![Página de apertura 9: Perdido.](https://postext.dev/cookbook/spanish-pocket-novel/es/p02.webp?v=d23e4327)

*Página 9: o rótulo e o título do capítulo, e a capitular elevada ao lado de uma primeira linha que termina rente ao texto justificado embaixo dela.*

### 4 · O mapa fica de frente para o capítulo como Figura 1

```js
// script.js, linhas 122–143
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
```

`position: 'here'` põe o mapa onde está `::resource{id="plano"}`, sob um título próprio. Esse título faz da página 8 do livro uma abertura, e os cabeços pulam as aberturas. O estilo dele esvazia o rodapé e define a sua própria quebra, o que mantém a prancha de frente para o capítulo I, sem cabeço nem fólio, mesmo quando os capítulos abrem em páginas ímpares ([estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)). Os tipos espanhóis da resposta curta imprimem *Figura 1.* sob o mapa (sem `resourceTypes`, sai *Figure 1.1*, qualquer que seja o locale) e *Fig. 1* no texto, que o `case="lower"` do `:ref` no Markdown transforma em *fig. 1*. A referência fica entre colchetes, que as edições espanholas usam para marcar o que o editor acrescenta ao texto do autor.

### 5 · O autor na página par, o título na ímpar

```js
// script.js, linhas 101–118
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
```

`parity: 'even'` põe o autor nas páginas pares e `'odd'`, o título nas ímpares, e `pages: 'body'` mantém os dois fora da abertura, onde o único elemento é o fólio ao pé, composto com `pages: 'opener'`. Cada cabeço é ancorado na página e levado por `SHIFT` para o centro da mancha, que fica meio milímetro fora do centro da página porque as margens interna e externa são diferentes. O fólio ao pé vem em duas paridades pelo mesmo motivo. Os fólios são compostos em Gentium, porque a Marcellus SC desenha 1 e 0 como I e O.

### 6 · Pontuação espanhola no Markdown

```js
// script.js, linhas 147–155
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
```

A [página 12](https://postext.dev/cookbook/spanish-pocket-novel/es/p05.webp?v=d23e4327), a última, termina com três estilos de parágrafo. Os asteriscos, em Bodoni cor de sangue de boi, ficam centralizados depois de um [`:::space{lines=2}`](https://postext.dev/pt/docs/document-format.md#space), já que linhas em branco não acrescentam espaço. A nota da edição tem 8,3 pt sobre 11 pt de entrelinha, e o colofão tem 7,5 pt no cinza dos cabeços. A nota fica na cor da tinta: um estilo de parágrafo não tem cor de itálico, então o *Marianela* em itálico numa nota cinza sairia mais escuro que as palavras em volta. No Markdown, o diálogo abre com uma raya (—), nunca com um hífen, porque um hífen e um espaço no início de uma linha abrem uma lista. *1878.* no início da nota abriria uma lista numerada, então um word joiner (U+2060) vai antes do ano. Os asteriscos são escapados (`\*`); sem o escape, o primeiro abriria uma lista com marcadores e os outros dois, um trecho em itálico, e a linha desapareceria.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: https://github.com/drnachio/postext/tree/main/cookbook/spanish-pocket-novel

### script.js

```js
// ═══ Postext Cookbook · Nº 016 · Justified Spanish in a pocket novel ═════════════════
// https://postext.dev/en/cookbook/spanish-pocket-novel
// Code: MIT · Text: B. Pérez Galdós, Marianela, 1878 (PD, Gutenberg #17340) · Map: drawn in code
// Fonts: Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.12.2
import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage } from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'spanish-pocket-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1e1b18', // text: a warm near-black
  oxblood: '#8b2e2a', // the one accent: chapter label, rule, initial, asterisks, route
  muted: '#6d645a', // running heads, the map's paths, the caption's credit
  paper: '#f7f2e8', // an ivory book paper
  slag: '#c8553d', moss: '#6f7a4f', water: '#58707f', // the map: mined earth, woods, river
};
// Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const TEXT = 'Gentium Book Plus'; // the text face
const DISPLAY = 'Libre Bodoni'; // the chapter's title and the initial
const LABEL = 'Marcellus SC'; // small capitals: chapter label, running heads, map names
const [BODY, LEAD] = [10, 13.8]; // pt: the body size, and its leading: the grid's pitch
const smallCaps = { fontFamily: LABEL, fontSize: pt(9), letterSpacing: pt(1.6), // 2 labels
  color: col('oxblood'), align: 'center' };

// #region page: a pocket paperback; the text block holds 30 whole lines
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
// #endregion

// #region answer: Spanish syllables, word spaces under 1.7×, captions that say Figura
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
// #endregion

// #region opener: the chapter spelled out, its title, and the lead under a raised initial
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
    // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
      placement: centred(TITLE_Y) },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
      lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
      color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
      dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
        color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
  ] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own: the text goes on
// on the line under the lead.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto; a drop folio on the opener
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
// #endregion

// #region figure: the map faces chapter I as Figura 1; the text cites it as [fig. 1]
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
// #endregion

// #region styles: the asterisks that close the excerpt, the edition's note, the colophon
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  resourceTypes,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    fontFamily: DISPLAY, // the designs paint the titles; this keeps Open Sans unloaded
    levels: [chapter],
  },
  headingStyles: [frontispiece],
  paragraphStyles,
  captionStyle,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Marianela"
author: "Benito Pérez Galdós"
---

# Las minas de Socartes {style="lamina"}

::resource{id="plano"}

# Perdido {ordinal="primero" lead="Se puso el sol. Tras el breve crepúsculo vino tranquila"}

y oscura la noche, en cuyo negro seno murieron poco a poco los últimos rumores de la tierra soñolienta, y el viajero siguió adelante en su camino, apresurando su paso a medida que avanzaba la noche. Iba por angosta vereda, de esas que sobre el césped traza el constante pisar de hombres y brutos, y subía sin cansancio por un cerro en cuyas vertientes se alzaban pintorescos grupos de guinderos, hayas y robles. (Ya se ve que estamos en el Norte de España.)

Era un hombre de mediana edad, de complexión recia, buena talla, ancho de espaldas, resuelto de ademanes, firme de andadura, basto de facciones, de mirar osado y vivo, ligero a pesar de su regular obesidad, y (dígase de una vez aunque sea prematuro) excelente persona por doquiera que se le mirara. Vestía el traje propio de los señores acomodados que viajan en verano, con el redondo sombrerete, que debe a su fealdad el nombre de hongo, gemelos de campo pendientes de una correa, y grueso bastón que, entre paso y paso, le servía para apalear las zarzas cuando extendían sus ramas llenas de afiladas uñas para atraparle la ropa.

Detúvose, y mirando a todo el círculo del horizonte, parecía impaciente y desasosegado. Sin duda no tenía gran confianza en la exactitud de su itinerario y aguardaba el paso de algún aldeano que le diese buenos informes topográficos para llegar pronto y derechamente a su destino.

—No puedo equivocarme —murmuró—. Me dijeron que atravesara el río por la pasadera… así lo hice. Después que marchara adelante, siempre adelante. En efecto, allá, detrás de mí queda esa apreciable villa, a quien yo llamaría *Villafangosa* por el buen surtido de lodos que hay en sus calles y caminos… De modo que por aquí, adelante, siempre adelante (me gusta esta frase, y si yo tuviera escudo no le pondría otra divisa) he de llegar a las famosas minas de Socartes [:ref{id="plano" case="lower"}].

Después de andar largo trecho, añadió:

—Me he perdido, no hay duda de que me he perdido… Aquí tienes, Teodoro Golfín, el resultado de tu «adelante, siempre adelante». Estos palurdos no conocen el valor de las palabras. O han querido burlarse de ti, o ellos mismos ignoran dónde están las minas de Socartes. Un gran establecimiento minero ha de anunciarse con edificios, chimeneas, ruido de arrastres, resoplido de hornos, relincho de caballos, trepidación de máquinas, y yo no veo, ni huelo, ni oigo nada… Parece que estoy en un desierto… ¡qué soledad! Si yo creyera en brujas, pensaría que mi destino me proporcionaba esta noche el honor de ser presentado a ellas… ¡Demonio!, ¿pero no hay gente en estos lugares?… Aún falta media hora para la salida de la luna. ¡Ah!, bribona, tú tienes la culpa de mi extravío… Si al menos pudiera conocer el sitio donde me encuentro… ¿Pero qué más da? (Al decir esto, hizo un gesto propio del hombre esforzado que desprecia los peligros). Golfín, tú que has dado la vuelta al mundo, ¿te acobardarás ahora?… ¡Ah!, los aldeanos tenían razón: adelante, siempre adelante. La ley universal de la locomoción no puede fallar en este momento.

Y puesta denodadamente en ejecución aquella osada ley, recorrió un kilómetro, siguiendo a capricho las veredas que le salían al paso y se cruzaban y se quebraban en ángulos mil, cual si quisiesen engañarle y confundirle más. Por grande que fuera su resolución e intrepidez, al fin tuvo que pararse. Las veredas, que al principio subían, luego empezaron a bajar, enlazándose; y al fin bajaron tanto, que nuestro viajero hallose en un talud, por el cual solo habría podido descender echándose a rodar.

—¡Bonita situación! —exclamó sonriendo y buscando en su buen humor lenitivo a la enojosa contrariedad—. ¿En dónde estás, querido Golfín? Esto parece un abismo. ¿Ves algo allá abajo? Nada, absolutamente nada… pero el césped ha desaparecido, el terreno está removido. Todo es aquí pedruscos y tierra sin vegetación, teñida por el óxido de hierro… Sin duda estoy en las minas… pero ni alma viviente, ni chimeneas humeantes, ni ruido, ni un tren que murmure a lo lejos, ni siquiera un perro que ladre… ¿Qué haré?, hay por aquí una vereda que vuelve a subir. ¿Seguirela? ¿Desandaré lo andado?… ¡Retroceder! ¡Qué absurdo! O yo dejo de ser quien soy, o llegaré esta noche a las famosas minas de Socartes y abrazaré a mi querido hermano. Adelante, siempre adelante.

Dio un paso y hundiose en la frágil tierra movediza.

—¿Esas tenemos, señor planeta?… ¿Conque quiere usted tragarme?… Si ese holgazán satélite quisiera alumbrar un poco, ya nos veríamos las caras usted y yo… Y a fe que por aquí abajo no hemos de ir a ningún paraíso. Parece esto el cráter de un volcán apagado… Hay que andar suavemente por tan delicioso precipicio. ¿Qué es esto? ¡Ah! Una piedra; magnífico asiento para echar un cigarro, esperando a que salga la luna.

El discreto Golfín se sentó tranquilamente como podría haberlo hecho en el banco de un paseo; y ya se disponía a fumar, cuando sintió una voz… sí, indudablemente era una voz humana que lejos sonaba, un quejido patético, mejor dicho, melancólico canto, formado de una sola frase, cuya última cadencia se prolongaba apianándose en la forma que los músicos llamaban *morendo*, y que se apagaba al fin en el plácido silencio de la noche, sin que el oído pudiera apreciar su vibración postrera.

:::space{lines=2}

:::paragraphs{style="asterismo"}
\*  \*  \*
:::

:::paragraphs{style="nota"}
⁠1878. Galdós publica *Marianela* en Madrid, en la Imprenta y Litografía de La Guirnalda. Seguimos el comienzo de su capítulo primero en el texto de dominio público de Project Gutenberg (eBook 17340), con la ortografía y la puntuación actuales: raya en los diálogos y comillas latinas para la consigna del viajero, que se vuelven inglesas cuando la cita va dentro de otra: «el resultado de tu “adelante, siempre adelante”». Los corchetes señalan lo que añade el editor.
:::

:::paragraphs{style="colofon"}
Compuesto en Gentium Book Plus, Libre Bodoni y Marcellus SC (SIL OFL)
:::
`; // content.es.md, inlined by the Cookbook

// #region art: the map of Socartes, in millimetres at its printed size, in the palette
function mulberry32(seed) { // a seeded PRNG: the same drawing on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(
  parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k)
  .toString(16).padStart(2, '0')).join('')}`;
const n2 = (v) => +v.toFixed(2);
// A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed.
function smooth(list, close = false) {
  const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)];
  let d = `M${n2(p[1][0])} ${n2(p[1][1])}`;
  for (let i = 1; i < p.length - 2; i++) {
    const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]];
    d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} `
      + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`;
  }
  return close ? `${d}Z` : d;
}
async function labelFace() { // Marcellus SC inside the SVG (gotcha: svg-no-webfonts)
  const url = 'https://cdn.jsdelivr.net/npm/@fontsource/marcellus-sc@5/files/'
    + 'marcellus-sc-latin-400-normal.woff2';
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  let bin = '';
  for (let i = 0; i < bytes.length; i += 8192) {
    bin += String.fromCharCode(...bytes.subarray(i, i + 8192));
  }
  return `@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`;
}
function drawMap(face, W, H) { // drawn for W 86 × H 112: the mines above, the town below
  const rnd = mulberry32(1878);
  const C = {
    ground: mix(palette.paper, palette.moss, 0.16), hill: mix(palette.moss, palette.paper, 0.45),
    wood: palette.moss, shade: mix(palette.moss, palette.ink, 0.35),
    earth: mix(palette.slag, palette.paper, 0.3), bench: palette.slag,
    deep: mix(palette.slag, palette.ink, 0.2), pit: mix(palette.slag, palette.ink, 0.45),
    edge: mix(palette.slag, palette.ink, 0.5),
    water: palette.water, ink: palette.ink, path: palette.muted, route: palette.oxblood,
    stream: mix(palette.water, palette.ink, 0.3), // the river's name: 5.7:1 on the ground
  };
  const out = [`<rect width="${W}" height="${H}" fill="${C.ground}"/>`];
  const fill = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`);
  const stroke = (d, color, w, extra = '') => out.push(`<path d="${d}" fill="none" `
    + `stroke="${color}" stroke-width="${w}" stroke-linecap="round" `
    + `stroke-linejoin="round"${extra}/>`);
  // The names, set first so that no tree grows over them.
  const labels = [[33, 30.5, 'minas de', 'end'], [33, 34, 'Socartes', 'end'],
    [34.4, 19, 'talleres', 'end'],
    [43.6, 52.4, 'talud', 'end', C.route], [12, 70.5, 'cerro', 'middle'],
    [25.5, 96.4, 'pasadera'], [70, 94.5, 'río', 'middle', C.stream],
    [3, 109.6, 'Villafangosa'], [5, 11.4, 'N', 'middle']];
  const box = ([x, y, text, anchor = 'start']) => {
    const w = text.length * 1.45;
    const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x;
    return [left - 0.8, y - 2.6, left + w + 0.8, y + 0.8];
  };
  const clear = (x, y) => labels.map(box).every(([a, b, c, d]) => x < a || x > c || y < b || y > d);
  // The mines: iron-stained earth cut in benches down to the pit, the "extinct crater".
  const mine = [[40, 6], [58, 3], [76, 5], [84, 17], [83, 35], [74, 47], [58, 52], [45, 50],
    [36, 40], [35, 20]];
  [[1, C.earth], [0.76, C.bench], [0.52, C.deep], [0.28, C.pit]].forEach(([k, color], i) => {
    const [cx, cy] = [60 + i * 1.6, 27 - i * 1.4]; // each bench deeper, the pit off-centre
    const ring = mine.map(([x, y]) => [cx + (x - 60) * k + (rnd() - 0.5) * 2.4 * k,
      cy + (y - 27) * k + (rnd() - 0.5) * 2.4 * k]);
    out.push(`<path d="${smooth(ring, true)}" fill="${color}" stroke="${C.edge}" `
      + 'stroke-width="0.25"/>');
  });
  fill('M28.4 11.6h6.5v3.2h-6.5zM33 7h0.9v4.7h-0.9z', C.ink); // workshops, chimney, by the rim
  // The hill of cherry, beech and oak, and the paths that cross it at a thousand angles.
  out.push(`<path d="${smooth([[19, 73], [23, 61], [36, 56], [50, 59], [55, 72], [49, 85],
    [35, 90], [23, 84]], true)}" fill="${C.hill}" stroke="${C.shade}" stroke-width="0.25"/>`);
  for (let i = 0; i < 100; i++) {
    const a = rnd() * Math.PI * 2;
    const r = Math.sqrt(rnd()) * 16;
    const [x, y] = [37 + Math.cos(a) * r, 73 + Math.sin(a) * r];
    const [size, shade] = [0.55 + rnd() * 0.55, rnd() < 0.3 ? C.shade : C.wood];
    if (clear(x, y)) {
      out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(size)}" fill="${shade}"/>`);
    }
  }
  [[[22, 80], [30, 74], [28, 64], [38, 58]], [[26, 62], [36, 70], [46, 64], [52, 70]],
    [[27, 87], [33, 79], [42, 82], [50, 76]], [[38, 57], [40, 67], [33, 76], [37, 89]]]
    .forEach((path) => stroke(smooth(path), C.path, 0.3, ' stroke-dasharray="1 0.8"'));
  // The river, the footbridge, and the town the traveller left behind.
  stroke(smooth([[0, 92], [12, 93], [22, 99], [34, 100], [50, 96], [66, 98], [W + 1, 102]]),
    C.water, 2.4);
  stroke('M20.4 95.2L18.6 101.2', C.ink, 0.7);
  for (let i = 0; i < 18; i++) {
    const [x, y] = [3 + (i % 6) * 2.2 + rnd() * 0.5, 98.6 + Math.floor(i / 6) * 2.3 + rnd() * 0.5];
    fill(`M${n2(x)} ${n2(y)}h1.5v1.2h-1.5z`, C.ink);
  }
  fill('M5.2 94.4h1v2.4h-1zM4 96.2h3.4v2h-3.4z', C.ink); // the church
  // Golfín's walk: over the footbridge, up the hill by its paths, down to the edge.
  stroke(smooth([[12, 101], [17, 99.4], [21, 94], [25, 88], [31, 84], [27, 78], [33, 72],
    [40, 75], [45, 68], [40, 62], [45.3, 53.6]]), C.route, 0.75,
  ' stroke-dasharray="0.05 1.3"');
  out.push(`<circle cx="46.4" cy="51.6" r="1.4" fill="none" stroke="${C.route}" `
    + 'stroke-width="0.6"/>');
  // North, up: a half-inked arrowhead over an N.
  fill('M5 3.2L6.6 8.2L5 7.2Z', C.ink);
  out.push(`<path d="M5 3.2L3.4 8.2L5 7.2Z" fill="none" stroke="${C.ink}" stroke-width="0.2"/>`);
  labels.forEach(([x, y, text, anchor = 'start', color = C.ink]) => out.push(`<text x="${x}" `
    + `y="${y}" text-anchor="${anchor}" fill="${color}">${text}</text>`));
  out.push(`<rect x="0.2" y="0.2" width="${W - 0.4}" height="${H - 0.4}" fill="none" `
    + `stroke="${C.ink}" stroke-width="0.4"/>`);
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" `
    + `viewBox="0 0 ${W} ${H}"><style>${face}text{font-family:L;font-size:2.35px}</style>`
    + `${out.join('')}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Gentium Book Plus': ['400', '400i', '700'], // text, captions (700: 'Figura 1.'), the note
  'Libre Bodoni': ['400', '400i', '700'], // asterisks, the chapter's title, the initial
  'Marcellus SC': ['400'], // chapter label, plate name, running heads
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('plano.svg', drawMap(await labelFace(), MEASURE, MAP_H));
// The map is book page 8, a verso, facing chapter I: folios and parity follow the book.
const continuation = { pageIndexOffset: 7, pageNumbering: { startAt: 8 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: 'Español justificado en una novela de bolsillo' });

// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** 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.
 *  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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  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 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 = `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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** 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, '-'); }

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

## Variações

### Abra cada capítulo numa página ímpar

Com `'odd'`, um capítulo que começaria numa página par recebe uma página em branco antes, como numa edição mais folgada. O capítulo I já abre numa ímpar, e a prancha mantém a sua página porque o estilo dela define a sua própria quebra.

```diff
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
```

### Numere as figuras por capítulo

Sem ajustes, os tipos espanhóis embutidos numeram as figuras dentro de cada título de nível 1: o mapa vira *Figura 1.1* e a referência, *fig. 1.1*.

```diff
-const resourceTypes = defaultResourceTypes(LOCALE)
-  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);
```

## Erros comuns

- **Só 8 idiomas têm hifenização, com o código exato.** A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano.
- **Traduza Figura e Tabela com defaultResourceTypes(locale).** O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes.
- **Texto de design nunca é justificado, então o texto ao lado da capitular fica em bandeira.** No postext 1.4.1, um elemento de texto de design se alinha à esquerda, ao centro ou à direita e quebra palavra por palavra: não há alinhamento justificado, e hyphenate: true só divide uma palavra longa demais para uma linha inteira. As linhas de um texto inicial composto ao lado de uma dropCap terminam, portanto, em bandeira junto ao texto corrido justificado. Limite o texto inicial às linhas ao lado da capitular e ajuste-as à mão: com lines: 1 (uma capitular elevada) o texto inicial é uma linha só, que o gap da dropCap consegue deixar rente; o parágrafo continua no Markdown.
- **'1998. ' ou '- ' no início de um parágrafo abre uma lista.** Um parágrafo que começa com um número, um ponto e um espaço, ou com um hífen e um espaço, vira item de lista. Coloque um word joiner (U+2060) antes do número e escreva os diálogos com travessão.
- **Valores de atributo: sem { ou }; aspas simples se tiver ".** Um valor de atributo termina na chave de fechamento, por isso não pode conter { nem }. Um valor que contém aspas duplas vai entre aspas simples; o cifrão não causa problema.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **O lineHeight de um texto de design é um múltiplo, nunca uma medida.** Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título.
- **O excesso de texto de design é 'ellipsis-end' por padrão.** Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas.
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **Um estilo de parágrafo não tem cor de itálico.** No postext 1.4.1, um estilo de parágrafo define color e boldColor, mas não italicColor: os trechos em itálico usam bodyText.italicColor. Um estilo atenuado (letra miúda, a linha “Fonte:” de uma tabela) imprime os títulos em itálico mais escuros que o texto em volta. Mantenha esses estilos na cor do texto corrido ou evite itálico neles.
- **Um estilo de título herda a quebra de página do seu nível.** Uma entrada de headingStyles recebe do seu nível de título todos os campos que não define, inclusive breakBefore. Um sumário ou um colofão com estilo sobre um H1 depois de um :::pagebreak herda a paridade 'odd' e cai depois de uma página em branco. Dê a esse estilo breakBefore: { enabled: false }.
- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **Coloque entre aspas cada valor do frontmatter.** O YAML lê title: 1984 como número e uma data como objeto Date, e valores que não são strings saem vazios nos placeholders e deixam o PDF sem título. Coloque cada valor entre aspas: title: "1984".
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.

- Um rebaixo de nove linhas em vez de oito deixou a página 11 com uma linha a menos, porque `avoidWidows` não deixaria um parágrafo começar na última linha da página. Depois de qualquer mudança nos ajustes ou no texto, confira o pé de cada página.
- O atributo `lead` para no meio da frase (*…vino tranquila*), onde o parágrafo do Markdown continua (*y oscura la noche*), porque guarda exatamente as palavras que cabem ao lado da capitular. Outra fonte, outro corpo, outra medida ou outro `INITIAL_GAP` desfazem o ajuste rente: passe palavras de um para o outro até a linha terminar rente de novo.
- Nenhuma destas fontes tem o asterismo ⁂ (U+2042) no arquivo latin do Fontsource, então o navegador o tiraria de uma fonte do sistema. De todo modo, os livros espanhóis marcam a pausa com três asteriscos espaçados.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: Marianela (1878), the opening of chapter I, “Perdido”, with today’s spelling and punctuation: Benito Pérez Galdós ([fonte](https://www.gutenberg.org/ebooks/17340)), domínio público
- Texto: The edition’s note and the map’s caption: Postext Cookbook, original
- Imagens: The conjectural map of the Socartes mines, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Tipos: Gentium Book Plus (OFL-1.1), Libre Bodoni (OFL-1.1), Marcellus SC (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: MIT

## Relacionadas

- [Nº 014 · Espaçamento uniforme em colunas estreitas justificadas](https://postext.dev/pt/cookbook/justification-lab.md): Quebra Knuth–Plass com os espaços entre 0,8 e 1,6 vez o normal, ao lado de um controle guloso com linhas frouxas, isoladas e curtas marcadas a partir do VDT. · Nível 3 (Avançado) · Revistas e fanzines
- [Nº 038 · Romance em brochura: aberturas rebaixadas em página ímpar](https://postext.dev/pt/cookbook/trade-paperback-novel.md): Quincas Borba em brochura de 140 × 216 mm: capa pintada, colofão sem cabeços e cada capítulo rebaixado numa página ímpar sob um numeral romano em itálico. · Nível 2 (Intermediário) · Ficção, teatro e prosa literária
- [Nº 065 · Clássico de bolso: capítulos curtos em sequência](https://postext.dev/pt/cookbook/short-chapters-run-on.md): Dom Casmurro em livro de bolso: capítulos curtos em sequência sob títulos desenhados na coluna; nenhuma quebra de página deixa linha ou título sozinhos. · Nível 2 (Intermediário) · Ficção, teatro e prosa literária
