# Guia de ancoragem: um cartaz de elementos encadeados

> Um cartaz A3 cujos elementos se penduram na sangria, na página, no seu encaixe ou uns nos outros, e uma segunda folha que emoldura cada elemento e rotula treze.

- Versão HTML: https://postext.dev/pt/cookbook/anchoring-cheat-sheet
- Receita Nº 029 · Títulos e aberturas · Nível 2 (Intermediário) · Saídas: Canvas
- Gêneros: Folhas avulsas e impressos efêmeros
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/anchoring-cheat-sheet/en/p01.webp?v=ac6b838b), [2](https://postext.dev/cookbook/anchoring-cheat-sheet/en/p02.webp?v=ac6b838b)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=anchoring-cheat-sheet&lang=en (.postext: https://postext.dev/cookbook/anchoring-cheat-sheet/en/anchoring-cheat-sheet.postext)
- Última atualização: 2026-09-26
- Outros idiomas: [en](https://postext.dev/en/cookbook/anchoring-cheat-sheet.md), [es](https://postext.dev/es/cookbook/anchoring-cheat-sheet.md), [ca](https://postext.dev/ca/cookbook/anchoring-cheat-sheet.md), [zh](https://postext.dev/zh/cookbook/anchoring-cheat-sheet.md), [ja](https://postext.dev/ja/cookbook/anchoring-cheat-sheet.md), [ar](https://postext.dev/ar/cookbook/anchoring-cheat-sheet.md)

## Em poucas palavras

Um cartaz de palestras de astronomia antes de um eclipse do Sol. Mostra como posicionar cada item medindo a partir da borda da página ou de outro item.

## O que você vai compor

Um cartaz de um clube de astronomia fictício para quatro palestras às quintas-feiras antes do eclipse total do Sol de 2 de agosto de 2027. Um céu azul-ultramar ocupa os dois terços superiores de uma folha A3. À direita, uma Lua preta cobre um Sol amarelo-limão cortado pelo refile, e uma coroa desenhada em código se espalha atrás dos dois. O título, *The Shadow Returns* em Funnel Display de 120 pt, se apoia na linha fina, no pé do céu. Abaixo do horizonte, quatro datas em azul encabeçam as palestras, e a letra miúda corre em duas colunas sobre uma faixa azul. Cada elemento se pendura na sangria, na página, no seu encaixe ou em outro elemento, e os deslocamentos contam a partir dali. A segunda folha repete o cartaz com uma moldura rosa em volta da caixa de cada elemento e dos contêineres do cabeçalho e do rodapé, e uma etiqueta em treze elementos que imprime o posicionamento de cada um.

**Esta receita responde a:**

- Como posiciono elementos de design em relação à página, à sangria ou uns aos outros, sem coordenadas?
- Como acrescento uma marca-d'água, um fundo de cor ou uma imagem decorativa em todas as páginas?

## A resposta curta

```js
// script.js, linhas 57–73
const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge
const AIR = 14; // mm between the standfirst and the horizon
const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order
  // 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box.
  { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed
    placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) },
  ...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun
  // The headline stands on the horizon and grows upwards: 'above' puts an element's
  // bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker.
  text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR),
    size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm
  text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\
  text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)),
  ...programme(), // four columns chained off the horizon
] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() }
// {titleText} is the heading's text; each {attr.*} is written on its line in content.md.
```

## Ingredientes

**Ensina**

- [Ancoragem de elementos de design](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos): Posiciona os elementos em relação ao contêiner, à página, à sangria ou a outro elemento (right-of, below, align-*) em vez de usar coordenadas.

**Também usa**

- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Faixas sangradas e guias laterais](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Cabeços por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Quebras de linha nos títulos](https://postext.dev/pt/docs/document-format.md#quebras-de-linha-em-títulos)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Sangria e marcas de corte](https://postext.dev/pt/docs/configuration.md#marcas-de-corte)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`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)

**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**

- Funnel Display (OFL-1.1), Funnel Sans (OFL-1.1), Martian Mono (OFL-1.1)

## Preparo

### 1 · Prenda dois elementos à folha e pendure o resto neles

O código é [a resposta curta](https://postext.dev/pt/cookbook/anchoring-cheat-sheet.md#a-resposta-curta) logo acima. Nele, `anchor.to` diz de onde um elemento se pendura: `'bleed'` e `'page'` são molduras da folha (a mesma caixa enquanto `page.cutLines` estiver desligado), `'container'` é a caixa do próprio encaixe e `'#id'` é outro elemento ([posicionamento de elementos](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos)). Uma caixa sem largura vai até a borda oposta da sua moldura, então o céu ocupa a sangria de um lado ao outro. Um elemento posicionado em relação ao céu mede a partir da borda esquerda do céu, que é a da sangria; por isso a linha fina usa `INSET`, 18 mm, como `x`. O título é montado de baixo para cima a partir da borda inferior do céu. `'align-bottom'` coloca o pé da caixa da linha fina `AIR`, 14 mm, acima dessa borda, e `'above'` apoia o pé de cada elemento no topo do que está embaixo, de modo que um título com mais linhas levanta o antetítulo em vez de cobrir a linha fina. Abaixo do design, a letra miúda começa na primeira linha da grade de 16 pt que fique pelo menos 3,2 mm abaixo do elemento mais baixo, a linha dos palestrantes; esses 3,2 mm são a margem inferior padrão do título.

### 2 · Pendure a Lua e a coroa no Sol

```js
// script.js, linhas 77–88
const SUN = 150; // mm across; the Moon is drawn the same size
const CORONA = 300; // mm, the square picture of the corona
const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side
const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left
const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement,
  size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } });
const eclipse = () => [
  { kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind
    placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) },
  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
  disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)),
];
```

O Sol é o único elemento preso à página, a caixa do refile. Medido a partir do canto `'top-right'`, um `x` positivo passa da borda direita, então 30 mm do disco ficam cortados. A Lua e a coroa se penduram no Sol com `'align-top'`, de modo que mover o Sol move os dois. Os deslocamentos contam a partir do canto superior esquerdo do Sol: a Lua fica 2 mm à direita e 2,5 mm abaixo, o que deixa uma lasca de Sol no alto à esquerda, e a coroa começa `HALO`, 75 mm, acima e à esquerda, o que centraliza o seu quadrado de 300 mm sobre o disco de 150 mm. A coroa é um elemento de imagem que assume o tamanho da sua caixa ([elementos de imagem](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)) e vem antes dos discos na lista, por isso é pintada atrás deles.

### 3 · Encadeie as noites em colunas

```js
// script.js, linhas 92–101
const GUT = 7; // mm between the evenings, and between the two columns of text under them
const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm
const DROP = 12; // mm from the horizon to the dates
const programme = () => [1, 2, 3, 4].flatMap((n) => [
  text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1
    ? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon
    : at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before
  text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))),
  text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))),
]);
```

Cada data, palestra e linha de palestrante tem um `size.width` fixo de `COL`, e `'right-of'` mais `GUT` coloca a data seguinte uma coluna adiante. Sem largura, uma data se ajusta aos seus algarismos (o “8” tem 17 mm de largura), então o “15” começaria 7 mm depois dele e cada palestra correria numa só linha até invadir a coluna seguinte. `COL` divide a largura do texto em quatro com a mesma medianiz de 7 mm das duas colunas de letra miúda abaixo, o que põe a segunda coluna de letra miúda sob o 22. Cada palestra se pendura `'below'` da sua data e cada linha de palestrante abaixo da sua palestra, então uma palestra que quebrasse numa terceira linha empurraria para baixo o seu próprio palestrante e nenhum outro.

### 4 · Ponha a faixa no rodapé, em todas as páginas

```js
// script.js, linhas 105–116
const MARK = 10; // mm, the club's mark
const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin
const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it
const footer = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled
    placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) },
  // The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down.
  { kind: 'image', id: 'mark', resourceId: 'mark',
    placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) },
  text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)),
  text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)),
] };
```

Tudo o que precisa sair em todas as páginas, como esta faixa, vai no encaixe do cabeçalho ou do rodapé, e um estilo de título com cabeçalho ou rodapé próprio substitui esse encaixe nas páginas da sua seção. O rodapé do estilo das guias repete estes quatro elementos com as suas molduras e etiquetas, e é por isso que a faixa e o emblema saem nas duas folhas. O contêiner do rodapé é a margem inferior, do texto até o refile: `'bottom-left'` conta para cima a partir do refile e `'top-right'` para baixo a partir do texto, e o −10 do emblema e o +10 da linha “Free entry” caem na mesma faixa de 10 mm no meio da faixa azul ([cabeçalhos e rodapés](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)). O rodapé é pintado depois do texto e não tira espaço dele, então `FOOT` define ao mesmo tempo a margem inferior e a altura da faixa, e a faixa nunca encosta nas linhas de cima.

### 5 · Desenhe as linhas de construção a partir da mesma configuração

```js
// script.js, linhas 120–147
const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) };
const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm
const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`,
  `${edge} ${to}`, // the two words of anchor: { to, edge }
  ['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '),
].filter(Boolean).join(' · ');
// A border never changes an element's size (padding does), so each frame traces its box.
// An image or a rounded box gets a square frame of its size, hung from its top-left corner.
const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }]
  : el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }]
    : [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE,
      placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]);
