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
// 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 module per area of the town hall, stacked by the page's modules group
Ingredients
- Features
- Modules stacked in a pageConnectors between framesGrids that repeat a frame setComposed pagesFrames that grow in a groupDecoration anchored to framesCallout boxesMirrored marginsRunning heads and foliosFigures and tables as resourcesSemantic colour palettePages on a canvasPDF export
- Type
- Public Sans, Source Serif 4 (SIL OFL 1.1)
- Assets
biblioteca-900.jpgcentro-mayores-900.jpgmercado-900.jpgmusica-900.jpgpiscina-900.jpgpolideportivo-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)
Method
#1 · A module per department
// 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
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
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
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
// ═══ 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),
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)
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.
- 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
- 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
- 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
- Fonts
- Public Sans (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


