# Poster scientifico su una sola grande pagina

> Un poster di 600 × 800 mm: fascia del titolo su tre colonne di pannelli in un gruppo a tutta pagina, mappa di calore con campioni e −4,2 °C a 150 pt.

- Versione HTML: https://postext.dev/it/cookbook/research-poster
- Ricetta Nº 059 · Pagina e griglia · Livello 3 (Avanzato) · Output: Canvas
- Generi: Articoli scientifici e testi accademici, Fogli singoli ed effimeri
- Richiede postext ≥ 1.28.0 · provata con 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/research-poster/en/p01.webp?v=65078a8e)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=research-poster&lang=en (.postext: https://postext.dev/cookbook/research-poster/en/research-poster.postext)
- Ultimo aggiornamento: 2026-10-11
- Altre lingue: [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), [de](https://postext.dev/de/cookbook/research-poster.md), [fr](https://postext.dev/fr/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)

## In breve

Un grande poster per un convegno scientifico, sugli alberi stradali e il caldo estivo. Mostra come disporre riquadri di testo e grafici in colonne su un’unica pagina molto grande.

## Che cosa comporrai

Un poster da convegno per un immaginario studio sugli alberi stradali e il calore dei marciapiedi, composto su un’unica pagina di 600 × 800 mm. Una fascia verde porta il convegno, il titolo in Bitter a 110 pt, gli autori con i segni di affiliazione e il marchio della School of Geography. Sotto un riassunto a due colonne, un gruppo `:::columns` attraversa entrambe le colonne del testo e compone sei pannelli in tre colonne, ciascuno un riquadro sotto una striscia di 6 pt. La colonna centrale si apre con −4,2 °C in arancione a 150 pt, poi una mappa di calore dell’aria per isolato e ora, con una legenda di campioni di colore, e un grafico a barre della pavimentazione. Una striscia colorata di chip con le parole chiave sta sul margine inferiore. Lo stesso file compone un’edizione spagnola, e il pen offre la pagina come PNG di 1.701 × 2.268 px.

**Questa ricetta risponde a:**

- Come compongo tre colonne di pannelli su una pagina a due colonne, come in un poster da convegno?
- Come metto due colonne dentro un riquadro (una colonna di testo accanto a una colonna con una figura)?
- Come aggiungo campioni di colore come legenda nel testo, nelle didascalie o nelle note di tabella?

## La risposta breve

```js
// script.js, righe 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') } };
```

## Ingredienti

**Insegna**

- [Colonne in un riquadro o nel testo](https://postext.dev/it/docs/document-format.md#columns): Due o più colonne impostate da un gruppo :::columns, dentro un riquadro o nel testo corrente: una colonna di testo accanto a una figura, un pannello a tre o una fascia di brevi attraverso la pagina.
- [Riquadri a tutta larghezza](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-callout): Un riquadro che attraversa tutte le colonne a metà pagina; le colonne sopra di esso finiscono pareggiate e riprendono sotto.

**Usa anche**

- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Ancoraggio degli elementi del progetto](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)
- [Campioni di colore](https://postext.dev/it/docs/document-format.md#formattazione-in-linea)
- [Chip in linea](https://postext.dev/it/docs/configuration-styles.md#stili-dei-chip)
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Formato della pagina](https://postext.dev/it/docs/configuration-page-layout.md#formati-di-pagina-predefiniti)
- [Pagine su un canvas](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)
- [Didascalie numerate](https://postext.dev/it/docs/document-format.md#numerazione-al-primo-rimando)
- [Righe di fonte e crediti](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie)
- [Riquadri flottanti](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-callout)
- [Griglia della linea di base](https://postext.dev/it/docs/configuration-page-layout.md#griglia-della-linea-di-base)
- [Fascia di capitolo a tutta larghezza](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Bibliografie e glossari](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Citazioni che collocano le figure](https://postext.dev/it/docs/document-format.md#rimando-in-linea-la-forma-principale)
- [Figura e Tabella nella tua lingua](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Colore della carta](https://postext.dev/it/docs/configuration-page-layout.md#pagina)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Spazio verticale esplicito](https://postext.dev/it/docs/document-format.md#space)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri), [`captionStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie), [`chipStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-chip), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`defaultResourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`inlineSvgFonts`](https://postext.dev/it/docs/configuration-resources.md#font-nel-testo-svg), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPage`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)

**Caratteri**

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

## Preparazione

### 1 · Imposta il foglio a 72 dpi

```js
// script.js, righe 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
```

Ai 300 dpi predefiniti il poster va a capo in ogni riga dove ci va a 72 dpi, ma la sua pagina misura 7.087 × 9.449 px, ovvero 268 MB di pixel una volta dipinta ([preset di formato della pagina](https://postext.dev/it/docs/configuration-page-layout.md#formati-di-pagina-predefiniti)). A 72 dpi un punto è un pixel. Il visualizzatore dipinge le miniature con `renderPageToCanvas` e una `scale`, e `renderPage` dà il PNG di 1.701 × 2.268 px che chiede la galleria online di un convegno ([rendering di una pagina in bitmap](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)). L’interlinea divide i 750 mm tra i margini in 56 righe, così la striscia delle parole chiave fatta flottare al piede della pagina finisce sul margine inferiore. Con un tondo 38 pt ci stanno solo 55 righe, l’ultima a 12,7 mm dal margine; la striscia delle parole chiave non sta più sotto i pannelli e passa a una seconda pagina.

### 2 · Disegna la fascia del titolo dal titolo

```js
// script.js, righe 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) } } },
  ] } };
```

L’H1 porta il convegno, il numero del poster, gli autori e le affiliazioni come attributi, e il suo disegno li colloca sopra un riquadro verde ancorato alla pagina, profondo 158 mm ([span e disegno avanzato](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)). Il riquadro finisce 133 mm sotto il margine superiore. Senza un `minHeight` il riassunto comincerebbe 0,9 mm sotto il verde; un `minHeight` di undici righe della griglia lascia 14 mm. I segni di affiliazione sono i caratteri ¹ ² ³, le sole cifre in apice nell’intervallo Latin-1.

### 3 · Componi i pannelli in un gruppo su tutta la pagina

```js
// script.js, righe 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') } };
```

Il testo scorre in due colonne e i pannelli in tre, quindi la griglia è un gruppo `:::columns{count=3}` aperto direttamente nel testo, e `span="page"` lo colloca su entrambe le colonne del testo, sotto il riassunto, con l’intercolonna della pagina tra le sue colonne ([`:::columns`](https://postext.dev/it/docs/document-format.md#columns)). Da solo, un gruppo nel testo è seguito da una riga di spaziatura tra paragrafi, qui 38 pt, e la striscia delle parole chiave passerebbe a una seconda pagina; `marginBottom="0.75em"` sul blocco prende il posto di quella riga (postext 1.28), e la striscia sta al piede della pagina. Ogni pannello è un riquadro nel gruppo: prende il suo stile, la larghezza di 175 mm della colonna e 18 mm di `marginTop` sotto il pannello sopra ([il contenitore `:::callout`](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-callout)). `breaks="3,4"` manda il terzo pannello, Results, in testa alla colonna centrale e il quarto, Conclusions, in testa all’ultima. Senza, il gruppo si bilancia e taglia al pannello più vicino a ogni terzo della pila. Con questo testo i tagli cadono sugli stessi due pannelli; l’attributo li tiene lì quando il testo cambia.

### 4 · Tieni ogni figura nel suo pannello

```js
// script.js, righe 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 });
```

Una figura collocata `'here'` resta nel pannello in cui `::resource` la mette; con la collocazione predefinita i tre disegni lasciano i loro pannelli e il poster arriva a una seconda pagina. `numberingTemplate: '{n}'` li numera 1, 2, 3, dove il `{h1}.{n}` predefinito stampa Figure 1.1 sotto un titolo che è un H1 ([tipi di risorsa](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)). La nota sotto ogni didascalia dice che i dati sono sintetici.

### 5 · Condividi i colori della mappa di calore con la sua legenda

```js
// script.js, righe 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' } },
];
```

Le celle della mappa di calore e le quattro sequenze `:swatch{color="…"}` della sua legenda nominano le stesse voci della tavolozza, così un nuovo valore per `cool` ridipinge insieme la mappa e la legenda ([formattazione in linea](https://postext.dev/it/docs/document-format.md#formattazione-in-linea)). La legenda è un secondo gruppo `:::columns`, due voci per colonna, dentro il pannello Results.

### 6 · Componi la cifra principale in uno stile di paragrafo

```js
// script.js, righe 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) } };
```

Gli stili di paragrafo si applicano dentro i riquadri, quindi −4,2 °C è un blocco `:::paragraphs{style="stat"}` in Bitter a 150 pt dentro il pannello Results ([stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)). La cifra è scritta in grassetto, quindi si stampa in Bitter 700 e nel `boldColor` dello stile, l’arancione del pannello. Un secondo stile dà ai riferimenti un rientro sporgente di 9 mm. La striscia delle parole chiave è un riquadro colorato con `placement="bottom"`, fatto flottare al piede della pagina sotto la griglia.

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: 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 ───────────────────────────────────────────────────────────────────────
```

## Varianti

### Esporta l’e-poster a 150 dpi

Un convegno che chiede un file più grande riceve un PNG di 3.543 × 4.724 px con le stesse divisioni di riga.

```diff
-  dpi: 72, backgroundColor: col('paper'),
+  dpi: 150, backgroundColor: col('paper'),
```

## Errori frequenti

- Rethink Sans disegna una singola cifra tra parentesi, come il numero del fascicolo in 11(3), come una cifra cerchiata sul canvas, quindi i riferimenti danno solo il volume e le pagine.
- Un riquadro a tutta larghezza che chiude il documento passa a una nuova pagina a meno che sotto non resti lo spazio di due righe di testo. Per questo la striscia delle parole chiave flotta con `placement="bottom"`, che la colloca sul margine inferiore, nei 50 mm rimasti sotto la griglia.
- `containerSpacing: 'add'` sugli stili `stat` e `refs` chiude i loro gruppi `:::paragraphs` sullo spazio proprio dello stile, il che tiene −4,2 °C sulla riga che lo spiega; ogni altro gruppo del poster segue la regola del documento (da postext 1.28; prima la regola si poteva impostare solo per l’intero documento, con `bodyText.paragraphContainerSpacing`). Sotto l’ultimo riferimento quello spazio è lo `spaceBetween` dello stile, 2,8 mm, quindi un `:::space{lines=0.75}` prima dei ringraziamenti apre 10,8 mm, vicino ai 10,6 mm tra gli altri paragrafi di un pannello. Senza, con il `'collapse'` del documento, un gruppo è seguito da una riga della spaziatura tra paragrafi del pannello: i ringraziamenti non richiedono `:::space`, ma la riga che aggiunge sotto −4,2 °C spinge la striscia delle parole chiave a una seconda pagina.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Immagini: 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
- Caratteri: Rethink Sans (OFL-1.1), Bitter (OFL-1.1), Saira Condensed (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 027 · Prima pagina di giornale](https://postext.dev/it/cookbook/newspaper-front-page.md): Prima pagina e pagina 2 di un settimanale locale su due colonne con filetti, titoli a tutta pagina come riquadri e brevi su quattro colonne flottanti al piede. · Livello 3 (Avanzato) · Giornali e newsletter
- [Nº 037 · Bilancio annuale con colonne pareggiate al piede](https://postext.dev/it/cookbook/annual-report-flush-columns.md): Il bilancio annuale di una cooperativa energetica su due colonne giustificate che finiscono sulla stessa riga, un riquadro di cifre chiave e una chiusa pari. · Livello 3 (Avanzato) · Rapporti
- [Nº 022 · Scheda di esercizi con spazi di risposta e banca di parole](https://postext.dev/it/cookbook/worksheet-answer-boxes.md): Una scheda di scienze di quattro pagine: riquadri bianchi per le risposte in schede verdi, 2 mm sotto ogni domanda e fuori griglia, con banche di parole e chip. · Livello 2 (Intermedio) · Quaderni ed eserciziari
