En pocas palabras
Dos páginas de una guía del ayuntamiento: quién lleva cada cosa, como un esquema de cajas unidas por líneas, y dónde está cada servicio, en filas de imágenes. Se hacen con unos pocos bloques reutilizables; el Markdown dice cuántos.
Lo que vas a componer
Una doble página de la guía de servicios de Valdeloma, un pueblo inventado de 8.400 vecinos. La página par es el organigrama del ayuntamiento: la alcaldía y el pleno en una caja roja, cuatro áreas en verde y, bajo cada área, sus servicios en cajas claras, unidos por líneas verdes con un punto o una flecha en el extremo. La impar cuenta dónde están los servicios: dos paneles de tres acuarelas, con el nombre, la dirección y el horario bajo cada imagen, y un recuadro para la oficina de atención al vecino. Nada se dibuja a mano. La plantilla tiene tres módulos (un área, un panel de fotos y un aviso) y cada página apila tantos como tiene su Markdown; las líneas siguen a las cajas allí donde el texto las lleve.
Esta receta responde a
- ¿Cómo hago un organigrama y una rejilla de fotos con módulos reutilizables, con las líneas trazadas por la plantilla?
La respuesta corta
// 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' } } }],
};
Ingredientes
- Funciones
- Módulos apilados en una páginaConectores entre marcosRejillas que repiten marcosPáginas compuestasMarcos que crecen en un grupoDecoración anclada a los marcosRecuadrosMárgenes simétricosCabeceras y foliosFiguras y tablas como recursosPaleta de color semánticaPáginas en un canvasExportación a PDF
- También usa
- Pestañas numeradas en los recuadrosFiguras justo aquíFuentes incrustadas en el PDFTipos de recurso propios
- Tipografía
- Public Sans, Source Serif 4 (SIL OFL 1.1)
- Recursos
biblioteca-900.jpgcentro-mayores-900.jpgmercado-900.jpgmusica-900.jpgpiscina-900.jpgpolideportivo-900.jpg- La sala de lectura de una biblioteca municipal, en acuarela. (Generated With Diffusion Models, original)
- Niños con violines y un piano en una escuela de música, en acuarela. (Generated With Diffusion Models, original)
- Mayores jugando a las cartas y pintando en un centro de día, en acuarela. (Generated With Diffusion Models, original)
- Una piscina cubierta bajo un techo de madera, en acuarela. (Generated With Diffusion Models, original)
- Niños jugando al baloncesto en un polideportivo, en acuarela. (Generated With Diffusion Models, original)
- Un mercado de sábado en los soportales de una plaza, en acuarela. (Generated With Diffusion Models, original)
Elaboración
#1 · Un módulo por á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 con role: 'module' es un módulo: unos pocos marcos, grupos y decoración pensados para volver a usarse. La página del organigrama tiene un grupo con modules: true, y cada :::module{template="area"} de su Markdown coloca un área más debajo de la anterior, a 6 mm. Los marcos del módulo toman el nombre de la instancia, area[1].concejalia, area[2].concejalia…, así que sus dos conectores se escriben una sola vez: rama, un árbol (tree) desde el pie del área hasta cada servicio de su rejilla (#servicio[*]), y tronco, un ángulo (elbow) desde un punto cerca de la izquierda de la caja de la alcaldía (at: 0.06) hasta el lado izquierdo del área. #alcaldia es un marco de la página, no del módulo: el módulo conserva ese extremo y la maquetación lo encuentra en la página, de modo que los cuatro ángulos comparten su primer tramo y se leen como un solo tronco con una flecha hacia cada área. Los servicios son una rejilla de tres columnas que repite un marco servicio, así que un área con dos servicios dibuja dos cajas y su árbol, dos ramas.
#2 · Paneles de fichas 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' } } }],
};
Un panel también es un módulo: un marco de cabecera que toma el título ## del módulo y una rejilla que repite un juego de dos marcos, la foto y las palabras de debajo. Cada :::frame{slot="foto"} es una ficha, y el :::frame{slot="pie"} que lleva dentro va a las palabras de esa ficha. El fondo tintado es decoración del módulo, una caja tan grande como el módulo, así que crece con las filas y con el pie más largo. La oficina de atención es un tercer módulo, un recuadro cuya pestaña es joined: pestaña y recuadro son un solo contorno con un solo relleno.
#3 · Imágenes al tamaño de impresión
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 } };
}));
Las seis acuarelas miden 900 × 600 px a 400 ppp, 57 mm de ancho, más o menos lo que mide una columna de la rejilla, así que la rejilla nunca las amplía. Su texto alternativo está aquí, en el script, porque las imágenes son las mismas en las dos ediciones; un pie que cambiara con el idioma sería una línea ::caption del Markdown.
#4 · Un solo verde para las líneas y las cajas
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' } },
];
Los conectores, las cajas de área y las cabeceras de los paneles usan green, una entrada de la paleta, así que un pueblo con otro color cambia un solo hexadecimal. La caja de la alcaldía lleva el único color cálido de la doble página, el sitio al que el lector mira primero.
La receta 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),
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)
Variantes
#Dos columnas de servicios
Con columns: 2 los servicios de cada área van de dos en dos; el árbol traza su barra solo sobre la primera fila, así que resérvalo para áreas con dos o cuatro servicios.
- 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),#Redondea las esquinas del tronco
radius redondea los codos de un ángulo y de un árbol: el ayudante line() da 2,5 mm a todos los conectores; con radius: mm(0) el organigrama se vuelve recto.
Errores frecuentes
- Un elemento de la rejilla es un bloque propio: las palabras de una ficha solo le pertenecen si su
:::frame{slot="pie"}está escrito dentro del:::frame{slot="foto"}de la ficha. - Un conector de un módulo que nombra un marco de la página solo se dibuja en las páginas que tienen ese marco; en otra se omite, y el registro de recomposición dice
connectorDropped. - Una pestaña unida queda sobre el borde del recuadro y toma su relleno: dale a sus palabras un
coloroscuro, porque un rótulo blanco sobre un recuadro claro no se lee.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Una guía de los servicios municipales de Valdeloma, un pueblo inventado, escrita para esta receta en español e inglés · Postext Cookbook · CC BY 4.0
- Imágenes
- La sala de lectura de una biblioteca municipal, en acuarela. · Generated With Diffusion Models · original
- Niños con violines y un piano en una escuela de música, en acuarela. · Generated With Diffusion Models · original
- Mayores jugando a las cartas y pintando en un centro de día, en acuarela. · Generated With Diffusion Models · original
- Una piscina cubierta bajo un techo de madera, en acuarela. · Generated With Diffusion Models · original
- Niños jugando al baloncesto en un polideportivo, en acuarela. · Generated With Diffusion Models · original
- Un mercado de sábado en los soportales de una plaza, en acuarela. · Generated With Diffusion Models · original
- Fuentes
- Public Sans (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1)
- Código
- MIT, como Postext
Editar este texto ↗ (se abre en una nueva pestaña)Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)


