Saltar al contenido principal
Receta número 162

Recetario · Capítulo 1 · Página y retícula

Un organigrama y una rejilla de fotos hechos con módulos

Una guía de servicios municipales hecha con módulos: un organigrama cuyas líneas traza la plantilla y rejillas que repiten una ficha por edificio.

En esta página
Salida
Canvas · PDF
Postext
Probada con Postext 1.48.1
Requiere ≥ 1.48.1 · postext-pdf ≥ 1.48.1
Licencia
Actualizada el 20 oct 2026
Código MIT · Texto CC BY 4.0

pp. 1–2 de 2

  • Formato 210 × 297 mm
  • 1 columna
  • Source Serif 4 10,5/15
  • Public Sans
  • 2 páginas
  • Nivel
  • Postext 1.48.1
  • Compuesto en 98 ms
  • 174 líneas de código

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

script.js · líneas 40–72en el código 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' } } }],
};

Ingredientes

Tipografía
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
  • 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

script.js · líneas 40–72en el código 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' } } }],
};

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

script.js · líneas 76–105en el código 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' } } }],
};

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

script.js · líneas 346–372en el código 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 } };
}));

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

script.js · líneas 15–28en el código 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' } },
];

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

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

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 color oscuro, porque un rótulo blanco sobre un recuadro claro no se lee.

Créditos

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