Vai al contenuto principale
Ricetta numero 162

Ricettario · Capitolo 1 · Pagina e griglia

Un organigramma e una griglia di foto fatti di moduli

Una guida ai servizi comunali fatta di moduli: un organigramma le cui linee le traccia il modello e griglie che ripetono una scheda per ogni edificio.

In questa pagina
Output
Canvas · PDF
Livello
Avanzato
Postext
Provata con Postext 1.48.1
Richiede ≥ 1.48.1 · postext-pdf ≥ 1.48.1
Licenza
Aggiornata il 20 ott 2026
Codice MIT · Testo CC BY 4.0

pp. 1–2 di 2

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 210 × 297 mm
  • 1 colonna
  • Source Serif 4 10,5/15
  • Public Sans
  • 2 pagine
  • Livello
  • Postext 1.48.1
  • Impaginato in 88 ms
  • 174 righe di codice

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

script.js · righe 40–72nel codice completo
// 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

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

script.js · righe 40–72nel codice completo
// 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

script.js · righe 76–105nel codice completo
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

script.js · righe 346–372nel codice completo
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

script.js · righe 15–28nel codice completo
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

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

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 color scuro, perché un’etichetta bianca su un riquadro chiaro non si legge.

Crediti

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