const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el),
  face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'),
    padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }),
  at(`#${el.id}`, edge, x, y));
const guides = (elements, tags) => [...elements.flatMap(frame),
  ...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))];
const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn
  placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) });
const TAGS = { // where each tag sits against its element: [edge, x, y, text]
  sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'],
  moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'],
  day1: ['above', 0, -6], day2: ['above'], who1: ['below'],
  mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2],
  header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })],
  footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })],
};
```

`guides()` percorre os elementos do cartaz. Um texto ganha uma borda na sua caixa, e uma caixa quadrada, uma borda no seu estilo; nenhuma das duas move nada, porque só o preenchimento aumenta uma caixa. Uma imagem ou um disco redondo ganha uma caixa quadrada do seu próprio tamanho pendurada no seu canto superior esquerdo, então o canto do Sol em que a Lua e a coroa se penduram também fica desenhado. Cada etiqueta é um texto pendurado no seu elemento, escrito por `describe()` a partir do `placement` desse mesmo elemento, então uma etiqueta não tem como discordar da configuração que ela descreve. `{style="guides"}` no segundo título traz este design, com cabeçalho e rodapé próprios ([estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)). Uma etiqueta sem largura só dispõe do espaço entre a sua âncora e a borda direita do contêiner, e é por isso que a etiqueta de `#free` começa 30 mm à esquerda do seu texto. `pageIndexOffset: 1`, na `continuation` do conteúdo, transforma o cartaz numa página par, e o visualizador põe as duas folhas lado a lado.

