Aller au contenu principal
Recette numéro 162

Recettes · Chapitre 1 · Page et grille

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.

Sur cette page
Sortie
Canvas · PDF
Niveau
Avancé
Postext
Testé avec Postext 1.48.1
Nécessite ≥ 1.48.1 · postext-pdf ≥ 1.48.1
Licence
Mis à jour le 20 oct. 2026
Code MIT · Texte CC BY 4.0

p. 1–2 sur 2

  • Exemple en anglais : pas encore d’édition française
  • Format 210 × 297 mm
  • 1 colonne
  • Source Serif 4 10,5/15
  • Public Sans
  • 2 pages
  • Niveau
  • Postext 1.48.1
  • Mis en page en 88 ms
  • 174 lignes de code

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 construire un organigramme et une grille de photos avec des modules réutilisables, les traits étant tracés par le modèle ?

La réponse courte

script.js · lignes 40–72dans le code complet
// 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

Typographie
Public Sans, Source Serif 4 (SIL OFL 1.1)
Ressources
  • biblioteca-900.jpg
  • centro-mayores-900.jpg
  • mercado-900.jpg
  • musica-900.jpg
  • piscina-900.jpg
  • polideportivo-900.jpg
  • The reading room of a town library, in watercolour. (Generated With Diffusion Models, original)
  • Children with violins and a piano at a music school, in watercolour. (Generated With Diffusion Models, original)
  • Older people playing cards and painting at a day centre, in watercolour. (Generated With Diffusion Models, original)
  • A covered pool under a timber roof, in watercolour. (Generated With Diffusion Models, original)
  • Children playing basketball in a sports hall, in watercolour. (Generated With Diffusion Models, original)
  • A Saturday market under the arcades of a town square, in watercolour. (Generated With Diffusion Models, original)

Préparation

#1 · Un module par pôle

script.js · lignes 40–72dans le code complet
// 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

script.js · lignes 76–105dans le code complet
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

script.js · lignes 346–372dans le code complet
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

script.js · lignes 15–28dans le code complet
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

Sandbox
// ═══ 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),

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

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.

-  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

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
  • Children with violins and a piano at a music school, in watercolour. · Generated With Diffusion Models · original
  • Older people playing cards and painting at a day centre, in watercolour. · Generated With Diffusion Models · original
  • A covered pool under a timber roof, in watercolour. · Generated With Diffusion Models · original
  • Children playing basketball in a sports hall, in watercolour. · Generated With Diffusion Models · original
  • A Saturday market under the arcades of a town square, in watercolour. · Generated With Diffusion Models · original
Polices
Public Sans (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1)
SandboxPDF