# Schede di catalogo di fronte alle loro tavole

> Ogni scheda apre un verso e cita la sua tavola nella prima frase del commento; la tavola flotta sul recto di fronte, scalata fino a riempire la gabbia.

- Versione HTML: https://postext.dev/it/cookbook/catalogue-facing-plates
- Ricetta Nº 034 · Figure e immagini · Livello 3 (Avanzato) · Output: Canvas
- Generi: Cataloghi
- Richiede postext ≥ 1.25.0 · provata con 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/catalogue-facing-plates/en/p01.webp?v=ad2b947c), [2](https://postext.dev/cookbook/catalogue-facing-plates/en/p02.webp?v=ad2b947c), [3](https://postext.dev/cookbook/catalogue-facing-plates/en/p03.webp?v=ad2b947c), [4](https://postext.dev/cookbook/catalogue-facing-plates/en/p04.webp?v=ad2b947c), [5](https://postext.dev/cookbook/catalogue-facing-plates/en/p05.webp?v=ad2b947c), [6](https://postext.dev/cookbook/catalogue-facing-plates/en/p06.webp?v=ad2b947c), [7](https://postext.dev/cookbook/catalogue-facing-plates/en/p07.webp?v=ad2b947c), [8](https://postext.dev/cookbook/catalogue-facing-plates/en/p08.webp?v=ad2b947c)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=catalogue-facing-plates&lang=en (.postext: https://postext.dev/cookbook/catalogue-facing-plates/en/catalogue-facing-plates.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [en](https://postext.dev/en/cookbook/catalogue-facing-plates.md), [es](https://postext.dev/es/cookbook/catalogue-facing-plates.md), [ca](https://postext.dev/ca/cookbook/catalogue-facing-plates.md), [de](https://postext.dev/de/cookbook/catalogue-facing-plates.md), [fr](https://postext.dev/fr/cookbook/catalogue-facing-plates.md), [pt](https://postext.dev/pt/cookbook/catalogue-facing-plates.md), [zh](https://postext.dev/zh/cookbook/catalogue-facing-plates.md), [ja](https://postext.dev/ja/cookbook/catalogue-facing-plates.md), [ar](https://postext.dev/ar/cookbook/catalogue-facing-plates.md)

## In breve

Un piccolo catalogo di mostra con tre antiche illustrazioni del Don Chisciotte. Ogni descrizione sta su una pagina sinistra, con la sua immagine che riempie la pagina destra di fronte.

## Che cosa comporrai

Un piccolo catalogo per una mostra di gabinetto con tre xilografie del *Don Quixote* di Gustave Doré (Parigi, 1863), su una pagina di 230 × 280 mm. La copertina compone DORÉ in Libre Bodoni da 88 pt su un nero caldo, sotto un dettaglio incorniciato della tavola dei mulini. Ogni scheda apre una pagina sinistra con un numero di catalogo seppia accanto a un titolo in corsivo, la scheda tecnica nella colonna esterna, un attacco con un capolettera su tre righe e il commento. La prima frase del commento cita la tavola, che apre la pagina destra di fronte, centrata e scalata finché tavola, didascalia e riga di crediti riempiono la gabbia, alta 215 mm. L’ultima pagina è un elenco delle opere con una miniatura nella prima cella di ogni riga e la pagina su cui si stampa ogni tavola.

**Questa ricetta risponde a:**

- Come compongo ogni scheda di catalogo su una pagina sinistra con la sua tavola di fronte, a destra?
- Come decido dove va una figura: in testa alla pagina, su entrambe le colonne, esattamente qui o nel margine?
- Come metto immagini o icone dentro le celle di una tabella?
- Come aggiungo a un’apertura una riga d’autore, un sommario o un attacco con capolettera?

## La risposta breve

```js
// script.js, righe 38–60
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
  level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
  breakBefore: { enabled: true, parity: 'even' }, // an entry opens a verso, facing its plate
  marginBottom: pt(LEAD), advancedDesign: entryOpener(),
  dropCap: { lines: 3, fontFamily: DISPLAY, color: col('sepia'), gap: mm(1.6) }, // the lead's
});
const plateType = {
  id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  // A plate taller than the recto's text block is scaled, proportions kept, until plate, caption
  // and credit line fit it; the caption takes the plate's width, and both are centred.
  defaultPlacement: { position: 'top', span: 'page', align: 'center',
    shrink: 'page', minScale: 0.5, captionMeasure: 'body' },
};
// Under the plate (pt): a caption and a credit line, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
  id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
  // The print master's pixels, about 283 dpi at this size (gotcha: bitmap-print-size).
  bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH }, createdAt: 0, updatedAt: 0,
});
```

## Ingredienti

**Insegna**

- [Collocazione delle figure](https://postext.dev/it/docs/document-format.md#collocazione): In testa, al piede, automatica o qui, in una colonna o a tutta larghezza, a una frazione della larghezza, per risorsa o per tipo; gli elementi flottanti di una stessa sequenza non si sorpassano mai.
- [Capitoli che si aprono su un recto](https://postext.dev/it/docs/configuration-text.md#interruzione-prima-del-titolo): Un livello di titolo comincia una nuova pagina: la successiva, il recto successivo o il verso successivo, aggiungendo una pagina bianca se serve.

**Usa anche**

- [Citazioni che collocano le figure](https://postext.dev/it/docs/document-format.md#rimando-in-linea-la-forma-principale)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Didascalie numerate](https://postext.dev/it/docs/document-format.md#numerazione-al-primo-rimando)
- [Stile delle didascalie](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie)
- [Righe di fonte e crediti](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Immagini nelle celle delle tabelle](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [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)
- [Capilettera nei paragrafi del testo corrente](https://postext.dev/it/docs/configuration-text.md#capilettera)
- [Testo, filetti e riquadri nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Margini speculari](https://postext.dev/it/docs/configuration-page-layout.md#margini-speculari)
- [Pagine su un canvas](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Colonna e mezza](https://postext.dev/it/docs/configuration-page-layout.md#tipi-di-impaginazione)
- [Titoli numerati](https://postext.dev/it/docs/configuration-text.md#sovrascritture-per-livello)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Colore della carta](https://postext.dev/it/docs/configuration-page-layout.md#pagina)
- [Testatine per sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Colonna a margine per gli elementi flottanti](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione)
- [A capo nei titoli](https://postext.dev/it/docs/document-format.md#a-capo-nei-titoli)
- [Capitoli non numerati](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [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), [`captionStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie), [`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), [`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), [`tableStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`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**

- Ibarra Real Nova (OFL-1.1), Libre Bodoni (OFL-1.1), Sofia Sans Condensed (OFL-1.1)

## Preparazione

### 1 · La tavola aspetta la pagina di fronte

Il codice è nella [risposta breve](https://postext.dev/it/cookbook/catalogue-facing-plates.md#la-risposta-breve) qui sopra. Ogni scheda va a capo su una pagina pari, e un elemento flottante `top` non cade mai sulla pagina che lo cita, quindi la tavola citata nella prima frase del commento salta il verso e apre il recto che ha di fronte. Il `defaultPlacement` del tipo tavola chiede `shrink: 'page'`: a tutta giustezza una tavola sarebbe alta 242 mm, più di quanto contenga la gabbia del recto, quindi Postext la scala, mantenendone le proporzioni, finché tavola, didascalia e riga di crediti stanno nella fascia lasciata sopra lo spazio dell’elemento flottante ([posizionamento](https://postext.dev/it/docs/document-format.md#collocazione)). `minScale: 0.5` permetterebbe a una tavola di scendere a metà della sua larghezza; qui ognuna ne conserva circa nove decimi. `captionMeasure: 'body'` compone la didascalia e la riga di crediti alla larghezza della tavola, e `align: 'center'` le centra entrambe sulla pagina.

### 2 · La testa della scheda viene dal suo titolo

```js
// script.js, righe 81–113
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt; a multiple of the size
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
// mm: the lead, the entry's first paragraph, 5 mm under the hairline on the next grid line;
// the opener ends a line above it (the level's marginBottom), and the tombstone faces it.
const LEAD_Y = Math.ceil((HEAD_RULE + 5) / PT / LEAD) * LEAD * PT;
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const BASE = 0.8; // a line's first baseline stands 0.8 down its line box
// em: Libre Bodoni's capitals, and its figures, which stop short of them ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [0.754, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
  - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT)) * PT;
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
  text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
  text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
    at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
  text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
    TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
  { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y - LEAD * PT),
  slot: { elements: [...head('Cat.', '{number}'),
  text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
    at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
  // Baseline on the lead's; the attribute's \n breaks give its three lines.
  text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
    at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
    { lineHeight: TOMB.lead / TOMB.size, reserve: false }), // beside the lead: no room of its own
] } });
```

Ogni titolo `#` porta il suo capitolo e la scheda tecnica come attributi, e lo slot grafico dell’apertura li disegna. Il numero grande è il `{number}` del titolo stesso, da `numberingTemplate: '{1}'`; con una tavola per scheda procede 1, 2, 3 al passo del contatore `Cat.` delle tavole. L’attacco è il primo paragrafo della scheda, in un gruppo `:::paragraphs{style="lead"}` a 13,5 pt, e il `dropCap` del livello lo apre con un’iniziale seppia su tre righe la cui sommità tocca le maiuscole della prima riga e il cui piede poggia sulla terza linea di base, con entrambe le altezze delle maiuscole misurate dai caratteri ([capilettera](https://postext.dev/it/docs/configuration-text.md#capilettera)). L’attacco è composto in bandiera: giustificato a questo corpo, le sue poche parole per riga si aprirebbero oltre l’1,5× del commento. `minHeight` chiude l’apertura una riga sopra `LEAD_Y`, la riga di griglia dove comincia l’attacco; la scheda tecnica sta di fronte alla prima riga dell’attacco senza occupare spazio proprio (`reserve: false`), e un `:::space` sotto l’attacco fa partire il commento a filo una riga più in basso. `TITLE_Y` compone il titolo 1,2 mm più in basso del numerale, il che allinea le sue maiuscole alle cifre del numerale: in Libre Bodoni le cifre sono alte 0,716 em e le maiuscole 0,754 em.

### 3 · Dichiara il master, disegna una copia più piccola

```js
// script.js, righe 316–333
// plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX);
// the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box.
async function scan(file) {
  const res = await fetch(asset(file));
  if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`);
  return createImageBitmap(await res.blob());
}
async function loadPlates() {
  const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan));
  const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail),
    'Detail of Cat. 3: the knight and his horse caught on the sail.')];
  for (const [i, p] of PLATES.entries()) {
    registerResourceImage(p.file, onPaper(scans[i]));
    resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size
      picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText));
  }
  return resources;
}
```

Postext dimensiona una bitmap dai pixel che dichiara, ai dpi del documento, e non la stampa mai più larga di quanto quei pixel consentano. `plate()` dichiara il master di ogni tavola, la scansione di Commons ridotta a 2.400 px di altezza: 1.900 × 2.400 px per Cat. 1, che potrebbe arrivare a 322 mm con `page.dpi: 150`. È quindi la gabbia a fissare la misura, 215 mm di altezza, e a quell’altezza 2.400 px fanno circa 283 dpi. Ai 300 dpi predefiniti la tavola si fermerebbe a 161 mm di larghezza. Il canvas disegna in quel riquadro qualunque immagine sia registrata, quindi il pen scarica le tre copie da 835 px in `assets/`, 0,97 MB contro i 5,8 MB dei master. Disegnati invece dai master da 2.400 px, i cieli si riempiono di bande di moiré e le immagini della cattura crescono di circa 0,3 MB, oltre il limite di 1,4 MB del Ricettario.

### 4 · Una copertina da uno stile di titolo

```js
// script.js, righe 117–141
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more) => text(id, content, family,
  size, color, at('page', 'top', 0, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
  id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
    { kind: 'box', id: 'night', style: { backgroundColor: col('night') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
    { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
      placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
        size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
    { kind: 'image', id: 'detail', resourceId: 'cover-detail',
      placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
    centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
    // Tracked capitals centre on their letters: the tracking after the É is left out.
    centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
      textTransform: 'uppercase', letterSpacing: pt(NAME.track) }),
    centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
      { italic: true }),
    centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
  ] } },
});
```

La copertina è il primo titolo, `# Doré {style="cover" …}`, disegnato da uno stile senza folio. Il suo elemento immagine disegna `windmills-detail-540.jpg`, registrato come risorsa che nessun paragrafo cita, quindi non riceve né numero né didascalia. Il file è un quadrato di 1.100 px ritagliato dalla scansione dei mulini e ridotto a 540 px, la sua larghezza su una pagina di 1.000 px. Il nome ha una spaziatura tra lettere di 8 pt, e la spaziatura dopo l’ultima lettera resta fuori dalla centratura, così le quattro maiuscole stanno sull’asse centrale della pagina senza scarto.