## 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/anchoring-cheat-sheet

### script.js

```js
// ═══ Postext Cookbook · Nº 029 · Anchoring cheat sheet: a poster built from chained elements ═══
// https://postext.dev/en/cookbook/anchoring-cheat-sheet
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Funnel Display, Funnel Sans, Martian Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// An A3 poster whose elements hang from the bleed, the page, their slot or one another, never
// from coordinates; the second sheet is the same poster with each element framed and tagged.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#0b0b0c', // text, and the Moon
  sky: '#1b3bff', // the sky, the foot band and the dates
  sun: '#ffe53b', // the Sun and the kicker
  paper: '#ffffff', // type on the sky
  muted: '#5c5f66', // speakers and the colophon
  guide: '#ff2d9b', // frames and tags on the construction sheet
};
// col(id): a palette-linked colour that also carries its hex, because 1.4.1 paints design
// elements from the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'sky (defaults)', value: { hex: palette.sky, model: 'hex' } },
];

const A3 = { width: 297, height: 420 }; // mm, the trim
const MARGIN = 18; // mm at the top and sides: the body's edges, and the header container's
const FOOT = 30; // mm, the bottom margin: the footer container and its band
const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below
// at(): a placement. Offsets are distances from the anchor point, never page coordinates.
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const wide = (w) => ({ width: mm(w) }); // fixed, so a text is as wide as its column
const square = (d) => ({ width: mm(d), height: mm(d) });
// Design text is centred and cut with '…' by default (gotcha: overflow-ellipsis-default).
const text = (id, content, face, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...face, placement });
const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size),
  fontWeight, color: col(colour), ...more });
const caps = (tracking) => ({ letterSpacing: pt(tracking), textTransform: 'uppercase' });
const FACE = { // lineHeight: a multiple, never pt() (gotcha: design-lineheight-multiple)
  kicker: face('Martian Mono', 12, 500, 'sun', caps(1.8)),
  title: face('Funnel Display', 120, 800, 'paper', { lineHeight: 0.86 }),
  deck: face('Funnel Sans', 22, 400, 'paper', { lineHeight: 1.22 }),
  day: face('Funnel Display', 84, 800, 'sky', { lineHeight: 0.9 }),
  talk: face('Funnel Sans', 19, 600, 'ink', { lineHeight: 1.14 }),
  who: face('Funnel Sans', 12.5, 400, 'muted', { lineHeight: 1.3, italic: true }),
  foot: face('Martian Mono', 9, 500, 'paper', caps(0.9)),
};

// #region answer: two elements pinned to frames of the sheet, every other one to an element
const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge
const AIR = 14; // mm between the standfirst and the horizon
const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order
  // 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box.
  { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed
    placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) },
  ...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun
  // The headline stands on the horizon and grows upwards: 'above' puts an element's
  // bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker.
  text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR),
    size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm
  text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\
  text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)),
  ...programme(), // four columns chained off the horizon
] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() }
// {titleText} is the heading's text; each {attr.*} is written on its line in content.md.
// #endregion

// #region eclipse: the Sun pinned to the trim's corner, the Moon and the corona to the Sun
const SUN = 150; // mm across; the Moon is drawn the same size
const CORONA = 300; // mm, the square picture of the corona
const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side
const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left
const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement,
  size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } });
const eclipse = () => [
  { kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind
    placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) },
  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
  disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)),
];
// #endregion

// #region programme: four evenings in fixed-width columns, chained right-of and below
const GUT = 7; // mm between the evenings, and between the two columns of text under them
const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm
const DROP = 12; // mm from the horizon to the dates
const programme = () => [1, 2, 3, 4].flatMap((n) => [
  text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1
    ? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon
    : at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before
  text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))),
  text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))),
]);
// #endregion

// #region footer: a band and the club's mark in the footer slot, which prints on every page
const MARK = 10; // mm, the club's mark
const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin
const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it
const footer = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled
    placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) },
  // The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down.
  { kind: 'image', id: 'mark', resourceId: 'mark',
    placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) },
  text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)),
  text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)),
] };
// #endregion

// #region guides: the same slots, each element framed and tagged with its own placement
const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) };
const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm
const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`,
  `${edge} ${to}`, // the two words of anchor: { to, edge }
  ['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '),
].filter(Boolean).join(' · ');
// A border never changes an element's size (padding does), so each frame traces its box.
// An image or a rounded box gets a square frame of its size, hung from its top-left corner.
const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }]
  : el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }]
    : [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE,
      placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]);
