# Un compito d’esame con il foglio delle risposte

> Un esame di storia di quattro pagine su US Letter: bolle e punteggi come chip, punteggi su un tabulatore, domande numerate 1, a), i) e righe da una tabella.

- Versione HTML: https://postext.dev/it/cookbook/exam-paper
- Ricetta Nº 049 · Riquadri e note · Livello 2 (Intermedio) · Output: Canvas
- Generi: Quaderni ed eserciziari
- Richiede postext ≥ 1.25.0 · provata con 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/exam-paper/en/p01.webp?v=9ec357fb), [2](https://postext.dev/cookbook/exam-paper/en/p02.webp?v=9ec357fb), [3](https://postext.dev/cookbook/exam-paper/en/p03.webp?v=9ec357fb), [4](https://postext.dev/cookbook/exam-paper/en/p04.webp?v=9ec357fb)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=exam-paper&lang=en (.postext: https://postext.dev/cookbook/exam-paper/en/exam-paper.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [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), [de](https://postext.dev/de/cookbook/exam-paper.md), [fr](https://postext.dev/fr/cookbook/exam-paper.md), [pt](https://postext.dev/pt/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)

## In breve

Un esame di storia di quattro pagine con una copertina, bolle per le risposte e pagine a righe per scrivere. Mostra come numerare le domande e le loro parti e indicare il punteggio di ciascuna.

## Che cosa comporrai

La seconda prova di una simulazione d’esame di storia sulla guerra civile americana, composta come un fascicolo di quattro pagine su US Letter. La copertina è una fascia cremisi con l’anno 1863 disegnato in contorno, sopra le caselle del candidato, le istruzioni su due colonne e una griglia di bolle con le lettere per la sezione A. La pagina 2 contiene dieci domande a scelta multipla, ciascuna seguita dalle sue quattro opzioni, e ogni opzione si apre con una bolla con la lettera come quelle della griglia. La pagina 3 compone il discorso di Gettysburg come Source A e numera le domande che lo riguardano 11, a), i), con il punteggio di ogni parte in un chip al margine destro. La pagina 4 è rigata per le risposte, un filetto ogni due righe di testo, e ogni gruppo di filetti si apre con il numero della sua parte. Ogni pagina dopo la copertina porta un folio e un margine filettato per l’esaminatore, e l’unica pagina destra tra queste dice «Turn over».

**Questa ricetta risponde a:**

- Come impagino un compito d’esame con griglia di bolle, parti a) e i), punteggi e righe per le risposte?
- Come personalizzo gli elenchi: punti elenco per livello, numerazione (a)/(i), caselle di controllo, spaziature che restano sulla griglia?
- Come creo chip in linea: tasti della tastiera, etichette, banche di parole per esercizi?
- Come do stili diversi a più tabelle (riempimenti, celle a righe alterne, cornici arrotondate) nello stesso documento?
- Come nascondo le testatine nelle aperture e nelle pagine bianche, o coloro un verso bianco con il colore della parte?

## La risposta breve

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

## Ingredienti

**Insegna**

