Pular para o conteúdo principal
Receita número 59

Receitas · Capítulo 1 · Página e grade

Pôster científico numa única página grande

Pôster de congresso de 600 × 800 mm: faixa de título sobre um box na largura da página com três colunas de painéis, mapa de calor e −4,2 °C em 150 pt.

  • Amostra em inglês: ainda sem edição em português
  • Refile 600 × 800 mm
  • 2 colunas, medianiz de 12 mm
  • Rethink Sans 28/38
  • Bitter
  • Saira Condensed
  • 1 página
  • Nível
  • Postext 1.19.1
  • Diagramado em 19 ms
  • 207 linhas de código

Em poucas palavras

Um pôster grande para um congresso científico, sobre as árvores da rua e o calor do verão. Mostra como organizar em colunas caixas de texto e gráficos numa única página muito grande.

O que você vai compor

Um pôster de congresso para um estudo inventado sobre árvores de rua e calor na calçada, composto numa única página de 600 × 800 mm. Uma faixa verde traz o encontro, o título em Bitter de 110 pt, os autores com as marcas de afiliação e a marca da School of Geography. Sob um resumo em duas colunas, um box sem moldura atravessa as duas colunas do corpo e contém uma grade de três colunas com seis painéis, cada um num box próprio sob uma tarja de 6 pt. A coluna do meio abre com −4,2 °C em laranja de 150 pt, seguido de um mapa de calor do ar por quarteirão e hora, com legenda em amostras de cor, e de um gráfico de barras da pavimentação. Uma faixa tingida com chips de palavras-chave fica apoiada na margem inferior. O mesmo arquivo compõe uma edição em espanhol, e o pen oferece a página como um PNG de 1.701 × 2.268 px.

Esta receita responde a

  • Como coloco um boxe atravessando as duas colunas no meio da página, como um painel “em números” com três blocos?
  • Como aninho boxes, como uma ficha de exercícios com caixas de resposta, com espaçamento exato?
  • Como coloco duas colunas dentro de um boxe (uma coluna de texto ao lado de uma coluna com figura)?
  • Como acrescento amostras de cor, como chave de cores, ao texto, às legendas ou às notas de tabela?

A resposta curta

script.js · linhas 88–111no código completo
// The body text runs in two columns. Three columns exist only inside a box: a :::columns
// group in a callout, and span="page" lays that callout across both body columns.
//   :::callout{type="grid" span="page"}       ← one frameless box across the page
//   :::columns{count=3 breaks="3,4"}          ← panel 3 opens column 2, panel 4 column 3
//   :::callout{type="panel" title="Introduction"}   ← each panel is a box nested in it
//   …
//   :::                                       ← closes the panel; then the other panels
//   :::                                       ← closes the columns group
//   :::                                       ← closes the grid (gotcha: callout-columns)
const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: mm(GAP) };
// A nested box takes the width of its column and ignores span and placement (gotcha:
// nested-callout-limits). Its one device is a 6 pt stripe along the top.
const panel = { id: 'panel', backgroundEnabled: false,
  stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') },
  padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700,
    textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) },
  body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText
  marginTop: mm(18) };
const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat
  stripe: { ...panel.stripe, color: col('heat') },
  titleStyle: { ...panel.titleStyle, color: col('heat') } };

Ingredientes

Tipografia
Rethink Sans, Bitter, Saira Condensed (SIL OFL 1.1)
Materiais
  • 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)

Preparo

#1 · Diagrame a folha a 72 dpi

script.js · linhas 38–48no código completo
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

Nos 300 dpi padrão, o pôster quebra cada linha no mesmo lugar que a 72 dpi, mas a página mede 7.087 × 9.449 px, ou 268 MB de pixels depois de pintada (tamanhos de página predefinidos). A 72 dpi, um ponto é um pixel. O visualizador pinta as miniaturas com renderPageToCanvas e uma scale, e renderPage gera o PNG de 1.701 × 2.268 px que a galeria on-line de um congresso pede (renderizar uma página como bitmap). A entrelinha divide os 750 mm entre as margens em 56 linhas, e assim a faixa de palavras-chave, flutuando no pé da página, termina na margem inferior. Com 38 pt redondos cabem só 55 linhas, e a última fica 12,7 mm acima da margem; a faixa de palavras-chave já não cabe sob os painéis e vai para uma segunda página.

#2 · Desenhe a faixa do título a partir do título

script.js · linhas 53–84no código completo
const BAND = 158; // mm from the top edge to the foot of the green field
const type = (id, content, family, size, look, placement) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left',
  overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
  lineHeight: 1.2, // a multiple of the size (gotcha: design-lineheight-multiple)
  ...look, placement });