const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el),
  face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'),
    padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }),
  at(`#${el.id}`, edge, x, y));
const guides = (elements, tags) => [...elements.flatMap(frame),
  ...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))];
const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn
  placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) });
const TAGS = { // where each tag sits against its element: [edge, x, y, text]
  sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'],
  moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'],
  day1: ['above', 0, -6], day2: ['above'], who1: ['below'],
  mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2],
  header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })],
  footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })],
};
// #endregion

const LEAD = 16; // body leading in pt
const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette,
  page: { width: mm(A3.width), height: mm(A3.height), dpi: 150, // 150 dpi is for the screen
    cutLines: { enabled: BLEED > 0, bleed: mm(BLEED) },
    margins: { top: mm(MARGIN), bottom: mm(FOOT), left: mm(MARGIN), right: mm(MARGIN) } },
  layout: { layoutType: 'double', gutterWidth: mm(GUT) }, // the text columns under the evenings
  bodyText: { fontFamily: 'Funnel Sans', fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0),
    paragraphSpacing: true },
  // The hidden title is still measured, so it needs a face FONTS loads.
  headings: { fontFamily: 'Funnel Display', fontWeight: 800,
    levels: [{ level: 1, span: 'page', advancedDesign: poster(),
      breakBefore: { enabled: true, parity: 'any' } }] }, // gotcha: headings-drop-h1-break
  // {style="guides"}: the same design framed and tagged, and a header and footer of its own.
  headingStyles: [{ id: 'guides',
    advancedDesign: { enabled: true, slot: { elements: guides(poster().slot.elements, TAGS) } },
    header: { elements: guides([container('header')], TAGS) },
    footer: { elements: guides([...footer.elements, container('footer')], TAGS) } }],
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Martian Mono', fontSize: pt(8),
    lineHeight: pt(LEAD * 0.75), color: col('muted') }],
  header: { elements: [] }, // the poster has none; the guides page draws the empty container
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# The\\Shadow\\Returns {kicker="April 2027 · Thursdays at 19:30" deck="Four talks to get ready for the total eclipse of 2 August 2027, when the Moon’s shadow crosses the Strait of Gibraltar." d1="8" t1="Why the Moon fits the Sun" s1="Marta Iribarren, astronomer" d2="15" t2="Where the shadow will fall" s2="Óscar Beltrán, meteorologist" d3="22" t3="What the corona tells us" s3="Lucía Ferrándiz, physicist" d4="29" t4="Looking at the Sun without harm" s4="Tomás Rey, optometrist" club="Umbra Circle · amateur astronomers since 1987" free="Free entry · no booking"}

