En poques paraules
Dues pàgines d’una guia de l’ajuntament: qui porta cada cosa, com un esquema de caixes unides per línies, i on és cada servei, en files d’imatges. Es fan amb uns pocs blocs reutilitzables; el Markdown diu quants.
Què compondràs
Una doble pàgina de la guia de serveis de Valdeloma, un poble inventat de 8.400 veïns. La pàgina parella és l’organigrama de l’ajuntament: l’alcaldia i el ple en una caixa vermella, quatre àrees en verd i, sota cada àrea, els seus serveis en caixes clares, units per línies verdes amb un punt o una fletxa a l’extrem. La senar explica on són els serveis: dos panells de tres aquarel·les, amb el nom, l’adreça i l’horari sota cada imatge, i un requadre per a l’oficina d’atenció al veí. Res no es dibuixa a mà. La plantilla té tres mòduls (una àrea, un panell de fotos i un avís) i cada pàgina n’apila tants com en té el seu Markdown; les línies segueixen les caixes allà on el text les porti.
Aquesta recepta respon a
- Com faig un organigrama i una graella de fotos amb mòduls reutilitzables, amb les línies traçades per la plantilla?
La resposta curta
// The page template draws the head of the chart and a group that takes the page's modules;
// the module draws one area: its box, a grid of its services and the lines between them.
const area = {
id: 'area', role: 'module',
frames: [
{ name: 'concejalia', style: 'area', width: mm(84), placement: at('container', 'top-left') },
{ name: 'servicio', style: 'servicio' }, // repeated: one per :::frame{slot="servicio"}
],
groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
repeat: { frames: ['servicio'] }, // rowAlign 'stretch': a row's boxes are one height
placement: { ...at('#concejalia', 'below', 0, 7), size: { width: 'fill', height: 'fill' } } }],
decoration: { elements: [
// From the area down to each of its services: a tree with a bar over the row.
line({ id: 'rama', routing: 'tree', from: { to: '#concejalia', side: 'bottom' },
to: { to: '#servicio[*]', side: 'top' }, endCap: 'dot', capSize: mm(1.8) }),
// From the mayor's box, a frame of the page, to this area: the spine and its branch.
line({ id: 'tronco', routing: 'elbow', width: pt(1.8),
from: { to: '#alcaldia', side: 'bottom', at: 0.06 }, to: { to: '#concejalia', side: 'left' },
endCap: 'arrow', capSize: mm(2.6) }),
] },
};
const chart = {
id: 'organigrama',
frames: [
{ name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
{ name: 'entradilla', flow: true, width: mm(W) },
{ name: 'alcaldia', style: 'alcaldia', width: mm(96) },
],
groups: [{ name: 'cabeza', kind: 'stack', gap: mm(6),
frames: ['titulo', 'entradilla', 'alcaldia'] },
{ name: 'areas', modules: true, gap: mm(6), placement: { ...at('#alcaldia', 'below', SPINE, 9),
size: { width: mm(W - SPINE), height: 'fill' } } }],
};
Ingredients
- Funcions
- Mòduls apilats en una pàginaConnectors entre marcsGraelles que repeteixen marcsPàgines compostesMarcs que creixen en un grupDecoració ancorada als marcsRequadresMarges simètricsCapçaleres i folisFigures i taules com a recursosPaleta de color semànticaPàgines en un canvasExportació a PDF
- També fa servir
- Pestanyes numerades als requadresFigures just aquíFonts incrustades al PDFTipus de recurs propis
- Tipografia
- Public Sans, Source Serif 4 (SIL OFL 1.1)
- Recursos
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)
Elaboració
#1 · Un mòdul per àrea
// 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' } } }],
};
Una plantilla amb role: 'module' és un mòdul: uns quants marcs, grups i decoració pensats per tornar-se a fer servir. La pàgina de l’organigrama té un grup amb modules: true, i cada :::module{template="area"} del seu Markdown col·loca una àrea més sota l’anterior, a 6 mm. Els marcs del mòdul prenen el nom de la instància, area[1].concejalia, area[2].concejalia…, de manera que els seus dos connectors s’escriuen una sola vegada: rama, un arbre (tree) des del peu de l’àrea fins a cada servei de la seva graella (#servicio[*]), i tronco, un angle (elbow) des d’un punt a prop de l’esquerra de la caixa de l’alcaldia (at: 0.06) fins al costat esquerre de l’àrea. #alcaldia és un marc de la pàgina, no del mòdul: el mòdul conserva aquest extrem i la maquetació el troba a la pàgina, de manera que els quatre angles comparteixen el primer tram i es llegeixen com un sol tronc amb una fletxa cap a cada àrea. Els serveis són una graella de tres columnes que repeteix un marc servicio, així que una àrea amb dos serveis dibuixa dues caixes i el seu arbre, dues branques.
#2 · Panells de fitxes amb foto
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 panell també és un mòdul: un marc de capçalera que pren el títol ## del mòdul i una graella que repeteix un joc de dos marcs, la foto i les paraules de sota. Cada :::frame{slot="foto"} és una fitxa, i el :::frame{slot="pie"} que porta a dins va a les paraules d’aquesta fitxa. El fons tintat és decoració del mòdul, una caixa tan gran com el mòdul, així que creix amb les files i amb el peu més llarg. L’oficina d’atenció és un tercer mòdul, un requadre la pestanya del qual és joined: pestanya i requadre són un sol contorn amb un sol emplenament.
#3 · Imatges a la mida d’impressió
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 sis aquarel·les fan 900 × 600 px a 400 ppp, 57 mm d’amplada, més o menys el que fa una columna de la graella, així que la graella no les amplia mai. El seu text alternatiu és aquí, a l’script, perquè les imatges són les mateixes a les dues edicions; un peu que canviés amb l’idioma seria una línia ::caption del Markdown.
#4 · Un sol verd per a les línies i les caixes
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' } },
];
Els connectors, les caixes d’àrea i les capçaleres dels panells fan servir green, una entrada de la paleta, així que un poble amb un altre color canvia un sol hexadecimal. La caixa de l’alcaldia porta l’únic color càlid de la doble pàgina, el lloc on el lector mira primer.
La recepta completa
// ═══ 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 = 'es'; // @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),
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Dues columnes de serveis
Amb columns: 2 els serveis de cada àrea van de dos en dos; l’arbre traça la seva barra només sobre la primera fila, així que reserva-ho per a àrees amb dos o quatre serveis.
- 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),#Arrodoneix les cantonades del tronc
radius arrodoneix els colzes d’un angle i d’un arbre: l’ajudant line() dona 2,5 mm a tots els connectors; amb radius: mm(0) l’organigrama es torna recte.
Errors freqüents
- Un element de la graella és un bloc propi: les paraules d’una fitxa només li pertanyen si el seu
:::frame{slot="pie"}està escrit dins del:::frame{slot="foto"}de la fitxa. - Un connector d’un mòdul que anomena un marc de la pàgina només es dibuixa a les pàgines que tenen aquest marc; a les altres s’omet, i el registre de recomposició diu
connectorDropped. - Una pestanya unida queda sobre la vora del requadre i en pren l’emplenament: dona a les seves paraules un
colorfosc, perquè un rètol blanc sobre un requadre clar no es llegeix.
Crèdits
- Recepta
- 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
- Imatges
- 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)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


