# Examen amb full de respostes

> Un examen d'història de quatre pàgines, amb bombolles i puntuacions fetes amb xips, preguntes numerades 1, a), i) i ratlles traçades amb una taula.

- Versió HTML: https://postext.dev/ca/cookbook/exam-paper
- Recepta Núm. 049 · Requadres i notes · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Quaderns i exercicis
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [1](https://postext.dev/cookbook/exam-paper/es/p01.webp?v=d106922a), [2](https://postext.dev/cookbook/exam-paper/es/p02.webp?v=d106922a), [3](https://postext.dev/cookbook/exam-paper/es/p03.webp?v=d106922a), [4](https://postext.dev/cookbook/exam-paper/es/p04.webp?v=d106922a)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=exam-paper&lang=es (.postext: https://postext.dev/cookbook/exam-paper/es/exam-paper.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/exam-paper.md), [es](https://postext.dev/es/cookbook/exam-paper.md), [zh](https://postext.dev/zh/cookbook/exam-paper.md), [ar](https://postext.dev/ar/cookbook/exam-paper.md)

## En poques paraules

Un examen d'història de quatre pàgines amb portada, bombolles per marcar i ratlles per escriure. Ensenya a numerar preguntes i apartats i a mostrar la puntuació de cadascun.

## Què compondràs

La prova 2 d'un simulacre d'examen d'història sobre la guerra de Secessió, en un quadernet de quatre pàgines en mida carta. La portada obre amb una banda carmesí i l'any 1863 dibuixat en contorn; a sota hi van les caselles de l'alumne, les instruccions a dues columnes i la plantilla de bombolles de la part A. A la pàgina 2 hi ha deu preguntes tipus test, cadascuna amb les seves quatre opcions a sota, i cada opció comença per una bombolla amb la seva lletra, com les de la plantilla. La pàgina 3 recull el discurs de Gettysburg com a font A i, després, les preguntes numerades 11, a), i), amb la puntuació de cada apartat en un xip. La pàgina 4 és per a les respostes: una ratlla cada dues línies de text, en grups que comencen pel número del seu apartat. Després de la portada, cada pàgina porta foli i un marge per al corrector, i l'única senar d'aquestes diu «Gira el full».

**Aquesta recepta respon a:**

- Com componc un examen amb plantilla de bombolles, apartats a) i i), puntuacions i ratlles de resposta?
- Com personalitzo les llistes: pics per nivell, numeració (a)/(i), caselles de tasques i un espaiat que respecti la retícula?
- Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
- Com dono estils diferents a diverses taules (emplenaments, files alternes, marcs arrodonits) en un mateix document?
- Com amago les capçaleres a les obertures i a les pàgines en blanc, o pinto una pàgina parella en blanc amb el color de la part?

## La resposta curta

```js
// script.js, línies 34–46
// A table row is one line (the table's size × the body's leading ratio) plus cellPadding above
// and below: at the body size, (ROW − LEAD) / 2 of padding makes every row ROW deep, so the
// rules keep to the text's 15 pt grid, 10.6 mm apart: room for handwriting.
const ROW = 2 * LEAD; // pt
const lines = { id: 'lines', rules: 'horizontal', // a rule on the top and foot of every row
  borderColor: col('rule'), borderWidth: pt(0.5), bodyFontFamily: LABEL, bodyFontSize: pt(BODY),
  bodyColor: col('crimson'), cellPadding: pt((ROW - LEAD) / 2) };
// Every table in this paper is set 'here': where its ::resource directive stands in the text.
const table = (id, styleId, model) => ({ id, typeId: 'form', kind: 'table', createdAt: 0,
  updatedAt: 0, placement: { position: 'here' }, table: { styleId, model } });
// One table per answer, the part's number in its first margin cell.
const answerLines = (id, part, rows) => table(id, 'lines', { columnWidths: [1, 5], rows: Array
  .from({ length: rows }, (_, i) => [{ content: i ? '' : `**${part}**` }, { content: '' }]) });
```

## Ingredients

**Ensenya**