Every lecture begins at 19:30 and lasts about an hour, questions included. The Umbra Circle hall is at 14 Lighthouse Road; the doors open at 19:00, entry is free and there is no booking, so the 120 seats go to whoever comes first.

On 29 April every listener takes home a pair of eclipse glasses made to ISO 12312-2: wear them through each partial phase, and take them off only while the Sun is completely covered. On eclipse morning the Circle sets up filtered telescopes on the harbour wall.

# The\\Shadow\\Returns {style="guides" kicker="April 2027 · Thursdays at 19:30" deck="Four talks to get ready for the total eclipse of 2 August 2027, when the Moon’s shadow crosses the Strait of Gibraltar." d1="8" t1="Why the Moon fits the Sun" s1="Marta Iribarren, astronomer" d2="15" t2="Where the shadow will fall" s2="Óscar Beltrán, meteorologist" d3="22" t3="What the corona tells us" s3="Lucía Ferrándiz, physicist" d4="29" t4="Looking at the Sun without harm" s4="Tomás Rey, optometrist" club="Umbra Circle · amateur astronomers since 1987" free="Free entry · no booking"}

**How to read this sheet.** Each pink frame is an element’s box as laid out, and a tag gives an element’s id, the edge it is pinned by, what it is pinned to and any offset in millimetres. Above the footer only two elements hang from the sheet itself: the sky from the bleed and the Sun from the page, which is the trim. Every other one hangs from an element, so a longer title lifts the kicker, and the four evenings move with the horizon.

The frames in the top and bottom margins are the header and footer containers. The footer container runs from the text down to the trim, so its bottom anchors count up from the trim and its top anchors down from the text.