const band = { enabled: true,
  // The field ends 133 mm under the top margin; eleven grid lines (147 mm) start the summary
  // 14 mm below it.
  minHeight: pt(11 * LEAD),
  slot: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('canopy') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    type('meeting', '{attr.meeting}', 'Saira Condensed', 22, { fontWeight: 600,
      textTransform: 'uppercase', letterSpacing: pt(3.5), color: col('tint') },
    { ...at('page', 'top-left', SHEET.margin, 18), size: { width: mm(420) } }),
    type('title', '{titleText}', 'Bitter', 110, { fontWeight: 800, lineHeight: 1 },
      { ...at('#meeting', 'below', 0, 7), size: { width: mm(380) } }),
    type('authors', '{attr.authors}', 'Rethink Sans', 30, { fontWeight: 700 },
      { ...at('#title', 'below', 0, 7), size: { width: 'fill' } }),
    // Design text prints plain text, so the affiliation marks are the characters ¹ ² ³
    // (gotcha: design-text-no-inline-marks).
    type('affiliations', '{attr.affiliations}', 'Rethink Sans', 21, { color: col('tint') },
      { ...at('#authors', 'below', 0, 2), size: { width: 'fill' } }),
    type('number', '{attr.poster}', 'Saira Condensed', 30, { fontWeight: 700,
      color: col('canopy'), box: { backgroundColor: col('paper'),
        padding: { top: mm(1.5), right: mm(4), bottom: mm(1), left: mm(4) } } },
    at('page', 'top-right', -SHEET.margin, 15)),
    // The School of Geography's mark, drawn in code.
    { kind: 'image', id: 'mark', resourceId: 'mark',
      placement: { ...at('page', 'top-right', -SHEET.margin, 38), size: { width: mm(92) } } },
  ] } };

O H1 leva como atributos o encontro, o número do pôster, os autores e as afiliações, e o seu design os compõe sobre uma caixa verde ancorada à página, com 158 mm de altura (span e design avançado). A caixa termina 133 mm abaixo da margem superior. Sem minHeight, o resumo começaria 0,9 mm abaixo do verde; um minHeight de onze linhas da grade deixa 14 mm. O texto de design imprime texto simples, então as marcas de afiliação são os caracteres ¹ ² ³, os únicos algarismos sobrescritos da faixa Latin-1.

#3 · Aninhe os painéis num box na largura da página

script.js · linhas 88–111no código completo
// The body text runs in two columns. Three columns exist only inside a box: a :::columns
// group in a callout, and span="page" lays that callout across both body columns.
//   :::callout{type="grid" span="page"}       ← one frameless box across the page
//   :::columns{count=3 breaks="3,4"}          ← panel 3 opens column 2, panel 4 column 3
//   :::callout{type="panel" title="Introduction"}   ← each panel is a box nested in it
//   …
//   :::                                       ← closes the panel; then the other panels
//   :::                                       ← closes the columns group
//   :::                                       ← closes the grid (gotcha: callout-columns)
const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: mm(GAP) };
// A nested box takes the width of its column and ignores span and placement (gotcha:
// nested-callout-limits). Its one device is a 6 pt stripe along the top.
const panel = { id: 'panel', backgroundEnabled: false,
  stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') },
  padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700,
    textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) },
  body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText
  marginTop: mm(18) };
const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat
  stripe: { ...panel.stripe, color: col('heat') },
  titleStyle: { ...panel.titleStyle, color: col('heat') } };

O corpo tem no máximo duas colunas, então a grade é um grupo :::columns{count=3} dentro de um callout, e span="page" estende esse callout sobre as duas colunas do corpo, sob o resumo (:::columns). Cada painel é um callout aninhado no grupo: recebe o seu próprio estilo, os 175 mm de largura da coluna e 18 mm de marginTop sob o painel de cima (o contêiner :::callout). breaks="3,4" manda o terceiro painel, Results, para o alto da coluna do meio e o quarto, Conclusions, para o alto da última. Sem ele, o grupo se equilibra e corta no painel mais próximo de cada terço da pilha. Com este texto, os cortes caem nos mesmos dois painéis; o atributo os mantém ali quando o texto mudar.

#4 · Mantenha cada figura no seu painel

script.js · linhas 158–165no código completo
// "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 });

Uma figura com posicionamento 'here' fica no painel em que ::resource a põe; com o posicionamento padrão, os três desenhos saem dos seus painéis e o pôster passa para duas páginas. numberingTemplate: '{n}' os numera 1, 2, 3, enquanto o {h1}.{n} padrão imprimiria Figure 1.1 sob um título que é um H1 (tipos de recurso). A nota sob cada legenda avisa que os dados são sintéticos.

#5 · Compartilhe as cores do mapa de calor com a legenda

script.js · linhas 15–34no código completo
const palette = {
  ink: '#102a2c', // text: a green-black
  paper: '#f7faf7', // the sheet, and type on the band
  canopy: '#2e7d4f', // the band, the stripes and titles of five panels, the tree crowns
  tint: '#e6f2ea', // the keyword strip, the park on the plan, lines of type on the band
  rule: '#bcd2c4', // buildings on the plan, the chart's grid
  muted: '#587068', // the notes under the figures, the axis titles inside them
  // The key of Figure 2, the air against the street mean at the same hour. The heat map's
  // cells and the :swatch runs of its key read the same four entries.
  cool: '#3b82c4', // −1.5 °C or less
  mist: '#a9c9e6', // −1.5 to 0 °C
  blush: '#f2b492', // 0 to +1.5 °C
  heat: '#d9572b', // +1.5 °C or more; also −4.2 °C, the Results panel, the loggers, the sun
};
// 1.4.1 designs paint the hex and ignore the paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the canopy green
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'canopy (defaults)', value: { hex: palette.canopy, model: 'hex' } },
];