### 5 · Miniature nelle celle dell’elenco

```js
// script.js, righe 145–167
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false, dropCap: false, // no initial
  advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
  id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
  altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
  table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
    ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
      cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
    ...plates.map((p, i) => [
      { content: '', image: { resourceId: `${p.id}-thumb` } },
      cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
      cell(String(platePage(i + 1)), { align: 'right' }),
    ]),
  ] } },
  createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
```

L’`image` di una cella adatta una risorsa bitmap alla larghezza interna della cella, qui 23 mm. Ogni miniatura è una copia propria da 300 px, dichiarata a quella misura, invece della tavola da 835 px ridotta al momento del disegno. La tabella non ha testo di didascalia e il suo tipo ha un prefisso di didascalia vuoto, quindi non si disegna alcuna didascalia. Lo stile dell’elenco imposta solo `numbered: false` e la sua grafica, e prende dal livello 1 l’estensione a tutta pagina, l’interruzione su un verso e il margine inferiore.

### 6 · Fermati quando una scheda supera la sua pagina

```js
// script.js, righe 349–354
const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length);
if (astray) { // text run past its verso, or the blank page that follows it
  const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`);
  kitFail(error); // the viewer's bar says why
  throw error;
}
```

Una scheda che va oltre il suo verso manda comunque la sua tavola alla pagina successiva. Il resto del commento segue allora la tavola, e una pagina bianca tiene la scheda seguente su un verso, così ogni tavola successiva si stampa due pagine dopo la pagina che l’elenco le attribuisce. Il controllo ferma la composizione alla prima pagina che non è un’apertura e non contiene una tavola, e mostra il numero di quella pagina nella barra del visualizzatore.

## 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/catalogue-facing-plates

### script.js

```js
// ═══ Postext Cookbook · Nº 034 · Catalogue entries facing their plates ══════════════
// https://postext.dev/en/cookbook/catalogue-facing-plates
// Code: MIT · Text: original (CC BY 4.0), Ormsby 1885 (PD) · Plates: Doré and Pisan, 1863 (PD)
// Fonts: Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'catalogue-facing-plates';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1b1918', paper: '#faf7f1', night: '#2a2724', // text; the page and plates; the cover
  sepia: '#8a6a45', gilt: '#c9ad86', // the accent on paper (4.6:1) and on the night (6.9:1)
  rule: '#cfc6b8', muted: '#6c665e', // hairlines; tombstones, credit lines, folios
};
// 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 });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Ibarra Real Nova', 'Libre Bodoni', 'Sofia Sans Condensed'];
const PT = 25.4 / 72; // mm per point
const [SIZE, LEAD, LINES] = [11.5, 16, 41]; // body pt, leading pt; 41 lines make the text block
const TRIM = { w: 230, h: 280 }; // mm: a catalogue trim
const [TOP, INNER, OUTER] = [24, 20, 18]; // margins, mm
const BLOCK_W = TRIM.w - INNER - OUTER; // 192 mm: the text block, and the widest plate
const BLOCK_H = LINES * LEAD * PT; // 231.4 mm
const [SIDE_PC, GUTTER] = [34, 7]; // the outer column: 65.3 mm for numeral and tombstone; mm
const SIDE = (BLOCK_W * SIDE_PC) / 100;
const MAIN_X = SIDE + GUTTER; // mm from the block's outer edge to the text column
const MAIN = BLOCK_W - MAIN_X; // 119.7 mm: the measure, about 71 characters