- [Llistes numerades](https://postext.dev/ca/docs/configuration.md#llistes-ordenades): Numeració aràbiga, romana o amb lletres per nivell, separadors amb estil propi i números de pas penjats.
- [Chips en línia](https://postext.dev/ca/docs/configuration.md#estils-de-xip): Caixes arrodonides al voltant de paraules que passen de línia com una unitat i no s'estiren mai: tecles, etiquetes, bancs de paraules, síl·labes.
- [Estils de taula amb nom](https://postext.dev/ca/docs/configuration.md#estils-de-taula-amb-nom): Diversos aspectes de taula en un mateix document, triats per id i que hereten l'estil de taula del document.

**També fa servir**

- [Taules a partir de dades](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Emplenaments de cel·la](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Estil dels peus](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Salts de pàgina i de columna](https://postext.dev/ca/docs/document-format.md#pagebreak)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Geometria 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)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Columnes dins d'un requadre](https://postext.dev/ca/docs/document-format.md#columns)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`chipStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-xip), [`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), [`orderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-ordenades), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-taula-amb-nom), [`unorderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)

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

- PT Serif (OFL-1.1), Inter Tight (OFL-1.1)

## Elaboració

### 1 · Numera les preguntes i posa'n les opcions a sota

```js
// script.js, línies 50–62
const [NUMBER, GAP] = [12.5, 6]; // pt: the question numbers' size; number to text
const orderedLists = { fontFamily: LABEL, color: col('ink'), // bold, by default
  separatorColor: col('crimson'), gap: pt(GAP), marginTop: pt(LEAD / 2), marginBottom: pt(0),
  itemSpacing: pt(5), // after every numbered item: to a question's options, or the next part
  levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic', '.' (gotcha: numbering-vocabularies)
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', separator: ')' }] };
// Options: a nested item with no bullet, indented by the widest number ('10.') plus GAP.
const width = (s) => { const ctx = new OffscreenCanvas(1, 1).getContext('2d');
  ctx.font = `700 ${NUMBER}pt "${LABEL}"`; return ctx.measureText(s).width * 0.75; }; // pt
const unorderedLists = () => ({ gap: pt(0), marginTop: pt(0), marginBottom: pt(0),
  itemSpacing: pt(LEAD), // after the options: a line before the next question
  levels: [{ level: 2, bulletChar: '', indent: pt(width('10') + width('.') + GAP) }] });
```

Cada nivell d'`orderedLists` té el seu propi `numberFormat` i el seu `separator`; `separatorColor` posa en el color d'accent el punt i el parèntesi de tancament, i el número queda en negre ([llistes ordenades](/ca/docs/configuration#llistes-ordenades)). Els números d'una llista s'alineen a la dreta amb el més ample del seu tram, de manera que el text de totes les preguntes comença a 23 pt: els 17 pt de «10.» més `GAP`. Les quatre opcions formen un element niat de llista no ordenada, amb `bulletChar` buit. El seu text comença a l'`indent` del nivell, que és l'amplada de «10.» mesurada en la font dels números més `GAP`, i així les opcions queden sota la primera lletra de la pregunta amb qualsevol tipus de lletra. La separació depèn de l'element anterior: 5 pt després d'un element numerat, 15 pt després d'una línia d'opcions.

### 2 · Fes les bombolles i les puntuacions amb xips

```js
// script.js, línies 66–75
// A chip is 0.8 em above the baseline and 0.25 em below, plus paddingY and the border on each
// side: 1.29 em and two borders. One capital is 0.63–0.72 em wide, so paddingX 0.3 em (and the
// same two borders) squares the box; a radius over half its smaller side is clamped to that half.
const chip = (id, look) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true,
  paddingY: em(0.12), ...look });
const bubble = (id, fill, ink, edge = 'crimson') => chip(id, { color: col(ink), paddingX: em(0.3),
  background: col(fill), borderColor: col(edge), borderWidth: pt(0.7), borderRadius: em(1) });
const chipStyles = [bubble('bubble', 'paper', 'crimson'), // first: a bare :chip[A] takes it
  bubble('filled', 'ink', 'paper', 'ink'), chip('marks', { color: col('crimson'),
    background: col('blush'), borderWidth: pt(0), paddingX: em(0.45), gap: em(0.5) })];
```

Un xip és una caixa dins de la línia, i un salt de línia no hi cau mai a dins ([xips en línia](/ca/docs/document-format#xips-en-línia)). Amb una sola majúscula a dins, `paddingX: em(0.3)` deixa la caixa gairebé tan ampla com alta: a la plantilla, de 4,5 a 4,8 mm d'amplada per 4,7 mm d'alçada. `borderRadius: em(1)` supera la meitat de la caixa, i com que el radi d'un xip es limita a la meitat del seu costat menor, els extrems queden arrodonits del tot. Un xip sense `style` pren el primer estil de `chipStyles`, de manera que les vuitanta bombolles, quaranta a la plantilla i quaranta a les opcions, s'escriuen sense estil, com `:chip[A]`; només l'exemple omplert de les instruccions i les puntuacions n'indiquen un. Com que Postext 1.4.1 no té tabulacions, la puntuació no pot anar alineada a la dreta; cada xip tanca el seu apartat, com a mínim a 0,5 em (`gap`) de l'última paraula.

### 3 · Dona a cada formulari el seu estil de taula

```js
// script.js, línies 79–101
// No table has a header row; the flag keeps the default grey fill off one added later.
const form = { bodyFontFamily: LABEL, borderRadius: mm(2), headerBackgroundEnabled: false };
const tableStyles = [lines,
  { ...form, id: 'candidate', rules: 'grid', borderColor: col('rule'), borderWidth: pt(0.75),
    bodyFontSize: pt(7.5), bodyColor: col('crimson'), cellPadding: mm(1.8) },
  // Tint fills leave hairline seams between cells on a canvas: grid rules in the tint hide them.
  { ...form, id: 'grid', rules: 'grid', borderColor: col('tint'), bodyBackgroundEnabled: true,
    bodyBackground: col('tint'), bodyFontSize: pt(11.5), cellPadding: mm(1.4) }];
const cell = (content, extra) => ({ content, align: 'center', verticalAlign: 'middle', ...extra });
// The MARK cell sets the row's depth: three lines, the blank one a U+2060 word joiner, since a
// cell line that is empty or holds only a no-break space is dropped (gotcha: cell-blank-line).
const candidate = table('candidate', 'candidate', { columnWidths: [4.2, 1.2, 2, 1.3], rows: [[
  ...t({ en: ['NAME', 'CLASS', 'CANDIDATE NUMBER'], es: ['NOMBRE Y APELLIDOS', 'GRUPO',
    'N.º DE EXAMEN'] }).map((l) => cell(`**${l}**`, { align: 'left', verticalAlign: 'top' })),
  cell(`**${t({ en: 'MARK', es: 'NOTA' })}**\n\u2060\n**/ ${t({ en: '25', es: '10' })}**`,
    { align: 'right', background: col('tint') })]] });
const GROUP = ['A', 'B', 'C', 'D'].map((l) => cell(`:chip[${l}]`)), num = (n) => cell(`**${n}**`);
const grid = { ...table('grid', 'grid', { columnWidths: [0.7, 1, 1, 1, 1, 1.4, 0.7, 1, 1, 1, 1],
  rows: [1, 2, 3, 4, 5].map((n) => [num(n), ...GROUP, cell(''), num(n + 5), ...GROUP]) }),
  caption: t({ en: '**Section A answer grid**', es: '**Parte A: plantilla de respuestas**' }) };
// The grid's title is its caption: a heading would stand a body line off any 'here' table.
const captionStyle = { fontFamily: LABEL, fontSize: pt(9.4), color: col('crimson'),
  position: 'above', gap: mm(1.6) }; // the rubric's size
```

Les caselles de l'alumne, la plantilla de bombolles i les ratlles són tres `tableStyles`, que cada taula tria amb `styleId` ([estils de taula amb nom](/ca/docs/configuration#estils-de-taula-amb-nom)). Les ratlles de [la resposta curta](#la-resposta-curta) fan servir `rules: 'horizontal'` i un farciment que dona a cada fila l'alçada de dues línies del text, de manera que cada filet cau 30 pt (10,6 mm) per sota de l'anterior, sobre una de cada dues línies de la retícula de base de 15 pt. Postext 1.4.1 descarta la línia d'una cel·la que és buida o només porta un espai no separable; per això la línia del centre de la casella NOTA, que va en blanc, porta un unidor de paraules (U+2060): aquesta cel·la ocupa tres línies i fixa l'alçada de tota la fila en 14,4 mm, enfront de 10,8 mm amb un espai no separable. Els filets de la plantilla van en el mateix rosa que el fons, perquè el llenç deixa fils clars entre cel·les emplenades. El rètol de la plantilla és un peu col·locat 1,6 mm per sobre ([estil dels peus de recurs](/ca/docs/configuration#estil-dels-peus-de-recurs)). Un títol quedaria com a mínim una línia de text (5,3 mm) més amunt, perquè tota taula col·locada amb 'here' deixa aquest espai al damunt. El tipus `form` té el `captionPrefix` buit, i per això el rètol no porta al davant una etiqueta «Form 1.».

### 4 · Dibuixa la portada amb els atributs del títol

```js
// script.js, línies 105–120
const BAND = 116; // mm: the crimson band, bled off the top and both sides
const YEAR = { cap: 62, cut: 12, pad: 2 }; // mm: the digits' cap height; the band cuts 12 off
// span 'page': an opener page, and the band paints above the column (a design in it is clipped).
const cover = { id: 'cover', span: 'page', margins: { right: mm(LEFT) }, // forms full width
  advancedDesign: { enabled: true, slot: { elements: [ // the date line sets the height
    { kind: 'box', id: 'band', style: { backgroundColor: col('crimson') },
      placement: at(0, 0, { width: mm(PAGE.w), height: mm(BAND) }) },
    // Design text has no outline, so the year is an image, cut off by the band's foot.
    { kind: 'image', id: 'year', resourceId: 'year', placement: at(0, BAND - YEAR.cap
      + YEAR.cut - YEAR.pad, { width: mm(PAGE.w), height: mm(YEAR.cap - YEAR.cut + YEAR.pad) }) },
    text('session', '{attr.session}', LABEL, 8.5, 600, 'blush', at(LEFT, 14), tag),
    // 0.9 mm to the left: the H's side bearing at 64 pt (84 of 2048 units), so its stem aligns.
    text('title', '{titleText}', LABEL, 64, 800, 'paper', at(LEFT - 0.9, 19), { lineHeight: 1 }),
    text('paper', '{attr.paper}', LABEL, 15, 700, 'paper', at(LEFT, 44)),
    text('topic', '{attr.topic}', TEXT, 15, 400, 'blush', at(LEFT, 52), { italic: true }),
    text('date', '{attr.date}', LABEL, 9.5, 700, 'ink', at(LEFT, BAND + 6))] } } };
```

La portada és un sol títol, `# Historia {style="cover" …}`, i el seu estil dibuixa la banda, l'any i quatre línies de text que pren dels atributs del títol. Amb `span: 'page'`, la pàgina compta com a obertura i la banda es pot pintar per sobre del marge superior de 22 mm. Sense això, el disseny queda retallat a dalt de la columna, de manera que la banda comença al marge i es perd la línia de la convocatòria. Els `margins` de l'estil eixamplen la columna de la portada fins als 168 mm per als formularis, i el títol següent, un de nivell 1 sense estil, torna les altres pàgines a la mesura de 134 mm.

### 5 · Deixa la portada sense foli ni «Gira el full»

```js
// script.js, línies 124–140
const [MARGIN, FOOT] = [LEFT + MEASURE + 7, -13]; // mm: the margin rule, 7 off the text; the foot
const body = { pages: 'body', ...tag }; // body pages only: never the cover
const header = { elements: [
  text('running', '{title} · {chapterTitle}', LABEL, 7.5, 600, 'muted', at(LEFT, 12), body),
  { kind: 'rule', id: 'margin', direction: 'vertical', pages: 'body', color: col('rule'),
    thickness: pt(0.75), placement: at(MARGIN, TOP, { height: mm(PAGE.h - TOP - BOTTOM) }) },
  text('note', t({ en: 'Do not write in this margin', es: 'No escribas en este margen' }), LABEL,
    7.5, 600, 'muted', at(MARGIN + 3, TOP, { width: mm(28) }), { ...body, lineHeight: 1.3 })] };
const footer = { elements: [
  text('notice', t({ en: 'Do not turn over until you are told to do so',
    es: 'No des la vuelta a la hoja hasta que se te indique' }), LABEL, 8.5, 700, 'crimson',
  at(0, FOOT, { width: mm(PAGE.w) }, 'bottom-left'), { pages: 'opener', align: 'center', ...tag }),
  text('folio', '{pageNumber}', LABEL, 9, 700, 'ink', at(LEFT, FOOT, { width: mm(MEASURE) },
    'bottom-left'), { ...body, align: 'center' }), // centred under the text
  // Rectos only: a verso faces the page that follows it.
  text('turn', t({ en: 'Turn over ›', es: 'Pasa la página ›' }), LABEL, 9, 700, 'ink',
    at(-RIGHT, FOOT, null, 'bottom-right'), { ...body, parity: 'odd', align: 'right' })] };
```

Tots els elements fixos de les pàgines de cos porten `pages: 'body'`, de manera que la portada, que és una obertura, només mostra el seu avís (`pages: 'opener'`). Per això cada part comença amb `:::pagebreak` i un títol sense `breakBefore`. Un títol que salta de pàgina converteix la seva en obertura, i les pàgines 2 i 3 perdrien el foli, la capçalera i el marge. `parity: 'odd'` deixa «Gira el full» a la pàgina senar, perquè la pàgina parella ja té al davant la següent.

## 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/exam-paper

### script.js

```js
// ═══ Postext Cookbook · Nº 049 · Exam paper with an answer sheet ═════════════════
// https://postext.dev/en/cookbook/exam-paper
// Code: MIT · Text: Lincoln (PD); questions, Spanish translation (CC BY 4.0) · Art: in code
// Fonts: PT Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1b1a1f', paper: '#ffffff', muted: '#6b6466', // heads, credits: 5.8:1
  crimson: '#9b1c31', // the one accent: cover, numbering, bubbles, marks (8.1:1 on white)
  blush: '#f3cdd4', tint: '#fbeff1', // type on the band, marks chips; the grid, the rubric box
  rule: '#b9aeb0' }; // answer lines and hairlines
// Hex and id: design slots and referenceColor read only the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults are linked to 'main-color': point it at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.crimson })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 215.9, h: 279.4 }]; // US Letter
const [TOP, BOTTOM, LEFT, MEASURE] = [22, 24, 24, 134]; // mm; 134 mm: about 75 characters
const RIGHT = PAGE.w - LEFT - MEASURE; // 57.9 mm: the examiner's margin, never mirrored
const [BODY, LEAD] = [11, 15]; // pt: text size and leading, the grid every line keeps to
const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text',
  id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color),
  align: 'left', overflow: 'wrap', placement, ...extra });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.4) }; // 0.16–0.19 em
const small = { fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') };

// #region answer: ruled answer lines: a table whose rows are two body lines deep
// A table row is one line (the table's size × the body's leading ratio) plus cellPadding above
// and below: at the body size, (ROW − LEAD) / 2 of padding makes every row ROW deep, so the
// rules keep to the text's 15 pt grid, 10.6 mm apart: room for handwriting.
const ROW = 2 * LEAD; // pt
const lines = { id: 'lines', rules: 'horizontal', // a rule on the top and foot of every row
  borderColor: col('rule'), borderWidth: pt(0.5), bodyFontFamily: LABEL, bodyFontSize: pt(BODY),
  bodyColor: col('crimson'), cellPadding: pt((ROW - LEAD) / 2) };
// Every table in this paper is set 'here': where its ::resource directive stands in the text.
const table = (id, styleId, model) => ({ id, typeId: 'form', kind: 'table', createdAt: 0,
  updatedAt: 0, placement: { position: 'here' }, table: { styleId, model } });
// One table per answer, the part's number in its first margin cell.
const answerLines = (id, part, rows) => table(id, 'lines', { columnWidths: [1, 5], rows: Array
  .from({ length: rows }, (_, i) => [{ content: i ? '' : `**${part}**` }, { content: '' }]) });
// #endregion

// #region numbering: 11 → a) → i), the separators in the accent; options under a question
const [NUMBER, GAP] = [12.5, 6]; // pt: the question numbers' size; number to text
const orderedLists = { fontFamily: LABEL, color: col('ink'), // bold, by default
  separatorColor: col('crimson'), gap: pt(GAP), marginTop: pt(LEAD / 2), marginBottom: pt(0),
  itemSpacing: pt(5), // after every numbered item: to a question's options, or the next part
  levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic', '.' (gotcha: numbering-vocabularies)
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', separator: ')' }] };
// Options: a nested item with no bullet, indented by the widest number ('10.') plus GAP.
const width = (s) => { const ctx = new OffscreenCanvas(1, 1).getContext('2d');
  ctx.font = `700 ${NUMBER}pt "${LABEL}"`; return ctx.measureText(s).width * 0.75; }; // pt
const unorderedLists = () => ({ gap: pt(0), marginTop: pt(0), marginBottom: pt(0),
  itemSpacing: pt(LEAD), // after the options: a line before the next question
  levels: [{ level: 2, bulletChar: '', indent: pt(width('10') + width('.') + GAP) }] });
// #endregion

// #region chips: bubbles that read as circles, and marks at the end of a part
// A chip is 0.8 em above the baseline and 0.25 em below, plus paddingY and the border on each
// side: 1.29 em and two borders. One capital is 0.63–0.72 em wide, so paddingX 0.3 em (and the
// same two borders) squares the box; a radius over half its smaller side is clamped to that half.
const chip = (id, look) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true,
  paddingY: em(0.12), ...look });
const bubble = (id, fill, ink, edge = 'crimson') => chip(id, { color: col(ink), paddingX: em(0.3),
  background: col(fill), borderColor: col(edge), borderWidth: pt(0.7), borderRadius: em(1) });
const chipStyles = [bubble('bubble', 'paper', 'crimson'), // first: a bare :chip[A] takes it
  bubble('filled', 'ink', 'paper', 'ink'), chip('marks', { color: col('crimson'),
    background: col('blush'), borderWidth: pt(0), paddingX: em(0.45), gap: em(0.5) })];
// #endregion

// #region forms: the candidate boxes and the bubble grid are named table styles too
// No table has a header row; the flag keeps the default grey fill off one added later.
const form = { bodyFontFamily: LABEL, borderRadius: mm(2), headerBackgroundEnabled: false };
const tableStyles = [lines,
  { ...form, id: 'candidate', rules: 'grid', borderColor: col('rule'), borderWidth: pt(0.75),
    bodyFontSize: pt(7.5), bodyColor: col('crimson'), cellPadding: mm(1.8) },
  // Tint fills leave hairline seams between cells on a canvas: grid rules in the tint hide them.
  { ...form, id: 'grid', rules: 'grid', borderColor: col('tint'), bodyBackgroundEnabled: true,
    bodyBackground: col('tint'), bodyFontSize: pt(11.5), cellPadding: mm(1.4) }];
const cell = (content, extra) => ({ content, align: 'center', verticalAlign: 'middle', ...extra });
// The MARK cell sets the row's depth: three lines, the blank one a U+2060 word joiner, since a
// cell line that is empty or holds only a no-break space is dropped (gotcha: cell-blank-line).
const candidate = table('candidate', 'candidate', { columnWidths: [4.2, 1.2, 2, 1.3], rows: [[
  ...t({ en: ['NAME', 'CLASS', 'CANDIDATE NUMBER'], es: ['NOMBRE Y APELLIDOS', 'GRUPO',
    'N.º DE EXAMEN'] }).map((l) => cell(`**${l}**`, { align: 'left', verticalAlign: 'top' })),
  cell(`**${t({ en: 'MARK', es: 'NOTA' })}**\n\u2060\n**/ ${t({ en: '25', es: '10' })}**`,
    { align: 'right', background: col('tint') })]] });
const GROUP = ['A', 'B', 'C', 'D'].map((l) => cell(`:chip[${l}]`)), num = (n) => cell(`**${n}**`);
const grid = { ...table('grid', 'grid', { columnWidths: [0.7, 1, 1, 1, 1, 1.4, 0.7, 1, 1, 1, 1],
  rows: [1, 2, 3, 4, 5].map((n) => [num(n), ...GROUP, cell(''), num(n + 5), ...GROUP]) }),
  caption: t({ en: '**Section A answer grid**', es: '**Parte A: plantilla de respuestas**' }) };
// The grid's title is its caption: a heading would stand a body line off any 'here' table.
const captionStyle = { fontFamily: LABEL, fontSize: pt(9.4), color: col('crimson'),
  position: 'above', gap: mm(1.6) }; // the rubric's size
// #endregion

// #region cover: a band with the outlined year, filled in from the heading's attributes
const BAND = 116; // mm: the crimson band, bled off the top and both sides
const YEAR = { cap: 62, cut: 12, pad: 2 }; // mm: the digits' cap height; the band cuts 12 off
// span 'page': an opener page, and the band paints above the column (a design in it is clipped).
const cover = { id: 'cover', span: 'page', margins: { right: mm(LEFT) }, // forms full width
  advancedDesign: { enabled: true, slot: { elements: [ // the date line sets the height
    { kind: 'box', id: 'band', style: { backgroundColor: col('crimson') },
      placement: at(0, 0, { width: mm(PAGE.w), height: mm(BAND) }) },
    // Design text has no outline, so the year is an image, cut off by the band's foot.
    { kind: 'image', id: 'year', resourceId: 'year', placement: at(0, BAND - YEAR.cap
      + YEAR.cut - YEAR.pad, { width: mm(PAGE.w), height: mm(YEAR.cap - YEAR.cut + YEAR.pad) }) },
    text('session', '{attr.session}', LABEL, 8.5, 600, 'blush', at(LEFT, 14), tag),
    // 0.9 mm to the left: the H's side bearing at 64 pt (84 of 2048 units), so its stem aligns.
    text('title', '{titleText}', LABEL, 64, 800, 'paper', at(LEFT - 0.9, 19), { lineHeight: 1 }),
    text('paper', '{attr.paper}', LABEL, 15, 700, 'paper', at(LEFT, 44)),
    text('topic', '{attr.topic}', TEXT, 15, 400, 'blush', at(LEFT, 52), { italic: true }),
    text('date', '{attr.date}', LABEL, 9.5, 700, 'ink', at(LEFT, BAND + 6))] } } };
// #endregion

// #region furniture: margin, folio and 'Turn over' on body pages; a notice on the cover
const [MARGIN, FOOT] = [LEFT + MEASURE + 7, -13]; // mm: the margin rule, 7 off the text; the foot
const body = { pages: 'body', ...tag }; // body pages only: never the cover
const header = { elements: [
  text('running', '{title} · {chapterTitle}', LABEL, 7.5, 600, 'muted', at(LEFT, 12), body),
  { kind: 'rule', id: 'margin', direction: 'vertical', pages: 'body', color: col('rule'),
    thickness: pt(0.75), placement: at(MARGIN, TOP, { height: mm(PAGE.h - TOP - BOTTOM) }) },
  text('note', t({ en: 'Do not write in this margin', es: 'No escribas en este margen' }), LABEL,
    7.5, 600, 'muted', at(MARGIN + 3, TOP, { width: mm(28) }), { ...body, lineHeight: 1.3 })] };
const footer = { elements: [
  text('notice', t({ en: 'Do not turn over until you are told to do so',
    es: 'No des la vuelta a la hoja hasta que se te indique' }), LABEL, 8.5, 700, 'crimson',
  at(0, FOOT, { width: mm(PAGE.w) }, 'bottom-left'), { pages: 'opener', align: 'center', ...tag }),
  text('folio', '{pageNumber}', LABEL, 9, 700, 'ink', at(LEFT, FOOT, { width: mm(MEASURE) },
    'bottom-left'), { ...body, align: 'center' }), // centred under the text
  // Rectos only: a verso faces the page that follows it.
  text('turn', t({ en: 'Turn over ›', es: 'Pasa la página ›' }), LABEL, 9, 700, 'ink',
    at(-RIGHT, FOOT, null, 'bottom-right'), { ...body, parity: 'odd', align: 'right' })] };
// #endregion

const section = { enabled: true, slot: { elements: [ // Section A, Section B: in the column
  { kind: 'rule', id: 'top', color: col('crimson'), thickness: pt(2),
    placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } },
  text('kicker', '{attr.section} · {attr.marks}', LABEL, 8.5, 700, 'crimson', { anchor: {
    to: 'container', edge: 'top-left' }, offset: { y: mm(3) } }, tag),
  text('title', '{titleText}', LABEL, 20, 800, 'ink', { anchor: { to: '#kicker',
    edge: 'below' }, offset: { y: mm(1.2) }, size: { width: 'fill' } }, { lineHeight: 1.05 })] } };

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: [{ id: 'form', name: 'Form', shortLabel: '', captionPrefix: '',
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no label
  colorPalette, chipStyles, tableStyles, captionStyle, orderedLists, header, footer,
  headingStyles: [cover], unorderedLists: unorderedLists(), // measured now that the fonts are in
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(TOP),
    bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } }, layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // No page break: a :::pagebreak opens each section, so its page stays a body page with a
  // folio. A heading that breaks the page makes an opener, which pages: 'body' leaves bare.
  headings: { fontFamily: LABEL, levels: [{ level: 1, breakBefore: { enabled: false },
    marginTop: pt(0), marginBottom: pt(0), advancedDesign: section }] },
  calloutStyles: [{ id: 'rubric', background: col('tint'), borderRadius: mm(2), columnGap: mm(8),
    padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) }, marginTop: pt(0),
    marginBottom: pt(0), lists: { bulletChar: '–', color: col('crimson'), gap: mm(2),
      itemSpacing: pt(3) }, body: { fontFamily: LABEL, fontSize: pt(9.4), lineHeight: pt(13),
      boldColor: col('crimson'), paragraphSpacing: false } },
  { id: 'source', backgroundEnabled: false, marginTop: pt(LEAD), stripe: { enabled: true,
    side: 'left', width: pt(3), color: col('crimson') }, padding: { top: mm(1), right: mm(0),
    bottom: mm(1), left: mm(6) }, titleStyle: { fontFamily: LABEL, fontSize: pt(8.5),
      fontWeight: 700, color: col('crimson'), gap: mm(2), ...tag }, body: { fontSize: pt(10.5),
      lineHeight: pt(LEAD), textAlign: 'justify', paragraphSpacing: false,
      firstLineIndent: mm(4) } }],
  paragraphStyles: [{ id: 'signature', textAlign: 'right' }, { id: 'credit', ...small },
    { id: 'end', ...small, boldColor: col('crimson'), textAlign: 'center', spaceBetween: pt(8) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Historia, prueba 2"
subtitle: "La guerra de Secesión, 1863"
---

# Historia {style="cover" session="Simulacro de examen · Primavera de 2026" paper="Prueba 2 · Fuentes e interpretaciones" topic="La guerra de Secesión, 1863" date="Jueves, 14 de mayo de 2026 · 9:00 · Duración: 1 hora y 15 minutos"}

::resource{id="candidate"}

:::callout{type="rubric"}
:::columns{count=2 breaks="6"}
**Instrucciones**

- Usa bolígrafo negro; para la parte A, lápiz.
- Responde a todas las preguntas.
- Parte A: rellena una burbuja por pregunta, así: :chip[C]{style="filled"}. Si cambias una respuesta, borra la marca.
- Parte B: escribe en las líneas de la página 4.

**Información**

- Junto a cada apartado figura su puntuación, por ejemplo: :chip[1 punto]{style="marks"}
- Parte A: 5 puntos. Parte B: 5 puntos.
- Dedica unos 45 minutos a la parte B.
:::
:::

::resource{id="grid"}

:::pagebreak

# Preguntas tipo test {section="Parte A" marks="5 puntos"}

Rellena una burbuja por pregunta en la plantilla de la página 1:

1. La Proclamación de Emancipación entró en vigor el 1 de enero de 1863. ¿En qué estados declaraba libres a las personas esclavizadas?
  - :chip[A] todos :chip[B] los fronterizos :chip[C] los rebeldes :chip[D] los del Oeste
2. ¿Quién mandaba el ejército de la Unión en la batalla de Gettysburg?
  - :chip[A] U. S. Grant :chip[B] G. G. Meade :chip[C] G. B. McClellan :chip[D] W. T. Sherman
3. ¿Qué plaza fuerte confederada se rindió el 4 de julio de 1863?
  - :chip[A] Nueva Orleans :chip[B] Vicksburg :chip[C] Memphis :chip[D] Baton Rouge
4. Si una *score* son veinte años, ¿cuántos son «four score and seven»?
  - :chip[A] 47 :chip[B] 67 :chip[C] 87 :chip[D] 107
5. ¿Quién pronunció el discurso principal, de dos horas, en la inauguración del cementerio de Gettysburg?
  - :chip[A] E. Everett :chip[B] F. Douglass :chip[C] W. H. Seward :chip[D] J. Hay
6. Con la Ley de Reclutamiento de marzo de 1863, ¿cuánto podía pagar un reclutado para quedar exento del servicio?
  - :chip[A] 100 dólares :chip[B] 300 dólares :chip[C] 500 dólares :chip[D] 1000 dólares
7. ¿Qué regimiento de soldados negros encabezó el asalto a Fort Wagner (Carolina del Sur) en julio de 1863?
  - :chip[A] 20.º Maine :chip[B] 9.º Ohio :chip[C] 2.º Iowa :chip[D] 54.º Massachusetts
8. ¿Qué estado se incorporó a la Unión el 20 de junio de 1863?
  - :chip[A] Nevada :chip[B] Virginia Occidental :chip[C] Kansas :chip[D] Nebraska
9. ¿En qué batalla de mayo de 1863 fue herido de muerte el general Thomas «Stonewall» Jackson?
  - :chip[A] Chancellorsville :chip[B] Antietam :chip[C] Fredericksburg :chip[D] Shiloh
10. ¿En qué ciudad hubo cuatro días de disturbios contra el reclutamiento en julio de 1863?
  - :chip[A] Boston :chip[B] Filadelfia :chip[C] Nueva York :chip[D] Chicago

:::pagebreak

# Comentario de una fuente {section="Parte B" marks="5 puntos"}

:::callout{type="source" title="Fuente A"}
Hace ochenta y siete años, nuestros padres crearon en este continente una nueva nación, concebida en la libertad y consagrada al principio de que todos los hombres son creados iguales.

Ahora estamos empeñados en una gran guerra civil que pone a prueba si esa nación, o cualquier nación así concebida y así consagrada, puede perdurar mucho tiempo. Nos hemos reunido en un gran campo de batalla de esa guerra. Hemos venido a dedicar una parte de ese campo como último lugar de reposo de quienes dieron aquí la vida para que esa nación pudiera vivir. Es del todo justo y apropiado que lo hagamos.

Pero, en un sentido más amplio, no podemos dedicar —no podemos consagrar, no podemos santificar— este suelo. Los valientes, vivos y muertos, que lucharon aquí ya lo han consagrado, muy por encima de nuestro pobre poder de añadir o quitar. El mundo apenas reparará en lo que aquí decimos, ni lo recordará por mucho tiempo, pero nunca podrá olvidar lo que ellos hicieron aquí. Somos más bien nosotros, los vivos, quienes debemos dedicarnos aquí a la obra inconclusa que los que aquí lucharon han hecho avanzar tan noblemente. Somos más bien nosotros quienes debemos dedicarnos aquí a la gran tarea que aún tenemos por delante: que de estos muertos venerados tomemos una devoción mayor por la causa a la que ellos dieron la última y plena medida de su devoción; que resolvamos aquí firmemente que estos muertos no habrán muerto en vano; que esta nación, bajo Dios, tenga un nuevo nacimiento de la libertad, y que el gobierno del pueblo, por el pueblo y para el pueblo no desaparezca de la tierra.

:::paragraphs{style="signature"}
*Abraham Lincoln. 19 de noviembre de 1863.*
:::
:::

:::paragraphs{style="credit"}
Discurso de Lincoln del 19 de noviembre de 1863 en la inauguración del Cementerio Nacional de los Soldados, en Gettysburg. Traducción, hecha para esta prueba, de la copia Bliss, la única que firmó.
:::

11. Lee la fuente A y responde en las líneas de la página 4.
  1. Lincoln empieza con las palabras «Hace ochenta y siete años».
    1. ¿A qué año se remonta? :chip[0,5 puntos]{style="marks"}
    2. ¿Por qué no cuenta desde 1787, el año de la Constitución? :chip[1 punto]{style="marks"}
  2. La guerra pone a prueba «si esa nación […] puede perdurar». Describe dos éxitos de la Unión entre enero y noviembre de 1863. :chip[1,5 puntos]{style="marks"}
  3. «El discurso honra a los muertos y dice poco del futuro de la nación». ¿Estás de acuerdo? Usa la fuente A y tus conocimientos. :chip[2 puntos]{style="marks"}

:::pagebreak

Escribe en estas líneas tus respuestas a la pregunta 11.

::resource{id="lines-ai"}

::resource{id="lines-aii"}

::resource{id="lines-b"}

::resource{id="lines-c"}

:::space

:::paragraphs{style="end"}
**FIN DE LAS PREGUNTAS**

Fuente A: discurso de Gettysburg, copia Bliss, de dominio público. Traducción y preguntas escritas para esta prueba, CC BY 4.0. Compuesto en PT Serif e Inter Tight, SIL Open Font License.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the outlined year
// The digits are Inter Tight 800 outlines (SIL OFL), 102.4 units to the em, cap height 74.5,
// on a baseline at 0: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
const YEAR_OUTLINE = 'M38.2-74.5V0H20.2V-57.7H19.8L3.1-47.5V-63.1L21.5-74.5ZM75.8 1Q67.1 1 60.3-1.8'
  + 'Q53.6-4.5 49.7-9.3Q45.8-14.1 45.8-20.1Q45.8-24.8 48-28.6Q50.2-32.5 54-35'
  + 'Q57.8-37.6 62.5-38.4V-38.9Q56.4-40.1 52.4-44.7Q48.5-49.2 48.5-55.4Q48.5-61.2 52-65.7'
  + 'Q55.6-70.2 61.8-72.9Q68-75.5 75.8-75.5Q83.7-75.5 89.9-72.9Q96.1-70.2 99.7-65.7'
  + 'Q103.2-61.2 103.2-55.4Q103.2-49.2 99.2-44.6Q95.2-40.1 89.2-38.9V-38.4'
  + 'Q93.8-37.6 97.6-35Q101.4-32.5 103.7-28.6Q105.9-24.8 105.9-20.1Q105.9-14.1 102-9.3'
  + 'Q98.2-4.5 91.4-1.8Q84.6 1 75.8 1ZM75.8-11.7Q79.2-11.7 81.7-13Q84.2-14.2 85.6-16.5'
  + 'Q87-18.8 87-21.7Q87-24.5 85.6-26.8Q84.1-29 81.6-30.3Q79.1-31.6 75.8-31.6'
  + 'Q72.6-31.6 70.1-30.3Q67.6-29 66.1-26.8Q64.7-24.6 64.7-21.7Q64.7-18.8 66.1-16.5'
  + 'Q67.5-14.3 70-13Q72.6-11.7 75.8-11.7ZM75.8-44.3Q78.7-44.3 80.9-45.5'
  + 'Q83.1-46.6 84.3-48.7Q85.6-50.8 85.6-53.4Q85.6-56 84.3-58Q83.1-60 80.9-61.1'
  + 'Q78.7-62.2 75.8-62.2Q73-62.2 70.8-61.1Q68.6-60 67.3-58Q66.1-56 66.1-53.4'
  + 'Q66.1-50.8 67.3-48.7Q68.6-46.7 70.8-45.5Q73-44.3 75.8-44.3ZM143.7 1Q137.6 1 132-1'
  + 'Q126.4-3 122.1-7.3Q117.7-11.7 115.2-18.7Q112.7-25.8 112.7-36Q112.7-45.2 114.9-52.5'
  + 'Q117.1-59.8 121.2-65Q125.4-70.1 131.1-72.8Q136.9-75.5 144-75.5Q151.9-75.5 157.8-72.5'
  + 'Q163.8-69.4 167.4-64.3Q171-59.2 171.7-53H154Q153.2-56.5 150.5-58.3Q147.8-60.2 144-60.2'
  + 'Q137.2-60.2 133.8-54.2Q130.5-48.4 130.5-38.4H130.9Q132.5-41.8 135.3-44.2'
  + 'Q138.2-46.6 141.9-47.8Q145.7-49.1 149.8-49.1Q156.5-49.1 161.6-46Q166.7-43 169.6-37.6'
  + 'Q172.5-32.2 172.5-25.3Q172.5-17.5 168.9-11.6Q165.2-5.7 158.7-2.3Q152.2 1 143.7 1Z'
  + 'M143.6-12.9Q146.9-12.9 149.5-14.4Q152.1-16 153.6-18.7Q155.1-21.4 155.1-24.7'
  + 'Q155.1-28.1 153.6-30.8Q152.1-33.5 149.5-35Q147-36.6 143.6-36.6Q140.4-36.6 137.7-35'
  + 'Q135.1-33.4 133.6-30.7Q132.1-28.1 132.1-24.7Q132.1-21.4 133.6-18.7Q135.1-16 137.7-14.4'
  + 'Q140.3-12.9 143.6-12.9ZM208.3 1Q199.8 1 193.2-1.9Q186.6-4.8 182.8-10'
  + 'Q179.1-15.2 179-21.9H197.1Q197.2-19.5 198.6-17.6Q200.1-15.7 202.7-14.7'
  + 'Q205.2-13.7 208.4-13.7Q211.6-13.7 214-14.8Q216.5-15.9 217.8-17.9Q219.2-19.9 219.2-22.5'
  + 'Q219.2-25.2 217.7-27.2Q216.2-29.2 213.4-30.4Q210.7-31.5 206.9-31.5H199.6V-44.3H206.9'
  + 'Q210.2-44.3 212.7-45.4Q215.2-46.5 216.6-48.5Q218-50.5 218-53Q218-55.6 216.8-57.4'
  + 'Q215.6-59.3 213.5-60.4Q211.3-61.5 208.4-61.5Q205.4-61.5 203-60.4Q200.6-59.3 199.2-57.4'
  + 'Q197.8-55.5 197.7-53H180.5Q180.5-59.6 184.2-64.7Q187.8-69.8 194.1-72.6'
  + 'Q200.4-75.5 208.5-75.5Q216.5-75.5 222.5-72.7Q228.6-69.9 232-65.1Q235.4-60.2 235.4-54.2'
  + 'Q235.4-48 231.3-43.9Q227.2-39.8 220.7-38.8V-38.2Q229.3-37.2 233.7-32.6'
  + 'Q238.1-28.1 238.1-21.2Q238.1-14.7 234.3-9.7Q230.5-4.7 223.7-1.8Q217 1 208.3 1Z';
function year() { // 10 units to the millimetre
  const [w, h] = [PAGE.w * 10, (YEAR.cap - YEAR.cut + YEAR.pad) * 10];
  const s = (YEAR.cap * 10) / 74.5; // scale: cap height to YEAR.cap
  const x = LEFT * 10 - 3.1 * s; // the 1's flag starts 3.1 units in: line it up with the text
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} `
    + `${h}"><path transform="translate(${x.toFixed(1)} ${(YEAR.cap + YEAR.pad) * 10}) `
    + `scale(${s.toFixed(4)})" d="${YEAR_OUTLINE}" fill="none" stroke="${palette.paper}" `
    + `stroke-width="${(8.5 / s).toFixed(3)}" stroke-linejoin="round"/></svg>`; // 0.85 mm
}
// typeId 'form' too: the drawing is placed by the cover's design, never by ::resource, and a
// type with no captionPrefix spares it a figure number.
const picture = { id: 'year', typeId: 'form', kind: 'svg', altText: '1863', createdAt: 0,
  updatedAt: 0, svg: { fileId: 'year.svg', width: PAGE.w * 10,
    height: (YEAR.cap - YEAR.cut + YEAR.pad) * 10 } }; // in the drawing's own units
// #endregion

const resources = [picture, candidate, grid, answerLines('lines-ai', '11 a) i)', 1),
  answerLines('lines-aii', '11 a) ii)', 2), answerLines('lines-b', '11 b)', 4),
  answerLines('lines-c', '11 c)', 8)];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'PT Serif': ['400', '400i', '700', '700i'], // (gotcha: fonts-first)
  'Inter Tight': ['400', '600', '700', '800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([loadFonts(FONTS, markdown), loadSvg('year.svg', year())]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'History Paper 2', es: 'Historia, prueba 2' }) });

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

### Imprimeix-lo en A4

La banda, l'any, el filet del marge i l'avís prenen les mides de `PAGE`, així que passar a A4 és canviar una línia. Els blocs de la portada no es mouen i el blanc sota la plantilla creix 17,6 mm en les dues edicions.

```diff
-const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 215.9, h: 279.4 }]; // US Letter
+const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 210, h: 297 }]; // A4
```

### Afegeix un banc de paraules o una llista de comprovació

Els bancs de paraules, els buits i els cercles per pintar també són xips: [la fitxa amb caixes de resposta i banc de paraules](https://postext.dev/ca/cookbook/worksheet-answer-boxes.md) compon un banc de xips grocs dins d'un requadre niat i una llista de comprovació que obre cada línia amb un cercle buit.

## Errors freqüents

- **Llistes 'arabic', recursos 'roman-upper', pàgines 'upper-roman'.** Cada opció de numeració escriu els formats a la seva manera: les llistes fan servir numberFormat 'arabic' ('decimal' imprimeix «undefined»), els tipus de recurs counterFormat 'roman-upper' i les pàgines i :::numbering 'upper-roman'.
- **Una línia en blanc dins d'una cel·la desapareix.** Un salt de línia dins d'una cel·la comença un paràgraf nou, però postext 1.4.1 descarta el paràgraf buit o que només porta espais de no separació (U+00A0), així que una cel·la escrita 'NOM\n\n' ocupa una sola línia. Per deixar línies on escriure, posa un unidor de paraules (U+2060) a cada línia en blanc: 'NOM\n\u2060\n\u2060' n'ocupa tres.
- **Una taula 'here' no es parteix mai.** Només es parteixen entre columnes i pàgines les taules flotants; una taula col·locada 'here' es mou sencera. Deixa flotar les taules llargues o mantén curtes les taules en línia.
- **En el text en bandera no s'eviten les línies curtes.** optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta.
- **Un $ sol obre matemàtiques: escriu \$.** El signe de dòlar obre matemàtiques en línia, de manera que un preu com $40 comença una fórmula. Escriu \$40.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **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().
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- Posar ratlles entre els apartats d'una pregunta en parteix la llista: la llista que segueix la taula comença un tram nou, i cada tram alinea els seus números amb el més ample dels seus, de manera que el text de ii) començaria 1 mm a la dreta del d'i). Aquí les respostes van en una pàgina pròpia i tots els apartats queden en una sola llista.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The Gettysburg Address (19 November 1863), text of the Bliss copy: Abraham Lincoln ([font](https://en.wikisource.org/wiki/Gettysburg_Address_(Bliss_copy))), domini públic
- Text: The questions, the instructions and the Spanish translation of Source A: Ignacio Ferro, CC-BY-4.0
- Tipus de lletra: PT Serif (OFL-1.1), Inter Tight (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 022 · Fitxa amb caixes de resposta i banc de paraules](https://postext.dev/ca/cookbook/worksheet-answer-boxes.md): Una fitxa de ciències de quatre pàgines: caixes de resposta blanques en targetes verd clar, a 2 mm de cada pregunta, i xips per a bancs de paraules i buits. · Nivell 2 (Intermedi) · Quaderns i exercicis
- [Núm. 060 · Cartilla de lectura amb síl·labes en xips](https://postext.dev/ca/cookbook/reading-primer-syllables.md): Una unitat de cartilla dedicada a la ema, amb cada síl·laba en un xip del color de la seva vocal i sis paraules en una taula, cadascuna sota la seva aquarel·la. · Nivell 2 (Intermedi) · Quaderns i exercicis
- [Núm. 033 · Fitxa de recepta: ingredients al costat de l'elaboració](https://postext.dev/ca/cookbook/recipe-card.md): Dues pàgines de receptari amb una fitxa amb la pestanya PARA 4: ingredients, etiquetes i llista de tasques a l'esquerra; passos amb xifres vermelles a la dreta. · Nivell 2 (Intermedi) · Manuals, guies i obres de consulta
