En poques paraules
Un pòster gran per a un congrés científic, sobre els arbres del carrer i la calor. Ensenya a ordenar en columnes requadres de text i gràfics en una sola pàgina molt gran.
Què compondràs
Un pòster de congrés de 600 × 800 mm per a un estudi inventat sobre arbrat viari i calor a la vorera. Una banda verda porta el nom de la trobada, el títol en Bitter a 110 pt, els autors amb les seves filiacions i la marca del Departament de Geografia. Sota un resum a dues columnes, un requadre sense marc ocupa tota l'amplada i conté sis panells en tres columnes, cadascun sota un filet de 6 pt. La columna central comença amb −4,2 °C en taronja a 150 pt; segueixen un mapa de calor de l'aire per illa de cases i hora, amb la seva llegenda de mostres de color, i un gràfic de barres del paviment. Una franja tintada amb xips de paraules clau es recolza al marge inferior. El mateix fitxer compon l'edició anglesa, i el pen ofereix la pàgina com a PNG de 1701 × 2268 px.
Aquesta recepta respon a
- Com poso un requadre que travessi les dues columnes a mitja pàgina, com un tauler de xifres en tres blocs?
- Com niuo requadres, com una fitxa d'exercicis amb caixes de resposta, amb un espaiat exacte?
- Com poso dues columnes dins d'un requadre (una de text al costat d'una altra amb una figura)?
- Com afegeixo mostres de color com a llegenda en el text, els peus o les notes de taula?
La resposta curta
// The body text runs in two columns. Three columns exist only inside a box: a :::columns
// group in a callout, and span="page" lays that callout across both body columns.
// :::callout{type="grid" span="page"} ← one frameless box across the page
// :::columns{count=3 breaks="3,4"} ← panel 3 opens column 2, panel 4 column 3
// :::callout{type="panel" title="Introduction"} ← each panel is a box nested in it
// …
// ::: ← closes the panel; then the other panels
// ::: ← closes the columns group
// ::: ← closes the grid (gotcha: callout-columns)
const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
columnGap: mm(GAP) };
// A nested box takes the width of its column and ignores span and placement (gotcha:
// nested-callout-limits). Its one device is a 6 pt stripe along the top.
const panel = { id: 'panel', backgroundEnabled: false,
stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') },
padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) },
titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700,
textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) },
body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText
marginTop: mm(18) };
const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat
stripe: { ...panel.stripe, color: col('heat') },
titleStyle: { ...panel.titleStyle, color: col('heat') } };
Ingredients
- Funcions
- Columnes dins d'un requadreRequadres niatsRequadres a tota l'ampladaObertures dissenyadesAtributs de títolAncoratge d'elements de dissenyImatges als dissenys de pàginaMostres de colorChips en líniaPaleta de color semànticaRequadresFigures just aquíEstils de paràgrafFormat de pàginaPàgines en un canvasPeus numeratsLínies de font i crèditRequadres flotantsRetícula de baseBanda de capítol d'amplada completa
- També fa servir
- Bibliografies i glossarisFigura i Taula en la teva llenguaColor del paperTipus de recurs propisFigures i taules com a recursosEspai vertical explícit
- Tipografia
- Rethink Sans, Bitter, Saira Condensed (SIL OFL 1.1)
- Recursos
- The plan of the street, the heat map, the bar chart and the School of Geography's mark, drawn in code from synthetic data in the poster's palette (Ignacio Ferro, CC BY 4.0)
Elaboració
#1 · Compon el plec a 72 ppp
const SHEET = { width: 600, height: 800, margin: 25 }; // mm: a portrait board, 25 mm all round
const GAP = 12; // mm between the two body columns and between the three panel columns
// The summary's leading is the page's baseline grid: 56 lines fill the 750 mm between the
// margins, so a box floated to the foot of the page ends on the bottom margin.
const LEAD = (((SHEET.height - 2 * SHEET.margin) / 25.4) * 72) / 56; // 37.96 pt
const page = { width: mm(SHEET.width), height: mm(SHEET.height),
// At 72 dpi a point is a pixel: the page is 1,701 × 2,268 px. The default 300 dpi would give
// 7,087 × 9,449 px (268 MB of canvas) for the same line breaks.
dpi: 72, backgroundColor: col('paper'),
margins: { top: mm(SHEET.margin), bottom: mm(SHEET.margin), left: mm(SHEET.margin),
right: mm(SHEET.margin) } }; // one sheet, nothing to mirror
A 300 ppp, el valor per defecte, el pòster talla cada línia al mateix lloc que a 72 ppp, però la pàgina fa 7087 × 9449 px, que un cop pintats ocupen 268 MB (mides de pàgina predefinides). A 72 ppp un punt és un píxel. El visor pinta les miniatures amb renderPageToCanvas i una scale, i renderPage genera el PNG de 1701 × 2268 px que demana la galeria en línia d'un congrés (renderitzar una pàgina a un mapa de bits). L'interlineat reparteix en 56 línies els 750 mm que hi ha entre els marges, així que la franja de paraules clau, flotada al peu de la pàgina, acaba al marge inferior. Amb un interlineat rodó de 38 pt només hi caben 55 línies, i l'última queda 12,7 mm per sobre del marge; la franja ja no cap sota els panells i passa a una segona pàgina.
#2 · Dibuixa la banda del títol des de l'encapçalament
const BAND = 158; // mm from the top edge to the foot of the green field
const type = (id, content, family, size, look, placement) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left',
overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
lineHeight: 1.2, // a multiple of the size (gotcha: design-lineheight-multiple)
...look, placement });
const band = { enabled: true,
// The field ends 133 mm under the top margin; eleven grid lines (147 mm) start the summary
// 14 mm below it.
minHeight: pt(11 * LEAD),
slot: { elements: [
{ kind: 'box', id: 'field', style: { backgroundColor: col('canopy') },
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
type('meeting', '{attr.meeting}', 'Saira Condensed', 22, { fontWeight: 600,
textTransform: 'uppercase', letterSpacing: pt(3.5), color: col('tint') },
{ ...at('page', 'top-left', SHEET.margin, 18), size: { width: mm(420) } }),
type('title', '{titleText}', 'Bitter', 110, { fontWeight: 800, lineHeight: 1 },
{ ...at('#meeting', 'below', 0, 7), size: { width: mm(380) } }),
type('authors', '{attr.authors}', 'Rethink Sans', 30, { fontWeight: 700 },
{ ...at('#title', 'below', 0, 7), size: { width: 'fill' } }),
// Design text prints plain text, so the affiliation marks are the characters ¹ ² ³
// (gotcha: design-text-no-inline-marks).
type('affiliations', '{attr.affiliations}', 'Rethink Sans', 21, { color: col('tint') },
{ ...at('#authors', 'below', 0, 2), size: { width: 'fill' } }),
type('number', '{attr.poster}', 'Saira Condensed', 30, { fontWeight: 700,
color: col('canopy'), box: { backgroundColor: col('paper'),
padding: { top: mm(1.5), right: mm(4), bottom: mm(1), left: mm(4) } } },
at('page', 'top-right', -SHEET.margin, 15)),
// The School of Geography's mark, drawn in code.
{ kind: 'image', id: 'mark', resourceId: 'mark',
placement: { ...at('page', 'top-right', -SHEET.margin, 38), size: { width: mm(92) } } },
] } };
L'H1 porta com a atributs la trobada, el número del pòster, els autors i les filiacions, i el seu disseny els compon sobre un rectangle verd de 158 mm d'alt, ancorat a la pàgina (span i disseny avançat). El rectangle acaba 133 mm per sota del marge superior. Sense minHeight, el resum començaria a 0,9 mm del verd; un minHeight d'onze línies de la retícula de base el separa 14 mm. El text de disseny imprimeix text pla, així que les crides de filiació són els caràcters ¹ ² ³, les úniques xifres volades del rang Latin-1.
#3 · Nia els panells en un requadre a tota l'amplada
// The body text runs in two columns. Three columns exist only inside a box: a :::columns
// group in a callout, and span="page" lays that callout across both body columns.
// :::callout{type="grid" span="page"} ← one frameless box across the page
// :::columns{count=3 breaks="3,4"} ← panel 3 opens column 2, panel 4 column 3
// :::callout{type="panel" title="Introduction"} ← each panel is a box nested in it
// …
// ::: ← closes the panel; then the other panels
// ::: ← closes the columns group
// ::: ← closes the grid (gotcha: callout-columns)
const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
columnGap: mm(GAP) };
// A nested box takes the width of its column and ignores span and placement (gotcha:
// nested-callout-limits). Its one device is a 6 pt stripe along the top.
const panel = { id: 'panel', backgroundEnabled: false,
stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') },
padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) },
titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700,
textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) },
body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText
marginTop: mm(18) };
const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat
stripe: { ...panel.stripe, color: col('heat') },
titleStyle: { ...panel.titleStyle, color: col('heat') } };
El cos admet com a molt dues columnes, així que la retícula és un grup :::columns{count=3} dins d'un requadre, i span="page" estén aquest requadre sobre les dues columnes del cos, sota el resum (:::columns). Cada panell és un requadre niat en el grup: pren el seu propi estil, els 175 mm d'amplada de la columna i 18 mm de marginTop sota el panell de sobre (el contenidor :::callout). breaks="3,4" porta el tercer panell, Resultados, al cap de la columna central, i el quart, Conclusiones, al de l'última. Sense això, el grup s'equilibra i talla pel panell més proper a cada terç de la pila. Amb aquest text els talls cauen en els mateixos dos panells; l'atribut els fixa per si el text canvia.
#4 · Deixa cada figura al seu panell
// "Figure 1", not "Figure 1.1": the poster has no chapters.
const figureType = { ...defaultResourceTypes(LANG)[0], numberingTemplate: '{n}' };
const resourceTypes = [figureType];
const figure = (id, [width, height], caption, altText) => ({ id, typeId: figureType.id,
kind: 'svg', svg: { fileId: `${id}.svg`, width: width * 10, height: height * 10 },
placement: { position: 'here' }, // stays in its panel, where ::resource puts it; else floats
caption, altText, note: t({ en: 'Synthetic data, generated for this poster.',
es: 'Datos sintéticos, generados para este póster.' }), createdAt: 0, updatedAt: 0 });
Una figura amb posició 'here' es queda al panell on la posa ::resource; amb la col·locació per defecte, els tres dibuixos surten dels seus panells i el pòster passa a dues pàgines. numberingTemplate: '{n}' les numera 1, 2 i 3, mentre que el {h1}.{n} per defecte imprimeix Figura 1.1 sota un títol que és un H1 (tipus de recurs). La nota sota cada peu avisa que les dades són sintètiques.
#5 · Comparteix els colors del mapa de calor amb la seva llegenda
const palette = {
ink: '#102a2c', // text: a green-black
paper: '#f7faf7', // the sheet, and type on the band
canopy: '#2e7d4f', // the band, the stripes and titles of five panels, the tree crowns
tint: '#e6f2ea', // the keyword strip, the park on the plan, lines of type on the band
rule: '#bcd2c4', // buildings on the plan, the chart's grid
muted: '#587068', // the notes under the figures, the axis titles inside them
// The key of Figure 2, the air against the street mean at the same hour. The heat map's
// cells and the :swatch runs of its key read the same four entries.
cool: '#3b82c4', // −1.5 °C or less
mist: '#a9c9e6', // −1.5 to 0 °C
blush: '#f2b492', // 0 to +1.5 °C
heat: '#d9572b', // +1.5 °C or more; also −4.2 °C, the Results panel, the loggers, the sun
};
// 1.4.1 designs paint the hex and ignore the paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the canopy green
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
{ id: 'main-color', name: 'canopy (defaults)', value: { hex: palette.canopy, model: 'hex' } },
];
Les cel·les del mapa de calor i les quatre mostres :swatch{color="…"} de la seva llegenda remeten a les mateixes entrades de la paleta, així que un valor nou per a cool canvia alhora el mapa i la llegenda (format en línia). La llegenda és un segon grup :::columns, amb dues entrades per columna, dins del panell de Resultados. Cada color porta a més el seu valor hexadecimal, que és el que la 1.4.1 pinta en el disseny de la banda.
#6 · Compon la xifra principal amb un estil de paràgraf
const paragraphStyles = [
// Paragraph styles apply inside boxes, nested ones included. They have no weight in
// 1.4.1, so the figure is written **bold**: that sets it in Bitter 700 and in boldColor.
{ id: 'stat', fontFamily: 'Bitter', fontSize: pt(150), lineHeight: pt(130),
boldColor: col('heat') },
{ id: 'refs', fontSize: pt(19), lineHeight: pt(26), hangingIndent: mm(9), spaceBetween: pt(8) },
{ id: 'key', fontSize: pt(19), lineHeight: pt(28) }, // the key of Figure 2
];
const chipStyles = [{ id: 'keyword', background: col('paper'), borderColor: col('canopy'),
borderWidth: pt(1.5), borderRadius: em(1), paddingX: em(0.55), paddingY: em(0.14),
color: col('canopy'), bold: true, gap: em(0.35) }];
const strip = { id: 'strip', background: col('tint'), // one device: a tint
padding: { top: mm(4), right: mm(8), bottom: mm(4), left: mm(8) }, columnGap: mm(GAP),
body: { fontSize: pt(18), lineHeight: pt(24) } };
Els estils de paràgraf s'apliquen dins dels requadres, també als niats, així que −4,2 °C és un bloc :::paragraphs{style="stat"} en Bitter a 150 pt dins del panell de Resultados (estils de paràgraf). A la 1.4.1 un estil de paràgraf no fixa el pes de la lletra, així que la xifra va en negreta i surt en Bitter 700. Un segon estil dona a les referències un sagnat francès de 9 mm. La franja de paraules clau és un requadre tintat amb placement="bottom", que flota al peu de la pàgina sota la retícula.
La recepta completa
// ═══ Postext Cookbook · Nº 059 · Research poster on one big page ══════════════════ // https://postext.dev/en/cookbook/research-poster // Code: MIT · Text and data: original, synthetic (CC BY 4.0) · Figures: generated in code // Fonts: Rethink Sans, Bitter, Saira Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPage, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'research-poster'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: six colours for the poster, four for the heat map's key const palette = { ink: '#102a2c', // text: a green-black paper: '#f7faf7', // the sheet, and type on the band canopy: '#2e7d4f', // the band, the stripes and titles of five panels, the tree crowns tint: '#e6f2ea', // the keyword strip, the park on the plan, lines of type on the band rule: '#bcd2c4', // buildings on the plan, the chart's grid muted: '#587068', // the notes under the figures, the axis titles inside them // The key of Figure 2, the air against the street mean at the same hour. The heat map's // cells and the :swatch runs of its key read the same four entries. cool: '#3b82c4', // −1.5 °C or less mist: '#a9c9e6', // −1.5 to 0 °C blush: '#f2b492', // 0 to +1.5 °C heat: '#d9572b', // +1.5 °C or more; also −4.2 °C, the Results panel, the loggers, the sun }; // 1.4.1 designs paint the hex and ignore the paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ // defaults link to 'main-color': point it at the canopy green ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'canopy (defaults)', value: { hex: palette.canopy, model: 'hex' } }, ]; // #endregion // #region sheet: one 600 × 800 mm page, laid out at 72 dpi const SHEET = { width: 600, height: 800, margin: 25 }; // mm: a portrait board, 25 mm all round const GAP = 12; // mm between the two body columns and between the three panel columns // The summary's leading is the page's baseline grid: 56 lines fill the 750 mm between the // margins, so a box floated to the foot of the page ends on the bottom margin. const LEAD = (((SHEET.height - 2 * SHEET.margin) / 25.4) * 72) / 56; // 37.96 pt const page = { width: mm(SHEET.width), height: mm(SHEET.height), // At 72 dpi a point is a pixel: the page is 1,701 × 2,268 px. The default 300 dpi would give // 7,087 × 9,449 px (268 MB of canvas) for the same line breaks. dpi: 72, backgroundColor: col('paper'), margins: { top: mm(SHEET.margin), bottom: mm(SHEET.margin), left: mm(SHEET.margin), right: mm(SHEET.margin) } }; // one sheet, nothing to mirror // #endregion const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region band: the title band is the H1's design, filled from its attributes const BAND = 158; // mm from the top edge to the foot of the green field const type = (id, content, family, size, look, placement) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left', overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default) lineHeight: 1.2, // a multiple of the size (gotcha: design-lineheight-multiple) ...look, placement }); const band = { enabled: true, // The field ends 133 mm under the top margin; eleven grid lines (147 mm) start the summary // 14 mm below it. minHeight: pt(11 * LEAD), slot: { elements: [ { kind: 'box', id: 'field', style: { backgroundColor: col('canopy') }, placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } }, type('meeting', '{attr.meeting}', 'Saira Condensed', 22, { fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(3.5), color: col('tint') }, { ...at('page', 'top-left', SHEET.margin, 18), size: { width: mm(420) } }), type('title', '{titleText}', 'Bitter', 110, { fontWeight: 800, lineHeight: 1 }, { ...at('#meeting', 'below', 0, 7), size: { width: mm(380) } }), type('authors', '{attr.authors}', 'Rethink Sans', 30, { fontWeight: 700 }, { ...at('#title', 'below', 0, 7), size: { width: 'fill' } }), // Design text prints plain text, so the affiliation marks are the characters ¹ ² ³ // (gotcha: design-text-no-inline-marks). type('affiliations', '{attr.affiliations}', 'Rethink Sans', 21, { color: col('tint') }, { ...at('#authors', 'below', 0, 2), size: { width: 'fill' } }), type('number', '{attr.poster}', 'Saira Condensed', 30, { fontWeight: 700, color: col('canopy'), box: { backgroundColor: col('paper'), padding: { top: mm(1.5), right: mm(4), bottom: mm(1), left: mm(4) } } }, at('page', 'top-right', -SHEET.margin, 15)), // The School of Geography's mark, drawn in code. { kind: 'image', id: 'mark', resourceId: 'mark', placement: { ...at('page', 'top-right', -SHEET.margin, 38), size: { width: mm(92) } } }, ] } }; // #endregion // #region answer: three columns of panels inside one box across the page // The body text runs in two columns. Three columns exist only inside a box: a :::columns // group in a callout, and span="page" lays that callout across both body columns. // :::callout{type="grid" span="page"} ← one frameless box across the page // :::columns{count=3 breaks="3,4"} ← panel 3 opens column 2, panel 4 column 3 // :::callout{type="panel" title="Introduction"} ← each panel is a box nested in it // … // ::: ← closes the panel; then the other panels // ::: ← closes the columns group // ::: ← closes the grid (gotcha: callout-columns) const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, columnGap: mm(GAP) }; // A nested box takes the width of its column and ignores span and placement (gotcha: // nested-callout-limits). Its one device is a 6 pt stripe along the top. const panel = { id: 'panel', backgroundEnabled: false, stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') }, padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) }, body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText marginTop: mm(18) }; const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat stripe: { ...panel.stripe, color: col('heat') }, titleStyle: { ...panel.titleStyle, color: col('heat') } }; // #endregion // #region type: paragraph styles for the figure, the references and the key; the foot strip const paragraphStyles = [ // Paragraph styles apply inside boxes, nested ones included. They have no weight in // 1.4.1, so the figure is written **bold**: that sets it in Bitter 700 and in boldColor. { id: 'stat', fontFamily: 'Bitter', fontSize: pt(150), lineHeight: pt(130), boldColor: col('heat') }, { id: 'refs', fontSize: pt(19), lineHeight: pt(26), hangingIndent: mm(9), spaceBetween: pt(8) }, { id: 'key', fontSize: pt(19), lineHeight: pt(28) }, // the key of Figure 2 ]; const chipStyles = [{ id: 'keyword', background: col('paper'), borderColor: col('canopy'), borderWidth: pt(1.5), borderRadius: em(1), paddingX: em(0.55), paddingY: em(0.14), color: col('canopy'), bold: true, gap: em(0.35) }]; const strip = { id: 'strip', background: col('tint'), // one device: a tint padding: { top: mm(4), right: mm(8), bottom: mm(4), left: mm(8) }, columnGap: mm(GAP), body: { fontSize: pt(18), lineHeight: pt(24) } }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, page, layout: { layoutType: 'double', gutterWidth: mm(GAP) }, // The summary's type; the boxes take the family, the rag and the paragraph spacing from it. bodyText: { fontFamily: 'Rethink Sans', fontSize: pt(28), lineHeight: pt(LEAD), color: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // Bold in the boxes and the :ref labels copy boldColor, and the italics of the references' // paragraph style take italicColor (gotcha: style-italic-colour); both are green otherwise. boldColor: col('ink'), italicColor: col('ink') }, // The band draws the title, but 1.4.1 still measures the H1's own text: in Bitter, a face // already loaded, instead of the default Open Sans 700. headings: { fontFamily: 'Bitter', levels: [ // Restated (gotcha: headings-drop-h1-break): a second poster in the file starts a page. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), advancedDesign: band }] }, calloutStyles: [grid, panel, results, strip], paragraphStyles, chipStyles, resourceTypes, captionStyle: { fontSize: pt(19), labelColor: col('canopy'), gap: mm(3), note: { fontSize: pt(17), color: col('muted') } }, header: { elements: [] }, footer: { elements: [] }, // a poster has no running heads }); // ─── 2 · Content ──────────────────────────────────────────────────────────── // #region figures: three drawings set inside their panels, numbered 1, 2, 3 // "Figure 1", not "Figure 1.1": the poster has no chapters. const figureType = { ...defaultResourceTypes(LANG)[0], numberingTemplate: '{n}' }; const resourceTypes = [figureType]; const figure = (id, [width, height], caption, altText) => ({ id, typeId: figureType.id, kind: 'svg', svg: { fileId: `${id}.svg`, width: width * 10, height: height * 10 }, placement: { position: 'here' }, // stays in its panel, where ::resource puts it; else floats caption, altText, note: t({ en: 'Synthetic data, generated for this poster.', es: 'Datos sintéticos, generados para este póster.' }), createdAt: 0, updatedAt: 0 }); // #endregion const markdown = String.raw`---Mostra en Markdown · 95 línies · content.es.md
title: "Arbolado viario y calor en la acera" author: "Nuria Castellví, Tomás Iribarren, Helena Quiroga" --- # Arbolado viario y calor en la acera {meeting="IV Encuentro sobre Calle y Clima · Almedo, 14–16 de octubre de 2026" poster="P-47" authors="Nuria Castellví¹ · Tomás Iribarren² · Helena Quiroga¹,³" affiliations="¹ Departamento de Geografía, Universidad de Almedo ² Servicio de Arbolado Viario, Ayuntamiento de Almedo ³ Observatorio del Clima Urbano"} **Resumen.** Medimos el aire a la altura de la cabeza en las dieciséis manzanas de la avenida de la Estación, y fotografiamos sus aceras con una cámara térmica, en las catorce tardes de 2025 que pasaron de 30 °C. De 13:00 a 17:00, bajo un 70 % de copa o más, el aire estuvo 4,2 °C más fresco que en el tramo comercial, casi sin árboles, y el pavimento 15 °C más fresco. El Ayuntamiento decidirá así cuáles de sus 212 alcorques vacíos plantará primero. :::callout{type="grid" span="page"} :::columns{count=3 breaks="3,4"} :::callout{type="panel" title="Introducción"} En julio, a media tarde, una acera de hormigón al sol puede pasar de 50 °C, y el aire que tiene encima se calienta con ella. Las copas detienen el sol antes de que llegue al pavimento [1, 2]. Casi todos los estudios comparan un parque con las calles que lo rodean [3]. La avenida pasa del pavimento desnudo a copas que se tocan sobre la calzada, así que cada manzana se compara con sus vecinas la misma tarde. **Pregunta.** ¿Cuánto refresca cada escalón de copa y a qué horas? ::: :::callout{type="panel" title="Métodos"} La avenida (:ref{id="plan"}) va del parque de la Alameda a la estación de autobuses: 800 m en dieciséis manzanas de 50 m. Tiene plátanos de 1908 junto al parque y tilos en las manzanas 13 a 15; el tramo comercial casi no tiene árboles. ::resource{id="plan"} - **Aire.** Un registrador por manzana, a 1,5 m en una farola, con una lectura cada 5 minutos. - **Pavimento.** Una cámara térmica recorrió las dos aceras a las 15:00. - **Copa.** La parte de acera bajo las copas, trazada sobre fotografías aéreas. El aire se da respecto a la media de la calle a esa hora, lo que descuenta el tiempo del día. ::: :::callout{type="results" title="Resultados"} :::paragraphs{style="stat"} **−4,2 °C** ::: **Aire a 1,5 m, de 13:00 a 17:00, con un 70 % de copa o más frente a menos del 10 %.** Plátanos y tilos refrescan sus manzanas toda la tarde; el tramo comercial sigue caliente (:ref{id="heat"}). :::columns{count=2 breaks="3"} :::paragraphs{style="key"} :swatch{color="cool"} −1,5 °C o menos :swatch{color="mist"} de −1,5 a 0 °C :swatch{color="blush"} de 0 a +1,5 °C :swatch{color="heat"} +1,5 °C o más ::: ::: :::space{lines=0.33} ::resource{id="heat"} A las 15:00 el pavimento (:ref{id="bars"}) daba 48,4 °C en el tramo comercial y 33,4 °C bajo copa densa. ::resource{id="bars"} ::: :::callout{type="panel" title="Conclusiones"} Cada 10 % de copa restó 2,1 °C al pavimento a las 15:00 y 0,6 °C al aire de 13:00 a 17:00. El efecto es mayor por la tarde, cuando se vuelve a casa a pie. Antes de las 09:00 y tras las 19:00 la diferencia no llega a 1,5 °C, y a las 20:00 la sombra está 0,4 °C más caliente. Los 200 m del tramo comercial son los más calurosos de la calle. Si la relación se mantiene, su pavimento pasará de 48,4 °C a unos 39 °C a las 15:00 cuando las copas cubran media acera. ::: :::callout{type="panel" title="Plan de plantación"} El Ayuntamiento arbolará primero las manzanas más calurosas, empezando por el tramo comercial. Repetiremos la campaña en 2028. 1. **Manzanas 7–10**: 38 árboles en 2026. 2. **Manzana 16**, la estación: 9 árboles en 2027. 3. **Manzanas 11–12**: 21 árboles en 2027. 4. **Manzanas 5–6**: 12 árboles en 2028. ::: :::callout{type="panel" title="Referencias"} :::paragraphs{style="refs"} [1] Oke, T.R. (1982). The energetic basis of the urban heat island. *Quarterly Journal of the Royal Meteorological Society*, 108, 1–24. [2] Armson, D., Stringer, P. y Ennos, A.R. (2012). The effect of tree shade and grass on surface and globe temperatures in an urban area. *Urban Forestry & Urban Greening*, 11, 245–255. [3] Bowler, D.E., Buyung-Ali, L., Knight, T.M. y Pullin, A.S. (2010). Urban greening to cool towns and cities: a systematic review of the empirical evidence. *Landscape and Urban Planning*, 97, 147–155. ::: :::space{lines=0.75} Gracias al alumbrado público de Almedo por las farolas y a Inês Barros por sus catorce vueltas con la cámara térmica. ::: ::: ::: :::callout{type="strip" span="page" placement="bottom"} :::columns{count=2 breaks="2"} **Palabras clave** :chip[arbolado viario]{style="keyword"} :chip[calor urbano]{style="keyword"} :chip[cobertura de copa]{style="keyword"} :chip[termografía]{style="keyword"} Calle, autores y datos inventados para el Recetario de Postext. Compuesto en Rethink Sans, Bitter y Saira Condensed (SIL OFL). Texto y figuras CC BY 4.0. ::: :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the synthetic survey, the plan, the heat map, the bars and the mark function mulberry32(seed) { // a seeded PRNG: the same survey on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } // Sixteen 50 m blocks, west to east: the park's planes, houses, the Parade, new planting, // old limes and the bus station. Canopy is the share of sidewalk under crowns, in %. const CANOPY = [84, 78, 72, 66, 45, 31, 4, 0, 6, 9, 18, 27, 58, 74, 71, 12]; const HOURS = [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; // How much of the shade shows in the air at each hour: most at 14:00–15:00, reversed by 20:00. const SUN = [0.3, 0.4, 0.55, 0.7, 0.85, 0.95, 1, 1, 0.95, 0.8, 0.55, 0.25, -0.1]; const SURVEY = (() => { // air: °C against the street mean at the hour; surface: °C at 15:00 const rand = mulberry32(25); // Six uniforms summed and scaled: close enough to a normal draw with a mean of 0 and an SD of 1. const noise = () => [...Array(6)].reduce((sum) => sum + rand(), -3) / Math.sqrt(0.5); const mean = CANOPY.reduce((a, b) => a + b) / CANOPY.length; return CANOPY.map((c) => ({ canopy: c, air: SUN.map((w) => (w * 6.2 * (mean - c)) / 100 + 0.22 * noise()), surface: 49.2 - 0.205 * c + 0.7 * noise() })); })(); const CLASSES = [[0, 10], [10, 30], [30, 50], [50, 70], [70, 101]]; // canopy classes, % const avg = (values) => values.reduce((a, b) => a + b, 0) / values.length; const surfaceOf = ([lo, hi]) => avg(SURVEY.filter((b) => b.canopy >= lo && b.canopy < hi) .map((b) => b.surface)); const classOf = (v) => (v <= -1.5 ? 'cool' : v < 0 ? 'mist' : v < 1.5 ? 'blush' : 'heat'); const f = (v) => +v.toFixed(2); const X0 = 12; // mm: the left gutter of the plan and the heat map const CELL = (175 - X0) / CANOPY.length; // mm per block: the plan and Figure 2 share columns const cx = (i) => f(X0 + (i + 0.5) * CELL); const label = (x, y, text, size, fill = palette.ink, anchor = 'middle', extra = '') => `<text x="${f(x)}" y="${f(y)}" font-size="${size}" text-anchor="${anchor}" fill="${fill}"` + `${extra}>${text}</text>`; const svg = (w, h, body, face) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${face}${body}</svg>`; // An SVG drawn as an image cannot use the page's web fonts (gotcha: svg-no-webfonts), so // each drawing carries its label face inline, as a data URL of the Fontsource file. async function inlineFace(family, weight) { const id = family.toLowerCase().replace(/\s+/g, '-'); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}` + '-normal.woff2'; const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return `<style>@font-face{font-family:F;src:url(data:font/woff2;base64,${btoa(bin)}) ` + `format('woff2')}text{font-family:F}</style>`; } const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k const FIG = { plan: [175, 62], heat: [175, 126], bars: [175, 83] }; // mm, at column width const LABEL = 5.4; // mm: figure labels, about 15 pt function planSvg(face) { // Figure 1: a schematic plan, west on the left const rand = mulberry32(1908); // seeded with the year the planes were planted const [W, H] = FIG.plan; const [NB, NS, ROAD, SS, SB] = [[11, 22], [22, 25], [25, 34], [34, 37], [37, 48]]; // y bands const SIDE = [4, 6, 10, 13]; // side streets east of these blocks (block index from 0) let out = `<rect x="${X0}" y="${ROAD[0]}" width="${W - X0}" height="${ROAD[1] - ROAD[0]}" ` + `fill="${mix(palette.paper, palette.ink, 0.16)}"/>`; for (let x = X0 + 2; x < W - 2; x += 6) { // the centre line out += `<path d="M${f(x)} 29.5h3" stroke="${palette.paper}" stroke-width="0.4"/>`; } CANOPY.forEach((c, i) => { // the frontages: houses, the park, the shops const x = X0 + i * CELL; const w = SIDE.includes(i) ? CELL - 3.2 : CELL; if (i < 3) { out += `<rect x="${f(x)}" y="${NB[0] - 2}" width="${f(w)}" height="${NB[1] - NB[0] + 2}" ` + `fill="${palette.tint}"/>`; } else { const fill = i >= 6 && i <= 9 ? mix(palette.rule, palette.ink, 0.3) : palette.rule; for (const [y0, y1] of [NB, SB]) { const cut = rand() * 0.3 + 0.35; out += `<rect x="${f(x + 0.4)}" y="${y0}" width="${f(w * cut - 0.8)}" height="${y1 - y0}"` + ` fill="${fill}"/><rect x="${f(x + w * cut + 0.4)}" y="${y0}" ` + `width="${f(w * (1 - cut) - 0.8)}" height="${y1 - y0}" fill="${fill}"/>`; } } }); out += `<rect x="${f(X0)}" y="${SB[0]}" width="${f(3 * CELL - 0.8)}" height="${SB[1] - SB[0]}" ` + `fill="${palette.rule}"/>`; // houses face the park across the road CANOPY.forEach((c, i) => { // crowns along both sidewalks, as many as the canopy share const n = Math.round((c / 100) * 3.6); // Under 14 % the share rounds to no crown: such a block gets one tree on the south side. const rows = n > 0 ? [[NS, n], [SS, n]] : c > 0 ? [[SS, 1]] : []; for (const [[y0, y1], count] of rows) { for (let k = 0; k < count; k++) { const x = X0 + i * CELL + ((k + 0.5) / count) * CELL + (rand() - 0.5) * 1.2; const r = 2 + (c / 100) * 1.3 + rand() * 0.5; // the old planes have the widest crowns out += `<circle cx="${f(x)}" cy="${f((y0 + y1) / 2)}" r="${f(r)}" fill="${palette.canopy}"` + ` fill-opacity="0.85" stroke="${palette.paper}" stroke-width="0.3"/>`; } } if (i < 3) { // the park's own trees for (let k = 0; k < 4; k++) { out += `<circle cx="${f(X0 + i * CELL + 2 + rand() * (CELL - 4))}" ` + `cy="${f(NB[0] + 1 + rand() * 7)}" r="${f(1.8 + rand())}" fill="${palette.canopy}" ` + 'fill-opacity="0.55"/>'; } } }); CANOPY.forEach((c, i) => { // the loggers, on the north kerb, one per block out += `<circle cx="${cx(i)}" cy="${NS[1]}" r="1.4" fill="${palette.heat}" ` + `stroke="${palette.ink}" stroke-width="0.35"/>` + label(cx(i), 55, i + 1, LABEL); }); const names = t({ en: ['ASHBY PARK', 'THE PARADE', 'BUS STATION'], es: ['PARQUE DE LA ALAMEDA', 'TRAMO COMERCIAL', 'ESTACIÓN DE AUTOBUSES'] }); const track = ' letter-spacing="0.4"'; out += label(X0, 6.5, names[0], LABEL - 0.6, palette.canopy, 'start', track) + label(X0 + 8 * CELL, 6.5, names[1], LABEL - 0.6, palette.ink, 'middle', track) + label(W, 6.5, names[2], LABEL - 0.6, palette.ink, 'end', track); // The north arrow in the gutter: a line and a triangle. out += `<path d="M5 27V18" stroke="${palette.ink}" stroke-width="0.7"/>` + `<path d="M5 13l2.6 5.4h-5.2z" fill="${palette.ink}"/>` + label(5, 34, 'N', LABEL); const bar = 2 * CELL; // 100 m: two blocks out += `<path d="M${X0} 60.6h${f(bar)}" stroke="${palette.ink}" stroke-width="0.9"/>` + label(X0 + bar + 2, 61.8, '100 m', LABEL - 0.6, palette.ink, 'start'); return svg(W, H, out, face); } function heatSvg(face) { // Figure 2: canopy bars over an hour × block heat map const [W, H] = FIG.heat; const [BASE, TOP, ROW] = [27, 30, 6.2]; // mm: foot of the bars, top of the grid, row height let out = ''; SURVEY.forEach((b, i) => { const h = (b.canopy / 100) * 18; out += `<rect x="${f(X0 + i * CELL + 1.6)}" y="${f(BASE - h)}" width="${f(CELL - 3.2)}" ` + `height="${f(h)}" fill="${palette.canopy}"/>` + label(cx(i), f(BASE - h - 1.6), b.canopy, LABEL - 0.8); b.air.forEach((v, r) => { out += `<rect x="${f(X0 + i * CELL + 0.3)}" y="${f(TOP + r * ROW + 0.3)}" ` + `width="${f(CELL - 0.6)}" height="${f(ROW - 0.6)}" fill="${palette[classOf(v)]}"/>`; }); out += label(cx(i), TOP + HOURS.length * ROW + 6.5, i + 1, LABEL); }); out += `<path d="M${X0} ${BASE + 0.2}H${W}" stroke="${palette.ink}" stroke-width="0.4"/>` + label(X0 - 1.5, BASE - 7, '%', LABEL, palette.ink, 'end'); // level with the bars HOURS.forEach((hour, r) => { if (hour % 2 === 0) { out += label(X0 - 1.5, TOP + r * ROW + 5, String(hour).padStart(2, '0'), LABEL, palette.ink, 'end'); } }); const track = ' letter-spacing="0.4"'; const [west, east, hour] = t({ en: ['WEST', 'EAST', 'HOUR'], es: ['OESTE', 'ESTE', 'HORA'] }); const mid = f(TOP + (HOURS.length * ROW) / 2); // the hour axis's title runs up the gutter out += label(X0, H - 0.5, west, LABEL - 1, palette.muted, 'start', track) + label(W, H - 0.5, east, LABEL - 1, palette.muted, 'end', track) + label(0, 0, hour, LABEL - 1, palette.muted, 'middle', `${track} transform="translate(3.4 ${mid}) rotate(-90)"`); return svg(W, H, out, face); } function barsSvg(face) { // Figure 3: how much cooler the paving was, by canopy class const [W, H] = FIG.bars; const [LEFT, ROW, SCALE] = [40, 13, 7.6]; // SCALE: mm per °C const names = t({ en: ['under 10 %', '10–30 %', '30–50 %', '50–70 %', '70 % or more'], es: ['menos del 10 %', '10–30 %', '30–50 %', '50–70 %', '70 % o más'] }); const base = surfaceOf(CLASSES[0]); // the paving under 10 % canopy: the zero of the scale const num = (v) => t({ en: v.toFixed(1), es: v.toFixed(1).replace('.', ',') }); const foot = 5 * ROW + 2; let out = ''; for (const step of [5, 10, 15]) { out += `<path d="M${LEFT + step * SCALE} 1V${foot}" stroke="${palette.rule}" ` + 'stroke-width="0.35"/>' + label(LEFT + step * SCALE, foot + 6.5, step, LABEL); } CLASSES.forEach((cls, i) => { const cooler = base - surfaceOf(cls); const y = 1 + i * ROW; const fill = mix(palette.tint, palette.canopy, 0.3 + 0.7 * (i / 4)); out += label(LEFT - 3, y + 8.3, names[i], LABEL, palette.ink, 'end'); if (i === 0) out += label(LEFT + 2, y + 8.3, `${num(base)} °C`, LABEL, palette.muted, 'start'); else { out += `<rect x="${LEFT}" y="${y + 1.2}" width="${f(cooler * SCALE)}" height="${ROW - 2.4}" ` + `fill="${fill}"/>` + label(LEFT + cooler * SCALE + 2, y + 8.3, num(cooler), LABEL, palette.ink, 'start'); } }); const axis = t({ en: '°C cooler than the blocks under 10 %', es: '°C por debajo de las manzanas de menos del 10 %' }); out += `<path d="M${LEFT} 0V${foot + 1}" stroke="${palette.ink}" stroke-width="0.6"/>` + label(LEFT, foot + 6.5, '0', LABEL) + label(LEFT + 7.5 * SCALE, foot + 14, axis, LABEL, palette.muted, 'middle'); return svg(W, H, out, face); } function markSvg() { // the School of Geography's mark: a crown shading a street, in a ring const shade = mix(palette.canopy, palette.ink, 0.45); return svg(10, 10, '<g transform="scale(0.1)">' + `<circle cx="50" cy="50" r="46" fill="none" stroke="${palette.paper}" stroke-width="3.2"/>` + `<circle cx="70" cy="25" r="8" fill="${palette.heat}"/>` // the sun, behind the crown + `<ellipse cx="43" cy="71" rx="23" ry="4.5" fill="${shade}"/>` // its shade on the paving + `<rect x="47" y="48" width="6" height="23" fill="${palette.paper}"/>` + `<circle cx="50" cy="37" r="17" fill="${palette.paper}"/>` + `<circle cx="35" cy="46" r="11" fill="${palette.paper}"/>` + `<circle cx="65" cy="46" r="11" fill="${palette.paper}"/>` + `<path d="M17 71H83" stroke="${palette.paper}" stroke-width="3.2"/></g>`, ''); } // #endregion const resources = [ figure('plan', FIG.plan, t({ en: 'Ashby Road, schematic plan: crowns from the canopy survey and the 16 loggers ' + '(orange). Widths across the street are not to scale.', es: 'La avenida en plano esquemático: las copas del inventario de arbolado y los 16 ' + 'registradores (naranja). Los anchos transversales no están a escala.' }), t({ en: 'Plan of a straight street in 16 numbered blocks, with a park at the west end, tree ' + 'crowns along both sidewalks and one tree or none on each of blocks 7 to 10.', es: 'Plano de una calle recta en 16 manzanas numeradas, con un parque en el extremo oeste, ' + 'copas en las dos aceras y un árbol o ninguno en cada una de las manzanas 7 a 10.' })), figure('heat', FIG.heat, t({ en: 'Canopy cover per block (bars, %) and the air at head height against the street mean ' + 'at the same hour, from 08:00 to 20:00; mean of 14 afternoons.', es: 'Copa por manzana (barras, %) y aire a la altura de la cabeza respecto a la media de ' + 'la calle a esa hora, de 08:00 a 20:00; media de 14 tardes.' }), t({ en: 'Bar chart of canopy cover for 16 blocks above a grid of coloured cells, hours down ' + 'and blocks across: blue under the tree-lined blocks in the afternoon, orange along ' + 'blocks 7 to 10.', es: 'Barras de cobertura de copa de 16 manzanas sobre una cuadrícula de celdas de color, ' + 'horas hacia abajo y manzanas en horizontal: azul bajo las manzanas arboladas por la ' + 'tarde, naranja en las manzanas 7 a 10.' })), figure('bars', FIG.bars, t({ en: 'The paving at 15:00 by canopy class, as the difference from the blocks under 10 %.', es: 'El pavimento a las 15:00 por clase de copa, como diferencia con las manzanas de ' + 'menos del 10 %.' }), t({ en: 'Horizontal bars that grow with the canopy class, from 2.7 to 15.0 °C cooler.', es: 'Barras horizontales que crecen con la clase de copa, de 2,7 a 15,0 °C menos.' })), // Not cited in the text: only the band's design draws it. { id: 'mark', typeId: figureType.id, kind: 'svg', svg: { fileId: 'mark.svg', width: 100, height: 100 }, altText: t({ en: 'The mark of the School of Geography', es: 'La marca del Departamento de Geografía' }), createdAt: 0, updatedAt: 0 }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Rethink Sans': ['400', '400i', '700'], Bitter: ['400', '700', '800'], // 400: the stat style's base face, which the build measures 'Saira Condensed': ['600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const face = await inlineFace('Saira Condensed', 600); await Promise.all([loadSvg('plan.svg', planSvg(face)), loadSvg('heat.svg', heatSvg(face)), loadSvg('bars.svg', barsSvg(face)), loadSvg('mark.svg', markSvg())]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Research poster', es: 'Póster científico' }) }); // The e-poster: renderPage paints the page at its own size, 1,701 × 2,268 px at 72 dpi. const png = Object.assign(document.createElement('a'), { download: `${RECIPE}.png`, textContent: t({ en: 'E-poster PNG', es: 'PNG del póster digital' }) }); renderPage(doc.pages[0], doc).toBlob((blob) => { png.href = URL.createObjectURL(blob); }); document.getElementById('pt-actions').append(png);Kit · core, fonts, viewer, images: igual a totes les receptes · 270 línies
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
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
#Exporta el pòster digital a 150 ppp
Si el congrés demana un fitxer més gran, surt un PNG de 3543 × 4724 px amb els mateixos talls de línia.
- dpi: 72, backgroundColor: col('paper'),
+ dpi: 150, backgroundColor: col('paper'),Errors freqüents
Error freqüent
:::columns només funciona dins d'un requadre i no es parteix
:::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un. Columnes dins d'un requadre →
Error freqüent
Un requadre imbricat ignora span, placement i snapToGrid
Un requadre imbricat en un altre ignora el seu span, placement, snapToGrid i floatBarrier: sempre es compon dins del requadre que el conté, amb l'amplada interior d'aquest. Requadres niats →
Error freqüent
El text de disseny no admet ^sup^ ni **negreta**
Els elements de text de disseny imprimeixen text pla, així que ^1^ o **negreta** en un atribut apareixen tal qual. Fes servir superíndexs Unicode (¹ ² ³ són al subconjunt latin) o un segon element amb un altre pes. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Una taula o una figura dins d'un requadre no porta aire al voltant
A postext 1.4.1, una taula o una figura que ::resource col·loca amb la posició 'here' dins d'un :::callout no rep res de l'aire que conserva al text corregut: toca el paràgraf de sobre i el de sota. Posa un :::space abans de la línia ::resource, i un altre després si hi segueix text; una fracció de línia, com lines=0.33, deixa un espai petit. Figures just aquí →
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
Un estil de paràgraf no té color de cursiva
A postext 1.4.1 un estil de paràgraf fixa color i boldColor, però no italicColor: les seves cursives prenen bodyText.italicColor. Un estil atenuat (la lletra petita, la línia «Font:» d'una taula) imprimeix els seus títols en cursiva més foscos que el text que els envolta. Deixa aquests estils en el color del text o evita-hi les cursives. Estils de paràgraf →
Error freqüent
En el text en bandera no s'eviten les línies curtes
optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta. Vídues, òrfenes i línies curtes →
Error freqüent
El text en bandera pot deixar sola la puntuació al costat d'una negreta o un :ref
A postext 1.4.1, el text que no va justificat (cossos de requadre, paràgrafs en bandera) pot partir la línia entre una negreta, una cursiva o un :ref i el signe de puntuació que hi va enganxat: un punt pot obrir la línia següent i el «(» d'una remissió pot tancar l'anterior. El text justificat no es parteix mai en aquest punt. Revisa els requadres de cada edició i reescriu la frase afectada perquè aquest tram quedi a mitja línia. Negreta, cursiva i els seus colors →
Error freqüent
Un espai de no separació continua partint la línia
A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase. Escapaments i caràcters literals →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
- Al canvas, Rethink Sans substitueix una xifra sola entre parèntesis, com el fascicle d'11(3), per la xifra en cercle, així que les referències donen només el volum i les pàgines.
- A la 1.4.1 un requadre a tota l'amplada que tanca el document passa a una pàgina nova si no li queden a sota dues línies del cos. Per això la franja de paraules clau flota amb
placement="bottom", que l'assenta al marge inferior, dins dels 50 mm que queden sota la retícula. - A la 1.4.1 un paràgraf que segueix un grup
:::paragraphsse'n separa només pelspaceBetweende l'estil, aquí 2,8 mm sota l'última referència. Un:::space{lines=0.75}abans de l'agraïment obre 10,8 mm, a prop dels 10,6 mm que separen els altres paràgrafs d'un panell.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Imatges
- The plan of the street, the heat map, the bar chart and the School of Geography's mark, drawn in code from synthetic data in the poster's palette · Ignacio Ferro · CC BY 4.0
- Fonts
- Rethink Sans (SIL OFL 1.1) · Bitter (SIL OFL 1.1) · Saira Condensed (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