- [Elenchi numerati](https://postext.dev/it/docs/configuration-text.md#elenchi-numerati): Numerazione araba, romana o alfabetica per livello, separatori con stile e numeri dei passaggi sospesi.
- [Chip in linea](https://postext.dev/it/docs/configuration-styles.md#stili-dei-chip): Riquadri arrotondati intorno alle parole che vanno a capo come un’unica unità e non si allargano mai: tasti, etichette, banche di parole, sillabe.
- [Tabulazioni e riempitivi](https://postext.dev/it/docs/configuration-text.md#punti-di-tabulazione): tabStops in uno stile di paragrafo, in bodyText o nel corpo di un riquadro, con :tab nel testo, allinea le parole dopo ogni tabulazione a un arresto (inizio, fine, centro o virgola decimale), con un riempitivo facoltativo di puntini o un filetto davanti: menu, listini prezzi, elenchi di personaggi, punteggi d’esame e moduli composti come testo.

**Usa anche**

- [Stili di tabella con nome](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome)
- [Tabelle dai dati](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Riempimenti delle celle](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Stile delle didascalie](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie)
- [Interruzioni di pagina e di colonna](https://postext.dev/it/docs/document-format.md#pagebreak)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Geometria della sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Colonne in un riquadro o nel testo](https://postext.dev/it/docs/document-format.md#columns)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Spazio verticale esplicito](https://postext.dev/it/docs/document-format.md#space)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri), [`captionStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie), [`chipStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-chip), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headingStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`orderedLists`](https://postext.dev/it/docs/configuration-text.md#elenchi-numerati), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`tableStyles`](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome), [`unorderedLists`](https://postext.dev/it/docs/configuration-text.md#elenchi-puntati)

**API**

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

**Caratteri**

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

## Preparazione

### 1 · Numera le domande e appendi le opzioni

```js
// script.js, righe 51–63
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' and '.', the defaults
    { 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: '' }] });
```

Ogni livello di `orderedLists` sceglie il proprio `numberFormat` e il proprio `separator`, e `separatorColor` colora il punto e le parentesi nel colore d’accento mentre il numero resta in inchiostro ([elenchi numerati](https://postext.dev/it/docs/configuration-text.md#elenchi-numerati)). Con `numberWidth: 'level'` i numeri si allineano a destra sul più largo della loro profondità, così il testo di ogni domanda comincia a 23 pt: i 17 pt di «10.» più `GAP`. Le quattro opzioni sono una sola voce puntata con un `bulletChar` vuoto, alla stessa profondità delle domande invece che annidata sotto di esse. Il suo testo comincia all’`indent` dell’elenco, cioè «10.» misurato nel carattere dei numeri più `GAP`, così le opzioni si allineano sotto la prima lettera della domanda con qualsiasi font; il riquadro delle istruzioni imposta `indent: pt(0)`, perché gli elenchi in un riquadro prendono il rientro del documento. Tra due voci della stessa profondità lo spazio è l’`itemSpacing` della prima: 5 pt dopo una voce numerata, 15 pt dopo una riga di opzioni. Annidata sotto la sua domanda, la riga di opzioni prenderebbe i 5 pt dell’elenco delle domande da entrambi i lati.

### 2 · Ricava le bolle e i punteggi dai chip

```js
// script.js, righe 67–76
// 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 chip è un riquadro dentro la riga, e un a capo non cade mai al suo interno ([chip in linea](https://postext.dev/it/docs/document-format.md#chip-in-linea)). Con una maiuscola dentro, `paddingX: em(0.3)` rende il riquadro largo quasi quanto è alto: da 4,5 a 4,8 mm di larghezza nella griglia, per un’altezza di 4,7 mm. `borderRadius: em(1)` è più della metà del riquadro, e il raggio di un chip viene limitato a metà del suo lato minore, quindi le estremità sono del tutto rotonde. Un chip senza `style` prende la prima voce di `chipStyles`, quindi le ottanta bolle, quaranta nella griglia e quaranta tra le opzioni, sono chip semplici come `:chip[A]`; solo l’esempio pieno nelle istruzioni e i punteggi nominano uno stile. I punteggi stanno a filo a destra: ogni parte finisce con `:tab` prima del suo chip, e l’unico tabulatore del testo, alla fine della giustezza con `align: 'end'`, fa finire lì il chip ([tabulazioni e tabulatori](https://postext.dev/it/docs/document-format.md#tabulazioni-e-tabulatori)). Le voci di elenco delle domande prendono i tabulatori del testo, così i chip di a) e di i) finiscono nello stesso punto. Una parte la cui ultima riga non ha più spazio per il suo chip porta con sé l’ultima parola alla riga successiva.

### 3 · Dai a ogni modulo il suo stile di tabella

```js
// script.js, righe 80–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) },
  // No rules: the tint of each cell meets its neighbours' and the grid reads as one panel.
  { ...form, id: 'grid', rules: 'none', 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 no-break space.
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\u00A0\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
```

Le caselle del candidato, la griglia di bolle e le righe per le risposte sono tre `tableStyles`, scelti per ogni tabella con `styleId` ([stili di tabella con nome](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome)). Le righe per le risposte nella [risposta breve](https://postext.dev/it/cookbook/exam-paper.md#la-risposta-breve) usano `rules: 'horizontal'` e un padding che rende ogni riga della tabella alta due righe di testo, così ogni filetto cade 30 pt (10,6 mm) sotto il precedente, su una riga ogni due della griglia di 15 pt del testo. La riga centrale vuota della cella MARK contiene uno spazio indivisibile, perché una riga di cella vuota o con soli spazi normali non aggiunge nulla: quella cella è alta tre righe e porta l’intera riga della tabella a 14,4 mm, contro i 10,8 mm con due righe. La griglia non disegna filetti, così la tinta di ogni cella tocca quella della vicina e la griglia si legge come un unico pannello. Il suo titolo è una didascalia composta 1,6 mm sopra ([stile delle didascalie](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie)). Un titolo starebbe almeno una riga di testo (5,3 mm) più in alto, perché ogni tabella collocata «here» tiene sopra di sé quello spazio. Il tipo `form` ha un `captionPrefix` vuoto, quindi nessuna etichetta «Form 1.» precede il titolo.

### 4 · Disegna la copertina dagli attributi del titolo

```js
// script.js, righe 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' makes the cover an opener page, which the body pages' furniture skips.
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 copertina è un solo titolo, `# History {style="cover" …}`, e il suo stile disegna una fascia, l’anno e quattro righe di testo riempite dagli attributi del titolo. Con `span: 'page'` la pagina conta come apertura, quindi il margine filettato, il folio e «Turn over» delle pagine di testo restano fuori, e l’avvertenza si stampa al suo piede. Senza, la copertina è una pagina di testo come le altre. I `margins` dello stile allargano la colonna della copertina ai 168 mm pieni per i moduli, e il titolo successivo, un livello 1 senza stile, riporta le pagine di testo alla giustezza di 134 mm.

### 5 · Tieni folio e «Turn over» fuori dalla copertina

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

Ogni elemento corrente delle pagine di testo porta `pages: 'body'`, così la copertina, un’apertura, mostra solo la propria avvertenza (`pages: 'opener'`). Le sezioni cominciano quindi con `:::pagebreak` e un titolo senza `breakBefore`. Un titolo che interrompe la pagina fa della sua pagina un’apertura, e le pagine 2 e 3 perderebbero folio, testatina e margine. `parity: 'odd'` tiene «Turn over» sul recto, dato che un verso ha già di fronte la pagina che lo segue.

## La ricetta completa

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

- Cartella della ricetta: https://github.com/drnachio/postext/tree/main/cookbook/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.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, inlineSvgFonts,
} 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
// col(id): a colour linked to its palette entry, with the entry's hex beside the id.
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' and '.', the defaults
    { 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) },
  // No rules: the tint of each cell meets its neighbours' and the grid reads as one panel.
  { ...form, id: 'grid', rules: 'none', 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 no-break space.
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\u00A0\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' makes the cover an opener page, which the body pages' furniture skips.
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 = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // the language of the hyphenation patterns
  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, tabStops: [{ position: 'end', align: 'end' }] },
  // 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? :tab :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. :tab :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. :tab :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. :tab :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 year is SVG text in Inter Tight 800, stroked and not filled: loadSvg embeds the face the
// text names (inlineSvgFonts). The capitals are 0.7275 em tall, and the 1's flag starts 0.03 em in.
function year() { // 10 units to the millimetre
  const [w, h] = [PAGE.w * 10, (YEAR.cap - YEAR.cut + YEAR.pad) * 10];
  const size = (YEAR.cap * 10) / 0.7275; // font size: cap height to YEAR.cap
  const x = LEFT * 10 - 0.0303 * size; // line the 1's flag up with the text
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} `
    + `${h}"><text x="${x.toFixed(1)}" y="${(YEAR.cap + YEAR.pad) * 10}" font-family="${LABEL}" `
    + `font-weight="800" font-size="${size.toFixed(1)}" fill="none" stroke="${palette.paper}" `
    + 'stroke-width="8.5" stroke-linejoin="round">1863</text></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'],
  'Inter Tight': ['400', '600', '700', '800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)), loadSvg('year.svg', year())]);
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

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

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

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

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

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

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

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

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

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Varianti

### Stampalo su A4

La fascia, l’anno, il margine filettato e l’avvertenza prendono le loro misure da `PAGE`, quindi l’A4 è una modifica di una riga. I blocchi della copertina restano al loro posto, e il bianco sotto la griglia cresce di 17,6 mm in entrambe le edizioni.

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

### Aggiungi un elenco di parole o una lista di controllo

Elenchi di parole, spazi da completare e cerchi da colorare sono anch’essi chip: [la scheda con caselle per le risposte e un elenco di parole](https://postext.dev/it/cookbook/worksheet-answer-boxes.md) compone un elenco di chip gialli in un riquadro annidato e una lista di controllo che apre ogni riga con un cerchio vuoto.

## Errori frequenti

- **Un $ da solo apre la matematica: scrivi \$.** Il simbolo del dollaro apre la matematica in linea, quindi un prezzo come $40 fa cominciare una formula. Scrivi \$40.

- Righe per le risposte tra le parti di una domanda spezzano il suo elenco: l’elenco dopo la tabella comincia una nuova sequenza, e una sequenza allinea a destra i suoi numeri sul proprio più largo, quindi il testo di ii) comincerebbe 1 mm più a destra del testo di i). Qui le risposte stanno su una pagina propria, e ogni parte resta in un solo elenco.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: The Gettysburg Address (19 November 1863), text of the Bliss copy: Abraham Lincoln ([fonte](https://en.wikisource.org/wiki/Gettysburg_Address_(Bliss_copy))), pubblico dominio
- Testo: The questions, the instructions and the Spanish translation of Source A: Ignacio Ferro, CC-BY-4.0
- Caratteri: PT Serif (OFL-1.1), Inter Tight (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 022 · Scheda di esercizi con spazi di risposta e banca di parole](https://postext.dev/it/cookbook/worksheet-answer-boxes.md): Una scheda di scienze di quattro pagine: riquadri bianchi per le risposte in schede verdi, 2 mm sotto ogni domanda e fuori griglia, con banche di parole e chip. · Livello 2 (Intermedio) · Quaderni ed eserciziari
- [Nº 060 · Abbecedario con chip delle sillabe](https://postext.dev/it/cookbook/reading-primer-syllables.md): Un’unità di abbecedario sulla M: i suoni di ogni parola come chip nel colore della sua vocale, e una griglia di parole illustrate con un acquerello per cella. · Livello 2 (Intermedio) · Quaderni ed eserciziari
- [Nº 033 · Scheda di ricetta: ingredienti accanto al procedimento](https://postext.dev/it/cookbook/recipe-card.md): Due pagine di ricettario con una scheda bianca sotto una linguetta SERVES 4: ingredienti, tag e checklist a sinistra, passi con numeri rossi da 26 pt a destra. · Livello 2 (Intermedio) · Manuali, guide e opere di consultazione
