Skip to main content
Recipe number 162

Cookbook · Chapter 1 · Page & grid

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.

On this page
Output
Canvas · PDF
Postext
Tested with Postext 1.48.1
Needs ≥ 1.48.1 · postext-pdf ≥ 1.48.1
Licence
Updated 20 Oct 2026
Code MIT · Text CC BY 4.0

pp. 1–2 of 2

  • Trim 210 × 297 mm
  • 1 column
  • Source Serif 4 10.5/15
  • Public Sans
  • 2 pages
  • Level
  • Postext 1.48.1
  • Laid out in 88 ms
  • 174 lines of code

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

script.js · lines 40–72in full code
// 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

Type
Public Sans, Source Serif 4 (SIL OFL 1.1)
Assets
  • 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)

Method

#1 · A module per department

script.js · lines 40–72in full code
// 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

script.js · lines 76–105in full code
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

script.js · lines 346–372in full code
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

script.js · lines 15–28in full code
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

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

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

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)
SandboxPDF