# Font del marchio in impaginazione, PDF e pacchetto

> Il manuale d’identità di una metro immaginaria composto con i font del marchio, scaricati una volta e riusati da impaginazione, PDF e pacchetto .postext.

- Versione HTML: https://postext.dev/it/cookbook/brand-fonts-identity-manual
- Ricetta Nº 040 · Output e integrazione · Livello 3 (Avanzato) · Output: Canvas, PDF, Pacchetto .postext
- Generi: Manuali, guide e opere di consultazione
- Richiede postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · provata con 1.31.0, postext-pdf 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p01.webp?v=96f21c1f), [2](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p02.webp?v=96f21c1f), [3](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p03.webp?v=96f21c1f), [4](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p04.webp?v=96f21c1f)
- PDF: https://postext.dev/cookbook/brand-fonts-identity-manual/en/brand-fonts-identity-manual.pdf?v=96f21c1f
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=brand-fonts-identity-manual&lang=en (.postext: https://postext.dev/cookbook/brand-fonts-identity-manual/en/brand-fonts-identity-manual.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [en](https://postext.dev/en/cookbook/brand-fonts-identity-manual.md), [es](https://postext.dev/es/cookbook/brand-fonts-identity-manual.md), [ca](https://postext.dev/ca/cookbook/brand-fonts-identity-manual.md), [de](https://postext.dev/de/cookbook/brand-fonts-identity-manual.md), [fr](https://postext.dev/fr/cookbook/brand-fonts-identity-manual.md), [pt](https://postext.dev/pt/cookbook/brand-fonts-identity-manual.md), [zh](https://postext.dev/zh/cookbook/brand-fonts-identity-manual.md), [ja](https://postext.dev/ja/cookbook/brand-fonts-identity-manual.md), [ar](https://postext.dev/ar/cookbook/brand-fonts-identity-manual.md)

## In breve

Un manuale di stile per una metropolitana inventata. Mostra come scaricare una sola volta i font di un marchio e usarli per l’anteprima delle pagine, per il PDF e per il file salvato.

## Che cosa comporrai

Quattro pagine del manuale d’identità di Metro de Alba, una metropolitana immaginaria di cinque linee. In copertina le cinque linee partono dal basso a sinistra su un binario comune e si diramano verso destra dopo aver attraversato un fiume grigio, sotto il nome in Big Shoulders Display. Ogni sezione si apre sotto una fascia color inchiostro, con un grande numero di due cifre nel colore di una linea. In *01 Colour* la prima colonna della tabella è il colore stesso; *02 Type* ha righe di campione e un cartello di banchina, e *03 Usage* chiude con due disegni di mappa incorniciati, uno fatto secondo le regole (Do) e uno contro (Don't). Tutto il testo è composto con sei file di font che lo script scarica prima della prima impaginazione. Il PDF è disegnato con gli stessi sei file, e il file `.postext` ne porta cinque, perché il carattere da titolo è marcato come non ridistribuibile.

**Questa ricetta risponde a:**

- Come compongo le pagine con i miei file di font e incorporo quegli stessi file nel PDF?
- Come esporto nel browser un vero PDF con i font incorporati?
- Come evito che titoli, parole in grassetto e punti elenco escano in blu?
- Come aggiungo campioni di colore come legenda nel testo, nelle didascalie o nelle note di tabella?
- Come creo un pacchetto .postext dal codice, per passare un documento alla Sandbox o a un altro programma?

## La risposta breve

```js
// script.js, righe 16–53
// Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here:
// point FONT_URL at your own server (same origin, or one that lets this page in by CORS).
const slug = (family) => family.toLowerCase().replaceAll(' ', '-');
const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/`
  + `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`;
const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal';
  return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` };
}));
const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once

// 1 · Layout measures with document.fonts: register every face before the first build.
async function loadBrandFonts() {
  await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => {
    const res = await fetch(FONT_URL({ family, weight, style }));
    if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`);
    fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer()));
    const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  }));
}

// 2 · The PDF embeds the same bytes as TrueType. renderToPdf asks for the faces the pages
// paint; a weight or slant the brand lacks gets the family's closest file, embedded once.
async function brandFontProvider(family, weight, style) {
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const own = brandFaces().filter((f) => f.family === family);
  if (!own.length) throw new Error(`${family} is not one of the brand's fonts`);
  return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId));
}

