# Da una stringa Markdown a una pagina progettata

> Una stringa Markdown con frontmatter si compone in pagine di rivista a due colonne disegnate su canvas, con sei colori nominati al posto del blu predefinito.

- Versione HTML: https://postext.dev/it/cookbook/first-page-from-markdown
- Ricetta Nº 012 · Output e integrazione · Livello 2 (Intermedio) · Output: Canvas
- Generi: Riviste e fanzine
- Richiede postext ≥ 1.25.0 · provata con 1.33.7 il 2026-10-15
- Pagine: [1](https://postext.dev/cookbook/first-page-from-markdown/it/p01.webp?v=0e52c844), [2](https://postext.dev/cookbook/first-page-from-markdown/it/p02.webp?v=0e52c844)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=first-page-from-markdown&lang=it (.postext: https://postext.dev/cookbook/first-page-from-markdown/it/first-page-from-markdown.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [en](https://postext.dev/en/cookbook/first-page-from-markdown.md), [es](https://postext.dev/es/cookbook/first-page-from-markdown.md), [ca](https://postext.dev/ca/cookbook/first-page-from-markdown.md), [de](https://postext.dev/de/cookbook/first-page-from-markdown.md), [fr](https://postext.dev/fr/cookbook/first-page-from-markdown.md), [pt](https://postext.dev/pt/cookbook/first-page-from-markdown.md), [zh](https://postext.dev/zh/cookbook/first-page-from-markdown.md), [ja](https://postext.dev/ja/cookbook/first-page-from-markdown.md), [ar](https://postext.dev/ar/cookbook/first-page-from-markdown.md)

## In breve

Le prime due pagine di un reportage di viaggio in una piccola rivista, con una fascia colorata e un’immagine. Mostra come un testo semplice con pochi titoli diventa una pagina progettata nei tuoi colori.

## Che cosa comporrai

Le pagine d’apertura di un reportage di viaggio in *Quaderno di campo*, una piccola rivista. La pagina 1 è un’apertura su recto: una fotografia di saline e di una duna al crepuscolo sul 58% superiore della pagina, il titolo in Young Serif da 54 pt e, sotto, due colonne giustificate che avviano il testo con un attacco in grassetto. La pagina 2, al verso, prosegue sotto una testatina in maiuscole spaziate e finisce con un segno di fine e un colophon. Il design è dato da una sola funzione che costruisce la configurazione e da una palette di sei colori. Il titolo viene dal titolo di primo livello, e sommario, autore e data dal frontmatter della stringa Markdown, quindi lo stesso pen compone qualunque articolo il cui titolo stia in due righe; solo la fotografia appartiene a questo.

**Questa ricetta risponde a:**

- Come compongo un Markdown su pagine progettate, senza il blu predefinito in titoli, grassetti ed elenchi?
- Come imposto una pagina di libro: formato, margini interni ed esterni speculari, due colonne e un intercolonna?
- Come trasformo una stringa Markdown in pagine composte e ne disegno una su un canvas?

## La risposta breve

```js
// script.js, righe 125–162
const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr', de: 'de', it: 'it' }), // the hyphenation
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name: there is no fallback chain
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. Parity 'odd' puts the
      // next article pasted in on a recto, with a blank verso only when one is needed.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
```

## Ingredienti

**Insegna**

- [Metadati del documento](https://postext.dev/it/docs/document-format.md#frontmatter): Frontmatter YAML con titolo, sottotitolo, autore e data, che i segnaposto di testatine e copertine stampano e che il PDF prende come suo titolo.
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori): Colori con nome a cui ogni impostazione si collega per id, così un intero documento cambia tinta cambiando un solo campione; i colori predefiniti derivano da main-color.
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina): Spazi di intestazione e piè di pagina con segnaposto ({pageNumber}, {chapterTitle}, {title}…) impostati per recto o verso, con i titoli lunghi troncati o mandati a capo.

**Usa anche**