As células do mapa de calor e os quatro trechos :swatch{color="…"} da legenda apontam para as mesmas entradas da paleta, então um novo valor para cool repinta o mapa e a legenda juntos (formatação inline). A legenda é um segundo grupo :::columns, com duas entradas por coluna, dentro do painel Results. Cada cor também leva o seu hexadecimal, que é o que a 1.4.1 pinta no design da faixa.

#6 · Componha o número de destaque com um estilo de parágrafo

script.js · linhas 115–128no código completo
const paragraphStyles = [
  // Paragraph styles apply inside boxes, nested ones included. They have no weight in
  // 1.4.1, so the figure is written **bold**: that sets it in Bitter 700 and in boldColor.
  { id: 'stat', fontFamily: 'Bitter', fontSize: pt(150), lineHeight: pt(130),
    boldColor: col('heat') },
  { id: 'refs', fontSize: pt(19), lineHeight: pt(26), hangingIndent: mm(9), spaceBetween: pt(8) },
  { id: 'key', fontSize: pt(19), lineHeight: pt(28) }, // the key of Figure 2
];
const chipStyles = [{ id: 'keyword', background: col('paper'), borderColor: col('canopy'),
  borderWidth: pt(1.5), borderRadius: em(1), paddingX: em(0.55), paddingY: em(0.14),
  color: col('canopy'), bold: true, gap: em(0.35) }];
const strip = { id: 'strip', background: col('tint'), // one device: a tint
  padding: { top: mm(4), right: mm(8), bottom: mm(4), left: mm(8) }, columnGap: mm(GAP),
  body: { fontSize: pt(18), lineHeight: pt(24) } };

Os estilos de parágrafo valem dentro dos boxes, inclusive dos aninhados, então −4,2 °C é um bloco :::paragraphs{style="stat"} em Bitter de 150 pt dentro do painel Results (estilos de parágrafo). Na 1.4.1 um estilo de parágrafo não define peso, então o número é escrito em negrito e sai em Bitter 700. Um segundo estilo dá às referências um recuo deslocado de 9 mm. A faixa de palavras-chave é um box tingido com placement="bottom", flutuando no pé da página sob a grade.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 059 · Research poster on one big page ══════════════════
// https://postext.dev/en/cookbook/research-poster
// Code: MIT · Text and data: original, synthetic (CC BY 4.0) · Figures: generated in code
// Fonts: Rethink Sans, Bitter, Saira Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPage, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = '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
};
// 1.4.1 designs paint the hex and ignore the paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the canopy green
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'canopy (defaults)', value: { hex: palette.canopy, model: 'hex' } },
];
// #endregion

// #region sheet: one 600 × 800 mm page, laid out at 72 dpi
const SHEET = { width: 600, height: 800, margin: 25 }; // mm: a portrait board, 25 mm all round
const GAP = 12; // mm between the two body columns and between the three panel columns
// The summary's leading is the page's baseline grid: 56 lines fill the 750 mm between the
// margins, so a box floated to the foot of the page ends on the bottom margin.
const LEAD = (((SHEET.height - 2 * SHEET.margin) / 25.4) * 72) / 56; // 37.96 pt
const page = { width: mm(SHEET.width), height: mm(SHEET.height),
  // At 72 dpi a point is a pixel: the page is 1,701 × 2,268 px. The default 300 dpi would give
  // 7,087 × 9,449 px (268 MB of canvas) for the same line breaks.
  dpi: 72, backgroundColor: col('paper'),
  margins: { top: mm(SHEET.margin), bottom: mm(SHEET.margin), left: mm(SHEET.margin),
    right: mm(SHEET.margin) } }; // one sheet, nothing to mirror
// #endregion
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region band: the title band is the H1's design, filled from its attributes
const BAND = 158; // mm from the top edge to the foot of the green field
const type = (id, content, family, size, look, placement) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left',
  overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
  lineHeight: 1.2, // a multiple of the size (gotcha: design-lineheight-multiple)
  ...look, placement });