// 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes
// of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative).
const customFonts = () => Object.keys(FONTS).map((name) => ({
  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
  variants: brandFaces().filter((f) => f.family === name)
    .map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })),
}));
```

## Ingredienti

**Insegna**

- [I tuoi font](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#font-personalizzati): Famiglie di marca o con licenza dichiarate in customFonts, registrate per l’impaginazione e incorporate nel PDF dagli stessi byte.
- [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.
- [Riempimenti delle celle](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita): Uno sfondo per cella collegato alla tavolozza, per mappe di calore, matrici di compatibilità e fasce che seguono i dati; le semplici righe alternate appartengono allo stile della tabella (bodyAlternateBackground).

**Usa anche**

- [Campioni di colore](https://postext.dev/it/docs/document-format.md#formattazione-in-linea)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Titoli numerati](https://postext.dev/it/docs/configuration-text.md#sovrascritture-per-livello)
- [Fascia di capitolo a tutta larghezza](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Stili di tabella con nome](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Stile delle didascalie](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie)
- [Collocazione delle figure](https://postext.dev/it/docs/document-format.md#collocazione)
- [Testo, filetti e riquadri nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Carattere del testo corrente](https://postext.dev/it/docs/configuration-text.md#testo-corrente)
- [Una o due colonne](https://postext.dev/it/docs/configuration-page-layout.md#tipi-di-impaginazione)
- [I font prima dell’impaginazione](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione)
- [Esportazione in PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#generare-pdf)
- [Pacchetti .postext](https://postext.dev/it/docs/configuration-programmatic-usage.md#pacchetti-file-postext)
- [Pagine su un canvas](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Citazioni che collocano le figure](https://postext.dev/it/docs/document-format.md#rimando-in-linea-la-forma-principale)
- [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)
- [Figura e Tabella nella tua lingua](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Colori per parte](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-part)
- [Font incorporati nel PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#perché-un-provider-di-font)
- [Testatine per sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Capitoli non numerati](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri), [`captionStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`customFonts`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#font-personalizzati), [`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), [`tableStyles`](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome)

**API**

