# Un organigramme et une grille de photos faits de modules

> Un guide des services d’une mairie fait de modules : un organigramme dont le gabarit trace les traits, des grilles qui répètent une fiche par bâtiment.

- Version HTML: https://postext.dev/fr/cookbook/org-chart-from-modules
- Recette Nº 162 · Page et grille · Niveau 3 (Avancé) · Sorties: Canvas, PDF
- Genres: Rapports, Catalogues
- Nécessite postext ≥ 1.48.1, postext-pdf ≥ 1.48.1 · testée avec 1.48.1, postext-pdf 1.48.1 le 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
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=org-chart-from-modules&lang=en (.postext: https://postext.dev/cookbook/org-chart-from-modules/en/org-chart-from-modules.postext)
- Dernière mise à jour: 2026-10-20
- Autres langues: [en](https://postext.dev/en/cookbook/org-chart-from-modules.md), [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), [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 bref

Deux pages d’un guide municipal : qui s’occupe de quoi, en un schéma de boîtes reliées par des traits, et où se trouve chaque service, en rangées d’images. Les pages sont faites de quelques blocs réutilisables ; le Markdown dit combien.

## Ce que vous allez composer

Une double page du guide des services de Valdeloma, une commune inventée de 8 400 habitants. La page de gauche est l’organigramme de la mairie : le maire et le conseil dans une boîte rouge, quatre pôles en vert et, sous chaque pôle, ses services dans des boîtes claires, reliés par des traits verts terminés par un point ou une flèche. La page de droite dit où se trouvent les services : deux panneaux de trois aquarelles, avec le nom, l’adresse et les horaires sous chaque image, et un encadré pour l’accueil des habitants. Rien n’est dessiné à la main. Le gabarit contient trois modules (un pôle, un panneau de photos et un avis), et chaque page en empile autant que son Markdown en compte ; les traits suivent les boîtes là où le texte les place.

**Cette recette répond à:**

- Comment faire un organigramme et une grille de photos avec des modules dont le gabarit trace les traits ?

## La réponse courte

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

## Ingrédients

**Enseigne**

- [Modules empilés dans une page](https://postext.dev/fr/docs/configuration-composed-pages.md#modules): Un module est un modèle de quelques blocs, groupes et décors fait pour resservir ; le groupe de modules d’un modèle de page prend les blocs :::module de la page dans l’ordre du Markdown et les empile l’un sous l’autre.
- [Connecteurs entre blocs](https://postext.dev/fr/docs/configuration-composed-pages.md#connecteurs): Un connecteur du décor d’un modèle trace un trait d’un bloc vers un ou plusieurs autres, droit, à angles droits ou en arbre, avec des points ou des flèches aux extrémités ; il est posé après les blocs et les suit quand un texte s’allonge.
- [Grilles qui répètent des blocs](https://postext.dev/fr/docs/configuration-composed-pages.md#grilles-qui-répètent-un-jeu-de-blocs): Un groupe en grille déclare une fois un jeu de blocs, une carte et sa légende, et le répète pour chaque élément que lui donne le Markdown de la page, en rangées de colonnes égales ; les copies s’appellent #carte[1], #carte[2]…

**Utilise aussi**

- [Pages composées](https://postext.dev/fr/docs/composed-pages.md#pages-composées)
- [Blocs qui s’agrandissent dans un groupe](https://postext.dev/fr/docs/composed-pages.md#quand-le-texte-sallonge)
- [Décor ancré aux blocs](https://postext.dev/fr/docs/composed-pages.md#décor-titres-courants-et-calques)
- [Encadrés](https://postext.dev/fr/docs/configuration-callouts.md#styles-dencadré)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-headers-footers.md#en-têtes-et-pieds-de-page)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format-resources.md#ressources)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Export PDF](https://postext.dev/fr/docs/configuration-pdf-output.md#générer-des-pdf)
- [Onglets numérotés d’encadré](https://postext.dev/fr/docs/configuration-callouts.md#styles-dencadré)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format-resources.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-pdf-output.md#pourquoi-un-fournisseur-de-polices)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-callouts.md#styles-dencadré), `canvasViewer`, [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-headers-footers.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-headers-footers.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-headings.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-language.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`pageTemplates`](https://postext.dev/fr/docs/configuration-composed-pages.md#gabarits-de-page), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)

**API**

- [`buildDocument`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#construire-un-document), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-pdf-output.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderToPdf`](https://postext.dev/fr/docs/configuration-pdf-output.md#générer-des-pdf), [`withLoadedFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)

**Caractères**

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

## Préparation

### 1 · Un module par pôle

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

Un gabarit avec `role: 'module'` est un module : quelques blocs, groupes et éléments de décor faits pour resservir. La page de l’organigramme a un groupe `modules: true`, et chaque `:::module{template="area"}` de son Markdown pose un pôle de plus sous le précédent, à 6 mm. Les blocs du module prennent le nom de l’instance, `area[1].concejalia`, `area[2].concejalia`…, si bien que ses deux connecteurs s’écrivent une seule fois : `rama`, un arbre (`tree`) du pied du pôle vers chaque service de sa grille (`#servicio[*]`), et `tronco`, un coude (`elbow`) d’un point proche de la gauche de la boîte du maire (`at: 0.06`) jusqu’au côté gauche du pôle. `#alcaldia` est un bloc de la page, pas du module : le module garde cette extrémité et la mise en page la trouve sur la page, de sorte que les quatre coudes partagent leur premier segment et se lisent comme un seul tronc avec une flèche vers chaque pôle. Les services forment une grille de trois colonnes qui répète un bloc `servicio` : un pôle à deux services dessine deux boîtes, et son arbre deux branches.

### 2 · Des panneaux de fiches photo

```js
// script.js, lignes 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 panneau est lui aussi un module : un bloc de titre qui reçoit le titre `##` du module, et une grille qui répète un jeu de deux blocs, la photo et le texte en dessous. Chaque `:::frame{slot="foto"}` est une fiche, et le `:::frame{slot="pie"}` qu’il contient va au texte de cette fiche. Le fond teinté est un décor du module, une boîte de la taille du module, qui grandit donc avec les rangées et la légende la plus longue. L’accueil des habitants est un troisième module, un encadré dont l’onglet est `joined` : l’onglet et l’encadré forment un seul contour avec un seul fond.

### 3 · Des images à leur taille d’impression

```js
// script.js, lignes 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 six aquarelles font 900 × 600 px déclarées à 400 ppp, soit 57 mm de large, à peu près la largeur d’une colonne de la grille : la grille ne les agrandit jamais. Leur texte alternatif est dans le script parce que les images sont les mêmes dans les deux éditions ; une légende qui changerait avec la langue serait une ligne `::caption` du Markdown.

### 4 · Un seul vert pour les traits et les boîtes

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

Les connecteurs, les boîtes de pôle et les titres des panneaux utilisent `green`, une entrée de la palette : une commune qui a une autre couleur change un seul code hexadécimal. La boîte du maire porte la seule couleur chaude de la double page, là où le lecteur regarde en premier.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Deux colonnes de services

Avec `columns: 2`, les services de chaque pôle vont deux par rangée ; l’arbre ne trace sa barre qu’au-dessus de la première rangée, réservez donc cette variante aux pôles à deux ou quatre 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),
```

### Arrondir les angles du tronc

`radius` arrondit les coudes d’un `elbow` et d’un arbre : l’assistant `line()` donne 2,5 mm à chaque connecteur ; avec `radius: mm(0)`, l’organigramme passe à angles droits.

## Erreurs fréquentes

- Un élément de grille forme un bloc à part : le texte d’une fiche photo ne lui appartient que si son `:::frame{slot="pie"}` est écrit à l’intérieur du `:::frame{slot="foto"}` de la fiche.
- Un connecteur de module qui nomme un bloc de la page ne se dessine que sur les pages qui ont ce bloc ; ailleurs il est omis, et le journal de recomposition indique `connectorDropped`.
- Un onglet joint se pose sur le bord de l’encadré et prend son fond : donnez à son texte une `color` sombre, car une étiquette blanche sur un encadré clair ne se lit pas.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: 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
- Caractères: Public Sans (OFL-1.1), Source Serif 4 (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 156 · Une unité de manuel scolaire en dix langues](https://postext.dev/fr/cookbook/children-textbook-pages.md): Neuf pages d’une unité de CE2 en dix langues, dont l’arabe et deux à la verticale : doubles pages colorées, encadrés à onglet qui grandissent, carte peinte. · Niveau 3 (Avancé) · Manuels scolaires
- [Nº 158 · Une frise chronologique illustrée sur une double page](https://postext.dev/fr/cookbook/illustrated-timeline-spread.md): Un gabarit en double page pour la frise d’un manuel : un bandeau passe le dos, une grille répète une fiche par date, un connecteur rejoint chaque fiche. · Niveau 3 (Avancé) · Manuels scolaires
- [Nº 082 · Un document officiel chinois selon GB/T 9704](https://postext.dev/fr/cookbook/chinese-official-document.md): Un avis de quatre pages A4 selon la norme nationale : 28 × 22 caractères de 三号, en-tête rouge, titres 一、（一）1.（1）, une annexe et des folios « — 1 — ». · Niveau 2 (Intermédiaire) · Rapports