const band = { enabled: true,
  // The field ends 133 mm under the top margin; eleven grid lines (147 mm) start the summary
  // 14 mm below it.
  minHeight: pt(11 * LEAD),
  slot: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('canopy') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    type('meeting', '{attr.meeting}', 'Saira Condensed', 22, { fontWeight: 600,
      textTransform: 'uppercase', letterSpacing: pt(3.5), color: col('tint') },
    { ...at('page', 'top-left', SHEET.margin, 18), size: { width: mm(420) } }),
    type('title', '{titleText}', 'Bitter', 110, { fontWeight: 800, lineHeight: 1 },
      { ...at('#meeting', 'below', 0, 7), size: { width: mm(380) } }),
    type('authors', '{attr.authors}', 'Rethink Sans', 30, { fontWeight: 700 },
      { ...at('#title', 'below', 0, 7), size: { width: 'fill' } }),
    // Design text prints plain text, so the affiliation marks are the characters ¹ ² ³
    // (gotcha: design-text-no-inline-marks).
    type('affiliations', '{attr.affiliations}', 'Rethink Sans', 21, { color: col('tint') },
      { ...at('#authors', 'below', 0, 2), size: { width: 'fill' } }),
    type('number', '{attr.poster}', 'Saira Condensed', 30, { fontWeight: 700,
      color: col('canopy'), box: { backgroundColor: col('paper'),
        padding: { top: mm(1.5), right: mm(4), bottom: mm(1), left: mm(4) } } },
    at('page', 'top-right', -SHEET.margin, 15)),
    // The School of Geography's mark, drawn in code.
    { kind: 'image', id: 'mark', resourceId: 'mark',
      placement: { ...at('page', 'top-right', -SHEET.margin, 38), size: { width: mm(92) } } },
  ] } };
// #endregion

// #region answer: three columns of panels inside one box across the page
// The body text runs in two columns. Three columns exist only inside a box: a :::columns
// group in a callout, and span="page" lays that callout across both body columns.
//   :::callout{type="grid" span="page"}       ← one frameless box across the page
//   :::columns{count=3 breaks="3,4"}          ← panel 3 opens column 2, panel 4 column 3
//   :::callout{type="panel" title="Introduction"}   ← each panel is a box nested in it
//   …
//   :::                                       ← closes the panel; then the other panels
//   :::                                       ← closes the columns group
//   :::                                       ← closes the grid (gotcha: callout-columns)
const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: mm(GAP) };
// A nested box takes the width of its column and ignores span and placement (gotcha:
// nested-callout-limits). Its one device is a 6 pt stripe along the top.
const panel = { id: 'panel', backgroundEnabled: false,
  stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') },
  padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700,
    textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) },
  body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText
  marginTop: mm(18) };
const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat
  stripe: { ...panel.stripe, color: col('heat') },
  titleStyle: { ...panel.titleStyle, color: col('heat') } };
// #endregion

// #region type: paragraph styles for the figure, the references and the key; the foot strip
const paragraphStyles = [
  // Paragraph styles apply inside boxes, nested ones included. They have no weight in
  // 1.4.1, so the figure is written **bold**: that sets it in Bitter 700 and in boldColor.
  { id: 'stat', fontFamily: 'Bitter', fontSize: pt(150), lineHeight: pt(130),
    boldColor: col('heat') },
  { id: 'refs', fontSize: pt(19), lineHeight: pt(26), hangingIndent: mm(9), spaceBetween: pt(8) },
  { id: 'key', fontSize: pt(19), lineHeight: pt(28) }, // the key of Figure 2
];
const chipStyles = [{ id: 'keyword', background: col('paper'), borderColor: col('canopy'),
  borderWidth: pt(1.5), borderRadius: em(1), paddingX: em(0.55), paddingY: em(0.14),
  color: col('canopy'), bold: true, gap: em(0.35) }];