- [`buildDocument`](https://postext.dev/it/docs/configuration-programmatic-usage.md#costruire-un-documento), [`createBundle`](https://postext.dev/it/docs/configuration-programmatic-usage.md#creare-un-pacchetto), [`decompressWoff2`](https://postext.dev/it/docs/configuration-programmatic-usage.md#provider-di-font-per-il-browser-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`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), [`renderToPdf`](https://postext.dev/it/docs/configuration-programmatic-usage.md#generare-pdf), [`setCellBackground`](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)

**Caratteri**

- Public Sans (OFL-1.1), Big Shoulders Display (OFL-1.1), Spline Sans Mono (OFL-1.1)

## Preparazione

### 1 · Registra i caratteri del marchio, componi e dai al PDF gli stessi byte

```js
// script.js, righe 448–453
await loadBrandFonts(); // the answer, step 1
for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup);
const doc = buildDocument({ markdown, resources }, config());
showPages(doc, { title: 'Metro de Alba · Identity manual' });
offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files
```

L’impaginazione misura ogni parola con i caratteri in `document.fonts`, quindi `loadBrandFonts()` nella [risposta breve](https://postext.dev/it/cookbook/brand-fonts-identity-manual.md#la-risposta-breve) scarica ogni file una volta, ne conserva i byte e registra un `FontFace` costruito da essi prima che giri `buildDocument`. I `kitFonts` del kit scaricano solo da Fontsource, mentre un marchio serve i suoi file dal proprio server. `renderToPdf` chiede i caratteri che le pagine disegnano, e `brandFontProvider` risponde a ogni richiesta con i byte già scaricati; un peso o un’inclinazione che il marchio non ha riceverebbe il file più vicino della stessa famiglia ([Why a font provider?](https://postext.dev/it/docs/configuration-programmatic-usage.md#perché-un-provider-di-font)). Il PDF incorpora i sei file che il canvas ha misurato, ognuno una volta.

### 2 · Il sistema dei colori è un solo elenco

```js
// script.js, righe 59–84
const COLOURS = [ // id, name, screen, print (coated stock)
  ['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'],
  ['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'],
  ['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'],
  ['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'],
  ['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'],
  ['signal-red', 'Signal red', '#c0391b', '10 88 100 2'],
  ['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'],
  ['ink', 'Ink', '#1f2124', '72 62 55 78'],
  ['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'],
];
const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])),
  paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section
// col(): a colour linked to its palette entry, with the entry's hex beside the link.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.ink]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself
  (model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)),
  { headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [
    ['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })),
    ...COLOURS.map(([, name, hex, cmyk]) =>
      ['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))),
  ] });
```

Poiché la palette e la tabella 1.1 si costruiscono dalle stesse nove righe, la tabella non può elencare un valore che le pagine non usano. `setCellBackground` riempie la prima cella vuota di ogni riga con il colore di quella riga, collegato alla palette. Nel testo, `:swatch{color="line-2"}` prende un id della palette e disegna un quadrato bordato nel colore del testo. `main-color` ha l’esadecimale di Ink, quindi titoli e tratti in grassetto e corsivo, collegati a esso per impostazione predefinita, si stampano in Ink invece che nel blu del motore. `bodyText.referenceColor: col('ink')` assegna Ink anche ai rimandi ([Color Palette](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)).

### 3 · Un’apertura, tre colori di linea

```js
// script.js, righe 93–110
// The body starts ten grid lines down, in both columns.
const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [
  { kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap',
    // Nudged by eye on the capture: the cap tops of number and title on one line.
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(-1.5), y: mm(1.3) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
    lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) },
      size: { width: mm(COL) } } },
] } };
// Each section sets `section` to its line's colour; the number and the head square use it.
const section = (id) => ({ id, palette: { section: palette[id] } });
```

`numberingTemplate: '{1:01}'` scrive il contatore con due cifre, e `{number}` stampa 01, 02 e 03 nell’apertura; il titolo di copertina è `numbered: false` e non riceve numero. I tre stili di sezione differiscono solo nella voce di palette `section`, che ognuno imposta al colore della sua linea. Il numero e il quadrato nella fascia sono collegati a `section`, così un solo design di apertura serve tutte e tre le sezioni. `minHeight` riserva all’apertura dieci righe della griglia da 14 pt, e il testo di ogni sezione comincia sulla stessa riga della griglia della sua pagina.

### 4 · Do e Don't sono tipi di figura

```js
// script.js, righe 159–164
const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  captionStyle: { labelColor: col(colour) } });
const resourceTypes = [...defaultResourceTypes(LANG),
  example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')];
const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column
```

Un tipo di risorsa può avere un `captionStyle` parziale, e questi due impostano solo il colore dell’etichetta, verde Signal per Do e rosso Signal per Don't ([Resource types](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)). I segnali arrivano a 5,1:1 e 5,5:1 sul bianco, mentre le linee 1, 2, 3 e 5 restano sotto il 4,5:1 che serve al testo piccolo. Entrambi i disegni flottano con `position: 'bottom'` e sono citati nello stesso paragrafo, il primo sotto «Drawing the map», quindi si dividono il piede di pagina 4, Do nella colonna sinistra e Don't in quella destra.

### 5 · Il pacchetto include i font che può condividere

```js
// script.js, righe 457–471
const pack = Object.assign(document.createElement('button'), { type: 'button',
  textContent: 'Build the .postext' });
pack.addEventListener('click', async () => {
  const { bytes, manifest, warnings } = await createBundle({
    name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources,
    files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes
  });
  const size = `${Math.round(bytes.length / 1024)} KB`;
  const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`;
  kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out
  pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`,
    href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })),
    textContent: `Download ${RECIPE}.postext · ${size}` }));
});
document.getElementById('pt-actions').append(pack);
```

`createBundle` legge `config.customFonts`, cerca i byte di ogni variante in `files` tramite il suo `fileId` e li scrive sotto `fonts/`, con una voce `fonts` nel manifest. La famiglia da titolo porta `redistributable: false`, al posto di una licenza che vieti di cedere i file (Big Shoulders Display in realtà è sotto OFL). Il suo file resta fuori, e `warnings` nomina la famiglia. Il file da 81 KB contiene il capitolo, la configurazione, i tre disegni e cinque file di font. L’impaginazione non legge mai `customFonts`; le pagine escono identiche senza ([Creating a bundle](https://postext.dev/it/docs/configuration-programmatic-usage.md#creare-un-pacchetto)).

## 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/brand-fonts-identity-manual

### script.js

```js
// ═══ Postext Cookbook · Nº 040 · Brand fonts in layout, PDF and bundle ════════════
// https://postext.dev/en/cookbook/brand-fonts-identity-manual
// Code: MIT · Text and drawings: original (CC BY 4.0) · Metro de Alba is a fictional network
// Fonts: Public Sans, Big Shoulders Display, Spline Sans Mono (OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocument, renderPageToCanvas, registerResourceImage, defaultResourceTypes, setCellBackground,
  createBundle,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'brand-fonts-identity-manual';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: the brand's font files, fetched once for the layout, the PDF and the bundle
// Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here:
// point FONT_URL at your own server (same origin, or one that lets this page in by CORS).
const slug = (family) => family.toLowerCase().replaceAll(' ', '-');
const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/`
  + `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`;
const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal';
  return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` };
}));
const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once

// 1 · Layout measures with document.fonts: register every face before the first build.
async function loadBrandFonts() {
  await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => {
    const res = await fetch(FONT_URL({ family, weight, style }));
    if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`);
    fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer()));
    const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  }));
}

// 2 · The PDF embeds the same bytes as TrueType. renderToPdf asks for the faces the pages
// paint; a weight or slant the brand lacks gets the family's closest file, embedded once.
async function brandFontProvider(family, weight, style) {
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const own = brandFaces().filter((f) => f.family === family);
  if (!own.length) throw new Error(`${family} is not one of the brand's fonts`);
  return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId));
}

// 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes
// of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative).
const customFonts = () => Object.keys(FONTS).map((name) => ({
  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
  variants: brandFaces().filter((f) => f.family === name)
    .map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })),
}));
// #endregion

const TEXT = 'Public Sans', DISPLAY = 'Big Shoulders Display', MONO = 'Spline Sans Mono';

// #region colours: the colour system, from which the palette and Table 1.1 are both built
const COLOURS = [ // id, name, screen, print (coated stock)
  ['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'],
  ['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'],
  ['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'],
  ['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'],
  ['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'],
  ['signal-red', 'Signal red', '#c0391b', '10 88 100 2'],
  ['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'],
  ['ink', 'Ink', '#1f2124', '72 62 55 78'],
  ['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'],
];
const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])),
  paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section