- [Pagine su un canvas](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Fascia di capitolo a tutta larghezza](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)
- [Ancoraggio degli elementi del progetto](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)
- [I font prima dell’impaginazione](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione)
- [Formato della pagina](https://postext.dev/it/docs/configuration-page-layout.md#formati-di-pagina-predefiniti)
- [Margini speculari](https://postext.dev/it/docs/configuration-page-layout.md#margini-speculari)
- [Una o due colonne](https://postext.dev/it/docs/configuration-page-layout.md#tipi-di-impaginazione)
- [Carattere del testo corrente](https://postext.dev/it/docs/configuration-text.md#testo-corrente)
- [Grassetto, corsivo e i loro colori](https://postext.dev/it/docs/configuration-text.md#testo-corrente)
- [Campioni di colore](https://postext.dev/it/docs/document-format.md#formattazione-in-linea)
- [Sillabazione e lingua del documento](https://postext.dev/it/docs/justification.md#lingue-supportate)
- [Elenchi puntati e liste di controllo](https://postext.dev/it/docs/configuration-text.md#elenchi-puntati)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [A capo nei titoli](https://postext.dev/it/docs/document-format.md#a-capo-nei-titoli)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)

**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), [`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), [`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), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`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), [`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**

- Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)

## Preparazione

### 1 · Ogni carattere, prima della prima composizione

```js
// script.js, righe 222–226
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
```

Postext misura il testo con i caratteri che il browser ha quando gira l’impaginazione, quindi una composizione eseguita prima dell’arrivo dei font manterrebbe gli a capo di un carattere sostitutivo. `buildDocumentWithFonts` carica ogni carattere che la configurazione nomina, e quelli in `FONTS`, prima della prima composizione; `kitFonts(FONTS)` gli fornisce i file di Fontsource, gli stessi che un PDF incorporerebbe. Poi esamina i caratteri che le pagine usano, carica quelli ancora mancanti e compone di nuovo. Un carattere che Fontsource non distribuisce resta sostituito ed è elencato in `doc.contentWarnings` come `fontFallback`. Il grassetto e il corsivo di un carattere del testo si caricano solo se la famiglia li ha.

### 2 · Dai un nome ai colori, poi riferisciti ai nomi

```js
// script.js, righe 23–41
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex. The design objects below are functions
// that config() calls, so col() copies the hex out of `palette` on every build: one edit
// to `palette` reaches the text, the folios and the drop folio alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, bold, italic, bullets, the default running head) link
  // to 'main-color': point it at the accent, so nothing left unset prints blue.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
```

Ogni colore della configurazione è `col('id')`, un id della palette con il suo esadecimale accanto. `bodyText.boldColor`, `italicColor`, `referenceColor` e `unorderedLists.color` nominano `ink`, e `headings.color` nomina l’accento. I valori predefiniti del motore per titoli, grassetto, corsivo e punti elenco sono collegati a `main-color`, e questa palette punta quella voce all’accento, quindi qualunque di essi tu ometta esce indaco invece che blu. Gli oggetti di design sono funzioni che `config()` chiama a ogni composizione, e `col()` copia ogni volta l’esadecimale corrente da `palette`, così una sola modifica a `palette` arriva al testo, ai folio e all’apertura. Il segno di fine è un campione di colore in linea che nomina `band`, quindi anche lui segue un cambio di tinta. Anche la testatina predefinita è collegata a `main-color`, quindi si stamperebbe nell’accento finché non la sostituisci (passo 5).

### 3 · Una fotografia tagliata sulla sua cornice

```js
// script.js, righe 45–51
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
```

Le saline, la duna e l’ometto di pietre sono una fotografia generata con modelli di diffusione, ripresa al crepuscolo perché il titolo chiaro si legga contro un cielo scuro. Il JPEG è tagliato sulla cornice della fascia, `PAGE.width` × `BAND` millimetri, e la risorsa dichiara i suoi pixel, 1440 × 1120, così a tutta larghezza l’immagine riempie esattamente la fascia. Una fascia più alta richiede un’immagine tagliata sulla nuova cornice. L’apertura la mostra tramite un elemento immagine che nomina la risorsa per id.

### 4 · Il frontmatter scrive l’apertura

```js
// script.js, righe 55–91
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo',
  fr: 'Carnet de terrain', de: 'Unterwegs', it: 'Quaderno di campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A bare
      // lineHeight multiplies the text's size: 1 sets the title solid. Both texts wrap.
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}',
          fr: 'Par {author} · {publishDate}', de: 'Von {author} · {publishDate}', it: 'Di {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
```

Il titolo di primo livello si estende su entrambe le colonne, e la sua apertura è uno spazio di elementi: la fotografia fino all’abbondanza e quattro testi concatenati uno sotto l’altro. Un titolo più lungo spinge in basso il sommario e la firma invece di sovrapporli, ma oltre le due righe la firma finisce sulle saline chiare. `{titleText}` è il testo del titolo; `{subtitle}`, `{author}` e `{publishDate}` sono campi del frontmatter. Il `\\` nel titolo lo spezza dove lo metti ([line breaks in titles](https://postext.dev/it/docs/document-format.md#a-capo-nei-titoli)). `minHeight` conta dal margine superiore, quindi l’altezza riservata finisce 5 mm sotto la fascia.

### 5 · Testatine al posto del blu predefinito

```js
// script.js, righe 95–114
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
```

Se ometti `header` torna quella predefinita del motore: Open Sans sopra un filetto a tutta larghezza, su ogni pagina. Qui ogni folio è ancorato alla pagina fisica e filtrato per parità, quindi sta sempre sul bordo esterno. La sua etichetta è ancorata al folio, a `GAP` millimetri con `right-of` o `left-of`, e un folio di tre o quattro cifre spinge l’etichetta invece di finirle addosso. `pages: 'body'` tiene tutti e quattro gli elementi lontani dall’apertura. Il piè di pagina contiene un solo folio in basso per l’apertura, e poiché `footer` è impostato, le pagine di testo perdono il folio centrato predefinito.

### 6 · Componi con una configurazione nuova, poi disegna

```js
// script.js, righe 231–241
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada',
    pt: 'De uma string Markdown a uma página diagramada',
    fr: 'D’une chaîne Markdown à une page mise en forme',
    de: 'Von einem Markdown-String zur gestalteten Seite',
    it: 'Da una stringa Markdown a una pagina progettata' }),
});
```

`config()` è una funzione perché ogni composizione legga la palette com’è in quel momento: richiama le funzioni di design del passo 2. Funzionerebbe anche un solo oggetto di configurazione modificato sul posto: da postext 1.25 il motore vede la modifica alla composizione successiva. `buildDocumentWithFonts` prende il contenuto, quella configurazione e le opzioni di font del kit, e restituisce il documento. `showPages` dispone le pagine come stanno in un libro rilegato, la pagina 1 da sola a destra, e disegna ognuna con `renderPageToCanvas`.

## 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/first-page-from-markdown

### script.js

```js
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════
// https://postext.dev/en/cookbook/first-page-from-markdown
// Code: MIT · Text: original (CC BY 4.0) · Photo: diffusion models
// Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.25.0
//
// This pen sets a Markdown string with a frontmatter block on two magazine pages and paints
// them on canvases. The whole design is in config(); swap config() for {} in the build
// below to see the engine's defaults.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'it'; // @lang: the sample document's language ('en' | 'es' | 'pt' | 'fr' | 'de' | 'it')
const RECIPE = 'first-page-from-markdown';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every setting that shapes the pages is in this part, from PAGE down to config().
const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side
const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on

// #region palette: six named colours; every colour in the config links to one of them
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex. The design objects below are functions
// that config() calls, so col() copies the hex out of `palette` on every build: one edit
// to `palette` reaches the text, the folios and the drop folio alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, bold, italic, bullets, the default running head) link
  // to 'main-color': point it at the accent, so nothing left unset prints blue.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
// #endregion

// #region art: a photograph of the salt pans and the dunes at Sorra, cut for a page × band frame
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
// #endregion

// #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo',
  fr: 'Carnet de terrain', de: 'Unterwegs', it: 'Quaderno di campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A bare
      // lineHeight multiplies the text's size: 1 sets the title solid. Both texts wrap.
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}',
          fr: 'Par {author} · {publishDate}', de: 'Von {author} · {publishDate}', it: 'Di {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
// #endregion

// #region running-heads: folio and title on body pages, a drop folio under the opener
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
// #endregion

// The colophon: small sans under a 0.5 pt hairline, with no box around it.
const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
  padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } });

// #region answer: one config factory in place of the default skin: page, type, colour, slots
const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr', de: 'de', it: 'it' }), // the hyphenation
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name: there is no fallback chain
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. Parity 'odd' puts the
      // next article pasted in on a recto, with a blank verso only when one is needed.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// content.<lang>.md, inlined by the Cookbook. Its frontmatter feeds the opener and the
// running heads. The end mark is an inline swatch that names `band`, so it follows the
// palette too.
const markdown = String.raw`---
title: "La via del sale"
subtitle: "Quattro giorni a piedi sul sentiero dei portatori, dalle saline di Arvela al mercato di Castrel."
author: "Lena Varga"
publishDate: "maggio 2026"
---

# La via \\ del sale

**I portatori partivano dalla diga di Arvela.** Con la bassa marea la piana si stende ai suoi piedi per tre chilometri, una distesa grigia solcata dai canali, e dalla diga si vedono le saline prima del mare: quaranta vasche basse di salamoia, disposte come le caselle di una scacchiera, ognuna di un bianco un po’ più acceso della precedente.

Per sei secoli il sale di Arvela ha lasciato la costa sulle spalle di uomini e donne. Si caricava all’alba, trenta chili per gerla di vimini, e si camminava verso l’interno su un sentiero che nessuno ha mai lastricato: attraverso la piana, sopra le dune di Sorra, dentro le pinete e su fino alla vecchia città di mercato di Castrel, a quattro giorni di cammino. La ferrovia ha messo fine al mestiere nel 1911, ma da allora gli escursionisti tengono aperto il sentiero.

L’ho percorso ad aprile con Tomás Reis, la cui nonna da bambina portava il sale, e che ha passato venti estati a segnare il tracciato con ometti di pietre bianche. «Cerca il *sale* per terra», mi ha detto la prima mattina. «Dove è caduto, non cresce niente.»

Il secondo giorno, tra le dune, il sentiero sparisce per ore; poi nella sabbia si vede una vena chiara, larga pochi centimetri, come tracciata col gesso. È il sale caduto dalle gerle in sei secoli, una crosta che il vento scopre a ogni primavera. Tomás le cammina accanto e non la calpesta mai.

Quella notte abbiamo dormito in un avvallamento fra le dune che i portatori chiamavano la Cucina, perché il vento non ci entra mai. Tomás non ha acceso il fuoco. Ha mangiato pane e pesce secco parlando della nonna, che assaggiava un solo granello e sapeva dire se veniva da Arvela o dalle saline del sud, e che non si è mai persa nella nebbia, dice lui, nemmeno una volta.

## Il carico del ritorno

Le gerle tornavano piene verso la costa. Un registro di portatore del 1887, conservato nell’archivio parrocchiale di Sorra, elenca i carichi di ritorno della sua prima primavera:

- **Grano**, soprattutto segale e orzo, dalle fattorie d’altura sopra Castrel.
- **Resina di pino** e catrame, per calafatare ogni inverno le barche da pesca di Arvela.
- **Lana e pelli** dalla fiera di maggio di Castrel.
- **Chiodi e ami** dalla fucina di Orsa.

Finché non arrivò il treno, una gerla di sale valeva una gerla di grano. Un buon portatore faceva undici viaggi di andata e ritorno all’anno, ottantotto giorni sul sentiero, e tra una partenza e l’altra rastrellava il sale nelle vasche.

Il registro è scritto da tre mani. La prima, una calligrafia inglese accurata, annota ogni carico al mezzo chilo; la seconda, dal 1893, scrive soltanto numeri; la terza, una mano di bambino, compare in certi inverni e scrive il nome della città con due elle: Castrell.

L’ultima annotazione del registro, a metà di una pagina dell’autunno 1910, è un carico d’orzo mai registrato come consegnato, e a Sorra nessuno sa perché. Tomás crede che il portatore sia andato a posare binari per la ferrovia, che pagava in contanti.

## Tra i pini

Il terzo giorno è il più lungo. Dopo Orsa il sentiero sale per otto ore in pinete che sanno di resina, oltre le case di sosta in rovina dove i portatori dormivano sulla paglia e pagavano il letto in sale. Ogni casa ha un ometto accanto alla porta, coronato da una pietra bianca che vi ha messo Tomás.

La meglio tenuta è la casa dei Nove Pozzi, un’ora sotto il passo. Il suo architrave è inciso da file di tacche corte, una per ogni notte che un portatore vi ha dormito, e in certi punti sono così fitte da essersi fuse in un solo solco. Tomás ha provato a contarle un inverno e si è arreso a quattromila.

Il quarto pomeriggio il sentiero esce dai pini su un crinale spoglio, e in basso appare Castrel: una città grigia costruita intorno a una piazza che si chiama ancora piazza del Sale, anche se lì il sale non lo vende più nessuno da cent’anni. Come i portatori, Tomás lascia una manciata di sale sul bordo di pietra della fontana prima di bere.

Poi si volta e riprende la strada verso il mare. I portatori non dormivano mai a Castrel, dice, perché le locande si pagavano in contanti e un portatore era pagato in grano. :swatch{color="band"}

:::callout{type="colophon"}
Opera di fantasia: Arvela, Sorra, Orsa e Castrel sono luoghi immaginari. Composto in Newsreader, Young Serif e Inter Tight (SIL Open Font License) · Testo: originale, CC BY 4.0 · Foto: modelli di diffusione.
:::
`;
// The opener's image element points at the photograph by id.
const resources = [landscape];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// #region fonts: every face the design uses, loaded before the first build
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the photograph, one buildDocumentWithFonts call with a fresh config, then paint
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada',
    pt: 'De uma string Markdown a uma página diagramada',
    fr: 'D’une chaîne Markdown à une page mise en forme',
    de: 'Von einem Markdown-String zur gestalteten Seite',
    it: 'Da una stringa Markdown a una pagina progettata' }),
});
// #endregion

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

### Guarda che cosa sostituisce la configurazione

Componi con una configurazione vuota e lo stesso Markdown esce su una pagina di 17 × 24 cm in EB Garamond da 8 pt, con titoli in Open Sans blu e una testatina blu sopra un filetto; il motore carica anche quelle due famiglie, perché la configurazione predefinita le nomina.

```diff
-const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
+const doc = await buildDocumentWithFonts({ markdown, resources }, {}, kitFonts(FONTS));
```

### Cambia la tinta dell’intera pagina

Cambia l’accento e l’esecuzione successiva disegna folio, titoletti e segno di fine nel nuovo colore, perché `col()` copia il nuovo esadecimale accanto all’id ogni volta che gira `config()`. La fotografia mantiene i suoi colori.

```diff
-  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
+  band: '#3f5b3a', // the one accent (a pine green): the folios, the subheads, the end mark
```

### Disegna una pagina sul tuo canvas

`renderPageToCanvas` dimensiona il canvas sulla pagina in pixel ai dpi del documento (300 per impostazione predefinita) per `scale`, quindi `scale: 0.5` disegna a circa 150 dpi, e il CSS decide quanto appare grande a schermo.

```diff
-showPages(doc, {
-  title: t({ en: 'From a Markdown string to a designed page',
-    es: 'De una cadena Markdown a una página diseñada',
-    pt: 'De uma string Markdown a uma página diagramada',
-    fr: 'D’une chaîne Markdown à une page mise en forme',
-    it: 'Da una stringa Markdown a una pagina progettata' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });
```

## Errori frequenti

- Young Serif ha un solo peso. I titoli sono in grassetto per impostazione predefinita, quindi qui serve `headings.fontWeight: 400`: senza, i titoletti chiedono uno Young Serif in grassetto che Fontsource non ha, il browser ispessisce il regular e `doc.contentWarnings` elenca il carattere come `fontFallback`.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Immagini: Le saline e la duna al tramonto dell’apertura, una fotografia: Generated With Diffusion Models, originale
- Caratteri: Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 004 · Servizio di rivista: dall’apertura con foto al segno finale](https://postext.dev/it/cookbook/magazine-feature-opener.md): Un’apertura advancedDesign compone una foto al vivo con occhiello, titolo, sommario e firma del titolo; poi due riquadri flottanti e un segno finale in un chip. · Livello 3 (Avanzato) · Riviste e fanzine
- [Nº 005 · Testatine per parità in un libro di saggi](https://postext.dev/it/cookbook/running-heads-by-parity.md): Intestazioni per parità e ruolo di pagina: titolo del libro sui versi, del saggio troncato sui recti, folio in linguetta a margine, al piede in apertura. · Livello 2 (Intermedio) · Narrativa, teatro e prosa letteraria
- [Nº 003 · Apertura di capitolo su una fascia al vivo](https://postext.dev/it/cookbook/chapter-opener-bleed-band.md): Un’apertura advancedDesign sul titolo di livello 1: una fascia al vivo, il numero del capitolo sul suo piede, occhiello e sommario dagli attributi del titolo. · Livello 3 (Avanzato) · Libri di testo
