# Poemas com forma: Easter Wings, de Herbert, verso a verso

> Easter Wings e The Altar, de George Herbert, em texto centralizado, um parágrafo por verso, para que o comprimento dos versos desenhe as duas figuras.

- Versão HTML: https://postext.dev/pt/cookbook/shaped-verse
- Receita Nº 062 · Tipo e texto · Nível 1 (Básico) · Saídas: Canvas
- Gêneros: Poesia
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/shaped-verse/en/p01.webp?v=dc0a7851), [2](https://postext.dev/cookbook/shaped-verse/en/p02.webp?v=dc0a7851), [3](https://postext.dev/cookbook/shaped-verse/en/p03.webp?v=dc0a7851), [4](https://postext.dev/cookbook/shaped-verse/en/p04.webp?v=dc0a7851)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=shaped-verse&lang=en (.postext: https://postext.dev/cookbook/shaped-verse/en/shaped-verse.postext)
- Última atualização: 2026-09-26
- Outros idiomas: [en](https://postext.dev/en/cookbook/shaped-verse.md), [es](https://postext.dev/es/cookbook/shaped-verse.md), [ca](https://postext.dev/ca/cookbook/shaped-verse.md), [zh](https://postext.dev/zh/cookbook/shaped-verse.md), [ja](https://postext.dev/ja/cookbook/shaped-verse.md), [ar](https://postext.dev/ar/cookbook/shaped-verse.md)

## Em poucas palavras

Dois poemas ingleses antigos impressos de modo que cada um pareça um desenho: um altar e um par de asas. Cada verso é apenas centralizado, e o seu comprimento desenha a forma.

## O que você vai compor

Uma plaquete de quatro páginas A5, uma folha A4 dobrada uma vez, com dois poemas visuais de *The Temple*, de George Herbert, na grafia da primeira edição, de 1633. A folha de rosto segue a de 1633, até na epígrafe do Salmo 29. Dentro, *The Altar* fica de frente para *Easter Wings*. Nenhum verso dos dois poemas é recuado ou preenchido com espaços; cada um é centralizado, de modo que o altar se apoia num fuste estreito e cada estrofe das asas se estreita até duas palavras e volta a se alargar. Na última página, sob o lema de Herbert, uma nota conta como os impressores de 1633 compuseram as asas de lado, e o colofão encurta linha a linha. Tudo é composto como texto corrido ou num de três níveis de título nos tipos IM Fell, sem estilos nomeados e sem designs de página. A edição espanhola compõe uma versão verso a verso que mantém as duas formas.

**Esta receita responde a:**

- Como espaço a folha de rosto e as estrofes de um poema, se linhas em branco no Markdown não dão espaço?
- Como componho poesia: um verso por linha, espaço entre estrofes, recuo deslocado nos versos que quebram e sem hifenização?
- Como componho uma epígrafe, uma dedicatória, uma assinatura ou um olho com aspas grandes?
- Como forço uma quebra de página ou de coluna e faço cada capítulo começar em página ímpar?

## A resposta curta

```js
// script.js, linhas 26–38
// Postext joins the lines of a Markdown paragraph, so every line of the poem is a paragraph
// of its own, with a blank line after it. Centred, each line keeps its natural width on the
// axis of the measure, and the lengths Herbert gave his lines draw the figure:
//   Lord, who createdst man in wealth and store,
//
//   Though foolishly he lost the same,
//
//   Decaying more and more,
const verse = {
  fontFamily: 'IM Fell English', fontSize: pt(12.4), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'center', // ragged on both sides; 1.4.1 hyphenates justified text only
  firstLineIndent: pt(0), // the default 1.5 em moves every line 3.3 mm off the titles' axis
};
```

## Ingredientes

**Ensina**

- [Recuos, alinhamento e espaço entre parágrafos](https://postext.dev/pt/docs/configuration.md#texto-do-corpo): Texto justificado ou em bandeira, recuo de primeira linha com o primeiro parágrafo sem recuo depois de um título, ou parágrafos em bloco separados por um espaço.
- [Níveis de título](https://postext.dev/pt/docs/configuration.md#sobrescritas-por-nível): Fonte, escala de corpos, cor, peso, margens, itálico e maiúsculas para cada nível de título, de H1 a H6.
- [Espaço vertical explícito](https://postext.dev/pt/docs/document-format.md#space): Acrescenta linhas inteiras ou fracionárias de espaço entre dois blocos, onde as linhas em branco não acrescentam nada; é descartado no topo de uma coluna.

**Também usa**

- [Quebras de página e de coluna](https://postext.dev/pt/docs/document-format.md#pagebreak)
- [Capítulos que abrem em página ímpar](https://postext.dev/pt/docs/configuration.md#quebra-antes)
- [Tipografia do texto](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)
- [Negrito, itálico e suas cores](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)
- [Formato da página](https://postext.dev/pt/docs/configuration.md#predefinições-de-tamanho-de-página)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Uma ou duas colunas](https://postext.dev/pt/docs/configuration.md#tipos-de-layout)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [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)

**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), [`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)

**API**

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

**Tipos**

- IM Fell English (OFL-1.1), IM Fell Great Primer (OFL-1.1), IM Fell English SC (OFL-1.1)

## Preparo

### 1 · Um verso é um parágrafo centralizado

O código é [a resposta curta](https://postext.dev/pt/cookbook/shaped-verse.md#a-resposta-curta) lá em cima. O Postext junta as linhas de um parágrafo do Markdown numa só, então cada verso é um parágrafo, com uma linha em branco depois. `textAlign: 'center'` compõe cada verso na sua largura natural, sobre o eixo da medida, e como a versão 1.4.1 só hifeniza texto justificado, nenhuma palavra é dividida ([texto do corpo](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)). Cada verso é a primeira linha do seu parágrafo, então o recuo de primeira linha padrão, de 1,5 em, o empurraria 3,3 mm para a direita do eixo dos títulos; `firstLineIndent: pt(0)` mantém poemas e títulos no mesmo eixo.

### 2 · A medida comporta o verso mais longo

```js
// script.js, linhas 42–47
// A line too long for the measure wraps, and both halves are centred, which adds a step to
// the outline.
// The widest line, 'Lord, who createdst man in wealth and store,', is 80.7 mm in 12.4 pt
// Fell, so the 110 mm measure takes every line of both poems whole.
const TRIM = { w: 148, h: 210 }; // mm: A5, one A4 sheet folded once
const MARGIN = { top: 22, bottom: 24, inner: 17, outer: 21 }; // mm: the measure is 110 mm
```

Um verso que não cabe passa para a linha seguinte, e as duas metades são centralizadas, então o contorno ganha um degrau que não existe na figura de Herbert. Com uma medida de 75 mm, três versos de *The Altar* e dois de *Easter Wings* quebram, e a plaquete passa a ter seis páginas. O verso mais largo aqui, o primeiro de *Easter Wings*, tem 80,7 mm em inglês e 80,0 mm em espanhol, então a medida de 110 mm da página A5 comporta todos os versos inteiros.

### 3 · Títulos simples, centralizados

```js
// script.js, linhas 51–72
// headings.textAlign centres every level. The Fell faces ship weight 400 only, so no level
// asks for bold.
const titles = {
  fontFamily: 'IM Fell Great Primer', fontWeight: 400, color: col('violet'), textAlign: 'center',
  levels: [
    // The keepsake's title opens page 1, where a break changes nothing. It is restated because
    // any headings object drops it, and a second # title would then run on
    // (gotcha: headings-drop-h1-break).
    { level: 1, fontSize: pt(40), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'odd' } },
    // A poem opens the next page, recto or verso: The Altar (page 2) faces Easter Wings.
    // 1.4.1 drops the top margin of a heading that opens a page, so the title is lowered by
    // its line box instead: three lines deep, it sets the title about 5 mm down. The default
    // half-em margin under it rounds up to a whole line, and each poem starts on line 5.
    { level: 2, fontSize: pt(24), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'any' } },
    // The small-capital labels on pages 1 and 4: the sister face at the body size, with no
    // margins, since the :::space lines place them.
    { level: 3, fontFamily: 'IM Fell English SC', fontSize: pt(12.4), lineHeight: pt(LEAD),
      marginTop: pt(0), marginBottom: pt(0) },
  ],
};
```

Nenhum nível tem design, só ajustes tipográficos, e `headings.textAlign` centraliza os três ([títulos](https://postext.dev/pt/docs/configuration.md#títulos)). A paridade `'any'` abre cada poema na página seguinte, par ou ímpar, de modo que o altar da página 2 fica de frente para as asas da página 3. Um `marginTop` não consegue baixar esses títulos, porque a versão 1.4.1 descarta o espaço acima de um título que abre página. Em vez disso, a caixa de linha do nível 2, com três linhas de altura, baixa cada título cerca de 5 mm, e os dois poemas começam na linha 5 da mancha. O nível 1 abre o documento e aqui não precisa de quebra; ela é declarada de novo para que um segundo título `#` ainda abra numa página ímpar. O nível 3 compõe os quatro rótulos das páginas 1 e 4 nos versaletes da IM Fell English SC, no corpo do texto.

### 4 · O Markdown espaça e quebra as páginas

```js
// script.js, linhas 76–94
// A blank line adds no space, so every gap on these pages is a :::space{lines=N}, which adds
// N lines of the 17 pt grid (one without the attribute). ## opens a page for each poem, and
// :::pagebreak opens the last page.
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  layout: { layoutType: 'single' }, // the default is two columns
  // 1.4.1 does not link referenceColor to main-color (gotcha: palette-skips-designs), so a
  // :ref added to these pages would print in the default blue.
  bodyText: { ...verse, referenceColor: col('ink') },
  headings: titles,
  header: { elements: [] }, // a folded keepsake of four pages: no running heads,
  footer: { elements: [] }, // and no folios
});
```

Uma linha em branco entre parágrafos não acrescenta espaço, então todo espaço destas páginas vem de `:::space`: uma linha entre as estrofes de *Easter Wings* e, na folha de rosto, cinco linhas acima do rótulo do Salmo e dez sob a epígrafe ([`:::space`](https://postext.dev/pt/docs/document-format.md#space)). Com essas contagens, o pé de imprensa da página 1 termina na última linha da mancha, e os rótulos acima do Salmo e acima da nota ficam os dois na linha 13, a 94 mm do alto da página. `:::pagebreak` abre a última página ([`:::pagebreak`](https://postext.dev/pt/docs/document-format.md#pagebreak)). O colofão também é composto com um parágrafo por linha, quebrado onde termina uma frase, de modo que as suas quatro linhas se afinam num fundo de lâmpada.

## 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/shaped-verse

### script.js

```js
// ═══ Postext Cookbook · Nº 062 · Shaped verse: Herbert's Easter Wings, centred line by line ═
// https://postext.dev/en/cookbook/shaped-verse
// Code: MIT · Text: G. Herbert, The Temple, 1633 (PD; EEBO-TCP, CC0) · Spanish version: CC BY 4.0
// Fonts: IM Fell English, Great Primer and English SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#241d29', // the text: a violet-black
  violet: '#5c2a6f', // the one accent: the titles and the small-capital labels
  paper: '#fbf7ee', // the sheet: an uncoated cream
};
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' } })),
  // Italic and bold take their colour from 'main-color' unless set. With 'main-color' in ink,
  // the italic subtitle, epigraph and motto print in ink instead of the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
const LEAD = 17; // pt: the leading of every line, verse and prose alike

// #region answer: centred text, one line of verse to a paragraph
// Postext joins the lines of a Markdown paragraph, so every line of the poem is a paragraph
// of its own, with a blank line after it. Centred, each line keeps its natural width on the
// axis of the measure, and the lengths Herbert gave his lines draw the figure:
//   Lord, who createdst man in wealth and store,
//
//   Though foolishly he lost the same,
//
//   Decaying more and more,
const verse = {
  fontFamily: 'IM Fell English', fontSize: pt(12.4), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'center', // ragged on both sides; 1.4.1 hyphenates justified text only
  firstLineIndent: pt(0), // the default 1.5 em moves every line 3.3 mm off the titles' axis
};
// #endregion

// #region measure: an A5 leaf whose measure holds the longest line whole
// A line too long for the measure wraps, and both halves are centred, which adds a step to
// the outline.
// The widest line, 'Lord, who createdst man in wealth and store,', is 80.7 mm in 12.4 pt
// Fell, so the 110 mm measure takes every line of both poems whole.
const TRIM = { w: 148, h: 210 }; // mm: A5, one A4 sheet folded once
const MARGIN = { top: 22, bottom: 24, inner: 17, outer: 21 }; // mm: the measure is 110 mm
// #endregion

// #region titles: plain headings, centred, in the display face and the accent
// headings.textAlign centres every level. The Fell faces ship weight 400 only, so no level
// asks for bold.
const titles = {
  fontFamily: 'IM Fell Great Primer', fontWeight: 400, color: col('violet'), textAlign: 'center',
  levels: [
    // The keepsake's title opens page 1, where a break changes nothing. It is restated because
    // any headings object drops it, and a second # title would then run on
    // (gotcha: headings-drop-h1-break).
    { level: 1, fontSize: pt(40), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'odd' } },
    // A poem opens the next page, recto or verso: The Altar (page 2) faces Easter Wings.
    // 1.4.1 drops the top margin of a heading that opens a page, so the title is lowered by
    // its line box instead: three lines deep, it sets the title about 5 mm down. The default
    // half-em margin under it rounds up to a whole line, and each poem starts on line 5.
    { level: 2, fontSize: pt(24), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'any' } },
    // The small-capital labels on pages 1 and 4: the sister face at the body size, with no
    // margins, since the :::space lines place them.
    { level: 3, fontFamily: 'IM Fell English SC', fontSize: pt(12.4), lineHeight: pt(LEAD),
      marginTop: pt(0), marginBottom: pt(0) },
  ],
};
// #endregion

// #region pages: four pages, spaced and broken from the Markdown
// A blank line adds no space, so every gap on these pages is a :::space{lines=N}, which adds
// N lines of the 17 pt grid (one without the attribute). ## opens a page for each poem, and
// :::pagebreak opens the last page.
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  layout: { layoutType: 'single' }, // the default is two columns
  // 1.4.1 does not link referenceColor to main-color (gotcha: palette-skips-designs), so a
  // :ref added to these pages would print in the default blue.
  bodyText: { ...verse, referenceColor: col('ink') },
  headings: titles,
  header: { elements: [] }, // a folded keepsake of four pages: no running heads,
  footer: { elements: [] }, // and no folios
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Temple"
author: "George Herbert"
---

# The Temple

*Sacred Poems and Private Ejaculations*

:::space

By Mr. George Herbert

:::space{lines=5}

### Psal. 29.

*In his Temple doth every man speak of his honour.*

:::space{lines=10}

### Two shaped poems

in the text of the first edition

Cambridge, 1633

## The Altar

A broken ALTAR, Lord, thy servant reares,

Made of a heart, and cemented with teares:

Whose parts are as thy hand did frame;

No workmans tool hath touch’d the same.

A HEART alone

Is such a stone,

As nothing but

Thy pow’r doth cut.

Wherefore each part

Of my hard heart

Meets in this frame,

To praise thy name.

That if I chance to hold my peace,

These stones to praise thee may not cease.

O let thy blessed SACRIFICE be mine,

And sanctifie this ALTAR to be thine.

## Easter Wings

Lord, who createdst man in wealth and store,

Though foolishly he lost the same,

Decaying more and more,

Till he became

Most poore:

With thee

O let me rise

As larks, harmoniously,

And sing this day thy victories:

Then shall the fall further the flight in me.

:::space

My tender age in sorrow did beginne:

And still with sicknesses and shame

Thou didst so punish sinne,

That I became

Most thinne.

With thee

Let me combine,

And feel this day thy victorie:

For, if I imp my wing on thine,

Affliction shall advance the flight in me.

:::pagebreak

### Herbert’s motto

*Lesse then the least of Gods mercies.*

:::space

With it, his printers wrote, he used to conclude “all things that might seem to tend any way to his own honour”.

:::space{lines=7}

### A note on the text

The poems keep the spelling of the first edition, printed at Cambridge in 1633. There “The Altar” stands upright on page 18, and the two stanzas of “Easter Wings” lie sideways on pages 34 and 35. Here they stand upright; give the booklet a quarter turn and each opens into a pair of wings.

:::space{lines=5}

Set in IM Fell English, English SC and Great Primer,

Igino Marini’s revivals of the types

John Fell gathered at Oxford.

Text from EEBO-TCP.
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses. Layout measures with the browser's fonts, so the
// kit loads them from Fontsource before the first build (gotcha: fonts-first).
const FONTS = {
  'IM Fell English': ['400', '400i'],
  'IM Fell Great Primer': ['400'],
  'IM Fell English SC': ['400'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: t({ en: 'Easter Wings and The Altar', es: 'Alas de Pascua y El altar' }) });

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

## Variações

### Abra cada poema numa página ímpar

Com a paridade `'odd'`, *The Altar* e *Easter Wings* abrem nas páginas 3 e 5, as duas ímpares, e a plaquete passa a ter seis páginas, com as páginas 2 e 4 em branco.

```diff
-      breakBefore: { enabled: true, parity: 'any' } },
+      breakBefore: { enabled: true, parity: 'odd' } },
```

### Componha os versos alinhados à esquerda

Com `textAlign: 'left'`, cada verso começa na margem e os dois poemas mantêm só o contorno da direita; o resto do texto corrido, do subtítulo ao colofão, também passa a ser alinhado à esquerda, e só os títulos continuam centralizados.

```diff
-  textAlign: 'center', // ragged on both sides; 1.4.1 hyphenates justified text only
+  textAlign: 'left', // ragged right; 1.4.1 hyphenates justified text only
```

## Erros comuns

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

- Um verso longo demais para a medida quebra, e as duas metades são centralizadas cada uma por si. Meça o verso mais largo antes de escolher o corpo e as margens. Para versos alinhados à esquerda, com as quebras em recuo, veja [Poemas compostos verso a verso](https://postext.dev/pt/cookbook/poetry-collection.md).
- Os espaços no início de um parágrafo são descartados, então não dá para dar forma a um verso com espaços. A largura de cada verso vem só das suas palavras, e uma tradução precisa encontrar versos mais ou menos tão largos quanto os do original, como faz a edição espanhola.
- A centralização mantém cada verso na sua largura natural, então a laje e a base de *The Altar* não saem retas: o terceiro e o quarto versos da laje são mais estreitos que os dois primeiros. Justificar também não os endireita, porque a versão 1.4.1 deixa a última linha de um parágrafo justificado na largura natural, e cada um destes versos é um parágrafo.
- A folha de rosto e a última página estão preenchidas até a última linha da grade. Uma linha a mais na folha de rosto, vinda de uma epígrafe mais longa, de um verso que quebra ou de um `:::space` maior, leva o rótulo do pé e as duas linhas embaixo dele para uma página própria; uma linha a mais na última página leva sozinha a última linha do colofão para uma quinta página. Para acrescentar uma linha a qualquer uma das duas páginas, tire uma de um `:::space` acima dela.
- O Postext não compõe texto de lado, como os impressores de 1633 compuseram *Easter Wings*: só figuras e tabelas inteiras giram. Aqui as estrofes ficam em pé, e a nota da página 4 diz isso.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: “The Altar” and “Easter wings”, the title, subtitle, byline and epigraph of the title page, and Herbert’s motto with a clause of the printers’ preface, from the first edition of The Temple (Cambridge, 1633) in the EEBO-TCP transcription (CC0 1.0); the letters and stops it marks as illegible or unknown at six line ends (four in The Altar, two in Easter wings) are supplied: George Herbert; Text Creation Partnership ([fonte](https://github.com/textcreationpartnership/A03058)), domínio público
- Texto: Psalm 29:9 in the Spanish edition, from the Reina-Valera Bible of 1909: Reina-Valera 1909 ([fonte](https://es.wikisource.org/wiki/Biblia_Reina-Valera_1909/Salmos/29)), domínio público
- Texto: The Spanish versions of both poems, of the subtitle, the motto and the printers’ clause, and the title-page imprint, the note on the text and the colophon of both editions, written for this recipe: Ignacio Ferro, CC-BY-4.0
- Tipos: IM Fell English (OFL-1.1), IM Fell Great Primer (OFL-1.1), IM Fell English SC (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 015 · Poemas compostos verso a verso](https://postext.dev/pt/cookbook/poetry-collection.md): Cada verso é um parágrafo, e o que não cabe continua com 4 em de recuo deslocado. Espaços eme guardam os recuos de 1918, e :::space separa as estrofes. · Nível 2 (Intermediário) · Poesia
- [Nº 043 · Tradução lado a lado, estrofe por estrofe](https://postext.dev/pt/cookbook/bilingual-facing-verse.md): Cada poema é um boxe sem moldura com duas colunas: uma quebra fixa abre o inglês no título, e :::space deixa cada estrofe na altura do original. · Nível 2 (Intermediário) · Poesia
- [Nº 044 · Edição crítica: versos numerados e notas por verso](https://postext.dev/pt/cookbook/critical-edition-line-numbers.md): Lycidas, de Milton, no texto de 1645: um script conta os versos, põe um boxe lateral a cada cinco e cada nota abre com o número do verso, composto como chip. · Nível 3 (Avançado) · Poesia
