# Prova com cartão-resposta

> Uma prova de história de quatro páginas em US Letter, com bolinhas e pontuações feitas com chips, questões numeradas 1, a), i) e linhas traçadas por uma tabela.

- Versão HTML: https://postext.dev/pt/cookbook/exam-paper
- Receita Nº 049 · Boxes e notas · Nível 2 (Intermediário) · Saídas: Canvas
- Gêneros: Cadernos de exercícios
- Requer postext ≥ 1.19.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/exam-paper/en/p01.webp?v=a3741e28), [2](https://postext.dev/cookbook/exam-paper/en/p02.webp?v=a3741e28), [3](https://postext.dev/cookbook/exam-paper/en/p03.webp?v=a3741e28), [4](https://postext.dev/cookbook/exam-paper/en/p04.webp?v=a3741e28)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=exam-paper&lang=en (.postext: https://postext.dev/cookbook/exam-paper/en/exam-paper.postext)
- Última atualização: 2026-10-06
- Outros idiomas: [en](https://postext.dev/en/cookbook/exam-paper.md), [es](https://postext.dev/es/cookbook/exam-paper.md), [ca](https://postext.dev/ca/cookbook/exam-paper.md), [zh](https://postext.dev/zh/cookbook/exam-paper.md), [ja](https://postext.dev/ja/cookbook/exam-paper.md), [ar](https://postext.dev/ar/cookbook/exam-paper.md)

## Em poucas palavras

Uma prova de história de quatro páginas com capa, bolinhas para marcar e páginas pautadas para escrever. Mostra como numerar as questões e os itens e indicar quanto vale cada um.

## O que você vai compor

A prova 2 de um simulado de história sobre a Guerra Civil Americana, composta como um caderno de quatro páginas em US Letter. A capa é uma faixa carmesim com o ano 1863 desenhado em contorno, sobre os campos do aluno, as instruções em duas colunas e um cartão-resposta de bolinhas com letras para a Section A. A página 2 traz dez questões de múltipla escolha, cada uma seguida das suas quatro alternativas, e cada alternativa abre com uma bolinha com letra, como as do cartão. A página 3 compõe o discurso de Gettysburg como Source A e numera as questões sobre ele como 11, a), i), com a pontuação de cada item num chip. A página 4 é pautada para as respostas, uma linha a cada duas linhas de texto, e cada grupo de linhas abre com o número do seu item. Toda página depois da capa leva fólio e uma margem com fio para o corretor, e a única página ímpar entre elas diz Turn over.

**Esta receita responde a:**

- Como faço para diagramar uma prova com cartão-resposta, itens a) e i), pontuação e linhas pautadas?
- Como personalizo listas: marcadores por nível, numeração (a)/(i), caixas de seleção de tarefas e um espaçamento que fique na grade?
- Como faço chips em linha: teclas, etiquetas, bancos de palavras para exercícios?
- Como dou estilos diferentes a várias tabelas (preenchimentos, células zebradas, molduras arredondadas) em um mesmo documento?
- Como oculto os cabeços nas aberturas e nas páginas em branco, ou pinto uma página par em branco com a cor da parte?

## A resposta curta

```js
// script.js, linhas 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: '' }]) });
```

## Ingredientes

**Ensina**