// col(): a colour linked to its palette entry, with the entry's hex beside the link.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.ink]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself
  (model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)),
  { headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [
    ['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })),
    ...COLOURS.map(([, name, hex, cmyk]) =>
      ['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))),
  ] });
// #endregion

const PAGE = { width: 210, height: 280 }; // mm
const MARGIN = { top: 24, bottom: 22, inner: 18, outer: 18 };
const GUTTER = 8, COL = (PAGE.width - MARGIN.inner - MARGIN.outer - GUTTER) / 2; // 83 mm
const BAND = 13, LEAD = 14; // mm: the ink band at the head of each page; pt: the leading

// #region sections: a giant zero-padded number in the section's line colour
// The body starts ten grid lines down, in both columns.
const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [
  { kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap',
    // Nudged by eye on the capture: the cap tops of number and title on one line.
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(-1.5), y: mm(1.3) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
    lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) },
      size: { width: mm(COL) } } },
] } };
// Each section sets `section` to its line's colour; the number and the head square use it.
const section = (id) => ({ id, palette: { section: palette[id] } });
// #endregion

// Running heads: an ink band across the head of the page, as on the platform signs.
const PT = 25.4 / 72; // mm in a point
const HEAD = 7.5, SQUARE = 4.5, STEP = 8; // pt: head size; mm: the square, and the spacing
const label = (size, colour) => ({ fontFamily: TEXT, fontSize: pt(size), fontWeight: 600,
  letterSpacing: pt(size * 0.16), textTransform: 'uppercase', color: col(colour) });
const inBand = (edge, x, height) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm((BAND - height) / 2) } }); // centred in the band
const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity,
  lineHeight: 1, ...label(HEAD, 'paper'), placement: inBand(edge, x, HEAD * PT) });
const square = (id, parity, edge, x) => ({ kind: 'box', id, parity,
  style: { backgroundColor: col('section') }, // the section's line, as on its signs
  placement: { ...inBand(edge, x, SQUARE), size: { width: mm(SQUARE), height: mm(SQUARE) } } });
const header = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('ink') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: 'fill', height: mm(BAND) } } },
  square('verso-line', 'even', 'top-left', MARGIN.outer),
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer + STEP),
  head('verso-title', '{title} · {subtitle}', 'even', 'top-left', MARGIN.outer + 2 * STEP),
  square('recto-line', 'odd', 'top-right', -MARGIN.outer),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -(MARGIN.outer + STEP)),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 2 * STEP)),
] };

// The cover: the network drawing fills the page; the heading and frontmatter give the texts.
const coverText = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', align: 'left', ...style });
const under = (id, gap) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(gap) },
  size: { width: mm(PAGE.width - 2 * MARGIN.outer) } });
const cover = { id: 'cover', numbered: false, span: 'page',
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'map', resourceId: 'network',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    coverText('kicker', '{attr.kicker}', { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(MARGIN.outer), y: mm(MARGIN.top - 2) } }, label(8, 'ink')),
    // At lineHeight 0.86 the capitals rise about 3 mm above the title's box: hence 6.5 mm.
    coverText('title', '{titleText}', under('kicker', 6.5), { fontFamily: DISPLAY,
      fontWeight: 800, fontSize: pt(88), lineHeight: 0.86, color: col('ink') }),
    coverText('subtitle', '{subtitle}', under('title', 3), { fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(20), lineHeight: 1.2, color: col('ink') }),
    coverText('edition', '{attr.edition}', under('subtitle', 1.5), { fontFamily: MONO,
      fontSize: pt(9), lineHeight: 1.3, color: col('ink') }),
  ] } } };

