# Boletim comunitário: matéria principal e notas

> Um boletim de horta comunitária em coluna e meia: a matéria enche a coluna larga e continua na página 2; quebras de coluna levam as notas à estreita.

- Versão HTML: https://postext.dev/pt/cookbook/community-newsletter
- Receita Nº 042 · Página e grade · Nível 2 (Intermediário) · Saídas: Canvas
- Gêneros: Jornais e boletins
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/community-newsletter/en/p01.webp?v=4bd66695), [2](https://postext.dev/cookbook/community-newsletter/en/p02.webp?v=4bd66695)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=community-newsletter&lang=en (.postext: https://postext.dev/cookbook/community-newsletter/en/community-newsletter.postext)
- Última atualização: 2026-09-26
- Outros idiomas: [en](https://postext.dev/en/cookbook/community-newsletter.md), [es](https://postext.dev/es/cookbook/community-newsletter.md), [ca](https://postext.dev/ca/cookbook/community-newsletter.md), [zh](https://postext.dev/zh/cookbook/community-newsletter.md), [ja](https://postext.dev/ja/cookbook/community-newsletter.md), [ar](https://postext.dev/ar/cookbook/community-newsletter.md)

## Em poucas palavras

Um boletim de duas páginas de um clube de jardinagem. A matéria principal ocupa uma coluna larga e continua na página 2, e as notícias curtas ficam numa coluna estreita ao lado.

## O que você vai compor

A edição de primavera de *The Allotment Gazette*, boletim de uma associação de horta comunitária inventada, impresso nos dois lados de uma folha A4. Uma faixa cor de palha leva o nome em Titan One, em duas linhas, uma fileira de mudas na borda inferior e uma aba cor de tomate com o número e a estação. Abaixo dela a página se divide numa coluna de 113 mm e noutra de 57 mm. A matéria principal, sobre três novas baias de compostagem, enche a coluna larga sob uma planta da horta desenhada em código e para numa etiqueta “Continued on page 2”. A coluna estreita reúne cinco notas, cada uma aberta por etiquetas em forma de pílula. A página 2 retoma a matéria sob um título de continuação e centraliza um gráfico de temperatura na coluna. Mais abaixo, um boxe em duas colunas põe o desenho de um canteiro ao lado do parágrafo que fala dele. Oito avisos enchem a coluna estreita.

**Esta receita responde a:**

- Como faço para pôr a matéria numa coluna larga, as notas numa estreita e um texto ao lado de uma imagem?
- O Postext consegue compor três ou mais colunas de texto, ou fazer o texto contornar uma imagem?
- Como coloco duas colunas dentro de um boxe (uma coluna de texto ao lado de uma coluna com figura)?
- Como faço chips em linha: teclas, etiquetas, bancos de palavras para exercícios?

## A resposta curta

```js
// script.js, linhas 33–52
const layout = {
  layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
  // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
  sideColumnPercent: 32, // 57 mm of the 178 mm measure
  gutterWidth: mm(8), // the wide column keeps the other 113 mm
  columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
//   ## Shared compost bays open by the top gate      ← the lead, in the wide column
//   … :chip[Continued on page 2]{style="jump"}
//   :::columnbreak                                    ← the briefs open the narrow column
//   ## In brief {style="rail"}
//   …
//   :::columnbreak                                    ← from the last column: next page
//   :chip[Continued from page 1]{style="jump"}        ← the lead goes on in the wide column
// Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that
// runs over into the other column keeps the measure it started with (gotcha:
// split-paragraph-measure).
```

## Ingredientes

**Ensina**

- [Coluna e meia](https://postext.dev/pt/docs/configuration.md#tipos-de-layout): Uma diagramação assimétrica: uma coluna principal larga e uma coluna lateral estreita, de um lado fixo ou na borda externa de cada página.
- [Quebras de página e de coluna](https://postext.dev/pt/docs/document-format.md#pagebreak): Levam o bloco seguinte para uma página nova, para a próxima página ímpar ou par (acrescentando uma página em branco quando necessário) ou para a próxima coluna.
- [Chips no texto](https://postext.dev/pt/docs/configuration.md#estilos-de-chip): Caixas arredondadas em volta de palavras, que mudam de linha como uma unidade e nunca se esticam: teclas, etiquetas, bancos de palavras, sílabas.

**Também usa**

- [Colunas dentro de um boxe](https://postext.dev/pt/docs/document-format.md#columns)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Posicionamento de figuras](https://postext.dev/pt/docs/document-format.md#posicionamento)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Fio entre colunas](https://postext.dev/pt/docs/configuration.md#fio-entre-colunas)
- [Metadados do documento](https://postext.dev/pt/docs/document-format.md#frontmatter)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Linhas de fonte e crédito](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`chipStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headingStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

- Work Sans (OFL-1.1), Titan One (OFL-1.1), Courier Prime (OFL-1.1)

## Preparo

### 1 · Deixe o Markdown encher cada coluna

As configurações de layout estão [na resposta curta](https://postext.dev/pt/cookbook/community-newsletter.md#a-resposta-curta), lá em cima. Num layout `oneAndHalf` a coluna estreita recebe texto por padrão, e o texto desce pela coluna larga, depois pela estreita, depois pela coluna larga da página seguinte ([tipos de layout](https://postext.dev/pt/docs/configuration.md#tipos-de-layout)). Um `:::columnbreak` encerra a coluna onde está ([diretivas](https://postext.dev/pt/docs/document-format.md#diretivas)): depois da etiqueta “Continued on page 2” da matéria, ele abre a coluna estreita para as notas, e depois da última nota, na última coluna da página, abre a página 2. Nesta edição as duas colunas da página 1 foram ajustadas para terminar na mesma linha da grade, a 275 mm da borda superior, então apagar qualquer uma das duas quebras deixa as páginas como estão. As quebras fazem diferença quando o texto muda. Corte o segundo parágrafo da matéria e, sem a primeira quebra, “In brief” começa a 247 mm, na coluna larga, embaixo da etiqueta, e a primeira nota transborda para a coluna estreita, onde a segunda metade mantém a medida de 113 mm com que começou e é cortada nos 57 mm da coluna.

### 2 · Monte o cabeçalho a partir do frontmatter

```js
// script.js, linhas 56–80
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
  minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
  slot: { elements: [
    // The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text
    // area on this page, behind the masthead, however deep the masthead is.
    { kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
      placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
    { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
      placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
    { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
      placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
      lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple)
      color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
      placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
    // {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter
    // (gotcha: quote-frontmatter): an unquoted date prints nothing here.
    { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
      align: 'right', box: { backgroundColor: col('tomato'),
        padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
      placement: at('page', 'top-right', -SIDE, 44) },
  ] } };
```

O H1 recebe um estilo de título cujo design desenha o cabeçalho do boletim, e `span: 'page'` põe esse design por cima das duas colunas. `{titleText}` em 60 pt quebra em duas linhas na sua caixa de 178 mm. A aba junta `{attr.issue}`, escrito na linha do H1, e `{publishDate}`, do frontmatter, que precisa ser uma string entre aspas ([elementos de texto](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)). Sem `minHeight`, o cabeçalho reservaria espaço até a borda inferior da faixa, a 62 mm da borda superior, e o texto começaria na linha seguinte da grade, a 62,5 mm, encostado na faixa. Um `minHeight` de dez linhas da grade faz o texto começar a 67 mm.

### 3 · Marque as notas com chips

```js
// script.js, linhas 84–94
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
  fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
  borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
  paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
  chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
  chip('free', { background: col('marigold'), color: col('ink') }),
  chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
    color: col('leaf') }),
  chip('jump', { background: col('straw'), color: col('ink') }),
];
```

Declarar `chipStyles` substitui o chip azul-claro padrão, e um chip sem `style` usa o primeiro ([estilos de chip](https://postext.dev/pt/docs/configuration.md#estilos-de-chip)). Um raio maior que metade da altura da caixa desenha uma pílula. Um chip nunca quebra por dentro, então a linha só quebra no espaço antes ou depois de um chip, e uma data como “Sat 11 April” fica numa linha só. As etiquetas são digitadas em maiúsculas no Markdown porque os estilos de chip não têm opção de caixa. O preenchimento vertical é pintado fora da linha, por isso a grade de 13,4 pt se mantém.

### 4 · Ponha o texto ao lado do desenho dentro de um boxe

```js
// script.js, linhas 98–109
// Text never runs round a picture in a column (text wrap is a gap), but a :::columns group
// inside a box sets blocks side by side; breaks="2" opens column two at the second block:
//   :::callout{type="bed" title="Sixteen squares by the gate"}
//   :::columns{count=2 breaks="2"}
//   The demonstration bed by the gate is 1.2 metres square …   ← block 1
//   ::resource{id="bed"}                                        ← block 2
//   :::
//   :::                                   (gotcha: callout-columns)
const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe
  padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5),
  titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) },
  body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } };
```

O Postext não contorna uma imagem com texto: uma figura ocupa uma faixa inteira da sua coluna. Dentro de um boxe, porém, um grupo `:::columns` põe blocos lado a lado ([`:::columns`](https://postext.dev/pt/docs/document-format.md#columns)). `breaks="2"` começa a segunda coluna, de 50 mm, no desenho, e assim o parágrafo fica inteiro na primeira. Nesta edição o parágrafo (65,6 mm) é mais baixo que o desenho com a legenda (71,7 mm), e apagar `breaks="2"` não muda nada. Ele está ali para um parágrafo mais longo: sem ele o grupo é equilibrado, e com um parágrafo de vinte linhas as duas últimas vão para o alto da segunda coluna, acima do desenho.

### 5 · Centralize um gráfico estreito na sua faixa

```js
// script.js, linhas 113–125
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
  placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
  caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
  createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
  drawing('bed', 50, 50), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];
```

`width: 0.62` dá ao gráfico 70,1 mm de largura na coluna de 113 mm, e `align: 'center'` deixa 21,5 mm de papel de cada lado ([posicionamento](https://postext.dev/pt/docs/document-format.md#posicionamento)). O texto acima e abaixo mantém a medida inteira e nunca entra nesse branco. Os desenhos pertencem a um tipo de recurso com `captionPrefix` vazio, então cada legenda começa com as próprias palavras em negrito, sem “Figura 1” na frente.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: https://github.com/drnachio/postext/tree/main/cookbook/community-newsletter

### script.js

```js
// ═══ Postext Cookbook · Nº 042 · Community newsletter: lead story and briefs ═════
// https://postext.dev/en/cookbook/community-newsletter
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Work Sans, Titan One, Courier Prime (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'community-newsletter';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: garden colours, every one linked by id
// ink: text · leaf: nameplate and headlines · tomato, the accent: tags, tab, column heads ·
// straw: the band · marigold: second tag · tint: the box · rule: hairlines · muted: notes
const palette = { ink: '#1d211c', leaf: '#2f6b3a', tomato: '#c43f2a', straw: '#f2d492',
  marigold: '#e2b33c', tint: '#eef4e8', rule: '#cfd3c6', muted: '#5f6659', paper: '#fbfaf5' };
// The hex rides along: 1.4.1 designs read it, not the link (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 leaf, never blue
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Work Sans', 'Titan One', 'Courier Prime'];
const SIDE = 16; // mm: the side margins of one A4 sheet, printed on both sides
const LEAD = 13.4; // body leading in pt: the baseline grid
const caps = (size, colour) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  textTransform: 'uppercase', letterSpacing: pt(size * 0.12), color: col(colour) });
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: a wide column for the lead, a narrow one for the briefs, one flow
const layout = {
  layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
  // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
  sideColumnPercent: 32, // 57 mm of the 178 mm measure
  gutterWidth: mm(8), // the wide column keeps the other 113 mm
  columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
//   ## Shared compost bays open by the top gate      ← the lead, in the wide column
//   … :chip[Continued on page 2]{style="jump"}
//   :::columnbreak                                    ← the briefs open the narrow column
//   ## In brief {style="rail"}
//   …
//   :::columnbreak                                    ← from the last column: next page
//   :chip[Continued from page 1]{style="jump"}        ← the lead goes on in the wide column
// Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that
// runs over into the other column keeps the measure it started with (gotcha:
// split-paragraph-measure).
// #endregion

// #region masthead: the H1 is the nameplate; the issue tab reads the frontmatter
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
  minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
  slot: { elements: [
    // The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text
    // area on this page, behind the masthead, however deep the masthead is.
    { kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
      placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
    { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
      placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
    { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
      placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
      lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple)
      color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
      placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
    // {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter
    // (gotcha: quote-frontmatter): an unquoted date prints nothing here.
    { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
      align: 'right', box: { backgroundColor: col('tomato'),
        padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
      placement: at('page', 'top-right', -SIDE, 44) },
  ] } };
// #endregion

// #region tags: four chip styles replace the built-in pale-blue chip
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
  fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
  borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
  paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
  chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
  chip('free', { background: col('marigold'), color: col('ink') }),
  chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
    color: col('leaf') }),
  chip('jump', { background: col('straw'), color: col('ink') }),
];
// #endregion

// #region box: two columns inside a box set a paragraph beside its drawing
// Text never runs round a picture in a column (text wrap is a gap), but a :::columns group
// inside a box sets blocks side by side; breaks="2" opens column two at the second block:
//   :::callout{type="bed" title="Sixteen squares by the gate"}
//   :::columns{count=2 breaks="2"}
//   The demonstration bed by the gate is 1.2 metres square …   ← block 1
//   ::resource{id="bed"}                                        ← block 2
//   :::
//   :::                                   (gotcha: callout-columns)
const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe
  padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5),
  titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) },
  body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } };
// #endregion

// #region band: a narrower figure still takes the whole band of its column
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
  placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
  caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
  createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
  drawing('bed', 50, 50), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];
// #endregion

const head = (id, content, edge, x, y, look) => ({ kind: 'text', id, content, pages: 'body',
  align: edge.slice(4), placement: at('page', edge, x, y), ...look });
const folio = { elements: [ // page 2's head: title and date over a hairline, the folio right
  head('name', '{title} · {publishDate}', 'top-left', SIDE, 11, caps(7.5, 'leaf')),
  head('folio', '{pageNumber}', 'top-right', -SIDE, 9.6,
    { fontFamily: DISPLAY, fontSize: pt(12), color: col('tomato') }),
  { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'), pages: 'body',
    placement: { ...at('page', 'top-left', SIDE, 15.5), size: { width: mm(210 - 2 * SIDE) } } },
] };

const label = (size, look = {}) => ({ fontFamily: LABEL, fontSize: pt(size), ...look });
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes, chipStyles, layout, calloutStyles: [bedBox],
  page: { width: mm(210), height: mm(297), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(20), bottom: mm(18), left: mm(SIDE), right: mm(SIDE) } },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    referenceColor: col('leaf'), // for a :ref label: the palette reaches bold but not this
    // colour, which would stay the default blue (gotcha: palette-skips-designs)
    // Ragged: no hyphens, no runt check (gotchas: ragged-no-hyphenation, ragged-runts)
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('leaf'), levels: [
    // Restated (gotcha: headings-drop-h1-break); span: 'page' sets the masthead over both columns
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(24), lineHeight: pt(2 * LEAD), marginTop: pt(0),
      marginBottom: pt(LEAD / 2) },
    { level: 3, fontFamily: TEXT, fontWeight: 700, fontSize: pt(10.2), lineHeight: pt(LEAD),
      color: col('ink'), marginTop: pt(LEAD), marginBottom: pt(0) }, // a brief's name
    { level: 4, ...caps(8.5, 'tomato'), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }, // a notice's name
  ] },
  headingStyles: [
    // marginBottom 0: the level's 0.5 em is added under minHeight and would drop the text a line
    { id: 'masthead', marginBottom: pt(0), advancedDesign: masthead },
    { id: 'rail', fontSize: pt(17), lineHeight: pt(2 * LEAD), color: col('tomato') },
    { id: 'jump', fontSize: pt(19), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD / 2) },
  ],
  paragraphStyles: [
    { id: 'standfirst', fontSize: pt(12), lineHeight: pt(16) },
    { id: 'byline', ...label(8, { color: col('muted'), marginBottom: pt(LEAD / 2) }) },
    { id: 'jump', ...label(8, { textAlign: 'right' }) }, // 'Continued on page 2'
    { id: 'from', ...label(8) }, // 'Continued from page 1'
    { id: 'colophon', ...label(7, { lineHeight: pt(9.4), color: col('muted'),
      marginTop: pt(LEAD) }) },
  ],
  captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), gap: mm(1.6),
    note: { fontFamily: LABEL, fontSize: pt(6.8), color: col('muted') } },
  header: folio,
  footer: { elements: [] }, // the folio is at the head of page 2; page 1 has the masthead
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Allotment Gazette"
subtitle: "Newsletter of the Wren Lane Allotment Society"
publishDate: "Spring 2026"
---

# The Allotment Gazette {style="masthead" issue="No. 47"}

:chip[SITE NEWS]{style="tag"}

## Shared compost bays open by the top gate

:::paragraphs{style="standfirst"}
Sixty plots now feed three timber bays instead of a heap each, and bay one reached 63 °C in five days. Its compost goes back to the plots in September.
:::

:::paragraphs{style="byline"}
Maureen Adeyemi, site secretary
:::

::resource{id="plan"}

On the first Saturday of March eleven of us put up three bays of reclaimed scaffold boards by the top gate. Each is a metre and a half square and a metre high, with loose boards at the front that lift out, so a heap can be forked into the next bay. Hallam’s yard on Station Road let us have the boards for the cost of the van, and Ron Ellis brought his drill.

Until now each plot has kept its own heap, and most of those heaps never get warm. A heap needs about a cubic metre of mixed material before it holds its heat, and few of us fill that much in a season. In one bay, with the stable manure the riding school leaves at the gate, the same waste heats up within days.

:::paragraphs{style="jump"}
:chip[Continued on page 2]{style="jump"}
:::

:::columnbreak

## In brief {style="rail"}

### Path day

:chip[WORK PARTY]{style="tag"} :chip[Sun 12 April]{style="when"} The grass paths between the plots need edging, and the main path a fresh load of gravel. Andy Price brings his edging iron; there are spades and barrows. Meet at the hut at nine.

### Seed swap

:chip[EVENT]{style="tag"} :chip[Sat 11 April]{style="when"} Bring spare seed in labelled envelopes to the hut between ten and twelve. Seed saved from open-pollinated plants is welcome; seed from F1 hybrids will not come true. Tea and flapjack, 50p.

### Stable manure

:chip[FREE]{style="free"} Whitecross Riding School leaves a trailer-load at the top gate every other Tuesday. Take what your plot needs, but top up bay one first, and keep the gateway clear for the tractor.

### Jam jars and a gazebo

:chip[WANTED]{style="tag"} Clean jars with lids for the jam stall at the summer show, and a gazebo to borrow on Saturday 18 July. Leave jars in the crate by the hut door; Sue Marsh collects them on Fridays.

### New neighbours

:chip[WELCOME]{style="free"} Four plots changed hands this winter. Welcome to the Okafors on plot 9, Priya and Tom on 23a, Mr Hensley on 31, and the Year 5 class at St Anne’s, who share plot 40 with Mrs Dunn.

:::columnbreak

:::paragraphs{style="from"}
:chip[Continued from page 1]{style="jump"}
:::

## What goes into the bays {style="jump"}

Bay one was filled on 7 March. On the 12th the thermometer at its centre read 63 °C, and the heap stayed at 55 °C or more for six days, long enough to kill most weed seeds. The morning readings are chalked on the board by the gate.

Fill one bay at a time, with green and brown in equal barrow-loads: grass cuttings, weeds without seed heads, spent crops and peelings with torn cardboard, straw and manure. Chop anything thicker than a thumb. Keep out clubroot-infected brassica roots, bindweed and couch grass, and cooked food, which brings rats.

::resource{id="heat"}

Turning lets air into the middle of the heap, and the temperature climbs again: bay one, turned on 18 March, was back above 60 °C two days later. The rota on the hut door puts two plots in charge of the fork each week. In September every plot can take away two barrow-loads of finished compost, and whatever is left goes on the demonstration bed by the gate.

:::callout{type="bed" title="Sixteen squares by the gate"}
:::columns{count=2 breaks="2"}
The demonstration bed by the gate is 1.2 metres square, edged with the same scaffold boards as the bays. This year it holds compost from the council depot, 15 centimetres deep; next spring it gets our own. Strings divide the bed into sixteen squares, 30 centimetres a side, each planted with one, four, nine or sixteen plants according to the size of the crop. Year 5 from St Anne’s planted it on 20 March and will keep its diary.

::resource{id="bed"}
:::
:::

:::columnbreak

## Notices {style="rail"}

#### Rents

Rents for 2026 are £52 for a full plot and £28 for a half, due by the end of April. Pay Dev Sharma on plot 18, or by bank transfer: the details are on the hut door.

#### Water

The troughs are filled from 1 April to the end of October. Use a hose to fill cans only, and never leave one running on a bed.

#### Bonfires

None from April to September, and none at any time when the wind blows towards the houses on Wren Lane.

#### Sheds

A new shed over two metres tall needs the committee’s approval. Send a sketch with its size to the secretary before you buy.

#### Waiting list

Thirty-one names are on the list. We expect to offer eight plots this year, most of them half plots.

#### Bees

On sunny Saturdays in April, Jan inspects the two hives on plot 52. Keep to the main path while her flag is up.

#### Lost

A green ten-litre watering can with a brass rose. Please bring it back to plot 7.

#### Next issue

Please leave copy for the summer issue in the letterbox on the hut door by 15 June.

:::paragraphs{style="colophon"}
The Allotment Gazette is written and put together by members of the Wren Lane Allotment Society. Set in Work Sans, Titan One and Courier Prime (SIL Open Font License) · Text: original, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the site plan, the heap's temperature and the square-metre bed
function mulberry32(seed) { // a seeded PRNG: the same plots 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;
  };
}
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 f = (v) => +v.toFixed(2);
const P = palette;
const SOIL = mix(P.tomato, P.ink, 0.62);
const EARTH = mix(SOIL, P.straw, 0.28);
const GRASS = mix(P.tint, P.leaf, 0.3);
const DEEP = mix(P.leaf, P.ink, 0.35);
const svgOf = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" `
  + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}">${style}${body}</svg>`;
const dot = (x, y, r, fill) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" fill="${fill}"/>`;
const rect = (x, y, w, h, fill, extra = '') => `<rect x="${f(x)}" y="${f(y)}" width="${f(w)}" `
  + `height="${f(h)}" fill="${fill}"${extra}/>`;
const line = (x1, y1, x2, y2, stroke, width) => `<path d="M${f(x1)} ${f(y1)}L${f(x2)} ${f(y2)}" `
  + `stroke="${stroke}" stroke-width="${width}" stroke-linecap="round"/>`;

// One plot seen from above: rows of crops across it, sometimes a shed or a greenhouse.
function plot(x, y, w, h, rand, extra) {
  let out = rect(x, y, w, h, EARTH);
  const strips = 2 + Math.floor(rand() * 3);
  let top = y + 0.8;
  for (let s = 0; s < strips; s++) {
    const room = y + h - 0.8 - top;
    if (room < 2) break;
    let sh = s === strips - 1 ? room : Math.max(4, (h - 1.6) * (0.2 + rand() * 0.3));
    if (room - sh < 4) sh = room; // no strip thinner than 4 mm
    const kind = Math.floor(rand() * 6);
    const [x0, x1] = [x + 0.9, x + w - 0.9];
    if (kind === 0) { // rows of lettuce and onions
      for (let yy = top + 1; yy < top + sh - 0.6; yy += 1.7) {
        for (let xx = x0 + 0.6; xx < x1; xx += 1.5) out += dot(xx, yy, 0.55, P.leaf);
      }
    } else if (kind === 1) { // peas and beans up canes
      for (let yy = top + 1.2; yy < top + sh - 0.8; yy += 2.6) out += line(x0, yy, x1, yy, DEEP, 1);
    } else if (kind === 2) { // brassicas under netting
      out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, mix(P.tint, P.paper, 0.4));
      for (let yy = top + 1.6; yy < top + sh - 1.2; yy += 3) {
        for (let xx = x0 + 1.4; xx < x1 - 0.8; xx += 3) {
          out += dot(xx, yy, 1.1, mix(P.leaf, P.rule, 0.45));
        }
      }
    } else if (kind === 3) { // rhubarb and squashes
      for (let xx = x0 + 2; xx < x1 - 1; xx += 4 + rand() * 2) {
        const yy = top + sh / 2 + (rand() - 0.5) * (sh - 3);
        out += dot(xx, yy, 1.8, P.leaf) + dot(xx + 0.6, yy - 0.5, 0.45, P.marigold);
      }
    } else if (kind === 4) { // dug over and raked
      for (let yy = top + 0.7; yy < top + sh - 0.3; yy += 0.9) {
        out += line(x0, yy, x1, yy, SOIL, 0.25);
      }
    } else { // straw round the strawberries
      out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, P.straw);
      for (let xx = x0 + 1; xx < x1; xx += 2.2) {
        out += dot(xx, top + sh / 2 - 0.2, 0.8, P.leaf)
          + dot(xx + 0.4, top + sh / 2 + 0.3, 0.3, P.tomato);
      }
    }
    top += sh;
  }
  if (extra === 'shed') { // a shed with its felt roof in tomato, and a water butt
    const sx = rand() < 0.5 ? x + 0.6 : x + w - 5.1;
    out += rect(sx, y + 0.6, 4.5, 3.4, P.tomato) + line(sx, y + 2.3, sx + 4.5, y + 2.3, SOIL, 0.3)
      + dot(sx + (sx > x + 1 ? -1.3 : 5.8), y + 1.6, 0.9, P.ink);
  } else if (extra === 'glass') { // a greenhouse: glass over the whole end of the plot
    out += rect(x + 0.8, y + h - 7.2, w - 1.6, 6.4, mix(P.paper, P.tint, 0.6))
      + [0.25, 0.5, 0.75].map((k) => line(x + 0.8 + k * (w - 1.6), y + h - 7.2,
        x + 0.8 + k * (w - 1.6), y + h - 0.8, P.rule, 0.3)).join('');
  }
  return out;
}

function planSvg(face) { // 114 × 56.5 mm: the top of the Wren Lane site, north up
  const rand = mulberry32(1921); // the year the society took the lease
  const [W, H, PATH] = [114, 56.5, 26.5];
  const label = (lx, ly, s, fill, anchor = 'start') => `<text x="${lx}" y="${ly}" `
    + `font-size="2.3" fill="${fill}" text-anchor="${anchor}">${s}</text>`;
  let out = rect(0, 0, W, H, GRASS) + rect(0, PATH, W, 4.5, P.straw); // the main path, gravel
  out += label(W - 2, PATH + 3, t({ en: 'MAIN PATH', es: 'CAMINO' }), SOIL, 'end');
  // The top gate at the end of the path, and the corner behind it.
  out += rect(0, PATH - 0.8, 1.2, 1.2, P.ink) + rect(0, PATH + 4.1, 1.2, 1.2, P.ink)
    + label(2.2, PATH + 3, t({ en: 'TOP GATE', es: 'PUERTA DE ARRIBA' }), SOIL);
  out += rect(2, 2.4, 11, 8, P.tomato) + rect(2, 6.4, 11, 4, mix(P.tomato, P.ink, 0.22)) // the hut
    + label(7.5, 7.2, t({ en: 'HUT', es: 'CASETA' }), P.paper, 'middle');
  out += rect(15.5, 3.2, 11.5, 3.6, mix(P.rule, P.ink, 0.3)) // the trough
    + rect(16.1, 3.8, 10.3, 2.4, mix(P.tint, P.rule, 0.4));
  for (let i = 0; i < 3; i++) { // the three bays in fresh boards: full, half full, empty
    const bx = 2 + i * 8.6;
    out += rect(bx, 13.5, 7.8, 7.8, P.marigold) + rect(bx + 0.8, 14.3, 6.2, 6.2, SOIL);
    for (let k = 0; k < 16 - i * 7; k++) {
      out += dot(bx + 1.5 + rand() * 4.8, 15 + rand() * 4.8, 0.5, i === 0 ? DEEP : EARTH);
    }
    out += label(bx + 3.9, 24.6, i + 1, P.ink, 'middle');
  }
  out += dot(25.5, 10.5, 3.6, DEEP); // the old pear
  // Plots: four above the path, five below; one split in halves, grass paths between.
  const row = (x0, x1, y, h, n, extras) => {
    const w = (x1 - x0 - (n - 1) * 1.4) / n;
    for (let i = 0; i < n; i++) {
      const px = x0 + i * (w + 1.4);
      if (extras[i] === 'halves') {
        const half = (h - 1.4) / 2;
        out += plot(px, y, w, half, rand) + plot(px, y + half + 1.4, w, half, rand, 'shed');
      } else out += plot(px, y, w, h, rand, extras[i]);
    }
  };
  row(31, W - 2, 2, PATH - 3.5, 4, ['shed', 'halves', '', 'shed']);
  row(2, W - 2, PATH + 6.5, H - PATH - 8.5, 5, ['', 'shed', 'glass', '', 'shed']);
  return svgOf(W, H, out, face);
}
// An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so the
// chart 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:L;src:url(data:font/woff2;base64,${btoa(bin)}) `
    + `format('woff2')}text{font-family:L;font-weight:700}</style>`;
}
const HEAT_LOG = [12, 24, 38, 49, 57, 63, 64, 62, 59, 55, 51, 47, 56, 61, 62, 60, 57, 54, 51, 48,
  46, 44]; // °C at the heap's centre, 7 to 28 March; turned after the reading on the 18th
function heatSvg(face) { // 68 × 40 mm; the plot runs 7–28 March and 0–70 °C
  const [W, H, X0, X1, Y0, Y1] = [68, 40, 9, 66, 3, 33];
  const x = (day) => X0 + ((day - 7) / 21) * (X1 - X0);
  const y = (deg) => Y1 - (deg / 70) * (Y1 - Y0);
  const text = (tx, ty, s, fill, anchor = 'end', size = 2.5) => `<text x="${f(tx)}" y="${f(ty)}" `
    + `font-size="${size}" fill="${fill}" text-anchor="${anchor}">${s}</text>`;
  let out = rect(X0, y(65), X1 - X0, y(55) - y(65), P.straw); // where weed seeds die
  for (const deg of [20, 40, 60]) {
    out += line(X0, y(deg), X1, y(deg), P.rule, 0.2) + text(X0 - 1.2, y(deg) + 0.9, deg, P.muted);
  }
  out += text(X0 - 1.2, y(70) + 0.9, '°C', P.muted); // the unit, over the scale
  for (const day of [7, 14, 21, 28]) {
    out += line(x(day), Y1, x(day), Y1 + 1, P.ink, 0.25)
      + text(x(day), Y1 + 3.8, day === 28 ? t({ en: '28 March', es: '28 marzo' }) : day, P.muted,
        day === 28 ? 'end' : 'middle');
  }
  out += line(x(18.5), y(70), x(18.5), Y1, P.leaf, 0.35)
    + text(x(18.5) + 1, y(70) + 2.2, t({ en: 'turned', es: 'volteo' }), P.leaf, 'start');
  const pts = HEAT_LOG.map((deg, i) => [x(7 + i), y(deg)]);
  out += `<path d="M${pts.map(([px, py]) => `${f(px)} ${f(py)}`).join('L')}" fill="none" `
    + `stroke="${P.tomato}" stroke-width="0.55" stroke-linejoin="round"/>`;
  out += pts.map(([px, py]) => dot(px, py, 0.6, P.tomato)).join('');
  out += line(X0, Y1, X1, Y1, P.ink, 0.3);
  return svgOf(W, H, out, face);
}

// The square-metre bed: sixteen squares of 30 cm, one to sixteen plants in each.
const CROPS = { // plants per square, size and colour of each plant seen from above
  cabbage: [1, 4.2, mix(P.leaf, P.rule, 0.35)], lettuce: [4, 2.1, mix(P.leaf, P.straw, 0.35)],
  chard: [4, 1.9, mix(P.leaf, P.tomato, 0.3)], marigold: [4, 1.5, P.marigold],
  beetroot: [9, 1.1, mix(P.tomato, P.ink, 0.35)], beans: [9, 1.2, P.leaf],
  onion: [9, 0.8, mix(P.straw, P.paper, 0.3)], radish: [16, 0.6, P.tomato],
  carrot: [16, 0.55, mix(P.marigold, P.tomato, 0.35)],
};
const BED = [['cabbage', 'lettuce', 'lettuce', 'marigold'],
  ['beetroot', 'radish', 'carrot', 'onion'], ['chard', 'beans', 'beetroot', 'radish'],
  ['marigold', 'onion', 'carrot', 'lettuce']];
function bedSvg() { // 50 × 50 mm: 1.2 m of bed inside its scaffold boards
  const [S, BOARD] = [50, 2.6];
  const cell = (S - 2 * BOARD) / 4;
  let out = rect(0, 0, S, S, P.marigold) + rect(BOARD, BOARD, S - 2 * BOARD, S - 2 * BOARD, SOIL);
  BED.forEach((cropRow, r) => cropRow.forEach((crop, c) => {
    const [n, size, fill] = CROPS[crop];
    const k = Math.sqrt(n);
    for (let i = 0; i < n; i++) {
      const cx = BOARD + c * cell + ((i % k) + 0.5) * (cell / k);
      const cy = BOARD + r * cell + (Math.floor(i / k) + 0.5) * (cell / k);
      out += dot(cx, cy, size, fill);
      if (crop === 'marigold') out += dot(cx, cy, size * 0.4, P.tomato);
    }
  }));
  for (let i = 1; i < 4; i++) { // the strings
    const at = BOARD + i * cell;
    out += line(at, BOARD, at, S - BOARD, P.paper, 0.25)
      + line(BOARD, at, S - BOARD, at, P.paper, 0.25);
  }
  return svgOf(S, S, out);
}
// A drill of seedlings along the foot of the masthead band, 210 × 8 mm: some show only their
// seed leaves, the rest their first true leaves too.
function leaf(x, y, len, wid, deg, fill) { // a leaf from its stalk end, deg from the vertical
  const a = (deg * Math.PI) / 180;
  const [dx, dy] = [Math.sin(a), -Math.cos(a)];
  const [mx, my, px, py] = [x + (dx * len) / 2, y + (dy * len) / 2, -dy * wid, dx * wid];
  return `<path d="M${f(x)} ${f(y)}Q${f(mx + px)} ${f(my + py)} ${f(x + dx * len)} `
    + `${f(y + dy * len)}Q${f(mx - px)} ${f(my - py)} ${f(x)} ${f(y)}Z" fill="${fill}"/>`;
}
function drillSvg() {
  const rand = mulberry32(47); // the issue number
  const [W, H, RIDGE] = [210, 8, 1.3];
  const SEED = mix(P.leaf, P.straw, 0.3);
  let out = rect(0, H - RIDGE, W, RIDGE, SOIL);
  for (let x = 2.6; x < W - 1; x += 4.6 + rand() * 1.2) {
    const ground = H - RIDGE;
    const grown = rand() < 0.62;
    const stem = grown ? 2.6 + rand() * 1.2 : 1.2 + rand() * 0.8;
    const lean = (rand() - 0.5) * 10;
    out += line(x, ground, x, ground - stem, P.leaf, 0.45);
    const top = ground - stem;
    if (grown) {
      out += leaf(x, ground - stem * 0.45, 1.6, 0.55, -68 + lean, SEED)
        + leaf(x, ground - stem * 0.45, 1.6, 0.55, 68 + lean, SEED)
        + leaf(x, top, 2.6 + rand() * 0.5, 1, -32 + lean, P.leaf)
        + leaf(x, top, 2.6 + rand() * 0.5, 1, 32 + lean, P.leaf);
    } else {
      out += leaf(x, top, 1.7, 0.6, -58 + lean, SEED) + leaf(x, top, 1.7, 0.6, 58 + lean, SEED);
    }
  }
  return svgOf(W, H, out);
}

// What the drawings say under them, in the sample's language: caption, credit note, alt text.
const CAPTIONS = {
  plan: [t({
    en: '**The top of the Wren Lane site, from the air.** The three new bays stand between '
      + 'the hut and the top gate.',
    es: '**La parte alta del Soto, desde el aire.** Los tres cajones nuevos están entre la '
      + 'caseta y la puerta de arriba.' }),
  t({ en: 'Drawing: The Gazette, from the society’s site plan',
    es: 'Dibujo: La Gaceta, a partir del plano de la asociación' }),
  t({ en: 'Plan of allotment plots seen from above, with three compost bays by the gate.',
    es: 'Plano de parcelas de huerto vistas desde arriba, con tres cajones de compost junto a la '
      + 'puerta.' })],
  heat: [t({
    en: '**Bay one in March.** Temperature at the centre of the heap, read at nine each '
      + 'morning. In the shaded band, 55 to 65 °C, most weed seeds die.',
    es: '**El cajón uno en marzo.** Temperatura en el centro, a las nueve. En la franja, de '
      + '55 a 65 °C, mueren casi todas las semillas de malas hierbas.' }),
  t({ en: 'Readings: the compost rota', es: 'Lecturas: el turno del compost' }),
  t({ en: 'Line chart: the heap rises from 12 °C to 64 °C in six days, cools to 47 °C, and '
      + 'climbs back to 62 °C after it is turned on 18 March.',
    es: 'Gráfico de líneas: el montón sube de 12 °C a 64 °C en seis días, baja a 47 °C y vuelve '
      + 'a 62 °C después del volteo del 18 de marzo.' })],
  bed: [t({
    en: '**What goes where.** One cabbage to a square; four lettuces, chard or marigolds; '
      + 'nine beetroot, beans or onions; sixteen radishes or carrots.',
    es: '**Cómo se planta el bancal.** Una col por cuadro; cuatro lechugas, acelgas o '
      + 'caléndulas; nueve remolachas, judías o cebollas; dieciséis rábanos o zanahorias.' }),
  t({ en: 'Drawing: The Gazette', es: 'Dibujo: La Gaceta' }),
  t({ en: 'A square bed divided by strings into sixteen squares, each planted with one to '
      + 'sixteen plants.', es: 'Un bancal cuadrado dividido con cuerdas en dieciséis cuadros, '
      + 'cada uno con entre una y dieciséis plantas.' })],
};
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Text, display and label faces, loaded before the build (gotcha: fonts-first).
const FONTS = {
  'Work Sans': ['400', '700'], 'Titan One': ['400'], 'Courier Prime': ['400', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const face = await inlineFace(LABEL, 700); // the label face, for the drawings' own lettering
await Promise.all([loadSvg('plan.svg', planSvg(face)), loadSvg('heat.svg', heatSvg(face)),
  loadSvg('bed.svg', bedSvg()), loadSvg('drill.svg', drillSvg())]);
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources: resources() }, config()), markdown);
showPages(doc, { title: t({ en: 'Community newsletter', es: 'Boletín vecinal' }) });

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

## Variações

### Ponha as notas à esquerda

A coluna larga continua sendo lida primeiro, então o mesmo Markdown põe a matéria à direita e as notas à esquerda.

```diff
   layoutType: 'oneAndHalf',
+  sideColumnSide: 'left',
```

### Distribua as notas na largura da página

O texto corrido tem no máximo duas colunas. A [primeira página de jornal](https://postext.dev/pt/cookbook/newspaper-front-page.md) espalha quatro notas na largura do pé da página num boxe com `:::columns{count=4}`.

## Erros comuns

- **Um parágrafo mantém a medida da coluna em que começa.** No postext 1.4.1, um parágrafo é quebrado em linhas uma vez só, com a largura da coluna em que começa. Numa página de coluna e meia com texto nas duas colunas, um parágrafo que passa da coluna larga para a estreita mantém as linhas longas, que são cortadas na borda da coluna estreita; um que passa da coluna estreita para a coluna larga da página seguinte mantém as linhas curtas. Termine cada coluna com um parágrafo inteiro: ajuste o texto e ponha um :::columnbreak depois do último parágrafo, para que o bloco seguinte abra a próxima coluna.
- **:::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.
- **Coloque entre aspas cada valor do frontmatter.** O YAML lê title: 1984 como número e uma data como objeto Date, e valores que não são strings saem vazios nos placeholders e deixam o PDF sem título. Coloque cada valor entre aspas: title: "1984".
- **Texto alinhado à esquerda nunca é hifenizado.** A hifenização só se aplica ao texto justificado; o texto alinhado à esquerda quebra entre palavras, então uma coluna estreita em bandeira fica muito irregular. Justifique o trecho ou aumente a medida.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.

- Neste layout, o fio entre colunas da página 1 começa no alto da mancha, atrás do cabeçalho, não importa quantas linhas da grade o cabeçalho reserve. A faixa cor de palha é pintada por cima; um cabeçalho sem faixa preenchida mostraria o fio atravessando o nome.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imagens: The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- Tipos: Work Sans (OFL-1.1), Titan One (OFL-1.1), Courier Prime (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 001 · Livro didático com coluna na margem](https://postext.dev/pt/cookbook/textbook-margin-column.md): Página em coluna e meia cuja coluna externa só recebe flutuantes: figuras e glosas com span 'side' se empilham ali, e captionSide leva as demais legendas. · Nível 3 (Avançado) · Livros didáticos
- [Nº 051 · Zine riso em duas cores: a arte numa cor especial](https://postext.dev/pt/cookbook/riso-zine-single-ink.md): Um zine de quatro páginas para risografia de dois tambores: os desenhos coloridos saem em tons de rosa, recoloridos pelo pen no canvas e por renderToPdf no PDF. · Nível 3 (Avançado) · Revistas e fanzines
- [Nº 009 · Figuras que flutuam até onde você as cita](https://postext.dev/pt/cookbook/figures-float-where-cited.md): Capítulo em duas colunas com sete figuras numeradas: seis flutuam do primeiro :ref até o primeiro espaço que a posição permite; uma fica onde ::resource a põe. · Nível 3 (Avançado) · Livros didáticos
