# Un organigrama i una graella de fotos fets amb mòduls

> Una guia de serveis municipals feta amb mòduls: un organigrama les línies del qual traça la plantilla i graelles que repeteixen una fitxa per edifici.

- Versió HTML: https://postext.dev/ca/cookbook/org-chart-from-modules
- Recepta Núm. 162 · Pàgina i retícula · Nivell 3 (Avançat) · Sortides: Canvas, PDF
- Gèneres: Informes i memòries, Catàlegs
- Requereix postext ≥ 1.48.1, postext-pdf ≥ 1.48.1 · provada amb 1.48.1, postext-pdf 1.48.1 el 2026-10-21
- Pàgines: [1](https://postext.dev/cookbook/org-chart-from-modules/es/p01.webp?v=d9715f93), [2](https://postext.dev/cookbook/org-chart-from-modules/es/p02.webp?v=d9715f93)
- PDF: https://postext.dev/cookbook/org-chart-from-modules/es/org-chart-from-modules.pdf?v=d9715f93
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=org-chart-from-modules&lang=es (.postext: https://postext.dev/cookbook/org-chart-from-modules/es/org-chart-from-modules.postext)
- Última actualització: 2026-10-20
- Altres idiomes: [en](https://postext.dev/en/cookbook/org-chart-from-modules.md), [es](https://postext.dev/es/cookbook/org-chart-from-modules.md), [de](https://postext.dev/de/cookbook/org-chart-from-modules.md), [fr](https://postext.dev/fr/cookbook/org-chart-from-modules.md), [it](https://postext.dev/it/cookbook/org-chart-from-modules.md), [pt](https://postext.dev/pt/cookbook/org-chart-from-modules.md), [zh](https://postext.dev/zh/cookbook/org-chart-from-modules.md), [ja](https://postext.dev/ja/cookbook/org-chart-from-modules.md), [ar](https://postext.dev/ar/cookbook/org-chart-from-modules.md)

## En poques paraules

Dues pàgines d’una guia de l’ajuntament: qui porta cada cosa, com un esquema de caixes unides per línies, i on és cada servei, en files d’imatges. Es fan amb uns pocs blocs reutilitzables; el Markdown diu quants.

## Què compondràs

Una doble pàgina de la guia de serveis de Valdeloma, un poble inventat de 8.400 veïns. La pàgina parella és l’organigrama de l’ajuntament: l’alcaldia i el ple en una caixa vermella, quatre àrees en verd i, sota cada àrea, els seus serveis en caixes clares, units per línies verdes amb un punt o una fletxa a l’extrem. La senar explica on són els serveis: dos panells de tres aquarel·les, amb el nom, l’adreça i l’horari sota cada imatge, i un requadre per a l’oficina d’atenció al veí. Res no es dibuixa a mà. La plantilla té tres mòduls (una àrea, un panell de fotos i un avís) i cada pàgina n’apila tants com en té el seu Markdown; les línies segueixen les caixes allà on el text les porti.

**Aquesta recepta respon a:**

- Com faig un organigrama i una graella de fotos amb mòduls, amb les línies traçades per la plantilla?

## La resposta curta

```js
// script.js, línies 40–72
// The page template draws the head of the chart and a group that takes the page's modules;
// the module draws one area: its box, a grid of its services and the lines between them.
const area = {
  id: 'area', role: 'module',
  frames: [
    { name: 'concejalia', style: 'area', width: mm(84), placement: at('container', 'top-left') },
    { name: 'servicio', style: 'servicio' }, // repeated: one per :::frame{slot="servicio"}
  ],
  groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
    repeat: { frames: ['servicio'] }, // rowAlign 'stretch': a row's boxes are one height
    placement: { ...at('#concejalia', 'below', 0, 7), size: { width: 'fill', height: 'fill' } } }],
  decoration: { elements: [
    // From the area down to each of its services: a tree with a bar over the row.
    line({ id: 'rama', routing: 'tree', from: { to: '#concejalia', side: 'bottom' },
      to: { to: '#servicio[*]', side: 'top' }, endCap: 'dot', capSize: mm(1.8) }),
    // From the mayor's box, a frame of the page, to this area: the spine and its branch.
    line({ id: 'tronco', routing: 'elbow', width: pt(1.8),
      from: { to: '#alcaldia', side: 'bottom', at: 0.06 }, to: { to: '#concejalia', side: 'left' },
      endCap: 'arrow', capSize: mm(2.6) }),
  ] },
};
const chart = {
  id: 'organigrama',
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
    { name: 'entradilla', flow: true, width: mm(W) },
    { name: 'alcaldia', style: 'alcaldia', width: mm(96) },
  ],
  groups: [{ name: 'cabeza', kind: 'stack', gap: mm(6),
    frames: ['titulo', 'entradilla', 'alcaldia'] },
    { name: 'areas', modules: true, gap: mm(6), placement: { ...at('#alcaldia', 'below', SPINE, 9),
      size: { width: mm(W - SPINE), height: 'fill' } } }],
};
```

## Ingredients

**Ensenya**

- [Mòduls apilats en una pàgina](https://postext.dev/ca/docs/configuration-composed-pages.md#mòduls): Un mòdul és una plantilla d’uns quants marcs, grups i decoració pensada per reutilitzar-se; el grup de mòduls d’una plantilla de pàgina pren els blocs :::module de la pàgina en l’ordre del Markdown i els apila l’un sota l’altre.
- [Connectors entre marcs](https://postext.dev/ca/docs/configuration-composed-pages.md#connectors): Un connector de la decoració d’una plantilla traça una línia d’un marc a un o més, recta, en angle o en arbre, amb punts o fletxes als extrems; es col·loca després dels marcs, així que els segueix quan un text creix.
- [Graelles que repeteixen marcs](https://postext.dev/ca/docs/configuration-composed-pages.md#graelles-que-repeteixen-un-joc-de-marcs): Un grup en graella declara una vegada un joc de marcs, una fitxa i el seu peu, i el repeteix per cada element que li dona el Markdown de la pàgina, en files de columnes iguals, amb les còpies anomenades #fitxa[1], #fitxa[2]…

**També fa servir**

- [Pàgines compostes](https://postext.dev/ca/docs/composed-pages.md#pàgines-compostes)
- [Marcs que creixen en un grup](https://postext.dev/ca/docs/composed-pages.md#quan-el-text-creix)
- [Decoració ancorada als marcs](https://postext.dev/ca/docs/composed-pages.md#decoració-capçaleres-i-capes)
- [Requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre)
- [Marges simètrics](https://postext.dev/ca/docs/configuration-page-layout.md#marges-simètrics)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)
- [Pestanyes numerades als requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre)
- [Figures just aquí](https://postext.dev/ca/docs/document-format-resources.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#per-què-un-proveïdor-de-fonts)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre), `canvasViewer`, [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`pageTemplates`](https://postext.dev/ca/docs/configuration-composed-pages.md#plantilles-de-pàgina), [`resourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#construir-un-document), [`decompressWoff2`](https://postext.dev/ca/docs/configuration-pdf-output.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits), [`renderToPdf`](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf), [`withLoadedFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre)

**Tipus de lletra**

- Public Sans (OFL-1.1), Source Serif 4 (OFL-1.1)

## Elaboració

### 1 · Un mòdul per àrea

```js
// script.js, línies 40–72
// The page template draws the head of the chart and a group that takes the page's modules;
// the module draws one area: its box, a grid of its services and the lines between them.
const area = {
  id: 'area', role: 'module',
  frames: [
    { name: 'concejalia', style: 'area', width: mm(84), placement: at('container', 'top-left') },
    { name: 'servicio', style: 'servicio' }, // repeated: one per :::frame{slot="servicio"}
  ],
  groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
    repeat: { frames: ['servicio'] }, // rowAlign 'stretch': a row's boxes are one height
    placement: { ...at('#concejalia', 'below', 0, 7), size: { width: 'fill', height: 'fill' } } }],
  decoration: { elements: [
    // From the area down to each of its services: a tree with a bar over the row.
    line({ id: 'rama', routing: 'tree', from: { to: '#concejalia', side: 'bottom' },
      to: { to: '#servicio[*]', side: 'top' }, endCap: 'dot', capSize: mm(1.8) }),
    // From the mayor's box, a frame of the page, to this area: the spine and its branch.
    line({ id: 'tronco', routing: 'elbow', width: pt(1.8),
      from: { to: '#alcaldia', side: 'bottom', at: 0.06 }, to: { to: '#concejalia', side: 'left' },
      endCap: 'arrow', capSize: mm(2.6) }),
  ] },
};
const chart = {
  id: 'organigrama',
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
    { name: 'entradilla', flow: true, width: mm(W) },
    { name: 'alcaldia', style: 'alcaldia', width: mm(96) },
  ],
  groups: [{ name: 'cabeza', kind: 'stack', gap: mm(6),
    frames: ['titulo', 'entradilla', 'alcaldia'] },
    { name: 'areas', modules: true, gap: mm(6), placement: { ...at('#alcaldia', 'below', SPINE, 9),
      size: { width: mm(W - SPINE), height: 'fill' } } }],
};
```

Una plantilla amb `role: 'module'` és un mòdul: uns quants marcs, grups i decoració pensats per tornar-se a fer servir. La pàgina de l’organigrama té un grup amb `modules: true`, i cada `:::module{template="area"}` del seu Markdown col·loca una àrea més sota l’anterior, a 6 mm. Els marcs del mòdul prenen el nom de la instància, `area[1].concejalia`, `area[2].concejalia`…, de manera que els seus dos connectors s’escriuen una sola vegada: `rama`, un arbre (`tree`) des del peu de l’àrea fins a cada servei de la seva graella (`#servicio[*]`), i `tronco`, un angle (`elbow`) des d’un punt a prop de l’esquerra de la caixa de l’alcaldia (`at: 0.06`) fins al costat esquerre de l’àrea. `#alcaldia` és un marc de la pàgina, no del mòdul: el mòdul conserva aquest extrem i la maquetació el troba a la pàgina, de manera que els quatre angles comparteixen el primer tram i es llegeixen com un sol tronc amb una fletxa cap a cada àrea. Els serveis són una graella de tres columnes que repeteix un marc `servicio`, així que una àrea amb dos serveis dibuixa dues caixes i el seu arbre, dues branques.

### 2 · Panells de fitxes amb foto

```js
// script.js, línies 76–105
const gallery = {
  id: 'galeria', role: 'module',
  frames: [
    { name: 'cabecera', takes: 'h2', placement: at('container', 'top-left', 4, 4) },
    { name: 'foto', style: 'marco' }, // the photo of a card
    { name: 'pie', style: 'ficha', placement: at('#foto', 'below', 0, 2.5) }, // and its words
  ],
  groups: [{ name: 'fotos', kind: 'grid', columns: 3, columnGap: mm(5), rowGap: mm(6),
    rowAlign: 'start', repeat: { frames: ['foto', 'pie'] },
    placement: { ...at('#cabecera', 'below', 0, 3), size: { width: mm(W - 8), height: 'fill' } } }],
  // The module's own ground: a tint as large as the module, whatever its rows.
  decoration: { elements: [{ kind: 'box', id: 'suelo', decorative: true,
    placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
    style: { backgroundColor: col('tint'), borderRadius: mm(3) } }] },
};
// A notice box as a module of its own: one frame whose tab grows out of the box (joined).
const notice = { id: 'aviso', role: 'module', frames: [{ name: 'texto', style: 'aviso',
  placement: at('container', 'top-left', 0, 7) }] };
const places = {
  id: 'centros',
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
    { name: 'entradilla', flow: true, width: mm(W) },
    { name: 'colofon', style: 'colofon', optional: true, width: mm(W),
      placement: at('container', 'bottom-left', 0, 8) },
  ],
  groups: [{ name: 'cabeza', kind: 'stack', gap: mm(5), frames: ['titulo', 'entradilla'] },
    { name: 'galerias', modules: true, gap: mm(7), placement: { ...at('#entradilla', 'below', 0, 7),
      size: { width: mm(W), height: 'fill' } } }],
};
```

Un panell també és un mòdul: un marc de capçalera que pren el títol `##` del mòdul i una graella que repeteix un joc de dos marcs, la foto i les paraules de sota. Cada `:::frame{slot="foto"}` és una fitxa, i el `:::frame{slot="pie"}` que porta a dins va a les paraules d’aquesta fitxa. El fons tintat és decoració del mòdul, una caixa tan gran com el mòdul, així que creix amb les files i amb el peu més llarg. L’oficina d’atenció és un tercer mòdul, un requadre la pestanya del qual és `joined`: pestanya i requadre són un sol contorn amb un sol emplenament.

### 3 · Imatges a la mida d’impressió

```js
// script.js, línies 346–372
const ART = {
  biblioteca: t({ es: 'La sala de lectura de la biblioteca, con estanterías de madera y vecinos '
      + 'de todas las edades leyendo en mesas largas.',
    en: 'The library reading room, with wooden shelves and neighbours of every age reading at '
      + 'long tables.' }),
  musica: t({ es: 'Un aula de la escuela de música: niños con violines, una profesora y una niña '
      + 'al piano.',
    en: 'A room of the music school: children with violins, a teacher and a girl at the piano.' }),
  'centro-mayores': t({ es: 'El centro de día: mayores jugando a las cartas y pintando junto a '
      + 'una puerta al jardín.',
    en: 'The day centre: older people playing cards and painting beside a door to the garden.' }),
  piscina: t({ es: 'La piscina cubierta bajo un techo de madera, con nadadores en las calles y un '
      + 'socorrista.',
    en: 'The covered pool under a timber roof, with swimmers in the lanes and a lifeguard.' }),
  polideportivo: t({ es: 'La pista del polideportivo, con niños jugando al baloncesto y gradas a '
      + 'un lado.',
    en: 'The sports hall court, with children playing basketball and stands along one side.' }),
  mercado: t({ es: 'El mercado de los sábados en los soportales de la plaza, con el ayuntamiento '
      + 'al fondo.',
    en: 'The Saturday market under the arcades of the square, with the town hall behind.' }),
};
const resources = await Promise.all(Object.entries(ART).map(async ([id, altText]) => {
  const fileId = `${id}-900.jpg`;
  await loadImage(fileId, asset(fileId));
  return { id, typeId: 'foto', kind: 'bitmap', caption: '', altText, createdAt: 0, updatedAt: 0,
    bitmap: { fileId, format: 'jpeg', width: 900, height: 600, resolution: 400 } };
}));
```

Les sis aquarel·les fan 900 × 600 px a 400 ppp, 57 mm d’amplada, més o menys el que fa una columna de la graella, així que la graella no les amplia mai. El seu text alternatiu és aquí, a l’script, perquè les imatges són les mateixes a les dues edicions; un peu que canviés amb l’idioma seria una línia `::caption` del Markdown.

### 4 · Un sol verd per a les línies i les caixes

```js
// script.js, línies 15–28
const palette = {
  ink: '#1f2b2a', // text
  green: '#1d6b57', // the lines of the chart, the areas' boxes, the headings
  mayor: '#a8452e', // the box at the head of the chart
  tint: '#e6f1ec', // the services' boxes and the galleries' ground
  rule: '#b9cbc3', // the hairline round a service
  muted: '#5c6b67', // running heads and the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'green (defaults)', value: { hex: palette.green, model: 'hex' } },
];
```

Els connectors, les caixes d’àrea i les capçaleres dels panells fan servir `green`, una entrada de la paleta, així que un poble amb un altre color canvia un sol hexadecimal. La caixa de l’alcaldia porta l’únic color càlid de la doble pàgina, el lloc on el lector mira primer.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/org-chart-from-modules

### script.js

```js
// ═══ Postext Cookbook · Nº 162 · An org chart and a photo grid built from modules ═══
// https://postext.dev/en/cookbook/org-chart-from-modules
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Public Sans, Source Serif 4 (SIL OFL 1.1) · Needs postext ≥ 1.48.1
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'org-chart-from-modules';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: an ink, the town's green for the lines and the areas, a warm mayor's box
const palette = {
  ink: '#1f2b2a', // text
  green: '#1d6b57', // the lines of the chart, the areas' boxes, the headings
  mayor: '#a8452e', // the box at the head of the chart
  tint: '#e6f1ec', // the services' boxes and the galleries' ground
  rule: '#b9cbc3', // the hairline round a service
  muted: '#5c6b67', // running heads and the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'green (defaults)', value: { hex: palette.green, model: 'hex' } },
];
// #endregion

const SANS = 'Public Sans', SERIF = 'Source Serif 4';
const PAGE = { w: 210, h: 297, top: 22, bottom: 22, inner: 18, outer: 15 }; // mm, mirrored
const W = PAGE.w - PAGE.inner - PAGE.outer; // the type area's width: 177 mm
const SPINE = 12; // mm: the room on the left of the areas where the chart's spine runs down
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const line = (more) => ({ kind: 'connector', color: col('green'), width: pt(1.2), radius: mm(2.5),
  ...more });

// #region answer: a module per area of the town hall, stacked by the page's modules group
// The page template draws the head of the chart and a group that takes the page's modules;
// the module draws one area: its box, a grid of its services and the lines between them.
const area = {
  id: 'area', role: 'module',
  frames: [
    { name: 'concejalia', style: 'area', width: mm(84), placement: at('container', 'top-left') },
    { name: 'servicio', style: 'servicio' }, // repeated: one per :::frame{slot="servicio"}
  ],
  groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
    repeat: { frames: ['servicio'] }, // rowAlign 'stretch': a row's boxes are one height
    placement: { ...at('#concejalia', 'below', 0, 7), size: { width: 'fill', height: 'fill' } } }],
  decoration: { elements: [
    // From the area down to each of its services: a tree with a bar over the row.
    line({ id: 'rama', routing: 'tree', from: { to: '#concejalia', side: 'bottom' },
      to: { to: '#servicio[*]', side: 'top' }, endCap: 'dot', capSize: mm(1.8) }),
    // From the mayor's box, a frame of the page, to this area: the spine and its branch.
    line({ id: 'tronco', routing: 'elbow', width: pt(1.8),
      from: { to: '#alcaldia', side: 'bottom', at: 0.06 }, to: { to: '#concejalia', side: 'left' },
      endCap: 'arrow', capSize: mm(2.6) }),
  ] },
};
const chart = {
  id: 'organigrama',
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
    { name: 'entradilla', flow: true, width: mm(W) },
    { name: 'alcaldia', style: 'alcaldia', width: mm(96) },
  ],
  groups: [{ name: 'cabeza', kind: 'stack', gap: mm(6),
    frames: ['titulo', 'entradilla', 'alcaldia'] },
    { name: 'areas', modules: true, gap: mm(6), placement: { ...at('#alcaldia', 'below', SPINE, 9),
      size: { width: mm(W - SPINE), height: 'fill' } } }],
};
// #endregion

// #region gallery: a module of a heading and a grid of photo cards; a notice box module
const gallery = {
  id: 'galeria', role: 'module',
  frames: [
    { name: 'cabecera', takes: 'h2', placement: at('container', 'top-left', 4, 4) },
    { name: 'foto', style: 'marco' }, // the photo of a card
    { name: 'pie', style: 'ficha', placement: at('#foto', 'below', 0, 2.5) }, // and its words
  ],
  groups: [{ name: 'fotos', kind: 'grid', columns: 3, columnGap: mm(5), rowGap: mm(6),
    rowAlign: 'start', repeat: { frames: ['foto', 'pie'] },
    placement: { ...at('#cabecera', 'below', 0, 3), size: { width: mm(W - 8), height: 'fill' } } }],
  // The module's own ground: a tint as large as the module, whatever its rows.
  decoration: { elements: [{ kind: 'box', id: 'suelo', decorative: true,
    placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
    style: { backgroundColor: col('tint'), borderRadius: mm(3) } }] },
};
// A notice box as a module of its own: one frame whose tab grows out of the box (joined).
const notice = { id: 'aviso', role: 'module', frames: [{ name: 'texto', style: 'aviso',
  placement: at('container', 'top-left', 0, 7) }] };
const places = {
  id: 'centros',
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
    { name: 'entradilla', flow: true, width: mm(W) },
    { name: 'colofon', style: 'colofon', optional: true, width: mm(W),
      placement: at('container', 'bottom-left', 0, 8) },
  ],
  groups: [{ name: 'cabeza', kind: 'stack', gap: mm(5), frames: ['titulo', 'entradilla'] },
    { name: 'galerias', modules: true, gap: mm(7), placement: { ...at('#entradilla', 'below', 0, 7),
      size: { width: mm(W), height: 'fill' } } }],
};
// #endregion

const text = (size, lead, more) => ({ fontFamily: SANS, fontSize: pt(size), lineHeight: pt(lead),
  color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0),
  textAlign: 'left', hyphenation: false, ...more });
const pad = (v, h) => ({ top: mm(v), right: mm(h), bottom: mm(v), left: mm(h) });
const calloutStyles = [
  { id: 'alcaldia', backgroundEnabled: true, background: col('mayor'), border: { enabled: false },
    borderRadius: mm(2), padding: pad(3, 4),
    body: text(10.5, 14, { color: col('paper'), boldColor: col('paper') }) },
  { id: 'area', backgroundEnabled: true, background: col('green'), border: { enabled: false },
    borderRadius: mm(2), padding: pad(2.2, 3.5),
    body: text(9.5, 12.5, { color: col('paper'), boldColor: col('paper') }) },
  { id: 'servicio', backgroundEnabled: true, background: col('tint'), borderRadius: mm(1.5),
    border: { enabled: true, color: col('rule'), width: pt(0.5) }, padding: pad(2, 2.8),
    body: text(8.4, 11, { boldColor: col('green') }) },
  { id: 'marco', backgroundEnabled: false, border: { enabled: false }, padding: pad(0, 0) },
  { id: 'ficha', backgroundEnabled: false, border: { enabled: false }, // room under the words
    padding: { ...pad(0, 0), bottom: mm(4) },
    body: text(8.2, 11, { boldColor: col('green') }) },
  { id: 'aviso', backgroundEnabled: true, background: col('tint'), borderRadius: mm(2.5),
    border: { enabled: true, color: col('green'), width: pt(1.2) }, padding: pad(3.5, 5),
    label: { position: 'top-left', joined: true, inset: mm(0), offset: mm(0), height: mm(7),
      paddingX: mm(4), fontFamily: SANS, fontSize: pt(9), fontWeight: 700, color: col('green'),
      rule: { enabled: false } }, // joined: the tab takes the box's fill and outline
    body: text(9.5, 13.5, { boldColor: col('green') }) },
  { id: 'colofon', backgroundEnabled: false, border: { enabled: false }, padding: pad(0, 0),
    body: text(6.8, 9, { color: col('muted') }) },
];

// Running heads: the guide's name on the outer side at the head, the folio at the foot.
const outer = (parity, v, y) => at('page', `${v}-${parity === 'even' ? 'left' : 'right'}`,
  parity === 'even' ? PAGE.outer : -PAGE.outer, y);
const head = (parity) => ({ kind: 'text', id: `head-${parity}`, content: '{title}', parity,
  fontFamily: SANS, fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted'), align: parity === 'even' ? 'left' : 'right',
  placement: outer(parity, 'top', 12) });
const folio = (parity) => ({ kind: 'text', id: `folio-${parity}`, content: '{pageNumber}',
  parity, fontFamily: SANS, fontSize: pt(9), fontWeight: 700, color: col('green'),
  align: parity === 'even' ? 'left' : 'right', placement: outer(parity, 'bottom', -11) });

const config = {
  canvasViewer: { pageGap: 0 }, // the two pages are shown as a spread
  locale: t({ es: 'es', en: 'en-gb' }),
  resourceTypes: [{ id: 'foto', name: t({ es: 'Foto', en: 'Photo' }), shortLabel: '',
    numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '',
    defaultPlacement: { position: 'here' } }],
  colorPalette, calloutStyles,
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: text(10.5, 15, { fontFamily: SERIF, paragraphSpacing: true }),
  headings: { fontFamily: SERIF, fontWeight: 700, color: col('ink'), levels: [
    { level: 1, fontSize: pt(28), lineHeight: pt(32), marginTop: pt(0), marginBottom: pt(0),
      breakBefore: { enabled: false } },
    { level: 2, fontFamily: SANS, fontSize: pt(9), lineHeight: pt(11), fontWeight: 700,
      letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('green'),
      marginTop: pt(0), marginBottom: pt(0) }] },
  header: { elements: [head('even'), head('odd')] },
  footer: { elements: [folio('even'), folio('odd')] },
  pageTemplates: [chart, area, places, gallery, notice],
};
// The guide opens on a verso, so its two pages face each other.
const continuation = { pageIndexOffset: 1 };

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Guía de servicios municipales de Valdeloma"
---

::::page{template="organigrama"}
# Así se organiza tu ayuntamiento

Valdeloma tiene 8.400 vecinos y un ayuntamiento con cuatro áreas de gobierno. Cada área depende de una concejalía y reúne los servicios que atiende: si no sabes a quién preguntar, busca aquí el servicio y llama a su área.

:::frame{slot="alcaldia"}
**Alcaldía y Pleno municipal** \\
Plaza Mayor, 1 · 13 concejales elegidos cada cuatro años
:::

:::module{template="area"}
:::frame{slot="concejalia"}
**Cultura y Educación** \\
Casa de Cultura, primera planta
:::
:::frame{slot="servicio"}
**Biblioteca municipal** \\
Préstamo, wifi y salas de estudio
:::
:::frame{slot="servicio"}
**Escuela de música** \\
Lenguaje musical e instrumento desde los seis años
:::
:::frame{slot="servicio"}
**Casa de Cultura** \\
Exposiciones, talleres y salón de actos
:::
:::

:::module{template="area"}
:::frame{slot="concejalia"}
**Deportes** \\
Polideportivo municipal
:::
:::frame{slot="servicio"}
**Piscina cubierta** \\
Cursos de natación y baño libre
:::
:::frame{slot="servicio"}
**Polideportivo** \\
Pistas, gimnasio y alquiler por horas
:::
:::frame{slot="servicio"}
**Escuelas deportivas** \\
Fútbol, baloncesto y atletismo de octubre a junio
:::
:::

:::module{template="area"}
:::frame{slot="concejalia"}
**Servicios Sociales y Mayores** \\
Calle del Pozo, 8
:::
:::frame{slot="servicio"}
**Centro de día** \\
Comedor, talleres y transporte adaptado
:::
:::frame{slot="servicio"}
**Ayuda a domicilio** \\
Aseo, compras y compañía en casa
:::
:::

:::module{template="area"}
:::frame{slot="concejalia"}
**Comercio y Medio Ambiente** \\
Plaza Mayor, 1, segunda planta
:::
:::frame{slot="servicio"}
**Mercado de los sábados** \\
Cuarenta puestos en los soportales
:::
:::frame{slot="servicio"}
**Punto limpio** \\
Muebles, aparatos y aceite usado
:::
:::frame{slot="servicio"}
**Parques y jardines** \\
Riego, poda y avisos de incidencias
:::
:::
::::

::::page{template="centros"}
# Dónde encontrarnos

Los servicios del ayuntamiento ocupan seis edificios, todos a menos de diez minutos a pie de la plaza Mayor. La entrada a cada uno es gratuita; los cursos y el alquiler de pistas se pagan en la oficina de atención al vecino, en la planta baja del ayuntamiento.

:::module{template="galeria"}
## Cultura y mayores

:::frame{slot="foto"}
::resource{id="biblioteca"}

:::frame{slot="pie"}
**Biblioteca municipal** \\
Plaza Mayor, 3 \\
De lunes a viernes, de 9 a 20 h
:::
:::

:::frame{slot="foto"}
::resource{id="musica"}

:::frame{slot="pie"}
**Escuela de música** \\
Calle de la Fuente, 12 \\
De lunes a jueves, de 16 a 21 h
:::
:::

:::frame{slot="foto"}
::resource{id="centro-mayores"}

:::frame{slot="pie"}
**Centro de día** \\
Calle del Pozo, 8 \\
Todos los días, de 9 a 18 h
:::
:::
:::

:::module{template="galeria"}
## Deporte y comercio

:::frame{slot="foto"}
::resource{id="piscina"}

:::frame{slot="pie"}
**Piscina cubierta** \\
Avenida del Río, s/n \\
De lunes a sábado, de 8 a 22 h
:::
:::

:::frame{slot="foto"}
::resource{id="polideportivo"}

:::frame{slot="pie"}
**Polideportivo** \\
Avenida del Río, s/n \\
Reserva de pistas en la oficina de atención
:::
:::

:::frame{slot="foto"}
::resource{id="mercado"}

:::frame{slot="pie"}
**Mercado de los sábados** \\
Soportales de la plaza Mayor \\
Sábados, de 8 a 14 h
:::
:::
:::

:::module{template="aviso"}
:::frame{slot="texto" label="Oficina de atención al vecino"}
Planta baja del ayuntamiento, Plaza Mayor, 1. De lunes a viernes, de 8:30 a 14:30, y los jueves también de 17 a 19 h. Aquí se piden los certificados, se pagan los cursos y las pistas y se recogen las quejas y las sugerencias de cualquier servicio. **Teléfono 010**, desde cualquier fijo o móvil del municipio.
:::
:::

:::frame{slot="colofon"}
Valdeloma es un municipio inventado para esta guía. Texto: CC BY 4.0. Ilustraciones generadas con modelos de difusión. Tipos: Public Sans y Source Serif 4 (SIL OFL).
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: six watercolours of the town's buildings, at the size they print
const ART = {
  biblioteca: t({ es: 'La sala de lectura de la biblioteca, con estanterías de madera y vecinos '
      + 'de todas las edades leyendo en mesas largas.',
    en: 'The library reading room, with wooden shelves and neighbours of every age reading at '
      + 'long tables.' }),
  musica: t({ es: 'Un aula de la escuela de música: niños con violines, una profesora y una niña '
      + 'al piano.',
    en: 'A room of the music school: children with violins, a teacher and a girl at the piano.' }),
  'centro-mayores': t({ es: 'El centro de día: mayores jugando a las cartas y pintando junto a '
      + 'una puerta al jardín.',
    en: 'The day centre: older people playing cards and painting beside a door to the garden.' }),
  piscina: t({ es: 'La piscina cubierta bajo un techo de madera, con nadadores en las calles y un '
      + 'socorrista.',
    en: 'The covered pool under a timber roof, with swimmers in the lanes and a lifeguard.' }),
  polideportivo: t({ es: 'La pista del polideportivo, con niños jugando al baloncesto y gradas a '
      + 'un lado.',
    en: 'The sports hall court, with children playing basketball and stands along one side.' }),
  mercado: t({ es: 'El mercado de los sábados en los soportales de la plaza, con el ayuntamiento '
      + 'al fondo.',
    en: 'The Saturday market under the arcades of the square, with the town hall behind.' }),
};
const resources = await Promise.all(Object.entries(ART).map(async ([id, altText]) => {
  const fileId = `${id}-900.jpg`;
  await loadImage(fileId, asset(fileId));
  return { id, typeId: 'foto', kind: 'bitmap', caption: '', altText, createdAt: 0, updatedAt: 0,
    bitmap: { fileId, format: 'jpeg', width: 900, height: 600, resolution: 400 } };
}));
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Public Sans': ['400', '600', '700'], 'Source Serif 4': ['400', '400i', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await withLoadedFonts(
  () => buildDocument({ markdown, resources, continuation }, config),
  { ...kitFonts(FONTS), text: markdown });
showPages(doc, { title: t({ es: 'Organigrama y rejilla de fotos con módulos',
  en: 'An org chart and a photo grid built from modules' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

// ─── Kit · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

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

// ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

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

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

## Variants

### Dues columnes de serveis

Amb `columns: 2` els serveis de cada àrea van de dos en dos; l’arbre traça la seva barra només sobre la primera fila, així que reserva-ho per a àrees amb dos o quatre serveis.

```diff
-  groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
+  groups: [{ name: 'servicios', kind: 'grid', columns: 2, columnGap: mm(4), rowGap: mm(4),
```

### Arrodoneix les cantonades del tronc

`radius` arrodoneix els colzes d’un angle i d’un arbre: l’ajudant `line()` dona 2,5 mm a tots els connectors; amb `radius: mm(0)` l’organigrama es torna recte.

## Errors freqüents

- Un element de la graella és un bloc propi: les paraules d’una fitxa només li pertanyen si el seu `:::frame{slot="pie"}` està escrit dins del `:::frame{slot="foto"}` de la fitxa.
- Un connector d’un mòdul que anomena un marc de la pàgina només es dibuixa a les pàgines que tenen aquest marc; a les altres s’omet, i el registre de recomposició diu `connectorDropped`.
- Una pestanya unida queda sobre la vora del requadre i en pren l’emplenament: dona a les seves paraules un `color` fosc, perquè un rètol blanc sobre un requadre clar no es llegeix.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: A guide to the council services of Valdeloma, an invented town, written for this recipe in Spanish and English: Postext Cookbook, CC-BY-4.0
- Imatges: The reading room of a town library, in watercolour.: Generated With Diffusion Models, original
- Imatges: Children with violins and a piano at a music school, in watercolour.: Generated With Diffusion Models, original
- Imatges: Older people playing cards and painting at a day centre, in watercolour.: Generated With Diffusion Models, original
- Imatges: A covered pool under a timber roof, in watercolour.: Generated With Diffusion Models, original
- Imatges: Children playing basketball in a sports hall, in watercolour.: Generated With Diffusion Models, original
- Imatges: A Saturday market under the arcades of a town square, in watercolour.: Generated With Diffusion Models, original
- Tipus de lletra: Public Sans (OFL-1.1), Source Serif 4 (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 156 · Una unitat de llibre de text infantil en deu idiomes](https://postext.dev/ca/cookbook/children-textbook-pages.md): Nou pàgines d’una unitat de 3r en deu idiomes, també en àrab i en vertical: dobles pàgines de color, requadres amb solapa que creixen i un mapa retolat. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 158 · Una cronologia il·lustrada a doble pàgina](https://postext.dev/ca/cookbook/illustrated-timeline-spread.md): Una plantilla a doble pàgina per a una cronologia: una banda travessa el llom, una retícula repeteix una fitxa per data i un connector arriba a cada fitxa. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 082 · Un document oficial xinès segons la GB/T 9704](https://postext.dev/ca/cookbook/chinese-official-document.md): Un avís de quatre pàgines en A4 segons la norma nacional: 28 × 22 caràcters de 三号, membret vermell, títols 一、（一）1.（1）, annex i folis «— 1 —». · Nivell 2 (Intermedi) · Informes i memòries
