# Fuentes de marca en la composición, el PDF y el paquete

> El manual de identidad de un metro ficticio con los archivos de fuente de la marca, descargados una vez para la composición, el PDF y un paquete .postext.

- Versión HTML: https://postext.dev/es/cookbook/brand-fonts-identity-manual
- Receta N.º 040 · Salida e integración · Nivel 3 (Avanzado) · Salidas: Canvas, PDF, Paquete .postext
- Géneros: Manuales, guías y obras de consulta
- Requiere postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · probada con 1.4.1, postext-pdf 1.4.1 el 2026-09-26
- Páginas: [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
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/brand-fonts-identity-manual.md)

## Lo que vas a componer

Cuatro páginas, en inglés, del manual de identidad del Metro de Alba, una red ficticia de cinco líneas. En la cubierta, las cinco líneas salen juntas de la esquina inferior izquierda y se separan hacia la derecha tras cruzar un río gris, bajo el nombre en Big Shoulders Display. Cada sección se abre bajo una franja negra, con un número de dos cifras en el color de una línea. En *01 Colour*, la primera columna de la tabla es el propio color; *02 Type* reúne líneas de muestra y un rótulo de andén, y *03 Usage* cierra con dos planos enmarcados, uno dibujado según las reglas (Do) y otro saltándoselas (Don't). Todo el texto sale de seis archivos de fuente que el código descarga antes de componer. El PDF se dibuja con esos seis archivos y el `.postext` lleva cinco, porque la fuente de rótulos está marcada como no redistribuible.

**Esta receta responde a:**

- ¿Cómo compongo con los archivos de fuente de mi marca y los incrusto en el PDF?
- ¿Cómo exporto un PDF de verdad en el navegador, con las fuentes incrustadas?
- ¿Cómo evito que los títulos, las negritas y las viñetas salgan en azul?
- ¿Cómo añado muestras de color como leyenda en el texto, los pies o las notas de tabla?
- ¿Cómo creo un paquete .postext desde el código para pasar un documento al Sandbox o a otro programa?

## La respuesta corta

```js
// script.js, líneas 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' })),
}));
```

## Ingredientes

**Enseña**

- [Tus propias fuentes](https://postext.dev/es/docs/configuration.md#fuentes-personalizadas): Familias corporativas o con licencia declaradas en customFonts, registradas para la composición e incrustadas en el PDF con los mismos bytes.
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores): Colores con nombre a los que cada ajuste se enlaza por id, de modo que todo el documento cambia de tono al modificar una muestra; los colores por defecto salen de main-color.
- [Rellenos de celda](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Un fondo por celda enlazado a la paleta, para mapas de calor, matrices de compatibilidad y filas alternas hechas a mano.

**También usa**

- [Muestras de color](https://postext.dev/es/docs/document-format.md#formato-en-línea)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Estilos de tabla con nombre](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Estilo de los pies](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Tipografía del texto](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Una o dos columnas](https://postext.dev/es/docs/configuration.md#tipos-de-disposición)
- [Fuentes antes de componer](https://postext.dev/es/docs/configuration.md#caché-de-medidas)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Paquetes .postext](https://postext.dev/es/docs/configuration.md#paquetes-archivos-postext)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Colores por parte](https://postext.dev/es/docs/configuration.md#el-contenedor-part)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Capítulos sin número](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`customFonts`](https://postext.dev/es/docs/configuration.md#fuentes-personalizadas), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`createBundle`](https://postext.dev/es/docs/configuration.md#crear-un-paquete), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf), [`setCellBackground`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)

**Tipografías**

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

## Elaboración

### 1 · Registra las fuentes de la marca, compón y dale al PDF los mismos bytes

```js
// script.js, líneas 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
```

La composición mide cada palabra con las fuentes de `document.fonts`, así que `loadBrandFonts()`, en [la respuesta corta](#la-respuesta-corta), descarga cada archivo una sola vez, guarda los bytes y registra con ellos un `FontFace` antes de llamar a `buildDocument`. El `loadFonts` del kit solo descarga de Fontsource, y una marca sirve sus archivos desde su propio servidor. En postext-pdf 1.4.1, `renderToPdf` pide diez variantes, cuatro de las cuales no aparecen en ningún texto (la cursiva de la fuente de rótulos y la cursiva, la seminegrita y la seminegrita cursiva de la monoespaciada), y `brandFontProvider` responde a cada petición con el archivo más cercano de la misma familia ([¿Por qué un proveedor de fuentes?](/es/docs/configuration#por-qué-un-proveedor-de-fuentes)). Las páginas solo usan los seis archivos que midió el canvas, pero la 1.4.1 escribe además una copia sin usar del archivo sustituto por cada una de esas cuatro peticiones.

### 2 · El sistema de color es una sola lista

```js
// script.js, líneas 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 }))),
  ] });
```

Como la paleta y la tabla 1.1 se construyen con las mismas nueve filas, la tabla no puede dar un valor que las páginas no usen. `setCellBackground` rellena la primera celda de cada fila, que va vacía, con el color de esa fila enlazado a la paleta. En el texto, `:swatch{color="line-2"}` recibe el identificador de una entrada de la paleta y dibuja un cuadrado perfilado con el color del texto. `main-color` toma el hex de Ink, de modo que los títulos, las negritas y las cursivas, enlazados a él por defecto, salen en Ink y no en el azul del motor. En la 1.4.1 las remisiones conservan ese azul aunque cambies `main-color`; por eso `bodyText.referenceColor: col('ink')` las pone también en Ink ([Paleta de colores](/es/docs/configuration#paleta-de-colores)).

### 3 · Una apertura, tres colores de línea

```js
// script.js, líneas 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}'` escribe el contador con dos cifras, y `{number}` imprime 01, 02 y 03 en la apertura; el título de la cubierta lleva `numbered: false` y se queda sin número. Los tres estilos de sección solo se diferencian en la entrada `section` de la paleta, a la que cada uno da el color de su línea. El número y el cuadrado de la franja están enlazados a `section`, de modo que un único diseño de apertura sirve para las tres secciones. `minHeight` reserva para la apertura diez líneas de la rejilla base de 14 pt, y el texto de cada sección empieza en la misma línea de su página.

### 4 · Do y Don't son tipos de figura

```js
// script.js, líneas 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
```

Un tipo de recurso admite un `captionStyle` parcial, y estos dos solo cambian el color de la etiqueta: Signal green en Do y Signal red en Don't ([Tipos de recurso](/es/docs/configuration#tipos-de-recurso)). Los dos colores de señal llegan a 5,1:1 y 5,5:1 sobre blanco, mientras que las líneas 1, 2, 3 y 5 se quedan por debajo del 4,5:1 que pide el texto pequeño. Los dos dibujos flotan con `position: 'bottom'` y se citan en el mismo párrafo, el primero de Drawing the map, así que comparten el pie de la página 4, Do en la columna izquierda y Don't en la derecha.

### 5 · El paquete lleva las fuentes que puede compartir

```js
// script.js, líneas 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` lee `config.customFonts`, busca en `files` los bytes de cada variante por su `fileId` y los guarda en `fonts/`, con una entrada `fonts` en el manifiesto. La familia de rótulos lleva `redistributable: false`, que aquí simula una licencia que no deja repartir los archivos (la de Big Shoulders Display, la OFL, sí lo permite). Su archivo se queda fuera y `warnings` nombra la familia. El archivo de 81 KB contiene el capítulo, la configuración, los tres dibujos y cinco archivos de fuente. La composición nunca lee `customFonts`, y sin él las páginas salen iguales ([Crear un paquete](/es/docs/configuration#crear-un-paquete)).

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Incluye también la fuente de rótulos

Si la licencia te permite repartir los archivos, quita `redistributable` y el paquete llevará las tres familias en 96 KB.

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

### Usa una negrita de verdad

Como la marca no tiene negrita, `boldFontWeight: 600` compone en seminegrita las negritas, los encabezados de tabla y las etiquetas de los pies; si tu familia la tiene, añade sus archivos a `FONTS`, borra el ajuste y cambia la línea de muestra del texto de ejemplo y su «There is no Bold», que dejarían de ser ciertos.

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

## Errores frecuentes

- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **customFonts no carga ninguna fuente.** config.customFonts solo nombra archivos de fuente por su fileId: buildDocument y los renderizadores de canvas, HTML y PDF no lo leen nunca, así que una familia que figura ahí y no se ha registrado se mide y se pinta con una fuente de reserva. Registra tú cada estilo (un FontFace con sus bytes) antes de la primera composición y pasa esos mismos bytes al proveedor de fuentes del PDF. createBundle lee la lista para meter los archivos en un .postext, y el Sandbox, para registrar las fuentes.
- **El PDF pide todos los pesos y estilos de cada familia.** renderToPdf pide al proveedor de fuentes la negrita, la cursiva y la negrita cursiva de cada familia que un bloque podría usar, aunque nunca se imprima, y un solo rechazo detiene la exportación. El proveedor debe ajustarse al peso más cercano que tenga la familia y volver a la redonda cuando no haya cursiva.
- **La negrita o la cursiva que la familia no trae se simula en pantalla, no en el PDF.** Cuando un texto pide un peso o un estilo que su familia no trae (una cabecera de tabla en negrita con una fuente de rótulos de un solo estilo, cursiva en una sans sin cursivas), el navegador lo sintetiza en el canvas y en HTML: engruesa o inclina la redonda con los mismos anchos. Un PDF solo incrusta fuentes reales, así que allí se imprime la fuente más cercana que dé el proveedor, sin engrosar ni inclinar. Pon en tu lista de fuentes solo las que la familia trae y ajusta cada estilo a ellas, por ejemplo con tableStyle.headerBold: false.
- **Los archivos latin de Fontsource solo traen glifos del rango latino.** El proveedor del PDF incrusta los archivos latin de Fontsource, que cubren el español y las lenguas de Europa occidental pero no →, ≈, ✓, ★, el griego ni las letras de Europa central; esos glifos faltan en el PDF. Mantén el texto del PDF dentro del rango latin.
- **El PDF guarda una copia de fuente por cada estilo que pide.** postext-pdf 1.4.1 escribe un programa de fuente por cada familia, peso y estilo que pide al proveedor de fuentes, aunque el proveedor responda a varios con el mismo archivo y ninguna página dibuje con ese estilo. Por eso, cada sustituto de una cursiva o de un peso que falta añade otra copia del archivo que toma prestado, sin usar si ningún texto va en ese estilo. pdffonts solo lista las fuentes que usan las páginas, pero las copias están en el archivo igualmente, y nada de lo que haga un pen las quita.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.

Pasa a `createBundle` los bytes de cada fuente con el `fileId` que les da `customFonts`. Una variante sin bytes se queda fuera con el aviso «missing file, skipped», y la familia que se queda sin variantes desaparece del manifiesto.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: El manual de identidad del Metro de Alba, una red ficticia: Ignacio Ferro, CC-BY-4.0
- Imágenes: El dibujo de la red en la cubierta y los dos ejemplos de plano, dibujados en código: Ignacio Ferro, CC-BY-4.0
- Tipografías: Public Sans (OFL-1.1), Big Shoulders Display (OFL-1.1), Spline Sans Mono (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 025 · Un PDF de verdad con las mismas fuentes incrustadas](https://postext.dev/es/cookbook/pdf-with-embedded-fonts.md): Un programa de mano exportado a PDF. Cada fuente se descarga una sola vez, para FontFace y para el PDF, y cada título se convierte en un marcador. · Nivel 2 (Intermedio) · Hojas sueltas y efímeros
- [N.º 026 · Muestrario con todas sus fuentes cargadas antes de componer](https://postext.dev/es/cookbook/fonts-before-layout.md): Un muestrario de cuatro páginas cuya primera composición nombra las fuentes que usa; el pen las carga, vacía la caché de anchos y vuelve a componer. · Nivel 2 (Intermedio) · Catálogos
- [N.º 019 · Partes en color con un solo atributo](https://postext.dev/es/cookbook/parts-in-colour.md): En esta guía de campo, cada :::part redefine un color de la paleta, que tiñe su portadilla, el dorso pintado, la pestaña, las negritas y su fila del índice. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