// #region do-dont: two figure types whose captions carry their own label colour
const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  captionStyle: { labelColor: col(colour) } });
const resourceTypes = [...defaultResourceTypes(LANG),
  example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')];
const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column
// #endregion

const config = () => ({ locale: 'en-us', // the sample is in English
  colorPalette, resourceTypes, customFonts: customFonts(),
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    referenceColor: col('ink'), // references in Ink, like the text around them
    boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis**
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  headings: { fontFamily: DISPLAY, fontWeight: 800, levels: [ // ink: main-color
    // Each section opens a page, on either side.
    { level: 1, span: 'page', numberingTemplate: '{1:01}', marginBottom: pt(0),
      breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, fontSize: pt(17), lineHeight: pt(2 * LEAD), marginTop: pt(0), // two lines,
      marginBottom: pt(0) }, // so a column that opens with one starts on the same line
  ] },
  headingStyles: [cover, section('line-1'), section('line-4'), section('line-5')],
  paragraphStyles: [
    { id: 'specimen', fontSize: pt(15), lineHeight: pt(LEAD * 1.5), marginBottom: pt(LEAD) },
    { id: 'specimen-mono', fontFamily: MONO, fontSize: pt(11), lineHeight: pt(LEAD * 1.5),
      marginBottom: pt(LEAD) },
    { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5) },
  ],
  calloutStyles: [
    { id: 'sign', span: 'page', placement: 'bottom', background: col('ink'),
      padding: { top: mm(6), right: mm(8), bottom: mm(6), left: mm(8) },
      titleStyle: { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(60), gap: mm(2),
        color: col('paper') }, body: { fontSize: pt(16), lineHeight: pt(LEAD * 1.5),
        color: col('paper'), boldColor: col('paper') } },
  ],
  tableStyles: [
    { id: 'swatches', rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
      headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5),
      bodyFontFamily: MONO, bodyFontSize: pt(8.5), cellPadding: mm(2) },
  ],
  captionStyle: { fontSize: pt(8.3) }, // face and ink from bodyText; the label in SemiBold
  header, footer: { elements: [] }, // the folio sits in the band
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Metro de Alba"
subtitle: "Identity manual"
author: "Metro de Alba brand office"
---

# Metro de Alba {style="cover" kicker="Brand office · Lines and stations" edition="Edition 3 · September 2026"}

# Colour {style="line-1" lead="Riders learn the colour of a line before its number. These values are fixed for print, screen and enamel."}

## Five lines, five colours

The network has five lines, and each owns one colour, listed in :ref{id="colours" style="full"}. The colour marks the line wherever it appears: the stripe on a platform wall, the band along a train, the line’s badge and its path on the map. It marks nothing else, so a leaflet about fares is printed in ink on paper and red always means line 1.

Take the values from the table, never from a screenshot, an old sign or a colour picker. Screens use the HEX value, and print uses the CMYK recipe on coated stock; for uncoated paper and newsprint, ask the brand office for the matching recipe. Enamel panels and vinyl are matched to the printed swatch card that the brand office keeps.

## Colour and contrast

Line colours are for fills. A line badge is a square in the line colour with the number inside it: white on lines :swatch{color="line-1"} 1, :swatch{color="line-4"} 4 and :swatch{color="line-5"} 5, ink on lines :swatch{color="line-2"} 2 and :swatch{color="line-3"} 3, whose teal and yellow measure 2.3:1 and 1.5:1 against white, below the 3:1 that large type needs.

Small text is never set in a line colour. Warnings and confirmations use :swatch{color="signal-red"} Signal red and :swatch{color="signal-green"} Signal green, which reach 5.5:1 and 5.1:1 on white.

## Neutrals

Ink, a blue-black, sets text and outlines. Paper is plain white, since a tint would dull the yellow of line 3. Rule grey draws rules and the river.

# Type {style="line-4" lead="Names and headings are set in a condensed display face. Running text is set in a sans, and a monospace takes the figures that have to line up."}

## Big Shoulders Display

Station names, line names and headings are set in Big Shoulders Display ExtraBold. It is narrow, so a long name such as Puerta del Mercado still fits a platform sign at a size that reads from the far end of the platform. Set it in capitals and lowercase, never in capitals alone, and never below 14 pt, where its narrow counters start to fill in.

A platform sign is an ink band 400 mm deep. The station name has a cap height of 150 mm and sits on a 100 mm margin. Under it, a square in the colour of each line that stops there comes before the line numbers, set in Public Sans SemiBold.

:::callout{type="sign" title="Puerta del Mercado"}
:swatch{color="line-2"} :swatch{color="line-3"} :swatch{color="line-5"} **Lines 2, 3 and 5**
:::

## Public Sans

Anything people read in sentences, such as a notice or this manual, is set in Public Sans. Text is 9.8 pt on a 14 pt line, ragged right, with no indents and a space between paragraphs.

Captions in print are 8.3 pt, and nothing a rider needs to read is set smaller. On screen, text starts at 16 px and never drops below 14 px.

## Weights

Public Sans is used in four faces. There is no Bold, and the SemiBold takes its place:

:::paragraphs{style="specimen"}
Regular and *Italic*

**SemiBold** and ***SemiBold Italic***
:::

Emphasis is **SemiBold, in ink**, as in this sentence; a heavier weight would compete with the station names. Italic marks the titles of documents and words in another language, such as *andén* on a bilingual sign.

## Spline Sans Mono

Departure times, platform codes and colour values are set in Spline Sans Mono, so that the figures on a departure board line up in columns without tabs:

:::paragraphs{style="specimen-mono"}
07:42 · 07:46 · 07:51 · #E4572E
:::

Use it for values only: at the same size, a sentence in it runs about 30% wider than in Public Sans.

# Usage {style="line-5" lead="The network map is the drawing we print most. The rules below apply to every copy of it, printed or on screen."}

## Drawing the map

Lines run horizontally, vertically or at 45 degrees, and change direction on a curve three line widths in radius, as in :ref{id="do-grid"}. Lines that share track run side by side at an equal spacing, in the order of their numbers, and bend round one centre. A station is a white dot with an ink rim, and an interchange is a single white capsule drawn across every line that stops there. :ref{id="dont-grid"} breaks each of these rules: free angles, curves of any radius, uneven spacing and stations in the line colour.

## Names on the map

Station names are set in Public Sans SemiBold, always horizontal, on the side of the line that has no other line. An interchange carries its name once, beside the capsule.

## Geography

Stations are evenly spaced. The one geographical feature on the map is the river, a band of Rule grey.

## Line width and scale

The line width sets the scale of the whole drawing: 6 mm on the platform map, 1.5 mm on the pocket map and 4 px on screen at the default zoom. A station dot is 1.4 line widths across with a rim of a quarter of a line width, and a capsule is a dot stretched across the lines it serves.

## Files for suppliers

Suppliers receive this manual as a PDF with its typefaces embedded, and as an editable source file that carries Public Sans and Spline Sans Mono. The display face is sent separately from the brand office’s type folder, so that every supplier sets names from the same version of it. Map artwork keeps station names as live text, never as outlines, so that a station renamed by the city can be corrected in every file.

:::paragraphs{style="colophon"}
Metro de Alba is a fictional network. Set in Public Sans, Big Shoulders Display and Spline Sans Mono, under the SIL Open Font License 1.1. Text and drawings: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook
// Its frontmatter fills {title} · {subtitle} in the band and the PDF's title and author.

const ART = {}; // fileId → SVG markup: registered for the pages, packed into the bundle
const drawing = (id, typeId, fileId, [w, h], caption, altText, placement) => ({ id, typeId,
  kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId, width: w * 10, height: h * 10 },
  caption, altText, placement });