const strip = { id: 'strip', background: col('tint'), // one device: a tint
  padding: { top: mm(4), right: mm(8), bottom: mm(4), left: mm(8) }, columnGap: mm(GAP),
  body: { fontSize: pt(18), lineHeight: pt(24) } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, page,
  layout: { layoutType: 'double', gutterWidth: mm(GAP) },
  // The summary's type; the boxes take the family, the rag and the paragraph spacing from it.
  bodyText: { fontFamily: 'Rethink Sans', fontSize: pt(28), lineHeight: pt(LEAD),
    color: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true,
    // The space under a :::paragraphs group as in 1.4.1: the style's own, with no line added
    // under −4.2 °C and the references (the :::space under them sets the gap).
    paragraphContainerSpacing: 'add',
    // Bold in the boxes and the :ref labels copy boldColor, and the italics of the references'
    // paragraph style take italicColor (gotcha: style-italic-colour); both are green otherwise.
    boldColor: col('ink'), italicColor: col('ink') },
  // The band draws the title, but 1.4.1 still measures the H1's own text: in Bitter, a face
  // already loaded, instead of the default Open Sans 700.
  headings: { fontFamily: 'Bitter', levels: [
    // Restated (gotcha: headings-drop-h1-break): a second poster in the file starts a page.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      marginBottom: pt(0), advancedDesign: band }] },
  calloutStyles: [grid, panel, results, strip],
  paragraphStyles, chipStyles, resourceTypes,
  captionStyle: { fontSize: pt(19), labelColor: col('canopy'), gap: mm(3),
    note: { fontSize: pt(17), color: col('muted') } },
  header: { elements: [] }, footer: { elements: [] }, // a poster has no running heads
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region figures: three drawings set inside their panels, numbered 1, 2, 3
// "Figure 1", not "Figure 1.1": the poster has no chapters.
const figureType = { ...defaultResourceTypes(LANG)[0], numberingTemplate: '{n}' };
const resourceTypes = [figureType];
const figure = (id, [width, height], caption, altText) => ({ id, typeId: figureType.id,
  kind: 'svg', svg: { fileId: `${id}.svg`, width: width * 10, height: height * 10 },
  placement: { position: 'here' }, // stays in its panel, where ::resource puts it; else floats
  caption, altText, note: t({ en: 'Synthetic data, generated for this poster.',
    es: 'Datos sintéticos, generados para este póster.' }), createdAt: 0, updatedAt: 0 });
// #endregion
const markdown = String.raw`---
Amostra em Markdown · 95 linhas · content.en.mdtitle: "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. :::callout{type="grid" span="page"} :::columns{count=3 breaks="3,4"} :::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 ::: ::: :::space{lines=0.33} ::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>`; const svg = (w, h, body, face) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${face}${body}</svg>`; // An SVG drawn as an image cannot use the page's web fonts (gotcha: svg-no-webfonts), so // each drawing carries its label face inline, as a data URL of the Fontsource file. async function inlineFace(family, weight) { const id = family.toLowerCase().replace(/\s+/g, '-'); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}` + '-normal.woff2'; const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return `<style>@font-face{font-family:F;src:url(data:font/woff2;base64,${btoa(bin)}) ` + `format('woff2')}text{font-family:F}</style>`; } const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k const FIG = { plan: [175, 62], heat: [175, 126], bars: [175, 75] }; // mm, at column width const LABEL = 5.4; // mm: figure labels, about 15 pt function planSvg(face) { // Figure 1: a schematic plan, west on the left const rand = mulberry32(1908); // seeded with the year the planes were planted const [W, H] = FIG.plan; const [NB, NS, ROAD, SS, SB] = [[11, 22], [22, 25], [25, 34], [34, 37], [37, 48]]; // y bands const SIDE = [4, 6, 10, 13]; // side streets east of these blocks (block index from 0) let out = `<rect x="${X0}" y="${ROAD[0]}" width="${W - X0}" height="${ROAD[1] - ROAD[0]}" ` + `fill="${mix(palette.paper, palette.ink, 0.16)}"/>`; for (let x = X0 + 2; x < W - 2; x += 6) { // the centre line out += `<path d="M${f(x)} 29.5h3" stroke="${palette.paper}" stroke-width="0.4"/>`; } CANOPY.forEach((c, i) => { // the frontages: houses, the park, the shops const x = X0 + i * CELL; const w = SIDE.includes(i) ? CELL - 3.2 : CELL; if (i < 3) { out += `<rect x="${f(x)}" y="${NB[0] - 2}" width="${f(w)}" height="${NB[1] - NB[0] + 2}" ` + `fill="${palette.tint}"/>`; } else { const fill = i >= 6 && i <= 9 ? mix(palette.rule, palette.ink, 0.3) : palette.rule; for (const [y0, y1] of [NB, SB]) { const cut = rand() * 0.3 + 0.35; out += `<rect x="${f(x + 0.4)}" y="${y0}" width="${f(w * cut - 0.8)}" height="${y1 - y0}"` + ` fill="${fill}"/><rect x="${f(x + w * cut + 0.4)}" y="${y0}" ` + `width="${f(w * (1 - cut) - 0.8)}" height="${y1 - y0}" fill="${fill}"/>`; } } }); out += `<rect x="${f(X0)}" y="${SB[0]}" width="${f(3 * CELL - 0.8)}" height="${SB[1] - SB[0]}" ` + `fill="${palette.rule}"/>`; // houses face the park across the road CANOPY.forEach((c, i) => { // crowns along both sidewalks, as many as the canopy share const n = Math.round((c / 100) * 3.6); // Under 14 % the share rounds to no crown: such a block gets one tree on the south side. const rows = n > 0 ? [[NS, n], [SS, n]] : c > 0 ? [[SS, 1]] : []; for (const [[y0, y1], count] of rows) { for (let k = 0; k < count; k++) { const x = X0 + i * CELL + ((k + 0.5) / count) * CELL + (rand() - 0.5) * 1.2; const r = 2 + (c / 100) * 1.3 + rand() * 0.5; // the old planes have the widest crowns out += `<circle cx="${f(x)}" cy="${f((y0 + y1) / 2)}" r="${f(r)}" fill="${palette.canopy}"` + ` fill-opacity="0.85" stroke="${palette.paper}" stroke-width="0.3"/>`; } } if (i < 3) { // the park's own trees for (let k = 0; k < 4; k++) { out += `<circle cx="${f(X0 + i * CELL + 2 + rand() * (CELL - 4))}" ` + `cy="${f(NB[0] + 1 + rand() * 7)}" r="${f(1.8 + rand())}" fill="${palette.canopy}" ` + 'fill-opacity="0.55"/>'; } } }); CANOPY.forEach((c, i) => { // the loggers, on the north kerb, one per block out += `<circle cx="${cx(i)}" cy="${NS[1]}" r="1.4" fill="${palette.heat}" ` + `stroke="${palette.ink}" stroke-width="0.35"/>` + label(cx(i), 55, i + 1, LABEL); }); const names = t({ en: ['ASHBY PARK', 'THE PARADE', 'BUS STATION'], es: ['PARQUE DE LA ALAMEDA', 'TRAMO COMERCIAL', 'ESTACIÓN DE AUTOBUSES'] }); const track = ' letter-spacing="0.4"'; out += label(X0, 6.5, names[0], LABEL - 0.6, palette.canopy, 'start', track) + label(X0 + 8 * CELL, 6.5, names[1], LABEL - 0.6, palette.ink, 'middle', track) + label(W, 6.5, names[2], LABEL - 0.6, palette.ink, 'end', track); // The north arrow in the gutter: a line and a triangle. out += `<path d="M5 27V18" stroke="${palette.ink}" stroke-width="0.7"/>` + `<path d="M5 13l2.6 5.4h-5.2z" fill="${palette.ink}"/>` + label(5, 34, 'N', LABEL); const bar = 2 * CELL; // 100 m: two blocks out += `<path d="M${X0} 60.6h${f(bar)}" stroke="${palette.ink}" stroke-width="0.9"/>` + label(X0 + bar + 2, 61.8, '100 m', LABEL - 0.6, palette.ink, 'start'); return svg(W, H, out, face); } function heatSvg(face) { // Figure 2: canopy bars over an hour × block heat map const [W, H] = FIG.heat; const [BASE, TOP, ROW] = [27, 30, 6.2]; // mm: foot of the bars, top of the grid, row height let out = ''; SURVEY.forEach((b, i) => { const h = (b.canopy / 100) * 18; out += `<rect x="${f(X0 + i * CELL + 1.6)}" y="${f(BASE - h)}" width="${f(CELL - 3.2)}" ` + `height="${f(h)}" fill="${palette.canopy}"/>` + label(cx(i), f(BASE - h - 1.6), b.canopy, LABEL - 0.8); b.air.forEach((v, r) => { out += `<rect x="${f(X0 + i * CELL + 0.3)}" y="${f(TOP + r * ROW + 0.3)}" ` + `width="${f(CELL - 0.6)}" height="${f(ROW - 0.6)}" fill="${palette[classOf(v)]}"/>`; }); out += label(cx(i), TOP + HOURS.length * ROW + 6.5, i + 1, LABEL); }); out += `<path d="M${X0} ${BASE + 0.2}H${W}" stroke="${palette.ink}" stroke-width="0.4"/>` + label(X0 - 1.5, BASE - 7, '%', LABEL, palette.ink, 'end'); // level with the bars HOURS.forEach((hour, r) => { if (hour % 2 === 0) { out += label(X0 - 1.5, TOP + r * ROW + 5, String(hour).padStart(2, '0'), LABEL, palette.ink, 'end'); } }); const track = ' letter-spacing="0.4"'; const [west, east, hour] = t({ en: ['WEST', 'EAST', 'HOUR'], es: ['OESTE', 'ESTE', 'HORA'] }); const mid = f(TOP + (HOURS.length * ROW) / 2); // the hour axis's title runs up the gutter out += label(X0, H - 0.5, west, LABEL - 1, palette.muted, 'start', track) + label(W, H - 0.5, east, LABEL - 1, palette.muted, 'end', track) + label(0, 0, hour, LABEL - 1, palette.muted, 'middle', `${track} transform="translate(3.4 ${mid}) rotate(-90)"`); return svg(W, H, out, face); } function barsSvg(face) { // Figure 3: how much cooler the paving was, by canopy class const [W, H] = FIG.bars; const [LEFT, ROW, SCALE] = [40, 13, 7.6]; // SCALE: mm per °C const names = t({ en: ['under 10 %', '10–30 %', '30–50 %', '50–70 %', '70 % or more'], es: ['menos del 10 %', '10–30 %', '30–50 %', '50–70 %', '70 % o más'] }); const base = surfaceOf(CLASSES[0]); // the paving under 10 % canopy: the zero of the scale const num = (v) => t({ en: v.toFixed(1), es: v.toFixed(1).replace('.', ',') }); const foot = 5 * ROW + 2; let out = ''; for (const step of [5, 10, 15]) { out += `<path d="M${LEFT + step * SCALE} 1V${foot}" stroke="${palette.rule}" ` + 'stroke-width="0.35"/>' + label(LEFT + step * SCALE, foot + 6.5, step, LABEL); } CLASSES.forEach((cls, i) => { const cooler = base - surfaceOf(cls); const y = 1 + i * ROW; const fill = mix(palette.tint, palette.canopy, 0.3 + 0.7 * (i / 4)); out += label(LEFT - 3, y + 8.3, names[i], LABEL, palette.ink, 'end'); if (i === 0) out += label(LEFT + 2, y + 8.3, `${num(base)} °C`, LABEL, palette.muted, 'start'); else { out += `<rect x="${LEFT}" y="${y + 1.2}" width="${f(cooler * SCALE)}" height="${ROW - 2.4}" ` + `fill="${fill}"/>` + label(LEFT + cooler * SCALE + 2, y + 8.3, num(cooler), LABEL, palette.ink, 'start'); } }); const axis = t({ en: '°C cooler than the blocks under 10 %', es: '°C por debajo de las manzanas de menos del 10 %' }); out += `<path d="M${LEFT} 0V${foot + 1}" stroke="${palette.ink}" stroke-width="0.6"/>` + label(LEFT, foot + 6.5, '0', LABEL) + label(LEFT + 7.5 * SCALE, foot + 14, axis, LABEL, palette.muted, 'middle'); return svg(W, H, out, face); } function markSvg() { // the School of Geography's mark: a crown shading a street, in a ring const shade = mix(palette.canopy, palette.ink, 0.45); return svg(10, 10, '<g transform="scale(0.1)">' + `<circle cx="50" cy="50" r="46" fill="none" stroke="${palette.paper}" stroke-width="3.2"/>` + `<circle cx="70" cy="25" r="8" fill="${palette.heat}"/>` // the sun, behind the crown + `<ellipse cx="43" cy="71" rx="23" ry="4.5" fill="${shade}"/>` // its shade on the paving + `<rect x="47" y="48" width="6" height="23" fill="${palette.paper}"/>` + `<circle cx="50" cy="37" r="17" fill="${palette.paper}"/>` + `<circle cx="35" cy="46" r="11" fill="${palette.paper}"/>` + `<circle cx="65" cy="46" r="11" fill="${palette.paper}"/>` + `<path d="M17 71H83" stroke="${palette.paper}" stroke-width="3.2"/></g>`, ''); } // #endregion const resources = [ figure('plan', FIG.plan, t({ en: 'Ashby Road, schematic plan: crowns from the canopy survey and the 16 loggers ' + '(orange). Widths across the street are not to scale.', es: 'La avenida en plano esquemático: las copas del inventario de arbolado y los 16 ' + 'registradores (naranja). Los anchos transversales no están a escala.' }), t({ en: 'Plan of a straight street in 16 numbered blocks, with a park at the west end, tree ' + 'crowns along both sidewalks and one tree or none on each of blocks 7 to 10.', es: 'Plano de una calle recta en 16 manzanas numeradas, con un parque en el extremo oeste, ' + 'copas en las dos aceras y un árbol o ninguno en cada una de las manzanas 7 a 10.' })), figure('heat', FIG.heat, t({ en: 'Canopy cover per block (bars, %) and the air at head height against the street mean ' + 'at the same hour, from 08:00 to 20:00; mean of 14 afternoons.', es: 'Copa por manzana (barras, %) y aire a la altura de la cabeza respecto a la media de ' + 'la calle a esa hora, de 08:00 a 20:00; media de 14 tardes.' }), t({ en: 'Bar chart of canopy cover for 16 blocks above a grid of coloured cells, hours down ' + 'and blocks across: blue under the tree-lined blocks in the afternoon, orange along ' + 'blocks 7 to 10.', es: 'Barras de cobertura de copa de 16 manzanas sobre una cuadrícula de celdas de color, ' + 'horas hacia abajo y manzanas en horizontal: azul bajo las manzanas arboladas por la ' + 'tarde, naranja en las manzanas 7 a 10.' })), figure('bars', FIG.bars, t({ en: 'The paving at 15:00 by canopy class, as the difference from the blocks under 10 %.', es: 'El pavimento a las 15:00 por clase de copa, como diferencia con las manzanas de ' + 'menos del 10 %.' }), t({ en: 'Horizontal bars that grow with the canopy class, from 2.7 to 15.0 °C cooler.', es: 'Barras horizontales que crecen con la clase de copa, de 2,7 a 15,0 °C menos.' })), // Not cited in the text: only the band's design draws it. { id: 'mark', typeId: figureType.id, kind: 'svg', svg: { fileId: 'mark.svg', width: 100, height: 100 }, altText: t({ en: 'The mark of the School of Geography', es: 'La marca del Departamento de Geografía' }), createdAt: 0, updatedAt: 0 }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Rethink Sans': ['400', '400i', '700'], Bitter: ['400', '700', '800'], // 400: the stat style's base face, which the build measures 'Saira Condensed': ['600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const face = await inlineFace('Saira Condensed', 600); await Promise.all([loadSvg('plan.svg', planSvg(face)), loadSvg('heat.svg', heatSvg(face)), loadSvg('bars.svg', barsSvg(face)), loadSvg('mark.svg', markSvg())]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Research poster', es: 'Póster científico' }) }); // The e-poster: renderPage paints the page at its own size, 1,701 × 2,268 px at 72 dpi. const png = Object.assign(document.createElement('a'), { download: `${RECIPE}.png`, textContent: t({ en: 'E-poster PNG', es: 'PNG del póster digital' }) }); renderPage(doc.pages[0], doc).toBlob((blob) => { png.href = URL.createObjectURL(blob); }); document.getElementById('pt-actions').append(png);
Kit · core, fonts, viewer, images: igual em todas as receitas · 271 linhas// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** 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, '-'); } /** 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 v1 ── 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. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)

Variações

#Exporte o e-pôster a 150 dpi

Um congresso que peça um arquivo maior recebe um PNG de 3.543 × 4.724 px com as mesmas quebras de linha.

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

Erros comuns

Erro comum

:::columns só funciona dentro de um boxe e nunca se divide

:::columns é ignorado fora de um boxe, e um boxe que se divide nunca corta dentro de um grupo de colunas. O atributo breaks conta blocos filhos, e um boxe aninhado conta como um. Colunas dentro de um boxe →

Erro comum

Um boxe aninhado ignora span, placement e snapToGrid

Um boxe aninhado em outro ignora seus span, placement, snapToGrid e floatBarrier: ele sempre é composto dentro do boxe que o contém, com a largura interna deste. Boxes aninhados →

Erro comum

Texto de design não aceita ^sup^ nem **negrito** em linha

Os elementos de texto de design imprimem texto simples, então ^1^ ou **negrito** em um atributo aparecem literalmente. Use sobrescritos Unicode (¹ ² ³ estão no subconjunto latin) ou um segundo elemento com outro peso. Textos, fios e caixas nos designs de página →

Erro comum

Uma tabela ou figura dentro de um boxe não ganha espaço em volta

No postext 1.4.1, uma tabela ou figura que ::resource coloca na posição 'here' dentro de um :::callout não recebe nada do espaço que mantém no texto corrido: encosta no parágrafo de cima e no de baixo. Ponha um :::space antes da linha ::resource, e outro depois quando houver texto em seguida; uma fração de linha, como lines=0.33, deixa um espaço pequeno. Figuras exatamente aqui →

Erro comum

O texto dentro de um SVG <img> não pode usar fontes web

Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →

Erro comum

Um estilo de parágrafo não tem cor de itálico

No postext 1.4.1, um estilo de parágrafo define color e boldColor, mas não italicColor: os trechos em itálico usam bodyText.italicColor. Um estilo atenuado (letra miúda, a linha “Fonte:” de uma tabela) imprime os títulos em itálico mais escuros que o texto em volta. Mantenha esses estilos na cor do texto corrido ou evite itálico neles. Estilos de parágrafo →

Erro comum

Texto em bandeira nunca é verificado contra linhas curtas

optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta. Viúvas, órfãs e linhas curtas →

Erro comum

Texto alinhado à esquerda pode isolar a pontuação ao lado de um negrito ou de um :ref

No postext 1.4.1, o texto que não é justificado (corpo de boxes, parágrafos em bandeira) pode quebrar a linha entre um trecho em negrito ou itálico, ou um :ref, e a pontuação colada nele: um ponto final pode abrir a linha seguinte, e o “(” antes de uma referência pode fechar a linha de cima. Texto justificado nunca quebra aí. Revise os boxes de cada edição e reescreva a frase onde isso acontecer, para que o trecho fique no meio da linha. Negrito, itálico e suas cores →

Erro comum

Um espaço não separável ainda quebra a linha

No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase. Escapes e caracteres literais →

Erro comum

Uma paleta trocada não chega aos elementos de design nem à cor das referências

postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →

Erro comum

O lineHeight de um texto de design é um múltiplo, nunca uma medida

Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →

Erro comum

O excesso de texto de design é 'ellipsis-end' por padrão

Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →

Erro comum

Qualquer objeto headings desativa a quebra de página do H1

Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →

  • No canvas, a Rethink Sans desenha um único algarismo entre parênteses, como o número do fascículo em 11(3), como um algarismo dentro de um círculo; por isso as referências trazem só o volume e as páginas.
  • Na 1.4.1, um box na largura da página que fecha o documento passa para uma nova página a menos que sobrem abaixo dele duas linhas do corpo. Por isso a faixa de palavras-chave flutua com placement="bottom", que a põe na margem inferior, nos 50 mm que sobram sob a grade.
  • Na 1.4.1, um parágrafo que vem depois de um grupo :::paragraphs fica apenas o spaceBetween do estilo abaixo dele, aqui 2,8 mm sob a última referência. Um :::space{lines=0.75} antes do agradecimento abre 10,8 mm, perto dos 10,6 mm entre os outros parágrafos de um painel.

Créditos

Texto
Texto original, CC BY 4.0
Imagens
  • 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
Fontes
Rethink Sans (SIL OFL 1.1) · Bitter (SIL OFL 1.1) · Saira Condensed (SIL OFL 1.1)
Sandbox