// #region answer: a Plate type that floats to the head of the facing recto, sized to fill it
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
  level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
  breakBefore: { enabled: true, parity: 'even' }, // an entry opens a verso, facing its plate
  marginBottom: pt(LEAD), advancedDesign: entryOpener(),
  dropCap: { lines: 3, fontFamily: DISPLAY, color: col('sepia'), gap: mm(1.6) }, // the lead's
});
const plateType = {
  id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  // A plate taller than the recto's text block is scaled, proportions kept, until plate, caption
  // and credit line fit it; the caption takes the plate's width, and both are centred.
  defaultPlacement: { position: 'top', span: 'page', align: 'center',
    shrink: 'page', minScale: 0.5, captionMeasure: 'body' },
};
// Under the plate (pt): a caption and a credit line, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
  id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
  // The print master's pixels, about 283 dpi at this size (gotcha: bitmap-print-size).
  bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH }, createdAt: 0, updatedAt: 0,
});
// #endregion
const MASTER_PX = { library: [1900, 2400], vigil: [1921, 2400], windmills: [1923, 2400] };
const CREDIT = 'Public domain; scan from Wikimedia Commons.'; // the artists are in the tombstone
// A picture drawn only in a design slot or a table cell: registered, never cited or numbered.
function picture(id, image, altText) {
  registerResourceImage(`${id}.jpg`, image);
  return { id, typeId: 'plate', kind: 'bitmap', altText, createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}.jpg`, format: 'jpeg', width: image.width, height: image.height } };
}

// Design-slot shorthands; every text wraps, the folio lines too.
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width !== undefined && { size: { width: width === 'fill' ? 'fill' : mm(width) } }) });
const text = (id, content, fontFamily, size, color, placement, more = {}) => ({ kind: 'text',
  id, content, fontFamily, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...more });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.2) });

// #region opener: the entry's head, a numeral and tombstone beside the title and the lead
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt; a multiple of the size
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
// mm: the lead, the entry's first paragraph, 5 mm under the hairline on the next grid line;
// the opener ends a line above it (the level's marginBottom), and the tombstone faces it.
const LEAD_Y = Math.ceil((HEAD_RULE + 5) / PT / LEAD) * LEAD * PT;
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const BASE = 0.8; // a line's first baseline stands 0.8 down its line box
// em: Libre Bodoni's capitals, and its figures, which stop short of them ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [0.754, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
  - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT)) * PT;
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
  text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
  text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
    at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
  text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
    TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
  { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y - LEAD * PT),
  slot: { elements: [...head('Cat.', '{number}'),
  text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
    at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
  // Baseline on the lead's; the attribute's \n breaks give its three lines.
  text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
    at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
    { lineHeight: TOMB.lead / TOMB.size, reserve: false }), // beside the lead: no room of its own
] } });
// #endregion

// #region cover: a heading style on warm black, a framed detail of Cat. 3 and the name
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more) => text(id, content, family,
  size, color, at('page', 'top', 0, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
  id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
    { kind: 'box', id: 'night', style: { backgroundColor: col('night') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
    { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
      placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
        size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
    { kind: 'image', id: 'detail', resourceId: 'cover-detail',
      placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
    centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
    // Tracked capitals centre on their letters: the tracking after the É is left out.
    centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
      textTransform: 'uppercase', letterSpacing: pt(NAME.track) }),
    centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
      { italic: true }),
    centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
  ] } },
});
// #endregion

// #region checklist: a table of the works with a thumbnail in each first cell
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false, dropCap: false, // no initial
  advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
  id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
  altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
  table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
    ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
      cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
    ...plates.map((p, i) => [
      { content: '', image: { resourceId: `${p.id}-thumb` } },
      cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
      cell(String(platePage(i + 1)), { align: 'right' }),
    ]),
  ] } },
  createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
// #endregion

const foot = (id, content, parity, edge, x, more) => text(id, content, LABEL, 7.5, 'muted',
  at('page', edge, x, TRIM.h - 15), { ...caps(7.5), parity, ...more });
const footer = { elements: [ // folios at the outer foot; entries (versos) add the title
  foot('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, { color: col('ink') }),
  foot('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
  foot('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER,
    { color: col('ink'), align: 'right' }),
] };

const config = () => ({ locale: 'en-us', // the sample is in English
  colorPalette, resourceTypes: [plateType, listType],
  // A bitmap is never set wider than its declared pixels at this dpi: at 150 dpi a 1,900-px
  // scan may reach 322 mm, so the text block decides (at 300 dpi it stops at 161 mm).
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.h - TOP - BLOCK_H), left: mm(INNER),
      right: mm(OUTER), mirror: true } },
  // Body text never enters the outer column: the openers draw in it and the plates span it.
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: SIDE_PC, sideColumnRole: 'floats',
    sideColumnSide: 'outer', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('sepia'), referenceBold: false, // 'Cat. 1' in the accent, regular weight
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.5,
  },
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [entryLevel()] },
  headingStyles: [coverStyle(), checklistStyle()],
  captionStyle: { fontFamily: LABEL, fontSize: pt(CAPTION.size), gap: pt(CAPTION.gap),
    labelColor: col('sepia'), // the label is bold by default
    note: { fontSize: pt(CAPTION.note), gap: pt(CAPTION.noteGap), color: col('muted') } },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    cellPadding: mm(1.8), headerBackground: col('ink'), headerColor: col('paper'),
    headerFontFamily: LABEL, headerFontSize: pt(8), bodyFontFamily: TEXT, bodyFontSize: pt(9.5) },
  paragraphStyles: [
    // The entry's lead, a size larger and ragged; a :::space under it starts the commentary flush.
    { id: 'lead', fontSize: pt(LEADIN.size), lineHeight: pt(LEADIN.lead), textAlign: 'left',
      firstLineIndent: pt(0) },
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header: { elements: [] }, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Doré: Three Plates for Don Quixote"
author: "The Print Room"
---

# Doré {style="cover" kicker="The Print Room · A cabinet exhibition" subtitle="Three plates for Don Quixote, 1863" foot="Wood engravings by Héliodore Pisan after Gustave Doré"}

# Don Quixote \\ in His Library {chapter="Part I · Chapter I" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"}

:::paragraphs{style="lead"}
Doré opens the book with its hero wide awake. Seated upright in a carved chair, the hidalgo holds up a sword in his right hand and a book in his left, while everything he has read comes loose from the page and fills the room around him.
:::

:::space

The plate (:ref{id="library"}, opposite) sets out the sentence in which the hidalgo loses his wits. In John Ormsby’s translation his fancy grew full of ‘enchantments, quarrels, battles, challenges, wounds, wooings, loves, agonies, and all sorts of impossible nonsense’, and Doré draws the list almost item by item. Knights ride out from behind the chair, a dragon uncoils above a shield of arms, a knight no taller than a folio stands on a book beside a giant’s severed head, and a lady in chains kneels at the fore-edge of a volume lettered AMADIS.

Cervantes says the hidalgo ‘sold many an acre of tillageland to buy books of chivalry to read’, and Doré scatters them about the room: open on the table under the window, fallen open at his feet, and one propped on its edge at the lower left, beside the giant’s head.

Daylight comes in at the window on the left and falls on his face and on the open page. The figures crowding the rest of the room have come out of the books, down to the two knights no taller than a hand who joust on the backs of mice among the flagstones. Doré gives these phantoms the same firm outline as the man who imagines them.

In wood engraving whatever the tool cuts away prints white, so Pisan had to turn each of Doré’s greys into a pattern of strokes: close parallel cuts for the heavy curtain, finer and more open ones for the light on the wall, broken curls for the smoke of figures behind the chair. Both men signed the block, Doré in script at the lower left and Pisan in capitals at the lower right.

# The Vigil \\ of Arms {chapter="Part I · Chapter III" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"}

:::paragraphs{style="lead"}
The innkeeper has no chapel, so Don Quixote keeps his vigil of arms in the yard. Doré makes the scene a nocturne: the armour stands on the well-head, and the bareheaded knight lifts his lance towards a full moon breaking through cloud.
:::

:::space

The plate (:ref{id="vigil"}, opposite) follows the text closely. The innkeeper has told his guest that armour may be watched anywhere, so Don Quixote lays his on a trough beside the well and paces up and down in front of it. In Ormsby’s translation the night closes in ‘with a light from the moon so brilliant that it might vie with his that lent it’.

Doré stands the armour upright on the well-head, helmet and breastplate and gauntlets, so that it reads as a second knight facing the thin man in his shirt who will wear it. On the right the inn is a dark mass with an outside stair, and a figure watches from its landing: in the novel the guests ‘flocked to see it from a distance’. Water lies in the ruts of the yard and catches the light, and the knight’s shadow runs across it towards the viewer.

Cervantes plays the vigil as farce. Two carriers come in turn to water their mules, each goes to move the armour off the trough, and each is laid out with the lance; the landlord then hurries the dubbing through before anyone else is hurt. Doré leaves all of that out and keeps the hour before it, when the knight is alone with his arms and the moon.

In this sky Pisan widens his level cuts as they near the moon, until the black left between them thins to hairlines around the halo. Short flicks pick out the edges of the clouds. A dark bar of cloud crosses the disc on the left, and a few faint strokes shade the moon’s face.

# The Adventure \\ of the Windmills {chapter="Part I · Chapter VIII" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"}

:::paragraphs{style="lead"}
Doré passes over the charge and draws the moment after it. The turning sail has caught the lance and lifted horse and rider clear of the plain, and Rocinante’s legs thrash at the empty air below them.
:::

:::space

The plate (:ref{id="windmills"}, opposite) illustrates the sentence in which the charge ends. As the knight drove his lance into the sail, in Ormsby’s words, ‘the wind whirled it round with such force that it shivered the lance to pieces, sweeping with it horse and rider, who went rolling over on the plain’. Doré stops before the fall. The broken lance is still caught in the canvas, and the knight hangs tangled against the sail with his shield flung out beside him.

The composition is built on one diagonal. The sail runs from the top left corner towards the centre, the horse hangs from it, and the line carries on down the slope to the thistles in the foreground. Against it Doré sets the level plain of La Mancha, where Cervantes counts ‘thirty or forty windmills’ and the plate lines up a row of them along the horizon, turning in the same wind. Sancho, hatless, throws up one arm and clutches his head with the other; his hat lies on the ground and his ass waits behind him. He had warned his master, in Ormsby’s version, that ‘what we see there are not giants but windmills, and what seem to be their arms are the sails that turned by the wind make the millstone go’.

Pisan cuts the sky in long even lines and the patched canvas of the sail in torn, crossing strokes. The mills on the horizon are drawn with a few lines each.

# Works in \\ the Exhibition {style="checklist" kicker="Checklist" range="1–3"}

All three plates come from the first volume of *L’Ingénieux Hidalgo Don Quichotte de la Manche*, in the French translation by Louis Viardot, published in two folio volumes by L. Hachette et Cie, Paris, in 1863 with 377 compositions by Gustave Doré, 120 of them full-page. Héliodore Pisan engraved the three plates on wood, and both men signed them in the block. Quotations are from John Ormsby’s English translation of 1885.

::resource{id="checklist"}

:::paragraphs{style="colophon"}
Set in Ibarra Real Nova, Libre Bodoni and Sofia Sans Condensed (SIL Open Font License). Commentary written for this catalogue under a CC BY 4.0 licence. The plates are in the public domain and reproduced from scans on Wikimedia Commons; the cover shows a detail of Cat. 3. Ormsby’s translation is Project Gutenberg eBook #996.
:::
`; // content.<lang>.md, inlined by the Cookbook

// id, scan file, caption, the chapter it illustrates and alt text: one block per plate.
const plateTexts = String.raw`library
library-835.jpg
Don Quixote in His Library
Part I, ch. I: ‘which treats of the character and pursuits of the famous gentleman Don Quixote of La Mancha’
Don Quixote sits upright in a carved chair, a book in his left hand and a sword raised in his right, surrounded by the figures of his reading: knights on horseback, a dragon, a giant’s severed head, a lady in chains and two tiny knights jousting on mice.

vigil
vigil-835.jpg
The Vigil of Arms
Part I, ch. III: ‘wherein is related the droll way in which Don Quixote had himself dubbed a knight’
A moonlit inn yard. Don Quixote, bareheaded and holding a lance and a buckler, looks up at the moon; his armour stands propped on the well-head facing him, and a figure watches from the inn’s outside stair.

windmills
windmills-835.jpg
The Adventure of the Windmills
Part I, ch. VIII: ‘the terrible and undreamt-of adventure of the windmills’
A windmill’s sail sweeps Don Quixote and Rocinante off the ground, the knight tangled against the canvas and his buckler flung out; a row of mills stands on the horizon and Sancho, hatless, throws up one arm and clutches his head beside his ass.
`;
const FIELDS = ['id', 'file', 'caption', 'illustrates', 'altText'];
const PLATES = plateTexts.trim().split(/\n\s*\n/).map((block) =>
  Object.fromEntries(block.split('\n').map((line, i) => [FIELDS[i], line.trim()])));

// #region art: the engravings printed on the page's paper
// Multiplying by the paper colour turns the scan's white into the page's cream.
function onPaper(source) {
  const canvas = new OffscreenCanvas(source.width, source.height);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(source, 0, 0);
  ctx.globalCompositeOperation = 'multiply';
  ctx.fillStyle = palette.paper;
  ctx.fillRect(0, 0, source.width, source.height);
  return canvas.transferToImageBitmap();
}
const resized = (scan, width) => createImageBitmap(scan,
  { resizeWidth: width, resizeQuality: 'high' });
// #endregion

// #region scans: the plates, their thumbnails and the cover's detail, fetched from assets/
// plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX);
// the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box.
async function scan(file) {
  const res = await fetch(asset(file));
  if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`);
  return createImageBitmap(await res.blob());
}
async function loadPlates() {
  const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan));
  const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail),
    'Detail of Cat. 3: the knight and his horse caught on the sail.')];
  for (const [i, p] of PLATES.entries()) {
    registerResourceImage(p.file, onPaper(scans[i]));
    resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size
      picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText));
  }
  return resources;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  'Ibarra Real Nova': ['400', '400i'], // text, the lead, the checklist
  'Libre Bodoni': ['400', '400i'], // numerals, titles, drop caps, the cover
  'Sofia Sans Condensed': ['400', '600', '700'], // labels, tombstones, captions, folios
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const words = `${markdown}\n${plateTexts}\n${CREDIT}`; // their letters decide the font subsets
const [, resources] = await Promise.all([
  prepareFonts(words, config(), kitFonts(FONTS)), loadPlates()]);
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
// #region check: every page past an opener holds a plate, so each plate faces its entry
const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length);
if (astray) { // text run past its verso, or the blank page that follows it
  const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`);
  kitFail(error); // the viewer's bar says why
  throw error;
}
// #endregion
showPages(doc, { title: t({ en: 'Catalogue entries facing their plates' }) });

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