const resources = [
  { id: 'colours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { span: 'page' },
    caption: 'The colour system. Line colours are fills; only Ink and the two signals set text.',
    note: 'HEX and RGB for screens; CMYK recipes for coated stock.',
    table: { styleId: 'swatches', model: swatchTable() } },
  drawing('network', 'figure', 'network.svg', [PAGE.width, PAGE.height], '',
    'Five coloured metro lines share a track from the lower left, then fan out to the right.'),
  drawing('do-grid', 'do', 'do-grid.svg', [COL, 43.5],
    'Lines at 0°, 45° and 90°, bent together at an even spacing; one capsule for the interchange.',
    'Three parallel lines bend at 45 degrees together; white stations with ink rims.', FOOT),
  drawing('dont-grid', 'dont', 'dont-grid.svg', [COL, 43.5],
    'Free angles, mixed radii, uneven spacing and stations drawn in the line colour.',
    'The same three lines drawn at free angles with coloured station dots.', FOOT),
];

// #region art: the drawings, made by the rules of section 03
const f = (n) => +n.toFixed(2);
const P = ([x, y]) => `${f(x)} ${f(y)}`;
const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
const add = (a, b, k = 1) => [a[0] + b[0] * k, a[1] + b[1] * k];
const unit = (v) => { const l = Math.hypot(v[0], v[1]); return [v[0] / l, v[1] / l]; };
const dotp = (a, b) => a[0] * b[0] + a[1] * b[1];
const left = ([x, y]) => [y, -x]; // the normal on the left of a direction (y runs down)
const HEADING = { E: [1, 0], NE: [1, -1], SE: [1, 1], S: [0, 1] };
/** From a point, a run of moves such as ['NE', 40]: 0°, 45° and 90° only. */
const walk = (from, moves) => moves.reduce((pts, [h, len]) =>
  [...pts, add(pts[pts.length - 1], unit(HEADING[h]), len)], [from]);
const dirs = (pts, i) => {
  const a = unit(sub(pts[i], pts[i - 1] ?? pts[i])), b = unit(sub(pts[i + 1] ?? pts[i], pts[i]));
  return [Number.isNaN(a[0]) ? b : a, Number.isNaN(b[0]) ? a : b];
};
/** A polyline moved d mm to its left, mitred at each bend, so parallel lines stay parallel. */
const shift = (pts, d) => pts.map((p, i) => {
  const [n1, n2] = dirs(pts, i).map(left);
  return add(p, add(n1, n2), d / (1 + dotp(n1, n2)));
});
/** A path through the points, each bend rounded: radius R, or R ± d for a line d mm off a
 *  shared centre line, so that the bends of parallel lines stay concentric. */
function track(pts, R, offsets = []) {
  let out = `M${P(pts[0])}`;
  for (let i = 1; i < pts.length - 1; i++) {
    const [a, b] = dirs(pts, i);
    const turn = Math.acos(Math.max(-1, Math.min(1, dotp(a, b))));
    const r = R + (offsets[i] ?? 0) * Math.sign(a[0] * b[1] - a[1] * b[0]);
    const cut = r * Math.tan(turn / 2);
    out += ` L${P(add(pts[i], a, -cut))} Q${P(pts[i])} ${P(add(pts[i], b, cut))}`;
  }
  return `${out} L${P(pts[pts.length - 1])}`;
}
/** The point `dist` mm along a polyline. */
function along(pts, dist) {
  for (let i = 1; i < pts.length; i++) {
    const len = Math.hypot(...sub(pts[i], pts[i - 1]));
    if (dist <= len) return add(pts[i - 1], unit(sub(pts[i], pts[i - 1])), dist);
    dist -= len;
  }
  return pts[pts.length - 1];
}
const stroke = (d, colour, w, cap = 'butt') => `<path d="${d}" fill="none" stroke="${colour}" `
  + `stroke-width="${f(w)}" stroke-linecap="${cap}" stroke-linejoin="round"/>`;