:::paragraphs{style="colophon"}
Set in Funnel Display, Funnel Sans and Martian Mono (SIL OFL) · Text and drawings: original, CC BY 4.0 · The Umbra Circle and its speakers are fictional.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the corona and the club's mark, drawn in code, and their resources
function mulberry32(seed) { // a seeded generator: the same corona on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function coronaSvg() { // a glow, then 14 streamers, 70 polar plumes and 160 fine rays
  const rand = mulberry32(20270802);
  const R = (500 * SUN) / CORONA; // the Sun's radius in the 1000-unit square
  const TILT = Math.PI / 4; // the Sun's equator: long streamers go up-left, clear of the kicker
  const TYPE = [2.2, 3.05]; // radians from the centre where the type is: rays there stop at 9 mm
  const p = (r, a) => `${(500 + r * Math.cos(a)).toFixed(1)} ${(500 + r * Math.sin(a)).toFixed(1)}`;
  const ray = (a, full, half, opacity) => { // a petal from the limb, tapered to a point
    const turn = ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
    const length = turn > TYPE[0] && turn < TYPE[1] ? Math.min(full, R * 0.12) : full;
    const bend = (rand() - 0.5) * 0.06;
    return `<path d="M${p(R * 0.97, a - half)}C${p(R + length * 0.35, a - half * 1.1)} `
      + `${p(R + length * 0.7, a + bend - half * 0.25)} ${p(R + length, a + bend)}`
      + `C${p(R + length * 0.7, a + bend + half * 0.25)} ${p(R + length * 0.35, a + half * 1.1)} `
      + `${p(R * 0.97, a + half)}Z" fill="${palette.paper}" fill-opacity="${opacity.toFixed(3)}"/>`;
  };
  const glow = Array.from({ length: 28 }, (_, i) => `<circle cx="500" cy="500" `
    + `r="${(R * (1.01 + i * 0.022)).toFixed(1)}" fill="${palette.paper}" fill-opacity="0.022"/>`)
    .reverse().join('');
  const out = [];
  for (let i = 0; i < 14; i++) { // helmet streamers, two fans across the equator
    const a = TILT + (i % 2) * Math.PI + (rand() - 0.5) * 1.1;
    out.push(ray(a, R * (0.55 + 0.45 * rand()), 0.1 + 0.14 * rand(), 0.06 + 0.06 * rand()));
  }
  for (let i = 0; i < 70; i++) { // polar plumes, shorter and thinner than the streamers
    const a = TILT + Math.PI / 2 + (i % 2) * Math.PI + (rand() - 0.5) * 1.3;
    out.push(ray(a, R * (0.18 + 0.3 * rand()), 0.008 + 0.012 * rand(), 0.1 + 0.12 * rand()));
  }
  for (let i = 0; i < 160; i++) { // fine rays all round
    const a = (i / 160) * 2 * Math.PI + (rand() - 0.5) * 0.04;
    out.push(ray(a, R * (0.12 + 0.3 * rand()), 0.006 + 0.014 * rand(), 0.05 + 0.07 * rand()));
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1500" `
    + `viewBox="0 0 1000 1000">${glow}${out.join('')}</svg>`;
}
function markSvg() { // the Umbra Circle's mark: a ring round an eclipsed Sun
  return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 100 100">`
    + `<circle cx="50" cy="50" r="45" fill="none" stroke="${palette.paper}" stroke-width="6"/>`
    + `<circle cx="50" cy="50" r="27" fill="${palette.sun}"/>`
    + `<circle cx="56" cy="45" r="27" fill="${palette.sky}"/></svg>`;
}
const svg = (id, size, altText) => ({ id, typeId: 'figure', kind: 'svg', altText,
  svg: { fileId: `${id}.svg`, width: size, height: size }, createdAt: 0, updatedAt: 0 });
// Image elements draw resources. Nothing cites them, so neither is placed or numbered as a figure.
const resources = [
  svg('corona', 1500, t({ en: 'The solar corona round the eclipsed Sun',
    es: 'La corona solar alrededor del Sol eclipsado' })),
  svg('mark', 200, t({ en: 'The Umbra Circle’s mark', es: 'El emblema del Círculo Umbra' })),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = {
  'Funnel Display': ['800'],
  'Funnel Sans': ['400', '400i', '600', '700'],
  'Martian Mono': ['400', '500'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('corona.svg', coronaSvg());
await loadSvg('mark.svg', markSvg());
await loadFonts(FONTS, markdown);
// pageIndexOffset 1 makes the poster a verso, so the viewer sets the two sheets side by side.
const content = { markdown, resources, continuation: { pageIndexOffset: 1 } };
const doc = await buildWithFonts(() => buildDocument(content, config()), markdown);
showPages(doc, { title: t({ en: 'Anchoring cheat sheet', es: 'Chuleta de anclajes' }) });

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

### Acrescente 3 mm de sangria e marcas de corte

Três milímetros de `BLEED` ligam as linhas de corte e empurram o céu e a faixa do rodapé 3 mm para dentro da sangria em que se penduram, enquanto `INSET` mantém o texto na margem; o Sol, a Lua e a coroa se penduram na página e, como a 1.4.1 não recorta os elementos de design na sangria, eles seguem pela margem branca até a borda da folha.

```diff
-const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below
+const BLEED = 3; // mm; 3 for the printer, which switches on page.cutLines below
```

### Suba o horizonte

Tirar 20 mm de `SKY` levanta a borda do céu e, com ela, a linha fina, o título, o antetítulo e os doze textos da programação, enquanto a letra miúda começa numa linha mais alta da grade; o Sol se pendura na página, então a segunda mudança o levanta os mesmos 20 mm, senão o “w” de *Shadow* encostaria na lasca de Sol.

```diff
-const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
+const SKY = 252; // mm from the trim's top edge to the horizon, the sky's lower edge
-  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
+  disc('sun', 'sun', at('page', 'top-right', 30, 5)), // 30 mm of it past the right edge
```

## Erros comuns

- **Os elementos de cabeçalho e rodapé são pintados sobre o texto.** Os elementos de cabeçalho e rodapé são pintados por cima da página, e a área de texto não abre espaço para eles. Mantenha esses elementos dentro das margens, que são o que reserva o espaço deles.
- **Valores de atributo: sem { ou }; aspas simples se tiver ".** Um valor de atributo termina na chave de fechamento, por isso não pode conter { nem }. Um valor que contém aspas duplas vai entre aspas simples; o cifrão não causa problema.
- **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.
- **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.
- **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.
- **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.
- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().
- **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.
- **Aviso de diagramação: Referência de ancoragem cíclica** (`designCyclicAnchor`). Elementos de design se ancoram uns nos outros em ciclo, então nenhum deles pode ser posicionado. Solução: Ancore um elemento da cadeia no contêiner, na página ou na sangria. ([Documentação](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos))
- **Aviso de diagramação: Referência de ancoragem quebrada** (`designDanglingAnchor`). Um elemento de design se ancora num #id que nenhum elemento do slot tem. Solução: Corrija o id ou acrescente o elemento a que ele se refere. ([Documentação](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos))

- Num pen, a 1.4.1 não avisa de âncoras cíclicas nem de âncoras soltas. O painel Verificações do Sandbox as aponta no cabeçalho, no rodapé, nas páginas de parte e nos níveis de título, mas não em estilos de título como `guides`. Um elemento preso a um `#id` que não existe é posicionado em relação ao contêiner: uma borda de elemento como `'below'` volta ao canto superior esquerdo do contêiner, com o deslocamento, e uma borda de contêiner como `'top-right'`, a esse canto. Num laço, um elemento volta da mesma forma e os outros se encadeiam nele.
- `'align-left'` e `'align-top'` levam ao mesmo ponto na 1.4.1: os dois põem o canto superior esquerdo do elemento sobre o canto superior esquerdo da sua âncora.
- Uma borda é pintada centrada no contorno da caixa, então cada moldura rosa de 1 pt avança 0,5 pt para fora da caixa que contorna, embora a documentação de configuração diga que os traços são pintados por dentro.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipos: Funnel Display (OFL-1.1), Funnel Sans (OFL-1.1), Martian Mono (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 058 · Carta comercial segundo a DIN 5008](https://postext.dev/pt/cookbook/din-business-letter.md): Um orçamento alemão de duas folhas A4. O assunto é o H1, e o design dele fixa o timbre, o endereço e o bloco de informações nas posições do formulário B. · Nível 2 (Intermediário) · Folhas avulsas e impressos efêmeros
- [Nº 063 · Certificado com moldura de guilhochê](https://postext.dev/pt/cookbook/certificate-single-page.md): Certificados de conclusão de curso desenhados por um estilo de título: o guilhochê no cabeçalho, o selo e as assinaturas no rodapé, o nome na abertura. · Nível 2 (Intermediário) · Folhas avulsas e impressos efêmeros
- [Nº 023 · Capa de revista e sumário por seções](https://postext.dev/pt/cookbook/magazine-cover-and-contents.md): Uma capa com foto cujas chamadas pendem do nome da revista e um sumário gerado com uma faixa de cor por seção. Cada seção é uma parte sem página própria. · Nível 3 (Avançado) · Revistas e fanzines