- [Listas numeradas](https://postext.dev/pt/docs/configuration.md#listas-numeradas): Numeração arábica, romana ou por letras em cada nível, separadores com estilo próprio e números de passo pendurados.
- [Chips no texto](https://postext.dev/pt/docs/configuration.md#estilos-de-chip): Caixas arredondadas em volta de palavras, que mudam de linha como uma unidade e nunca se esticam: teclas, etiquetas, bancos de palavras, sílabas.
- [Estilos de tabela com nome](https://postext.dev/pt/docs/configuration.md#estilos-de-tabela-nomeados): Vários visuais de tabela no mesmo documento, cada um escolhido por id e herdando o estilo de tabela do documento.

**Também usa**

- [Tabelas a partir de dados](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Tipos de recurso personalizados](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)
- [Preenchimento de células](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Quebras de página e de coluna](https://postext.dev/pt/docs/document-format.md#pagebreak)
- [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)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Geometria por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [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)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Colunas dentro de um boxe](https://postext.dev/pt/docs/document-format.md#columns)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Espaço vertical explícito](https://postext.dev/pt/docs/document-format.md#space)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`chipStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-chip), [`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), [`orderedLists`](https://postext.dev/pt/docs/configuration.md#listas-numeradas), [`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), [`tableStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-tabela-nomeados), [`unorderedLists`](https://postext.dev/pt/docs/configuration.md#listas-com-marcadores)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`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**

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

## Preparo

### 1 · Numere as questões e ponha as alternativas embaixo

```js
// script.js, linhas 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), numberWidth: 'level', // 5 pt to the options or next part; text after '10.'
  levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic', '.' (gotcha: numbering-vocabularies)
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', separator: ')' }] };
// Options: a bulletless item at the questions' depth, '10.' plus GAP in (boxes set their own).
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
  indent: pt(width('10') + width('.') + GAP), levels: [{ level: 1, bulletChar: '' }] });
```

Cada nível de `orderedLists` escolhe o seu `numberFormat` e o seu `separator`, e `separatorColor` pinta o ponto e os parênteses na cor de destaque enquanto o número fica na cor da tinta ([listas numeradas](https://postext.dev/pt/docs/configuration.md#listas-numeradas)). Com `numberWidth: 'level'`, os números se alinham à direita pelo mais largo do seu nível, então o texto de todas as questões começa a 23 pt: os 17 pt de '10.' mais `GAP`. As quatro alternativas são um único item de lista com marcador com `bulletChar` vazio, no mesmo nível das questões e não aninhado nelas. O texto dele começa no `indent` da lista, que é '10.' medido na fonte dos números mais `GAP`, e assim as alternativas se alinham sob a primeira letra da questão com qualquer fonte; o boxe de instruções define `indent: pt(0)`, porque as listas dentro de um boxe usam o recuo do documento. Entre dois itens do mesmo nível, o espaço é o `itemSpacing` do primeiro: 5 pt depois de um item numerado, 15 pt depois de uma linha de alternativas. Aninhada na sua questão, a linha de alternativas pegaria os 5 pt da lista de questões dos dois lados.

### 2 · Faça as bolinhas e as pontuações com chips

```js
// script.js, linhas 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) })];
```

Um chip é uma caixa dentro da linha, e uma quebra de linha nunca cai dentro dele ([chips em linha](https://postext.dev/pt/docs/document-format.md#chips-em-linha)). Com uma única maiúscula dentro, `paddingX: em(0.3)` deixa a caixa quase tão larga quanto alta: de 4,5 a 4,8 mm de largura no cartão, para uma altura de 4,7 mm. `borderRadius: em(1)` passa da metade da caixa, e o raio de um chip é limitado à metade do seu lado menor, então as pontas ficam totalmente redondas. Um chip sem `style` usa a primeira entrada de `chipStyles`, por isso as oitenta bolinhas, quarenta no cartão e quarenta nas alternativas, são chips simples como `:chip[A]`; só o exemplo preenchido das instruções e as pontuações citam um estilo. O Postext 1.4.1 não tem tabulações, então as pontuações não podem ficar alinhadas à direita; cada chip fecha o seu item, pelo menos 0,5 em (`gap`) depois da última palavra.

### 3 · Dê a cada formulário o seu estilo de tabela

```js
// script.js, linhas 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
```

Os campos do aluno, o cartão-resposta e as linhas de resposta são três `tableStyles`, escolhidos por tabela com `styleId` ([estilos de tabela com nome](https://postext.dev/pt/docs/configuration.md#estilos-de-tabela-nomeados)). As linhas de resposta da [resposta curta](https://postext.dev/pt/cookbook/exam-paper.md#a-resposta-curta) usam `rules: 'horizontal'` e um espaçamento interno que dá a cada linha da tabela a altura de duas linhas do texto, então cada fio cai 30 pt (10,6 mm) abaixo do anterior, numa de cada duas linhas da grade de 15 pt do texto. O Postext 1.4.1 descarta uma linha de célula vazia ou que só tenha um espaço inseparável, por isso a linha do meio, em branco, do campo MARK leva um word joiner U+2060: essa célula tem três linhas e define a linha inteira da tabela em 14,4 mm, contra 10,8 mm com um espaço inseparável. O cartão traça os fios no mesmo tom do preenchimento, porque o canvas deixa emendas claras entre células preenchidas. O título dele é uma legenda posta 1,6 mm acima ([estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)). Um título de seção ficaria pelo menos uma linha do texto (5,3 mm) mais alto, porque toda tabela posicionada 'here' mantém esse espaço acima dela. O tipo `form` tem `captionPrefix` vazio, então nenhum rótulo 'Form 1.' aparece antes do título.

### 4 · Desenhe a capa com os atributos do título

```js
// script.js, linhas 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))] } } };
```

A capa é um único título, `# History {style="cover" …}`, e o estilo dele desenha uma faixa, o ano e quatro linhas de texto preenchidas com os atributos do título. Com `span: 'page'`, a página conta como abertura e a faixa pode ser pintada acima da margem superior de 22 mm. Sem isso, o design é cortado no alto da coluna, então a faixa começa na margem e a linha da sessão se perde. As `margins` do estilo alargam a coluna da capa para os 168 mm inteiros por causa dos formulários, e o título seguinte, um nível 1 sem estilo, devolve as páginas do miolo à medida de 134 mm.

### 5 · Tire o fólio e o Turn over da capa

```js
// script.js, linhas 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' })] };
```

Todo elemento fixo das páginas do miolo leva `pages: 'body'`, então a capa, que é uma abertura, mostra só o seu próprio aviso (`pages: 'opener'`). Por isso as seções começam com `:::pagebreak` e um título sem `breakBefore`. Um título que quebra a página faz dela uma abertura, e as páginas 2 e 3 perderiam o fólio, o cabeço e a margem. `parity: 'odd'` mantém o Turn over na página ímpar, já que uma página par já está de frente para a página seguinte.

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

const LANG = 'en'; // @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), numberWidth: 'level', // 5 pt to the options or next part; text after '10.'
  levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic', '.' (gotcha: numbering-vocabularies)
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', separator: ')' }] };
// Options: a bulletless item at the questions' depth, '10.' plus GAP in (boxes set their own).
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
  indent: pt(width('10') + width('.') + GAP), levels: [{ level: 1, bulletChar: '' }] });
// #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), indent: pt(0),
      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: "History Paper 2"
subtitle: "The American Civil War, 1863"
---

# History {style="cover" session="Mock examinations · Spring 2026" paper="Paper 2 · Sources and interpretations" topic="The American Civil War, 1863" date="Thursday 14 May 2026 · Morning · Time allowed: 1 hour 15 minutes"}

::resource{id="candidate"}

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

- Use black ink. Use a pencil for Section A.
- Answer every question.
- Section A: shade one bubble for each question, like this: :chip[C]{style="filled"}. Rub out any mark you change.
- Section B: write on the lines on page 4.

**Information**

- The number of marks is shown at the end of each part, like this: :chip[4 marks]{style="marks"}
- Section A: 10 marks. Section B: 15 marks.
- Spend about 45 minutes on Section B.
:::
:::

::resource{id="grid"}

:::pagebreak

# Multiple choice {section="Section A" marks="10 marks"}

Shade one bubble for each question in the grid on page 1:

1. The Emancipation Proclamation took effect on 1 January 1863. Where did it declare enslaved people free?
- :chip[A] every state :chip[B] border states :chip[C] rebel states :chip[D] western states
2. Who commanded the Union army at the Battle of Gettysburg?
- :chip[A] U. S. Grant :chip[B] G. G. Meade :chip[C] G. B. McClellan :chip[D] W. T. Sherman
3. Which Confederate stronghold surrendered on 4 July 1863?
- :chip[A] New Orleans :chip[B] Vicksburg :chip[C] Memphis :chip[D] Baton Rouge
4. How many years are “four score and seven”?
- :chip[A] 47 :chip[B] 67 :chip[C] 87 :chip[D] 107
5. Who gave the two-hour main oration at the dedication of the cemetery at Gettysburg?
- :chip[A] E. Everett :chip[B] F. Douglass :chip[C] W. H. Seward :chip[D] J. Hay
6. Under the Enrollment Act of March 1863, what could a drafted man pay to be excused from service?
- :chip[A] \$100 :chip[B] \$300 :chip[C] \$500 :chip[D] \$1,000
7. Which regiment of Black soldiers led the assault on Fort Wagner, South Carolina, in July 1863?
- :chip[A] 20th Maine :chip[B] 9th Ohio :chip[C] 2nd Iowa :chip[D] 54th Massachusetts
8. Which state joined the Union on 20 June 1863?
- :chip[A] Nevada :chip[B] West Virginia :chip[C] Kansas :chip[D] Nebraska
9. At which battle in May 1863 was General Thomas “Stonewall” Jackson mortally wounded?
- :chip[A] Chancellorsville :chip[B] Antietam :chip[C] Fredericksburg :chip[D] Shiloh
10. Which city saw four days of riots against the draft in July 1863?
- :chip[A] Boston :chip[B] Philadelphia :chip[C] New York :chip[D] Chicago

:::pagebreak

# Source-based questions {section="Section B" marks="15 marks"}

:::callout{type="source" title="Source A"}
Four score and seven years ago our fathers brought forth, on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.

Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived and so dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this.

But, in a larger sense, we can not dedicate—we can not consecrate—we can not hallow—this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before us—that from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotion—that we here highly resolve that these dead shall not have died in vain—that this nation, under God, shall have a new birth of freedom—and that government of the people, by the people, for the people, shall not perish from the earth.

:::paragraphs{style="signature"}
*Abraham Lincoln. November 19, 1863.*
:::
:::

:::paragraphs{style="credit"}
Lincoln’s address at the dedication of the Soldiers’ National Cemetery, Gettysburg, 19 November 1863; the text of the Bliss copy, the last of five in his hand and the only one he signed.
:::

11. Study Source A, then answer every part on the lines on page 4.
  1. Lincoln counts back “four score and seven years” in his first line.
    1. Which year is he counting back to? :chip[1 mark]{style="marks"}
    2. Explain why Lincoln dated the nation’s birth from that year, and not from 1787, when the Constitution was written. :chip[2 marks]{style="marks"}
  2. Lincoln says the war is “testing whether that nation … can long endure”. Describe two ways in which the Union was stronger in November 1863 than in January. :chip[4 marks]{style="marks"}
  3. “The address honours the dead but says little about the future of the nation.” How far do you agree? Explain your answer with Source A and your own knowledge. :chip[8 marks]{style="marks"}

:::pagebreak

Write your answers to Question 11 on these lines.

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

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

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

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

:::space

:::paragraphs{style="end"}
**END OF QUESTIONS**

Source A: the Gettysburg Address, Bliss copy, in the public domain. Questions written for this paper, CC BY 4.0. Set in PT Serif and 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 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

### Imprima em A4

A faixa, o ano, o fio da margem e o aviso tiram as medidas de `PAGE`, então passar para A4 é mudar uma linha. Os blocos da capa mantêm o lugar, e o branco sob o cartão cresce 17,6 mm nas duas edições.

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

### Acrescente um banco de palavras ou uma lista de verificação

Bancos de palavras, lacunas e círculos para colorir também são chips: [a folha de atividades com caixas de resposta e banco de palavras](https://postext.dev/pt/cookbook/worksheet-answer-boxes.md) compõe um banco de chips amarelos num boxe aninhado e uma lista de verificação que abre cada linha com um círculo vazio.

## Erros comuns

- **Listas usam 'arabic', recursos 'roman-upper', páginas 'upper-roman'.** Cada configuração de numeração escreve os formatos de um jeito: as listas usam numberFormat 'arabic' ('decimal' imprime “undefined”), os tipos de recurso usam counterFormat 'roman-upper', e as páginas e :::numbering usam 'upper-roman'.
- **Uma linha em branco dentro de uma célula de tabela desaparece.** Uma quebra de linha dentro de uma célula começa um parágrafo novo, mas o postext 1.4.1 descarta o parágrafo vazio ou que só contém espaços não separáveis (U+00A0), então uma célula escrita 'NOME\n\n' tem uma linha de altura. Para deixar linhas em que se possa escrever, coloque uma junção de palavras (word joiner, U+2060) em cada linha em branco: 'NOME\n\u2060\n\u2060' tem três linhas de altura.
- **Uma tabela 'here' nunca se divide.** Só as tabelas flutuantes se dividem entre colunas e páginas; uma tabela posicionada como 'here' se move inteira. Deixe as tabelas longas flutuarem ou mantenha curtas as tabelas em linha.
- **Texto em bandeira nunca é verificado contra linhas curtas.** optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta.
- **Um $ solto abre matemática: escreva \$.** O cifrão abre matemática em linha, então um preço como $40 inicia uma fórmula. Escreva \$40.
- **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.
- **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.
- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().
- **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.
- **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.

- Linhas de resposta entre os itens de uma questão partem a lista dela: a lista depois da tabela começa uma nova sequência, e cada sequência alinha os números à direita pelo seu próprio número mais largo, então o texto de ii) começaria 1 mm à direita do texto de i). Aqui as respostas ficam numa página própria, e todos os itens ficam numa só lista.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: The Gettysburg Address (19 November 1863), text of the Bliss copy: Abraham Lincoln ([fonte](https://en.wikisource.org/wiki/Gettysburg_Address_(Bliss_copy))), domínio público
- Texto: The questions, the instructions and the Spanish translation of Source A: Ignacio Ferro, CC-BY-4.0
- Tipos: PT Serif (OFL-1.1), Inter Tight (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 022 · Atividade com caixas de resposta e banco de palavras](https://postext.dev/pt/cookbook/worksheet-answer-boxes.md): Uma folha de ciências de quatro páginas: caixas de resposta brancas em cartões verde-claros, 2 mm abaixo de cada pergunta e fora da grade, e lacunas em chips. · Nível 2 (Intermediário) · Cadernos de exercícios
- [Nº 060 · Cartilha de alfabetização com sílabas em chips](https://postext.dev/pt/cookbook/reading-primer-syllables.md): Uma unidade de cartilha em espanhol para a letra M: sílabas em chips na cor da sua vogal e uma grade de palavras ilustradas, com uma aquarela em cada célula. · Nível 2 (Intermediário) · Cadernos de exercícios
- [Nº 033 · Ficha de receita: ingredientes ao lado do modo de preparo](https://postext.dev/pt/cookbook/recipe-card.md): Duas páginas de livro de receitas com uma ficha sob a aba SERVES 4: ingredientes, etiquetas e checklist à esquerda; passos com números vermelhos à direita. · Nível 2 (Intermediário) · Manuais, guias e obras de referência
