Saltar al contingut principal
Recepta número 162

Receptari · Capítol 1 · Pàgina i retícula

Un organigrama i una graella de fotos fets amb mòduls

Una guia de serveis municipals feta amb mòduls: un organigrama les línies del qual traça la plantilla i graelles que repeteixen una fitxa per edifici.

En aquesta pàgina
Sortida
Canvas · PDF
Nivell
Avançat
Postext
Provada amb Postext 1.48.1
Requereix ≥ 1.48.1 · postext-pdf ≥ 1.48.1
Llicència
Actualitzada el 20 d’oct. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en castellà: encara no hi ha edició en català
  • Format 210 × 297 mm
  • 1 columna
  • Source Serif 4 10,5/15
  • Public Sans
  • 2 pàgines
  • Nivell
  • Postext 1.48.1
  • Compost en 98 ms
  • 174 línies de codi

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

script.js · línies 40–72al codi complet
// 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

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

Elaboració

#1 · Un mòdul per àrea

script.js · línies 40–72al codi complet
// 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

script.js · línies 76–105al codi complet
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ó

script.js · línies 346–372al codi complet
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

script.js · línies 15–28al codi complet
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

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 = '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 color fosc, perquè un rètol blanc sobre un requadre clar no es llegeix.

Crèdits

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