## Varianti

### Lascia aria sotto le tavole

Una frazione di larghezza restringe l’elemento flottante stesso: al 78 % della gabbia ogni tavola è alta circa 188 mm, centrata, con circa 27 mm di gabbia vuota sotto la riga di crediti, e nessuna deve essere ridotta.

```diff
-  defaultPlacement: { position: 'top', span: 'page', align: 'center',
-    shrink: 'page', minScale: 0.5, captionMeasure: 'body' },
+  defaultPlacement: { position: 'top', span: 'page', align: 'center', width: 0.78 },
```

### Componi le figure nella colonna esterna

Per figure e glosse che si impilano in una colonna esterna riservata agli elementi flottanti accanto al paragrafo che le cita, vedi [il manuale con una colonna a margine](https://postext.dev/it/cookbook/textbook-margin-column.md).

## Errori frequenti

- **Un elemento flottante 'top' va alla pagina dopo la sua citazione, a meno che non possa aprire la pagina che lo cita.** Per impostazione predefinita un elemento flottante non va mai sopra il proprio riferimento, quindi un flottante 'top' a tutta larghezza citato a pagina N apre la pagina N+1. Imposta placement.citingPage: true sulla risorsa o sul defaultPlacement del suo tipo (o layout.floatsAtCitingPage per tutto il libro, da postext 1.25) e aprirà invece la pagina N, con il testo sopra il riferimento che scende sotto di esso, quando entra entro layout.maxTopFraction e la riga che lo cita resta su quella pagina. Non apre mai la prima pagina di un capitolo né una pagina dopo un’interruzione forzata: lì apre ancora la pagina successiva, e 'auto' o 'bottom' possono prendere il piede della pagina che lo cita.
- **Le bitmap prendono la dimensione di stampa da una risoluzione: dichiarala, non pixel alla dimensione di stampa.** Una bitmap senza risoluzione viene impaginata a un pixel per pixel di impaginazione ai dpi del documento, quindi una foto di 2400 px è larga 406 mm su una pagina a 150 dpi. Dichiara i pixel reali del file e dai all’immagine la sua risoluzione (bitmap.resolution: 300), o imposta layout.bitmapResolution per ogni bitmap: un valore in ppi, o 'file' per leggere quello indicato dal file. La colonna limita comunque l’immagine.
- **Avviso di impaginazione: Immagine a bassa risoluzione** (`bitmapTooSmall`). Una bitmap è disegnata più di 1,5 volte più larga dei suoi pixel, quindi in stampa apparirà sfocata. Soluzione: Fornisci circa 300 dpi alla dimensione di stampa, e dichiara la larghezza e l’altezza reali della bitmap. ([Documentazione](https://postext.dev/it/docs/document-format.md#risorse))

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: The catalogue entries, captions, tombstones and checklist: Postext Cookbook, CC-BY-4.0
- Testo: Quotations from Don Quixote in John Ormsby’s English translation (1885): Miguel de Cervantes · John Ormsby ([fonte](https://www.gutenberg.org/ebooks/996)), pubblico dominio
- Immagini: Cat. 1, Don Quixote in His Library (Part I, ch. I), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide: Gustave Doré · Héliodore Pisan ([fonte](https://commons.wikimedia.org/wiki/File:Gustave_Dor%C3%A9_-_Miguel_de_Cervantes_-_Don_Quixote_-_Part_1_-_Chapter_1_-_Plate_1_%22A_world_of_disorderly_notions,_picked_out_of_his_books,_crowded_into_his_imagination%22.jpg)), pubblico dominio
- Immagini: Cat. 2, The Vigil of Arms (Part I, ch. III), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide: Gustave Doré · Héliodore Pisan ([fonte](https://commons.wikimedia.org/wiki/File:Cap_III_Quixote_a_velar_armas.jpg)), pubblico dominio
- Immagini: Cat. 3, The Adventure of the Windmills (Part I, ch. VIII), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide: Gustave Doré · Héliodore Pisan ([fonte](https://commons.wikimedia.org/wiki/File:Adventure_with_the_Windmills.jpg)), pubblico dominio
- Immagini: The cover’s detail of Cat. 3: a 1,100-px square cut from the same scan and reduced to 540 px: Gustave Doré · Héliodore Pisan ([fonte](https://commons.wikimedia.org/wiki/File:Adventure_with_the_Windmills.jpg)), pubblico dominio
- Caratteri: Ibarra Real Nova (OFL-1.1), Libre Bodoni (OFL-1.1), Sofia Sans Condensed (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 036 · Catalogo per corrispondenza con immagini nelle celle](https://postext.dev/it/cookbook/seed-catalogue.md): Un catalogo di sementi il cui listino è una tabella letta da TSV, con una bustina dipinta nella cella di ogni varietà e l’elenco diviso su una doppia pagina. · Livello 3 (Avanzato) · Cataloghi
- [Nº 068 · Saggio fotografico con tavole al vivo](https://postext.dev/it/cookbook/photo-essay-full-bleed.md): Tavole come stili di titolo costruiti da un elenco, ognuna stesa sulla pagina fino al rifilo; la tavola della tempesta attraversa la doppia pagina in due metà. · Livello 2 (Intermedio) · Libri fotografici
- [Nº 001 · Libro di testo con una colonna a margine](https://postext.dev/it/cookbook/textbook-margin-column.md): Una pagina a colonna e mezza con la colonna esterna solo per i flottanti: vi si impilano figure e glosse span 'side', e captionSide vi sposta le didascalie. · Livello 3 (Avanzato) · Libri di testo
