# An org chart and a photo grid built from modules

> A town hall's services guide set from modules: an org chart whose lines the template draws, and photo grids that repeat one card per building.

- HTML version: https://postext.dev/en/cookbook/org-chart-from-modules
- Recipe Nº 162 · Page & grid · Level 3 (Advanced) · Outputs: Canvas, PDF
- Genres: Reports, Catalogues
- Requires postext ≥ 1.48.1, postext-pdf ≥ 1.48.1 · tested with 1.48.1, postext-pdf 1.48.1 on 2026-10-21
- Pages: [1](https://postext.dev/cookbook/org-chart-from-modules/en/p01.webp?v=a92f7911), [2](https://postext.dev/cookbook/org-chart-from-modules/en/p02.webp?v=a92f7911)
- PDF: https://postext.dev/cookbook/org-chart-from-modules/en/org-chart-from-modules.pdf?v=a92f7911
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=org-chart-from-modules&lang=en (.postext: https://postext.dev/cookbook/org-chart-from-modules/en/org-chart-from-modules.postext)
- Last updated: 2026-10-20
- Other languages: [es](https://postext.dev/es/cookbook/org-chart-from-modules.md), [ca](https://postext.dev/ca/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)

## In short

Two pages of a council guide: who runs what, as a chart of boxes joined by lines, and where each service is, as rows of pictures. The pages are made of a few reusable blocks; the Markdown says how many.

## What you'll build

A spread from the services guide of Valdeloma, an invented town of 8,400 people. The left page is the council's org chart: the mayor and council in a red box, four departments in green, and each department's services in pale boxes under it, joined by green lines with a dot or an arrow at their ends. The right page shows where the services are: two panels of three watercolours each, with the name, the address and the opening hours under every picture, and a box for the residents' office. Nothing is drawn by hand. The template holds three modules (a department, a panel of photos and a notice), and each page stacks as many of them as its Markdown has; the lines follow the boxes wherever the text puts them.

**This recipe answers:**

- How do I build an org chart and a photo grid from reusable modules, with the lines drawn by the template?

## The short answer

A module per area of the town hall, stacked by the page's modules group.

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

**Teaches**

- [Modules stacked in a page](https://postext.dev/en/docs/configuration-composed-pages.md#modules): A module is a template of a few frames, groups and decoration meant to be reused; a page template's modules group takes the :::module blocks of the page in Markdown order and stacks one under another.
- [Connectors between frames](https://postext.dev/en/docs/configuration-composed-pages.md#connectors): A connector of a template's decoration draws a line from one frame to one or more others, straight, square-cornered or as a tree, with dots or arrows at its ends; it is laid after the frames, so it follows them when a text grows.
- [Grids that repeat a frame set](https://postext.dev/en/docs/configuration-composed-pages.md#grids-that-repeat-a-frame-set): A grid group declares a set of frames once, a card and its caption, and lays it again for every item the page's Markdown gives it, in rows of equal columns, naming the copies #card[1], #card[2]…

**Also uses**

- [Composed pages](https://postext.dev/en/docs/composed-pages.md#composed-pages)
- [Frames that grow in a group](https://postext.dev/en/docs/composed-pages.md#when-the-text-grows)
- [Decoration anchored to frames](https://postext.dev/en/docs/composed-pages.md#decoration-running-heads-and-layers)
- [Callout boxes](https://postext.dev/en/docs/configuration-callouts.md#callout-styles)
- [Mirrored margins](https://postext.dev/en/docs/configuration-page-layout.md#mirrored-margins)
- [Running heads and folios](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format-resources.md#resources)
- [Semantic colour palette](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette)
- [Pages on a canvas](https://postext.dev/en/docs/configuration-programmatic-usage.md#rendering-a-page-to-a-bitmap)
- [PDF export](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)
- [Numbered box tabs](https://postext.dev/en/docs/configuration-callouts.md#callout-styles)
- [Figures exactly here](https://postext.dev/en/docs/document-format-resources.md#block-embed-optional-explicit-inline-placement)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration-pdf-output.md#why-a-font-provider)
- [Custom resource types](https://postext.dev/en/docs/configuration-resources.md#resource-types)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration-text.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration-callouts.md#callout-styles), `canvasViewer`, [`colorPalette`](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration-headings.md#headings), [`layout`](https://postext.dev/en/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/en/docs/configuration-language.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration-page-layout.md#page), [`pageTemplates`](https://postext.dev/en/docs/configuration-composed-pages.md#page-templates), [`resourceTypes`](https://postext.dev/en/docs/configuration-resources.md#resource-types)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration-programmatic-usage.md#building-a-document), [`decompressWoff2`](https://postext.dev/en/docs/configuration-pdf-output.md#browser-font-provider-fontsource--woff2), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration-programmatic-usage.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs), [`withLoadedFonts`](https://postext.dev/en/docs/configuration-programmatic-usage.md#loading-fonts-before-layout)

**Typefaces**

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

## Method

### 1 · A module per department

```js
// script.js, lines 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' } } }],
};
```

A template with `role: 'module'` is a module: a few frames, groups and decoration meant to be used again. The chart page has a group with `modules: true`, and each `:::module{template="area"}` of its Markdown lays one more department under the one before, 6 mm apart. The module's frames get the instance's name, `area[1].concejalia`, `area[2].concejalia`…, so its two connectors are written once: `rama`, a `tree` from the department's foot to every service of its grid (`#servicio[*]`), and `tronco`, an `elbow` from a point near the left of the mayor's box (`at: 0.06`) to the department's left side. `#alcaldia` is a frame of the page, not of the module: the module keeps the end and the layout finds it on the page, so the four elbows share their first run and read as one spine with an arrow into each department. The services are a grid of three columns that repeats one `servicio` frame, so a department with two services draws two boxes and the tree has two drops.

### 2 · Panels of photo cards

```js
// script.js, lines 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' } } }],
};
```

A panel is a module too: a heading frame that takes the module's `##` heading, and a grid that repeats a set of two frames, the photo and the words under it. Each `:::frame{slot="foto"}` is one card, and the `:::frame{slot="pie"}` inside it goes to that card's words. The tinted ground is decoration of the module, a box as large as the module, so it grows with the rows and the longest caption. The residents' office is a third module, one box whose tab is `joined`: the tab and the box are one outline with one fill.

### 3 · Pictures at the size they print

```js
// script.js, lines 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 } };
}));
```

The six watercolours are 900 × 600 px declared at 400 ppi, 57 mm wide, about the width of a grid column, so the grid never enlarges them. Their alternative text is in the script here because the pictures are the same in both editions; a caption that changed with the language would be a `::caption` line in the Markdown.

### 4 · One green for the lines and the boxes

```js
// script.js, lines 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' } },
];
```

The connectors, the department boxes and the panel headings use `green`, a palette entry, so a town with another colour changes one hex. The mayor's box takes the one warm colour of the spread, the place a reader looks first.

## The whole recipe

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

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/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 = 'en'; // @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: "Valdeloma Town Hall: A Guide to Council Services"
---

::::page{template="organigrama"}
# How your town hall is run

Valdeloma has 8,400 residents and a town hall with four departments. Each department answers to a councillor and gathers the services it runs: if you do not know whom to ask, find the service here and call its department.

:::frame{slot="alcaldia"}
**Mayor and Town Council** \\
1 Plaza Mayor · 13 councillors elected every four years
:::

:::module{template="area"}
:::frame{slot="concejalia"}
**Culture and Education** \\
Arts Centre, first floor
:::
:::frame{slot="servicio"}
**Public library** \\
Loans, wifi and study rooms
:::
:::frame{slot="servicio"}
**Music school** \\
Music theory and an instrument from age six
:::
:::frame{slot="servicio"}
**Arts Centre** \\
Exhibitions, workshops and a hall
:::
:::

:::module{template="area"}
:::frame{slot="concejalia"}
**Sport** \\
Municipal sports centre
:::
:::frame{slot="servicio"}
**Covered pool** \\
Swimming lessons and open sessions
:::
:::frame{slot="servicio"}
**Sports hall** \\
Courts, gym and hourly hire
:::
:::frame{slot="servicio"}
**Sports clubs** \\
Football, basketball and athletics from October to June
:::
:::

:::module{template="area"}
:::frame{slot="concejalia"}
**Social Services and Older People** \\
8 Calle del Pozo
:::
:::frame{slot="servicio"}
**Day centre** \\
Lunches, workshops and adapted transport
:::
:::frame{slot="servicio"}
**Home help** \\
Washing, shopping and company at home
:::
:::

:::module{template="area"}
:::frame{slot="concejalia"}
**Trade and Environment** \\
1 Plaza Mayor, second floor
:::
:::frame{slot="servicio"}
**Saturday market** \\
Forty stalls under the arcades
:::
:::frame{slot="servicio"}
**Recycling centre** \\
Furniture, appliances and used oil
:::
:::frame{slot="servicio"}
**Parks and gardens** \\
Watering, pruning and fault reports
:::
:::
::::

::::page{template="centros"}
# Where to find us

The council's services fill six buildings, all less than ten minutes' walk from the Plaza Mayor. Every building is free to enter; courses and court hire are paid at the residents' office on the ground floor of the town hall.

:::module{template="galeria"}
## Culture and older people

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

:::frame{slot="pie"}
**Public library** \\
3 Plaza Mayor \\
Monday to Friday, 9 am to 8 pm
:::
:::

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

:::frame{slot="pie"}
**Music school** \\
12 Calle de la Fuente \\
Monday to Thursday, 4 pm to 9 pm
:::
:::

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

:::frame{slot="pie"}
**Day centre** \\
8 Calle del Pozo \\
Every day, 9 am to 6 pm
:::
:::
:::

:::module{template="galeria"}
## Sport and trade

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

:::frame{slot="pie"}
**Covered pool** \\
Avenida del Río \\
Monday to Saturday, 8 am to 10 pm
:::
:::

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

:::frame{slot="pie"}
**Sports hall** \\
Avenida del Río \\
Book courts at the residents' office
:::
:::

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

:::frame{slot="pie"}
**Saturday market** \\
Arcades of the Plaza Mayor \\
Saturdays, 8 am to 2 pm
:::
:::
:::

:::module{template="aviso"}
:::frame{slot="texto" label="Residents' office"}
Ground floor of the town hall, 1 Plaza Mayor. Monday to Friday, 8.30 am to 2.30 pm, and on Thursdays also 5 pm to 7 pm. This is where you ask for certificates, pay for courses and courts, and hand in a complaint or a suggestion about any service. **Phone 010**, from any landline or mobile in the town.
:::
:::

:::frame{slot="colofon"}
Valdeloma is a town invented for this guide. Text: CC BY 4.0. Illustrations generated with diffusion models. Type: Public Sans and 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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Two columns of services

With `columns: 2` each department's services stand two to a row; the tree draws its bar over the first row only, so keep it for departments with two or four services.

```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),
```

### Round the corners of the spine

`radius` rounds the bends of an elbow and of a tree: the `line()` helper gives every connector 2.5 mm; with `radius: mm(0)` the chart turns square.

## Pitfalls

- A grid item is a fence of its own: the words of a photo card belong to it only when their `:::frame{slot="pie"}` is written inside the card's `:::frame{slot="foto"}`.
- A connector of a module that names a frame of the page draws only on pages that have that frame; on another page it is left out, and the reflow log says `connectorDropped`.
- A joined tab sits on the box's edge and takes the box's fill: give its words a dark `color`, since a white label on a pale box does not read.

## Credits

- Recipe: 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
- Images: The reading room of a town library, in watercolour.: Generated With Diffusion Models, original
- Images: Children with violins and a piano at a music school, in watercolour.: Generated With Diffusion Models, original
- Images: Older people playing cards and painting at a day centre, in watercolour.: Generated With Diffusion Models, original
- Images: A covered pool under a timber roof, in watercolour.: Generated With Diffusion Models, original
- Images: Children playing basketball in a sports hall, in watercolour.: Generated With Diffusion Models, original
- Images: A Saturday market under the arcades of a town square, in watercolour.: Generated With Diffusion Models, original
- Type: Public Sans (OFL-1.1), Source Serif 4 (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 156 · A children's textbook unit in ten languages](https://postext.dev/en/cookbook/children-textbook-pages.md): Nine pages of a Year 3 unit in ten languages, right to left in Arabic and vertical in Chinese and Japanese: coloured spreads, growing boxes, a labelled map. · Level 3 (Advanced) · Textbooks
- [Nº 158 · An illustrated timeline across a double page](https://postext.dev/en/cookbook/illustrated-timeline-spread.md): A spread template lays a textbook timeline over two pages: a band across the spine, a grid that repeats a card per date, a connector to every card. · Level 3 (Advanced) · Textbooks
- [Nº 082 · A Chinese official document to GB/T 9704](https://postext.dev/en/cookbook/chinese-official-document.md): A four-page notice on A4 to the national standard: 28 × 22 characters of 三号, a red letterhead, heads 一、（一）1.（1）, an annex and “— 1 —” folios. · Level 2 (Intermediate) · Reports
