In breve
Due pagine della guida di un comune: chi si occupa di cosa, come uno schema di caselle unite da linee, e dove si trova ogni servizio, in file di immagini. Le pagine sono fatte di pochi blocchi riutilizzabili; il Markdown dice quanti.
Che cosa comporrai
Una doppia pagina della guida ai servizi di Valdeloma, un paese inventato di 8.400 abitanti. La pagina di sinistra è l’organigramma del comune: il sindaco e il consiglio in una casella rossa, quattro aree in verde e, sotto ogni area, i suoi servizi in caselle chiare, uniti da linee verdi con un punto o una freccia all’estremità. Quella di destra dice dove sono i servizi: due pannelli di tre acquerelli, con il nome, l’indirizzo e gli orari sotto ogni immagine, e un riquadro per l’ufficio relazioni con il pubblico. Niente è disegnato a mano. Il modello contiene tre moduli (un’area, un pannello di foto e un avviso) e ogni pagina ne impila quanti ne ha il suo Markdown; le linee seguono le caselle ovunque il testo le porti.
Questa ricetta risponde a
- Come costruisco un organigramma e una griglia di foto con moduli riutilizzabili, con le linee tracciate dal modello?
La risposta breve
// 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' } } }],
};
Ingredienti
- Funzionalità
- Moduli impilati in una paginaConnettori tra corniciGriglie che ripetono corniciPagine composteRiquadri che crescono in un gruppoDecorazione ancorata alle corniciRiquadriMargini speculariTestatine e folioFigure e tabelle come risorseTavolozza di colori semanticaPagine su un canvasEsportazione in PDF
- Usa anche
- Linguette numerate dei riquadriFigure proprio quiFont incorporati nel PDFTipi di risorsa personalizzati
- Tipo
- Public Sans, Source Serif 4 (SIL OFL 1.1)
- Risorse
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, originale)
- Children with violins and a piano at a music school, in watercolour. (Generated With Diffusion Models, originale)
- Older people playing cards and painting at a day centre, in watercolour. (Generated With Diffusion Models, originale)
- A covered pool under a timber roof, in watercolour. (Generated With Diffusion Models, originale)
- Children playing basketball in a sports hall, in watercolour. (Generated With Diffusion Models, originale)
- A Saturday market under the arcades of a town square, in watercolour. (Generated With Diffusion Models, originale)
Preparazione
#1 · Un modulo per area
// 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 modello con role: 'module' è un modulo: qualche cornice, qualche gruppo e un po’ di decorazione pensati per essere riusati. La pagina dell’organigramma ha un gruppo con modules: true, e ogni :::module{template="area"} del suo Markdown mette un’area in più sotto la precedente, a 6 mm. Le cornici del modulo prendono il nome dell’istanza, area[1].concejalia, area[2].concejalia…, così i suoi due connettori si scrivono una volta sola: rama, un albero (tree) dal piede dell’area a ogni servizio della sua griglia (#servicio[*]), e tronco, un gomito (elbow) da un punto vicino al lato sinistro della casella del sindaco (at: 0.06) al lato sinistro dell’area. #alcaldia è una cornice della pagina, non del modulo: il modulo tiene quell’estremità e l’impaginazione la trova sulla pagina, così i quattro gomiti condividono il primo tratto e si leggono come un unico tronco con una freccia verso ogni area. I servizi sono una griglia di tre colonne che ripete una cornice servicio, quindi un’area con due servizi disegna due caselle e il suo albero ha due rami.
#2 · Pannelli di schede con 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' } } }],
};
Anche un pannello è un modulo: una cornice di testata che prende il titolo ## del modulo e una griglia che ripete una coppia di cornici, la foto e il testo sotto. Ogni :::frame{slot="foto"} è una scheda, e il :::frame{slot="pie"} che contiene va al testo di quella scheda. Il fondo colorato è decorazione del modulo, una casella grande quanto il modulo, quindi cresce con le file e con la didascalia più lunga. L’ufficio relazioni con il pubblico è un terzo modulo, un riquadro la cui linguetta è joined: linguetta e riquadro sono un solo contorno con un solo riempimento.
#3 · Immagini alla misura di stampa
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 } };
}));
I sei acquerelli sono di 900 × 600 px dichiarati a 400 ppi, cioè 57 mm di larghezza, più o meno quanto una colonna della griglia, così la griglia non li ingrandisce mai. Il loro testo alternativo è qui nello script perché le immagini sono le stesse nelle due edizioni; una didascalia che cambiasse con la lingua sarebbe una riga ::caption del Markdown.
#4 · Un solo verde per linee e caselle
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' } },
];
I connettori, le caselle delle aree e le testate dei pannelli usano green, una voce della tavolozza, così un paese con un altro colore cambia un solo valore esadecimale. La casella del sindaco ha l’unico colore caldo della doppia pagina, il punto dove il lettore guarda per primo.
La ricetta 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 = '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),
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Due colonne di servizi
Con columns: 2 i servizi di ogni area stanno due per fila; l’albero traccia la sua barra solo sopra la prima fila, quindi usalo per le aree con due o quattro servizi.
- 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),#Arrotonda gli angoli del tronco
radius arrotonda le pieghe di un gomito e di un albero: l’aiutante line() dà 2,5 mm a ogni connettore; con radius: mm(0) l’organigramma diventa ad angoli vivi.
Errori frequenti
- Un elemento della griglia è un recinto a sé: il testo di una scheda con foto le appartiene solo se il suo
:::frame{slot="pie"}è scritto dentro il:::frame{slot="foto"}della scheda. - Un connettore di un modulo che nomina una cornice della pagina si disegna solo sulle pagine che hanno quella cornice; sulle altre viene omesso, e il registro di reimpaginazione riporta
connectorDropped. - Una linguetta unita sta sul bordo del riquadro e ne prende il riempimento: dai al suo testo un
colorscuro, perché un’etichetta bianca su un riquadro chiaro non si legge.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- 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
- Immagini
- The reading room of a town library, in watercolour. · Generated With Diffusion Models · originale
- Children with violins and a piano at a music school, in watercolour. · Generated With Diffusion Models · originale
- Older people playing cards and painting at a day centre, in watercolour. · Generated With Diffusion Models · originale
- A covered pool under a timber roof, in watercolour. · Generated With Diffusion Models · originale
- Children playing basketball in a sports hall, in watercolour. · Generated With Diffusion Models · originale
- A Saturday market under the arcades of a town square, in watercolour. · Generated With Diffusion Models · originale
- Font
- Public Sans (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


