En bref
Un grand poster pour un congrès scientifique, sur les arbres d’alignement et la chaleur de l’été. On y voit comment disposer des blocs de texte et des graphiques en colonnes sur une très grande page.
Ce que vous allez composer
Un poster de congrès pour une étude inventée sur les arbres d’alignement et la chaleur des trottoirs, composé sur une seule page de 600 × 800 mm. Un bandeau vert porte le congrès, le titre en Bitter de 110 pt, les auteurs avec leurs appels d’affiliation et le logo de la School of Geography. Sous un résumé sur deux colonnes, un seul groupe :::columns traverse les deux colonnes du corps et dispose six panneaux sur trois colonnes, chacun un encadré sous une bande de 6 pt. La colonne du milieu s’ouvre sur −4,2 °C en orange à 150 pt, puis une carte thermique de l’air par îlot et par heure, avec une légende en pastilles de couleur, et un diagramme en barres des revêtements. Une bande teintée de pastilles de mots-clés repose sur la marge de pied. Le même fichier compose une édition espagnole, et le pen propose la page en PNG de 1 701 × 2 268 px.
Cette recette répond à
- Comment composer un encadré sur les deux colonnes au milieu de la page, comme un panneau « en chiffres » à trois volets ?
- Comment mettre deux colonnes dans un encadré (une colonne de texte à côté d’une colonne de figure) ?
- Comment ajouter des pastilles de couleur de légende au texte, aux légendes ou aux notes de tableau ?
La réponse courte
// The body text runs in two columns and the panels in three: a :::columns group in the
// text, and span="page" lays it across both body columns, the gutter apart.
// :::columns{count=3 breaks="3,4" span="page" marginBottom="0.75em"}
// ← panel 3 opens column 2, panel 4 column 3
// :::callout{type="panel" title="Introduction"} ← each panel is a box in the group
// …
// ::: ← closes the panel; then the other panels
// ::: ← closes the columns group
// marginBottom sets the space under the group: without it the group is followed by a
// line of the summary's paragraph spacing, 38 pt.
// A box in a group takes the width of its column. 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') } };
Ingrédients
- Fonctionnalités
- Colonnes dans un encadré ou dans le texteEncadrés sur toute la largeurOuvertures composéesAttributs de titreAncrer les éléments de maquetteImages dans les maquettes de pagePastilles de couleurPastilles dans le textePalette de couleurs sémantiqueEncadrésDes figures exactement iciStyles de paragrapheFormat de la pagePages sur un canvasLégendes numérotéesLignes de source et de créditEncadrés flottantsGrille de ligne de baseBandeau de chapitre sur toute la largeur
- Utilise aussi
- Bibliographies et glossairesDes citations qui placent les figuresFigure et Tableau dans votre langueCouleur du papierTypes de ressource personnalisésFigures et tableaux comme ressourcesEspace vertical explicite
- Typographie
- Rethink Sans, Bitter, Saira Condensed (SIL OFL 1.1)
- Ressources
- 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)
Préparation
#1 · Mettre la feuille en page à 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
À 300 dpi, la valeur par défaut, le poster coupe chaque ligne au même endroit qu’à 72 dpi, mais sa page mesure 7 087 × 9 449 px, soit 268 Mo de pixels une fois peinte (formats de page prédéfinis). À 72 dpi, un point vaut un pixel. La visionneuse peint ses vignettes avec renderPageToCanvas et un scale, et renderPage donne le PNG de 1 701 × 2 268 px que demande la galerie en ligne d’un congrès (rendre une page en image bitmap). L’interlignage divise les 750 mm entre les marges en 56 lignes, si bien que la bande de mots-clés flottée au pied de la page finit sur la marge de pied. Avec 38 pt tout rond, seules 55 lignes tiennent, la dernière à 12,7 mm de la marge ; la bande de mots-clés ne tient plus sous les panneaux et passe sur une deuxième page.
#2 · Dessiner le bandeau de titre à partir du titre
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', lineHeight: 1.2,
...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' } }),
// The affiliation marks are the characters ¹ ² ³, typed in the heading's attributes.
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) } } },
] } };
Le H1 porte le congrès, le numéro du poster, les auteurs et les affiliations sous forme d’attributs, et sa maquette les pose sur un bloc vert ancré à la page, haut de 158 mm (pleine largeur et maquette avancée). Le bloc se termine 133 mm sous la marge de tête. Sans minHeight, le résumé commencerait 0,9 mm sous le vert ; une minHeight de onze lignes de grille laisse 14 mm. Les appels d’affiliation sont les caractères ¹ ² ³, les seuls chiffres en exposant de la plage Latin-1.
#3 · Disposer les panneaux dans un groupe sur toute la largeur
// The body text runs in two columns and the panels in three: a :::columns group in the
// text, and span="page" lays it across both body columns, the gutter apart.
// :::columns{count=3 breaks="3,4" span="page" marginBottom="0.75em"}
// ← panel 3 opens column 2, panel 4 column 3
// :::callout{type="panel" title="Introduction"} ← each panel is a box in the group
// …
// ::: ← closes the panel; then the other panels
// ::: ← closes the columns group
// marginBottom sets the space under the group: without it the group is followed by a
// line of the summary's paragraph spacing, 38 pt.
// A box in a group takes the width of its column. 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') } };
Le corps court sur deux colonnes et les panneaux sur trois : la grille est donc un groupe :::columns{count=3} ouvert directement dans le texte, et span="page" l’étend sur les deux colonnes du corps, sous le résumé, avec la gouttière de la page entre ses colonnes (:::columns). Laissé à lui-même, un groupe dans le texte est suivi d’une ligne d’espacement de paragraphe, 38 pt ici, et la bande de mots-clés passerait sur une deuxième page ; marginBottom="0.75em" sur le bloc remplace cette ligne (postext 1.28), et la bande tient au pied de la page. Chaque panneau est un encadré du groupe : il prend son propre style, les 175 mm de largeur de la colonne et 18 mm de marginTop sous le panneau du dessus (le conteneur :::callout). breaks="3,4" envoie le troisième panneau, Results, en tête de la colonne du milieu et le quatrième, Conclusions, en tête de la dernière. Sans lui, le groupe s’équilibre et coupe au panneau le plus proche de chaque tiers de la pile. Avec ce texte, les coupes tombent sur les deux mêmes panneaux ; l’attribut les y maintient quand le texte change.
#4 · Garder chaque figure dans son panneau
// "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 });
Une figure placée en 'here' reste dans le panneau où ::resource la pose ; avec le placement par défaut, les trois dessins quittent leurs panneaux et le poster déborde sur une deuxième page. numberingTemplate: '{n}' les numérote 1, 2, 3, là où le {h1}.{n} par défaut imprimerait Figure 1.1 sous un titre qui est un H1 (types de ressources). La note sous chaque légende précise que les données sont synthétiques.
#5 · Partager les couleurs de la carte thermique avec sa légende
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
};
// Every colour is linked to its palette entry by id.
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 cellules de la carte thermique et les quatre segments :swatch{color="…"} de sa légende nomment les mêmes entrées de palette : une nouvelle valeur pour cool repeint à la fois la carte et la légende (mise en forme dans le texte). La légende est un second groupe :::columns, deux entrées par colonne, à l’intérieur du panneau Results.
#6 · Composer le chiffre phare dans un style de paragraphe
const paragraphStyles = [
// Paragraph styles apply inside boxes. The figure is written **bold**: that sets it in
// Bitter 700 and in the style's boldColor.
// containerSpacing 'add': the group ends on the style's own space, with no line of
// paragraph spacing under it. −4.2 °C sits on the line that explains it, and a :::space
// sets the gap under the references.
{ id: 'stat', fontFamily: 'Bitter', fontSize: pt(150), lineHeight: pt(130),
boldColor: col('heat'), containerSpacing: 'add' },
{ id: 'refs', fontSize: pt(19), lineHeight: pt(26), hangingIndent: mm(9), spaceBetween: pt(8),
containerSpacing: 'add' },
{ 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) } };
Les styles de paragraphe s’appliquent à l’intérieur des encadrés : −4,2 °C est donc un bloc :::paragraphs{style="stat"} en Bitter de 150 pt dans le panneau Results (styles de paragraphe). Le chiffre est écrit en gras : il s’imprime donc en Bitter 700 et dans le boldColor du style, l’orange du panneau. Un second style donne aux références un retrait suspendu de 9 mm. La bande de mots-clés est un encadré teinté avec placement="bottom", flotté au pied de la page sous la grille.
La recette complète
// ═══ 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.28.0 import { buildDocumentWithFonts, renderPage, renderPageToCanvas, registerResourceImage, defaultResourceTypes, inlineSvgFonts, } from 'https://esm.sh/postext'; const LANG = 'en'; // @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 }; // Every colour is linked to its palette entry by id. 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', lineHeight: 1.2, ...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' } }), // The affiliation marks are the characters ¹ ² ³, typed in the heading's attributes. 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 in a group across the page // The body text runs in two columns and the panels in three: a :::columns group in the // text, and span="page" lays it across both body columns, the gutter apart. // :::columns{count=3 breaks="3,4" span="page" marginBottom="0.75em"} // ← panel 3 opens column 2, panel 4 column 3 // :::callout{type="panel" title="Introduction"} ← each panel is a box in the group // … // ::: ← closes the panel; then the other panels // ::: ← closes the columns group // marginBottom sets the space under the group: without it the group is followed by a // line of the summary's paragraph spacing, 38 pt. // A box in a group takes the width of its column. 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. The figure is written **bold**: that sets it in // Bitter 700 and in the style's boldColor. // containerSpacing 'add': the group ends on the style's own space, with no line of // paragraph spacing under it. −4.2 °C sits on the line that explains it, and a :::space // sets the gap under the references. { id: 'stat', fontFamily: 'Bitter', fontSize: pt(150), lineHeight: pt(130), boldColor: col('heat'), containerSpacing: 'add' }, { id: 'refs', fontSize: pt(19), lineHeight: pt(26), hangingIndent: mm(9), spaceBetween: pt(8), containerSpacing: 'add' }, { 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 = () => ({ colorPalette, page, locale: t({ en: 'en-us', es: 'es' }), // the poster's language, and its bundle's 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, the :ref labels and the italics of the references in ink: left to their defaults // they follow main-color, the canopy green. boldColor: col('ink'), italicColor: col('ink') }, // The band draws the title, but the H1's own text is still measured: in Bitter, a face // already loaded, instead of the default Open Sans 700. headings: { fontFamily: 'Bitter', levels: [ // parity 'any': a second poster in the file would start the next page, either side. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), advancedDesign: band }] }, calloutStyles: [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`---Exemple Markdown · 91 lignes · content.en.md
title: "Street trees and sidewalk heat" author: "Amara Osei, Tomás Lindqvist, Hannah Kiel" --- # Street trees and sidewalk heat {meeting="Fourth Meeting on Streets and Climate · Wrenfield, 14–16 October 2026" poster="P-47" authors="Amara Osei¹ · Tomás Lindqvist² · Hannah Kiel¹,³" affiliations="¹ School of Geography, University of Wrenfield ² Street Trees Team, Wrenfield City Council ³ Lowmoor Institute for Urban Climate"} **Summary.** We logged the air at head height on the sixteen blocks of Ashby Road, and photographed both sidewalks with a thermal camera, on the fourteen afternoons of 2025 above 30 °C. From 13:00 to 17:00 the air on the blocks under 70 % canopy or more was 4.2 °C cooler than on the near-treeless blocks of the Parade, and the paving 15 °C cooler. The council will use the result to choose which of its 212 empty tree pits to plant first. :::columns{count=3 breaks="3,4" span="page" marginBottom="0.75em"} :::callout{type="panel" title="Introduction"} By mid-afternoon in July a sunlit concrete sidewalk can pass 50 °C, and the air above it warms with it. Tree crowns stop the sun before it reaches the paving [1, 2]. Most studies compare a park with the streets around it [3]. Ashby Road runs from bare paving to crowns that meet over the road, so each block can be compared with its neighbours on the same afternoon. **Question.** How much cooler is a block for each step of canopy cover, and at which hours? ::: :::callout{type="panel" title="Methods"} Ashby Road (:ref{id="plan"}) runs 800 m from Ashby Park to the bus station, in sixteen blocks of 50 m. Planes from 1908 line the park end and limes from the 1950s shade blocks 13 to 15; the Parade, a row of shops, has almost no trees. ::resource{id="plan"} - **Air.** A logger per block, 1.5 m up a lamp post, read every 5 minutes. - **Paving.** A thermal camera walked both sidewalks at 15:00. - **Canopy.** The share of sidewalk under crowns, traced on aerial photographs. Air is given against the street mean at the same hour, which takes out the day's weather. ::: :::callout{type="results" title="Results"} :::paragraphs{style="stat"} **−4.2 °C** ::: **Air at head height from 13:00 to 17:00, under 70 % canopy or more, against the blocks under 10 %.** The planes and the limes keep their blocks cool all afternoon; the Parade stays warm (:ref{id="heat"}). :::columns{count=2 breaks="3"} :::paragraphs{style="key"} :swatch{color="cool"} −1.5 °C or less :swatch{color="mist"} −1.5 to 0 °C :swatch{color="blush"} 0 to +1.5 °C :swatch{color="heat"} +1.5 °C or more ::: ::: ::resource{id="heat"} At 15:00 the paving (:ref{id="bars"}) was 48.4 °C on the Parade and 33.4 °C under the densest crowns. ::resource{id="bars"} ::: :::callout{type="panel" title="Conclusions"} Every 10 % of canopy cover took 2.1 °C off the paving at 15:00, and 0.6 °C off the air between 13:00 and 17:00. The effect is largest in the afternoon, when people walk home. Before 09:00 and after 19:00 the difference stays under 1.5 °C, and at 20:00 the shade is 0.4 °C warmer. The Parade is the hottest 200 m of the street. If the relation holds, the paving there would drop from 48.4 °C to about 39 °C at 15:00 once new crowns cover half the sidewalk. ::: :::callout{type="panel" title="Planting plan"} The council plants the hottest blocks first. We will repeat the survey in the summer of 2028. 1. **Blocks 7–10**, the Parade: 38 trees in 2026. 2. **Block 16**, the bus station: 9 trees in 2027. 3. **Blocks 11–12**: 21 trees in 2027. 4. **Blocks 5–6**: 12 trees in 2028. ::: :::callout{type="panel" title="References"} :::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. and 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. and 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} We thank the Wrenfield street-lighting depot for the lamp posts, and Inês Barros for walking the thermal survey fourteen times. ::: ::: :::callout{type="strip" span="page" placement="bottom"} :::columns{count=2 breaks="2"} **Keywords** :chip[street trees]{style="keyword"} :chip[urban heat]{style="keyword"} :chip[canopy cover]{style="keyword"} :chip[thermal imaging]{style="keyword"} The street, the council, the authors and the data are invented for the Postext Cookbook. Set in Rethink Sans, Bitter and Saira Condensed (SIL OFL). Text and figures 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>`; // A drawing with labels names their face on its root element: loadSvg embeds it for the // canvas (inlineSvgFonts). const LABELS = 'font-family="Saira Condensed" font-weight="600"'; 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>`; 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, 75] }; // mm, at column width const LABEL = 5.4; // mm: figure labels, about 15 pt function planSvg() { // 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, LABELS); } function heatSvg() { // 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, LABELS); } function barsSvg() { // 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, LABELS); } 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 '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 Promise.all([loadSvg('plan.svg', planSvg()), loadSvg('heat.svg', heatSvg()), loadSvg('bars.svg', barsSvg()), loadSvg('mark.svg', markSvg())]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); 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 : identique dans toutes les recettes · 270 lignes
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Exporter l’e-poster à 150 dpi
Un congrès qui demande un fichier plus grand reçoit un PNG de 3 543 × 4 724 px avec les mêmes coupures de lignes.
- dpi: 72, backgroundColor: col('paper'),
+ dpi: 150, backgroundColor: col('paper'),Erreurs fréquentes
- Rethink Sans dessine un chiffre seul entre parenthèses, comme le numéro de fascicule dans 11(3), sous forme de chiffre cerclé sur le canvas : les références ne donnent donc que le volume et les pages.
- Un encadré sur toute la largeur de la page qui termine le document passe sur une nouvelle page, sauf s’il reste sous lui la place de deux lignes du corps. La bande de mots-clés est donc flottée avec
placement="bottom", ce qui la pose sur la marge de pied, dans les 50 mm laissés sous la grille. containerSpacing: 'add'sur les stylesstatetrefstermine leurs groupes:::paragraphssur l’espace propre du style, ce qui garde −4,2 °C sur la ligne qui l’explique ; tous les autres groupes du poster suivent la règle du document (depuis postext 1.28 ; avant, la règle ne pouvait se régler que pour tout le document, avecbodyText.paragraphContainerSpacing). Sous la dernière référence, cet espace est lespaceBetweendu style, 2,8 mm : un:::space{lines=0.75}avant les remerciements ouvre donc 10,8 mm, près des 10,6 mm entre les autres paragraphes d’un panneau. Sans ce réglage, avec le'collapse'du document, un groupe est suivi d’une ligne de l’espacement de paragraphe du panneau : les remerciements n’ont besoin d’aucun:::space, mais la ligne ajoutée sous −4,2 °C repousse la bande de mots-clés sur une deuxième page.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Images
- 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
- Polices
- Rethink Sans (SIL OFL 1.1) · Bitter (SIL OFL 1.1) · Saira Condensed (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