/** A station: a white dot with an ink rim. An interchange: one capsule from a to b. */
const station = (p, w) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.7 * w)}" `
  + `fill="${palette.paper}" stroke="${palette.ink}" stroke-width="${f(w / 4)}"/>`;
const capsule = (a, b, w) => stroke(`M${P(a)} L${P(b)}`, palette.ink, 1.65 * w, 'round')
  + stroke(`M${P(a)} L${P(b)}`, palette.paper, 1.15 * w, 'round'); // a dot's cross-section
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}"><clipPath id="frame"><rect width="${w}" `
  + `height="${h}"/></clipPath><g clip-path="url(#frame)">${body}</g></svg>`;
const LINES = ['line-1', 'line-2', 'line-3', 'line-4', 'line-5'];

function networkArt(W, H) {
  const w = 6.5, gap = 9.5, R = 3 * w; // line width, spacing and corner radius, in mm
  const trunk = walk([-20, 272], [['NE', 100], ['E', 72]]); // shared track from the lower left
  const offsets = LINES.map((_, i) => (2 - i) * gap); // line 1 on the left, line 5 on the right
  const branches = [[['E', 8], ['NE', 95], ['E', 60]], [['E', 34], ['NE', 38], ['E', 60]],
    [['E', 110]], [['E', 26], ['SE', 36], ['E', 60]], [['E', 4], ['SE', 44], ['S', 60]]];
  const river = walk([-10, 148], [['E', 40], ['SE', 52], ['S', 120]]);
  let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>`
    + stroke(track(river, 28), palette.rule, 15);
  const lines = LINES.map((id, i) => {
    const own = shift(trunk, offsets[i]);
    const branch = walk(own[own.length - 1], branches[i]);
    const d = [...own.map(() => offsets[i]), ...branches[i].map(() => 0)];
    out += stroke(track([...own, ...branch.slice(1)], R, d), palette[id], w);
    return branch;
  });
  const across = (p, dir) => [add(p, left(dir), 2 * gap + 0.05 * w),
    add(p, left(dir), -2 * gap - 0.05 * w)];
  out += capsule(...across(along(trunk, 62), unit(HEADING.NE)), w)
    + capsule(...across(along(trunk, 158), HEADING.E), w);
  const stops = [[45, 80], [12, 90], [34, 68], [44, 82], [32, 88]]; // on straights only
  lines.forEach((branch, i) => { for (const s of stops[i]) out += station(along(branch, s), w); });
  return svg(W, H, out);
}

/** Three lines and their stops, drawn by the rules (good) or against every one of them. */
function gridArt(W, H, good) {
  const w = 3.6, gap = 5.4, R = 3 * w;
  const ids = ['line-1', 'line-4', 'line-5'];
  const dot = (p, id) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.75 * w)}" `
    + `fill="${palette[id]}"/>`;
  let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>`;
  if (good) {
    const trunk = walk([-4, 11], [['E', 30], ['SE', 20]]);
    const lines = [['E', 70], ['E', 70], ['S', 40]].map((move, i) =>
      shift([...trunk, ...walk(trunk[2], [move]).slice(1)], (1 - i) * gap));
    lines.forEach((pts, i) => {
      const shared = (k) => k === 1 || (k === 2 && i < 2); // bends two or three lines share
      out += stroke(track(pts, R, pts.map((_, k) => (shared(k) ? (1 - i) * gap : 0))),
        palette[ids[i]], w);
    });
    const hub = along(trunk, 12); // the interchange, on the shared straight
    out += capsule(add(hub, [0, -gap - 0.05 * w]), add(hub, [0, gap + 0.05 * w]), w);
    for (const [i, s] of [[0, 72], [1, 84], [2, 52]]) out += station(along(lines[i], s), w);
  } else { // free angles, radii and spacing; stops as coloured dots, three dots for the hub
    const lines = [[[-4, 7], [22, 7], [44, 19], [90, 17]], [[-4, 15], [30, 14], [46, 31], [90, 31]],
      [[-4, 20], [24, 21], [40, 35], [45, 60]]];
    lines.forEach((pts, i) => { out += stroke(track(pts, [2, 15, 6][i]), palette[ids[i]], w); });
    lines.forEach((pts, i) => { out += dot(along(pts, 12), ids[i]); });
    for (const [i, s] of [[0, 72], [1, 84], [2, 58]]) out += dot(along(lines[i], s), ids[i]);
  }
  const frame = `<rect x="0.15" y="0.15" width="${W - 0.3}" height="${H - 0.3}" fill="none" `
    + `stroke="${palette.rule}" stroke-width="0.3"/>`; // a hairline in Rule grey
  return svg(W, H, out + frame);
}
ART['network.svg'] = networkArt(PAGE.width, PAGE.height);
ART['do-grid.svg'] = gridArt(COL, 43.5, true);
ART['dont-grid.svg'] = gridArt(COL, 43.5, false);
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// The brand's files and no others: no Bold (the SemiBold stands in), the display face in
// ExtraBold only. The PDF shows Fontsource's names ('PublicSansThin-SemiBold'); yours show theirs.
const FONTS = {
  'Public Sans': ['400', '400i', '600', '600i'],
  'Big Shoulders Display': ['800'],
  'Spline Sans Mono': ['400'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the brand's faces first, then the pages, then a PDF from the same bytes
await loadBrandFonts(); // the answer, step 1
for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup);
const doc = buildDocument({ markdown, resources }, config());
showPages(doc, { title: 'Metro de Alba · Identity manual' });
offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files
// #endregion

// #region bundle: one .postext file with the text, the design, the drawings and the fonts
const pack = Object.assign(document.createElement('button'), { type: 'button',
  textContent: 'Build the .postext' });
pack.addEventListener('click', async () => {
  const { bytes, manifest, warnings } = await createBundle({
    name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources,
    files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes
  });
  const size = `${Math.round(bytes.length / 1024)} KB`;
  const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`;
  kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out
  pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`,
    href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })),
    textContent: `Download ${RECIPE}.postext · ${size}` }));
});
document.getElementById('pt-actions').append(pack);
// #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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

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

### Includi anche il carattere da titolo

Se la licenza ti permette di cedere i file, togli il flag, e il pacchetto porta tutte e tre le famiglie in 96 KB.

```diff
-  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
+  name,
```

### Usa un vero Bold

`boldFontWeight: 600` compone i tratti in grassetto, le intestazioni delle tabelle e le etichette delle didascalie nel SemiBold, perché il marchio non ha un Bold; se la tua famiglia ce l’ha, aggiungi i suoi file a `FONTS`, elimina l’impostazione e riscrivi la riga di campione dell’esempio e il suo “There is no Bold”, che non sarebbe più vero.

```diff
-    boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis**
-  'Public Sans': ['400', '400i', '600', '600i'],
+  'Public Sans': ['400', '400i', '600', '600i', '700', '700i'],
```

## Errori frequenti

- **customFonts non carica alcun font.** config.customFonts si limita a nominare i file dei font per fileId: buildDocument e i renderer canvas, HTML e PDF non lo leggono mai, quindi una famiglia elencata lì e non registrata viene misurata e disegnata con un font di ripiego. Registra tu ogni variante (un FontFace creato dai suoi byte) prima della prima build e passa gli stessi byte al provider dei font del PDF. createBundle legge l’elenco per mettere i file in un .postext, e la Sandbox lo legge per registrare le varianti.
- **Un grassetto o un corsivo che la famiglia non ha viene simulato a schermo, non nel PDF.** Quando il testo chiede un peso o uno stile che la sua famiglia non include (un’intestazione di tabella in grassetto con un font per etichette a una sola variante, un corsivo in un sans senza corsivi), il browser lo sintetizza sul canvas e in HTML, ispessendo o inclinando il tondo sulle stesse larghezze. Un PDF incorpora solo varianti reali, quindi lì la variante più vicina fornita dal provider si stampa in tondo. Metti nell’elenco dei font solo le varianti che la famiglia include e imposta ogni stile di conseguenza, per esempio tableStyle.headerBold: false.
- **I file di Fontsource coprono le lettere, non i simboli.** Il kit incorpora il file latin di Fontsource di un carattere, più il suo file latin-ext per č, ł, † e il suo file greek per α, χ (nelle famiglie che ne hanno uno: Lora e Gelasio no), quando il testo li compone, a schermo e nel PDF. Simboli come →, ≈, ✓ e ★ non sono in nessuno di quei file e spariscono nel PDF: disegnali, componili come matematica o scegli un carattere i cui file li contengano.

Passa a `createBundle` i byte di ogni font sotto il `fileId` che gli assegna `customFonts`. Una variante senza byte viene esclusa con un avviso “missing file, skipped”, e una famiglia che perde tutte le varianti sparisce dal manifest.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: The Metro de Alba identity manual, a fictional network: Ignacio Ferro, CC-BY-4.0
- Immagini: The network drawing on the cover and the two map examples, drawn in code: Ignacio Ferro, CC-BY-4.0
- Caratteri: Public Sans (OFL-1.1), Big Shoulders Display (OFL-1.1), Spline Sans Mono (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 025 · Un vero PDF con gli stessi font incorporati](https://postext.dev/it/cookbook/pdf-with-embedded-fonts.md): Il programma di un recital esportato in PDF. Ogni carattere si scarica una volta sola, per FontFace e per il PDF, e ogni titolo diventa un segnalibro. · Livello 2 (Intermedio) · Fogli singoli ed effimeri
- [Nº 026 · Un campionario con ogni font caricato prima di impaginare](https://postext.dev/it/cookbook/fonts-before-layout.md): Un campionario di quattro pagine costruito una volta troppo presto, come prova, e poi con ogni carattere chiesto da config e testo caricato prima di misurare. · Livello 2 (Intermedio) · Cataloghi
- [Nº 019 · Parti a colori da un solo attributo](https://postext.dev/it/cookbook/parts-in-colour.md): Una guida tascabile dove ogni :::part imposta una voce di palette, e pagina di parte, verso dipinto, linguette, segni e riga d’indice ne prendono il colore. · Livello 3 (Avanzato) · Manuali, guide e opere di consultazione
