# Un poster scientifique sur une seule grande page

> Poster de congrès de 600 × 800 mm : bandeau de titre sur trois colonnes de panneaux en un groupe pleine largeur, carte thermique à pastilles, −4,2 °C en 150 pt.

- Version HTML: https://postext.dev/fr/cookbook/research-poster
- Recette Nº 059 · Page et grille · Niveau 3 (Avancé) · Sorties: Canvas
- Genres: Articles et publications universitaires, Feuilles volantes et éphémères
- Nécessite postext ≥ 1.28.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/research-poster/en/p01.webp?v=65078a8e)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=research-poster&lang=en (.postext: https://postext.dev/cookbook/research-poster/en/research-poster.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/research-poster.md), [es](https://postext.dev/es/cookbook/research-poster.md), [ca](https://postext.dev/ca/cookbook/research-poster.md), [pt](https://postext.dev/pt/cookbook/research-poster.md), [zh](https://postext.dev/zh/cookbook/research-poster.md), [ja](https://postext.dev/ja/cookbook/research-poster.md), [ar](https://postext.dev/ar/cookbook/research-poster.md)

## 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 trois colonnes de panneaux sur une page à deux colonnes, comme sur un poster de congrès ?
- 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

```js
// script.js, lignes 86–107
// 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

**Enseigne**

- [Colonnes dans un encadré ou dans le texte](https://postext.dev/fr/docs/document-format.md#columns): Deux colonnes ou plus composées par un groupe :::columns, dans un encadré ou dans le texte courant : une colonne de texte à côté d’une figure, un panneau en trois volets ou une bande de brèves sur toute la largeur de la page.
- [Encadrés sur toute la largeur](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout): Un encadré qui traverse toutes les colonnes en milieu de page ; les colonnes au-dessus finissent à la même hauteur et reprennent en dessous.

**Utilise aussi**

- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Ancrer les éléments de maquette](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Pastilles de couleur](https://postext.dev/fr/docs/document-format.md#mise-en-forme-en-ligne)
- [Pastilles dans le texte](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Format de la page](https://postext.dev/fr/docs/configuration-page-layout.md#formats-de-page-prédéfinis)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Légendes numérotées](https://postext.dev/fr/docs/document-format.md#numérotation-au-premier-renvoi)
- [Lignes de source et de crédit](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Encadrés flottants](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Grille de ligne de base](https://postext.dev/fr/docs/configuration-page-layout.md#grille-de-ligne-de-base)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Bibliographies et glossaires](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Figure et Tableau dans votre langue](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Espace vertical explicite](https://postext.dev/fr/docs/document-format.md#space)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`chipStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`defaultResourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`inlineSvgFonts`](https://postext.dev/fr/docs/configuration-resources.md#polices-du-texte-svg), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPage`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

- Rethink Sans (OFL-1.1), Bitter (OFL-1.1), Saira Condensed (OFL-1.1)

## Préparation

### 1 · Mettre la feuille en page à 72 dpi

```js
// script.js, lignes 38–48
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](https://postext.dev/fr/docs/configuration-page-layout.md#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](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-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

```js
// script.js, lignes 53–82
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](https://postext.dev/fr/docs/configuration-text.md#étendue-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

```js
// script.js, lignes 86–107
// 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`](https://postext.dev/fr/docs/document-format.md#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`](https://postext.dev/fr/docs/configuration-styles.md#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

```js
// script.js, lignes 156–163
// "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](https://postext.dev/fr/docs/configuration-resources.md#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

```js
// script.js, lignes 15–34
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](https://postext.dev/fr/docs/document-format.md#mise-en-forme-en-ligne)). 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

```js
// script.js, lignes 111–128
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](https://postext.dev/fr/docs/configuration-styles.md#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

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: https://github.com/drnachio/postext/tree/main/cookbook/research-poster

### script.js

```js
// ═══ 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`---
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 ── 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 ───────────────────────────────────────────────────────────────────────
```

## 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.

```diff
-  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 styles `stat` et `refs` termine leurs groupes `:::paragraphs` sur 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, avec `bodyText.paragraphContainerSpacing`). Sous la dernière référence, cet espace est le `spaceBetween` du 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 ([@drnachio](https://github.com/drnachio))
- 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
- Caractères: Rethink Sans (OFL-1.1), Bitter (OFL-1.1), Saira Condensed (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 027 · La une d’un journal](https://postext.dev/fr/cookbook/newspaper-front-page.md): La une et la page 2 d’un hebdomadaire local en deux colonnes à filet, gros titres en encadrés pleine page et bandeau de brèves sur quatre colonnes en pied. · Niveau 3 (Avancé) · Journaux et lettres d’information
- [Nº 037 · Rapport annuel aux colonnes alignées en pied](https://postext.dev/fr/cookbook/annual-report-flush-columns.md): Le rapport annuel d’une coopérative d’énergie sur deux colonnes justifiées qui finissent sur la même ligne, un encadré de chiffres clés et une fin nivelée. · Niveau 3 (Avancé) · Rapports
- [Nº 022 · Fiche d’exercices avec cases réponse et banque de mots](https://postext.dev/fr/cookbook/worksheet-answer-boxes.md): Une fiche de sciences de quatre pages : cases réponse blanches dans des cartes vert pâle, 2 mm sous chaque question, hors grille ; mots et blancs en pastilles. · Niveau 2 (Intermédiaire) · Cahiers et exercices
