# Brand fonts in layout, PDF and bundle

> A fictional metro's identity manual set in the brand's own font files, fetched once and reused by the layout, the PDF's font provider and a .postext bundle.

- HTML version: https://postext.dev/en/cookbook/brand-fonts-identity-manual
- Recipe Nº 040 · Output & integration · Level 3 (Advanced) · Outputs: Canvas, PDF, .postext bundle
- Genres: Manuals, guides & reference
- Requires postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · tested with 1.4.1, postext-pdf 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p01.webp?v=db408b93), [2](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p02.webp?v=db408b93), [3](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p03.webp?v=db408b93), [4](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p04.webp?v=db408b93)
- PDF: https://postext.dev/cookbook/brand-fonts-identity-manual/en/brand-fonts-identity-manual.pdf?v=db408b93
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/brand-fonts-identity-manual.md)

## What you'll build

Four pages of the identity manual for Metro de Alba, a fictional metro of five lines. On the cover, the five lines leave the lower left on one shared track and branch to the right after crossing a grey river, under the name in Big Shoulders Display. Each section opens under an ink band, with a large two-digit number in the colour of one line. In *01 Colour* the first column of the table is the colour itself; *02 Type* has specimen lines and a platform sign, and *03 Usage* closes on two framed map drawings, one drawn by the rules (Do) and one against them (Don't). All the text is set in six font files that the script downloads before the first layout. The PDF is drawn with the same six files, and the `.postext` file carries five, because the display face is marked as not redistributable.

**This recipe answers:**

- How do I set pages in my own font files and embed those same files in the PDF?
- How do I export a real PDF in the browser with the fonts embedded?
- How do I stop headings, bold words and bullets from coming out blue?
- How do I add colour-key swatches to text, captions or table notes?
- How do I create a .postext bundle from code, to hand a document to the Sandbox or another program?

## The short answer

The brand's font files, fetched once for the layout, the PDF and the bundle.

```js
// script.js, lines 16–54
// 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. It also asks for faces no text uses (the
// display face's italic, the monospace's SemiBold): answer with the family's closest file.
// 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies).
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' })),
}));
```

## Ingredients

**Teaches**

- [Your own fonts](https://postext.dev/en/docs/configuration.md#custom-fonts): Brand or licensed families declared in customFonts, registered for layout and embedded in the PDF from the same bytes.
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette): Named colours that every setting links to by id, so a whole document retints by changing one swatch; the default colours derive from main-color.
- [Cell fills](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement): A palette-linked background per cell, for heat maps, compatibility matrices and zebra rows set by hand.

**Also uses**

- [Colour swatches](https://postext.dev/en/docs/document-format.md#inline-formatting)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Named table styles](https://postext.dev/en/docs/configuration.md#named-table-styles)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Caption style](https://postext.dev/en/docs/configuration.md#caption-style)
- [Figure placement](https://postext.dev/en/docs/document-format.md#placement)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Body type](https://postext.dev/en/docs/configuration.md#body-text)
- [One or two columns](https://postext.dev/en/docs/configuration.md#layout-types)
- [Fonts before layout](https://postext.dev/en/docs/configuration.md#measurement-cache)
- [PDF export](https://postext.dev/en/docs/configuration.md#generating-pdfs)
- [.postext bundles](https://postext.dev/en/docs/configuration.md#bundles-postext-files)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Figure and Table in your language](https://postext.dev/en/docs/configuration.md#resource-types)
- [Colours per part](https://postext.dev/en/docs/configuration.md#the-part-container)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration.md#why-a-font-provider)
- [Running heads per section](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Unnumbered chapters](https://postext.dev/en/docs/configuration.md#heading-styles)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`captionStyle`](https://postext.dev/en/docs/configuration.md#caption-style), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`customFonts`](https://postext.dev/en/docs/configuration.md#custom-fonts), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyles`](https://postext.dev/en/docs/configuration.md#named-table-styles)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`createBundle`](https://postext.dev/en/docs/configuration.md#creating-a-bundle), [`decompressWoff2`](https://postext.dev/en/docs/configuration.md#browser-font-provider-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration.md#generating-pdfs), [`setCellBackground`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)

**Typefaces**

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

## Method

### 1 · Register the brand's faces, build, and give the PDF the same bytes

```js
// script.js, lines 451–456
await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first)
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
```

Layout measures every word with the faces in `document.fonts`, so `loadBrandFonts()` in [the short answer](#the-short-answer) fetches each file once, keeps its bytes and registers a `FontFace` built from them before `buildDocument` runs. The kit's `loadFonts` only fetches from Fontsource, and a brand serves its files from its own server. In postext-pdf 1.4.1 `renderToPdf` asks for ten faces, four of which no text uses (the display face's italic, and the italic, SemiBold and SemiBold italic of the monospace), and `brandFontProvider` answers each request with the closest file of the same family ([Why a font provider?](/en/docs/configuration#why-a-font-provider)). The pages use only the six files the canvas measured, but 1.4.1 also writes an unused copy of the stand-in file for each of those four requests.

### 2 · The colour system is one list

```js
// script.js, lines 60–85
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() writes the hex too: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
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 }))),
  ] });
```

Because the palette and Table 1.1 are built from the same nine rows, the table cannot list a value the pages do not use. `setCellBackground` fills the empty first cell of each row with that row's colour, linked to the palette. In the text, `:swatch{color="line-2"}` takes a palette id and draws a square outlined in the text colour. `main-color` has the hex of Ink, so headings and bold and italic runs, whose defaults link to it, print in Ink instead of the engine's blue. References keep that blue in 1.4.1 whatever `main-color` holds; `bodyText.referenceColor: col('ink')` sets them in Ink as well ([Color Palette](/en/docs/configuration#color-palette)).

### 3 · One opener, three line colours

```js
// script.js, lines 94–112
// Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha:
// design-lineheight-multiple). 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}'` writes the counter with two digits, and `{number}` prints 01, 02 and 03 in the opener; the cover heading is `numbered: false` and gets no number. The three section styles differ only in the palette entry `section`, which each sets to its line's colour. The number and the square in the band are linked to `section`, so one opener design serves all three sections. `minHeight` reserves ten lines of the 14 pt grid for the opener, and the text of every section starts on the same grid line of its page.

### 4 · Do and Don't are figure types

```js
// script.js, lines 161–166
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
```

A resource type can carry a partial `captionStyle`, and these two set only the label colour, Signal green for Do and Signal red for Don't ([Resource types](/en/docs/configuration#resource-types)). The signals reach 5.1:1 and 5.5:1 on white, while lines 1, 2, 3 and 5 stay under the 4.5:1 that small text needs. Both drawings float with `position: 'bottom'` and are cited in the same paragraph, the first under Drawing the map, so they share the foot of page 4, Do in the left column and Don't in the right.

### 5 · The bundle packs the fonts it may share

```js
// script.js, lines 460–474
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` reads `config.customFonts`, looks up each variant's bytes in `files` by its `fileId` and writes them under `fonts/`, with a `fonts` entry in the manifest. The display family carries `redistributable: false`, standing in for a licence that forbids handing the files on (Big Shoulders Display itself is under the OFL). Its file stays out, and `warnings` names the family. The 81 KB file holds the chapter, the configuration, the three drawings and five font files. Layout never reads `customFonts`; the pages come out the same without it ([Creating a bundle](/en/docs/configuration#creating-a-bundle)).

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: 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 (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, 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. It also asks for faces no text uses (the
// display face's italic, the monospace's SemiBold): answer with the family's closest file.
// 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies).
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() writes the hex too: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
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
// Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha:
// design-lineheight-multiple). 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 = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  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 skip the palette (gotcha: palette-skips-designs)
    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
    // span: 'page' breaks already; restated in case it goes (gotcha: headings-drop-h1-break)
    { 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:
// every value is quoted (gotcha: quote-frontmatter).

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 (gotcha: fonts-first)
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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

// ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show 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 v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

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

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

## Variations

### Pack the display face too

If the licence lets you hand the files on, drop the flag, and the bundle carries all three families in 96 KB.

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

### Use a real Bold

`boldFontWeight: 600` sets bold runs, table heads and caption labels in the SemiBold, because the brand has no Bold; if your family has one, add its files to `FONTS`, delete the setting and rewrite the sample's specimen line and its “There is no Bold”, which would no longer hold.

```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'],
```

## Pitfalls

- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.
- **customFonts loads no font.** config.customFonts only names font files by fileId: buildDocument and the canvas, HTML and PDF renderers never read it, so a family listed there and not registered is measured and painted in a fallback. Register each face yourself (a FontFace from its bytes) before the first build and give the same bytes to the PDF font provider. createBundle reads the list to pack the files into a .postext, and the Sandbox reads it to register the faces.
- **The PDF asks for every weight and style of every family.** renderToPdf asks the font provider for the bold, italic and bold-italic faces of every family a block could use, even ones never printed, and a single rejection stops the export. The provider must snap to the nearest weight the family ships and fall back to upright when there is no italic.
- **A bold or italic the family lacks is faked on screen, not in the PDF.** When text asks for a weight or style its family does not ship (a bold table header in a single-face label font, italic in a sans with no italics), the browser synthesises it on the canvas and in HTML, thickening or slanting the upright face on the same widths. A PDF embeds real faces only, so there the provider's closest face prints plain. Put only faces the family ships in your font list and set every style to match, such as tableStyle.headerBold: false.
- **Fontsource latin files drop glyphs outside Latin.** The PDF provider embeds Fontsource's latin files, which cover Spanish and Western European text but not →, ≈, ✓, ★, Greek or Central European letters; those glyphs go missing in the PDF. Keep PDF text inside the latin range.
- **The PDF keeps a font copy for every face it asks for.** postext-pdf 1.4.1 writes one font program for every family, weight and style it asks the font provider for, even when the provider answers several of them with the same file and no page draws with the face. Each stand-in for a missing italic or weight therefore adds another copy of the file it borrows, unused when no text is set in that face. pdffonts lists only the fonts the pages use, but the copies are in the file all the same, and nothing a pen does removes them.
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **A design text's lineHeight is a multiple, never a dimension.** In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title.
- **Design text overflow defaults to 'ellipsis-end'.** A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines.
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().
- **Quote every frontmatter value.** YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984".
- **A no-break space still breaks the line.** In postext 1.4.1 the line breaker treats U+00A0 as an ordinary space, so 0.08 %, 2.006 s or Section 2 can split across two lines. Close the pair up (0.08%) or reword the sentence.

Pass `createBundle` each font's bytes under the `fileId` that `customFonts` gives it. A variant without bytes is left out with a “missing file, skipped” warning, and a family that loses every variant drops out of the manifest.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The Metro de Alba identity manual, a fictional network: Ignacio Ferro, CC-BY-4.0
- Images: The network drawing on the cover and the two map examples, drawn in code: Ignacio Ferro, CC-BY-4.0
- Type: Public Sans (OFL-1.1), Big Shoulders Display (OFL-1.1), Spline Sans Mono (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 025 · A real PDF with the same fonts embedded](https://postext.dev/en/cookbook/pdf-with-embedded-fonts.md): A recital programme exported to PDF. Each face is fetched once, for FontFace and for the PDF, and each heading becomes a bookmark. · Level 2 (Intermediate) · Single sheets & ephemera
- [Nº 026 · Type specimen with every font loaded before layout](https://postext.dev/en/cookbook/fonts-before-layout.md): A four-page type specimen whose first build names every face its pages use; the pen loads them, clears the width cache and builds again. · Level 2 (Intermediate) · Catalogues
- [Nº 019 · Parts in colour from one attribute](https://postext.dev/en/cookbook/parts-in-colour.md): A pocket field guide where each :::part sets one palette entry, and its divider, painted verso, thumb tabs, field marks and contents row print in that colour. · Level 3 (Advanced) · Manuals, guides & reference
