Em poucas palavras
Duas páginas do guia de uma prefeitura: quem cuida de quê, num esquema de caixas ligadas por linhas, e onde fica cada serviço, em fileiras de imagens. As páginas são feitas de poucos blocos reutilizáveis; o Markdown diz quantos.
O que você vai compor
Uma página dupla do guia de serviços de Valdeloma, uma cidade inventada de 8.400 habitantes. A página da esquerda é o organograma da prefeitura: o gabinete do prefeito e a câmara numa caixa vermelha, quatro secretarias em verde e, embaixo de cada uma, seus serviços em caixas claras, ligados por linhas verdes com um ponto ou uma seta na ponta. A da direita mostra onde ficam os serviços: dois painéis de três aquarelas, com o nome, o endereço e o horário embaixo de cada imagem, e um boxe para o atendimento ao cidadão. Nada é desenhado à mão. O modelo tem três módulos (uma secretaria, um painel de fotos e um aviso), e cada página empilha tantos quantos o seu Markdown tiver; as linhas acompanham as caixas para onde o texto as levar.
Esta receita responde a
- Como faço um organograma e uma grade de fotos com módulos reutilizáveis, com as linhas traçadas pelo modelo?
A resposta curta
// 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
- Funcionalidades
- Módulos empilhados numa páginaConectores entre blocosGrades que repetem blocosPáginas compostasQuadros que crescem num grupoDecoração ancorada aos blocosBoxesMargens espelhadasCabeços e fóliosFiguras e tabelas como recursosPaleta de cores semânticaPáginas em um canvasExportação para PDF
- Também usa
- Abas numeradas nos boxesFiguras exatamente aquiFontes incorporadas ao PDFTipos de recurso personalizados
- Tipografia
- Public Sans, Source Serif 4 (SIL OFL 1.1)
- Materiais
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, 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)
Preparo
#1 · Um módulo por secretaria
// 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' } } }],
};
Um modelo com role: 'module' é um módulo: alguns quadros, grupos e enfeites feitos para serem usados de novo. A página do organograma tem um grupo com modules: true, e cada :::module{template="area"} do seu Markdown põe mais uma secretaria embaixo da anterior, a 6 mm. Os quadros do módulo recebem o nome da instância, area[1].concejalia, area[2].concejalia…, e por isso os seus dois conectores são escritos uma vez só: rama, uma árvore (tree) do pé da secretaria até cada serviço da sua grade (#servicio[*]), e tronco, um cotovelo (elbow) de um ponto perto da esquerda da caixa do gabinete (at: 0.06) até o lado esquerdo da secretaria. #alcaldia é um quadro da página, não do módulo: o módulo guarda essa ponta e a diagramação a encontra na página, de modo que os quatro cotovelos dividem o primeiro trecho e se leem como um só tronco com uma seta para cada secretaria. Os serviços são uma grade de três colunas que repete um quadro servicio, então uma secretaria com dois serviços desenha duas caixas, e a sua árvore, dois ramos.
#2 · Painéis de fichas com 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' } } }],
};
Um painel também é um módulo: um quadro de cabeçalho que recebe o título ## do módulo e uma grade que repete um par de quadros, a foto e o texto embaixo dela. Cada :::frame{slot="foto"} é uma ficha, e o :::frame{slot="pie"} que vai dentro dele vai para o texto dessa ficha. O fundo tingido é enfeite do módulo, uma caixa do tamanho do módulo, então cresce com as fileiras e com a legenda mais longa. O atendimento ao cidadão é um terceiro módulo, um boxe cuja aba é joined: aba e boxe são um só contorno com um só preenchimento.
#3 · Imagens no tamanho de impressão
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 } };
}));
As seis aquarelas têm 900 × 600 px declarados a 400 ppi, ou 57 mm de largura, mais ou menos a largura de uma coluna da grade, e por isso a grade nunca as amplia. O texto alternativo delas está aqui no script porque as imagens são as mesmas nas duas edições; uma legenda que mudasse com o idioma seria uma linha ::caption do Markdown.
#4 · Um só verde para as linhas e as caixas
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' } },
];
Os conectores, as caixas das secretarias e os cabeçalhos dos painéis usam green, uma entrada da paleta, então uma cidade com outra cor muda um único hexadecimal. A caixa do gabinete leva a única cor quente da página dupla, o lugar para onde o leitor olha primeiro.
A receita 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),
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Duas colunas de serviços
Com columns: 2, os serviços de cada secretaria ficam dois por fileira; a árvore traça a sua barra só sobre a primeira fileira, então use isso nas secretarias com dois ou quatro serviços.
- 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),#Arredondar os cantos do tronco
radius arredonda as dobras de um cotovelo e de uma árvore: o auxiliar line() dá 2,5 mm a todos os conectores; com radius: mm(0), o organograma fica com cantos retos.
Erros comuns
- Um item da grade é um bloco à parte: o texto de uma ficha com foto só pertence a ela se o seu
:::frame{slot="pie"}estiver escrito dentro do:::frame{slot="foto"}da ficha. - Um conector de um módulo que cita um quadro da página só é desenhado nas páginas que têm esse quadro; nas outras ele é omitido, e o registro de recomposição diz
connectorDropped. - Uma aba unida fica sobre a borda do boxe e recebe o preenchimento dele: dê ao texto dela uma
colorescura, porque um rótulo branco sobre um boxe claro não se lê.
Créditos
- Receita
- Ignacio Ferro
- Texto
- 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
- Imagens
- 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
- Fontes
- Public Sans (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


