# Poemas con forma: Alas de Pascua, con cada verso centrado

> Alas de Pascua y El altar, de George Herbert, compuestos en texto centrado, un párrafo por verso, para que la longitud de los versos dibuje las dos figuras.

- Versión HTML: https://postext.dev/es/cookbook/shaped-verse
- Receta N.º 062 · Texto y tipografía · Nivel 1 (Básico) · Salidas: Canvas
- Géneros: Poesía
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/shaped-verse/es/p01.webp?v=d74e5f10), [2](https://postext.dev/cookbook/shaped-verse/es/p02.webp?v=d74e5f10), [3](https://postext.dev/cookbook/shaped-verse/es/p03.webp?v=d74e5f10), [4](https://postext.dev/cookbook/shaped-verse/es/p04.webp?v=d74e5f10)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/shaped-verse.md)

## Lo que vas a componer

Un cuadernillo de cuatro páginas A5, un pliego A4 doblado por la mitad, que reúne en versión castellana dos poemas con forma de *The Temple*, de George Herbert. La portada imita la de 1633, epígrafe del salmo 29 incluido. Dentro, *El altar* queda frente a *Alas de Pascua*. Ningún verso lleva sangría ni espacios de relleno; cada uno va centrado, de modo que el altar se apoya en un fuste estrecho y cada estrofa de las alas adelgaza hasta «junto a ti» para abrirse de nuevo. En la última página, bajo el lema de Herbert, una nota cuenta que los impresores de 1633 pusieron las alas de lado, y el colofón se acorta línea a línea. Todo se compone con el texto de cuerpo y tres niveles de título en los tipos IM Fell, sin estilos con nombre ni diseños de página.

**Esta receta responde a:**

- ¿Cómo espacio una portada y las estrofas de un poema, si las líneas en blanco del Markdown no añaden espacio?
- ¿Cómo compongo poesía: un verso por línea, espacio entre estrofas, sangría francesa en los versos que no caben y sin separación silábica?
- ¿Cómo compongo un epígrafe, una dedicatoria, una firma o una cita destacada con una comilla grande?
- ¿Cómo fuerzo un salto de página o de columna, y hago que cada capítulo empiece en página impar?

## La respuesta corta

```js
// script.js, líneas 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

**Enseña**

- [Sangrías, alineación y separación de párrafos](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo): Texto justificado o en bandera, sangría de primera línea con el primer párrafo sin sangrar tras un título, o párrafos en bloque separados por un espacio.
- [Niveles de título](https://postext.dev/es/docs/configuration.md#configuración-por-nivel): Fuente, escala de cuerpos, color, peso, márgenes, cursiva y mayúsculas para cada nivel de título, de H1 a H6.
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space): Añade líneas enteras o fraccionarias de espacio entre dos bloques, donde las líneas en blanco no añaden nada; se descarta al principio de una columna.

**También usa**

- [Saltos de página y de columna](https://postext.dev/es/docs/document-format.md#pagebreak)
- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes)
- [Tipografía del texto](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Formato de página](https://postext.dev/es/docs/configuration.md#tamaños-de-página-predefinidos)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Una o dos columnas](https://postext.dev/es/docs/configuration.md#tipos-de-disposición)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`page`](https://postext.dev/es/docs/configuration.md#página)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

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

## Elaboración

### 1 · Cada verso es un párrafo centrado

El código está en [la respuesta corta](#la-respuesta-corta), más arriba. Postext junta en un solo párrafo las líneas seguidas del Markdown, así que cada verso va en un párrafo propio, con una línea en blanco detrás. `textAlign: 'center'` centra cada verso, con su ancho natural, sobre el eje de la medida, y como la 1.4.1 solo aplica la separación silábica al texto justificado, ninguna palabra se parte ([texto de cuerpo](/es/docs/configuration#texto-de-cuerpo)). Cada verso abre su párrafo, así que la sangría de primera línea por defecto, de 1,5 em, lo desplazaría 3,3 mm a la derecha del eje de los títulos; con `firstLineIndent: pt(0)`, poemas y títulos comparten eje.

### 2 · La medida admite el verso más largo

```js
// script.js, líneas 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
```

Un verso que no cabe pasa a una segunda línea, y como las dos mitades se centran por separado, la silueta muestra un escalón que el original no tiene. Con una medida de 75 mm se parten un verso de *El altar* y otro de *Alas de Pascua*, y el cuadernillo pasa a seis páginas. El verso más ancho, el primero de las alas, mide 80,0 mm en castellano y 80,7 mm en inglés, así que en los 110 mm de medida de la página A5 caben todos enteros.

### 3 · Títulos sencillos, centrados

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

Ningún nivel lleva diseño, solo ajustes tipográficos, y `headings.textAlign` centra los tres ([encabezados](/es/docs/configuration#encabezados)). La paridad `'any'` abre cada poema en la página siguiente, sea par o impar, de modo que el altar de la página 2 queda frente a las alas de la 3. Un `marginTop` no bajaría esos títulos, porque la 1.4.1 descarta el espacio sobre un título que abre página. En su lugar, el interlineado del nivel 2, de tres líneas de la rejilla, deja cada título unos 5 mm más abajo, y los dos poemas empiezan en la línea 5 de la caja. El nivel 1 abre el documento y aquí no necesita salto, pero lo lleva igualmente para que un segundo título `#` abra en página impar. El nivel 3 compone los cuatro rótulos de las páginas 1 y 4 en las versalitas de IM Fell English SC, al cuerpo del texto.

### 4 · El Markdown espacia y corta las páginas

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

Una línea en blanco entre párrafos no añade espacio, así que cada blanco de estas páginas sale de `:::space`: una línea entre las estrofas de *Alas de Pascua* y, en la portada, cinco sobre el rótulo del salmo y diez bajo el epígrafe ([`:::space`](/es/docs/document-format#space)). Con esas cuentas, la última línea de la portada cae en la última línea de la caja, y los rótulos del salmo y de la nota quedan los dos en la línea 13, a 94 mm del borde superior. `:::pagebreak` abre la última página ([`:::pagebreak`](/es/docs/document-format#pagebreak)). El colofón también lleva un párrafo por línea; cada corte cae donde lo pide el sentido, y las cinco líneas se estrechan como un culo de lámpara.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 = 'es'; // @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: "El templo"
author: "George Herbert"
---

# El templo

*Poemas sagrados y jaculatorias privadas*

:::space

de George Herbert

:::space{lines=5}

### Salmo 29

*Y en su templo todos los suyos le dicen gloria.*

:::space{lines=10}

### Dos poemas con forma

según la primera edición, Cambridge, 1633,

en versión castellana

## El altar

Un ALTAR roto, Señor, te alza tu siervo,

hecho de un corazón y unido con lágrimas:

sus piezas son como tu mano las hizo;

no lo tocó la herramienta de un cantero.

Solo un CORAZÓN

es una piedra tal

que nada salvo

tu poder la talla.

Por eso cada parte

de mi duro pecho

se une en este marco

y alaba tu nombre,

para que, si llego a guardar silencio,

no cesen estas piedras de alabarte.

Oh, sea mío tu bendito SACRIFICIO,

y santifica este ALTAR, que sea tuyo.

## Alas de Pascua

Señor, que creaste al hombre rico y colmado,

aunque él, necio, lo perdió todo,

decayendo cada vez más

hasta volverse

pobrísimo:

junto a ti

déjame alzarme

como alondras, en armonía,

y cantar este día tus victorias:

entonces la caída impulsará en mí el vuelo.

:::space

Mi tierna edad comenzó en el dolor:

y sin tregua, con males y vergüenza,

castigaste tanto el pecado

que me volví

flaquísimo.

Junto a ti

déjame unirme,

y sentir este día tu victoria:

pues si injerto mi ala en la tuya,

la aflicción hará avanzar en mí el vuelo.

:::pagebreak

### El lema de Herbert

*Menos que la menor de las misericordias de Dios.*

:::space

Con él, escribieron sus impresores, cerraba cuanto pudiera redundar en su propia honra.

:::space{lines=7}

### Nota sobre el texto

Las versiones siguen la edición príncipe (Cambridge, 1633). Allí «The Altar» se lee derecho en la página 18, mientras que «Easter Wings» va de lado en las páginas 34 y 35, una estrofa en cada una. Aquí van derechas; si giras el cuadernillo un cuarto de vuelta, cada estrofa se abre en dos alas. Cada verso castellano mide casi lo que el original, pero pierde la rima.

:::space{lines=3}

Compuesto en IM Fell English, English SC y Great Primer,

recreaciones de Igino Marini de los tipos

que John Fell reunió en Oxford.

Texto inglés de EEBO-TCP;

salmo de la Reina-Valera.
`; // 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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. 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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      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` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs 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; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
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' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Abre cada poema en página impar

Con la paridad `'odd'`, *El altar* y *Alas de Pascua* abren en las páginas 3 y 5, las dos impares, y el cuadernillo pasa a seis páginas, con la 2 y la 4 en blanco.

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

### Compón los versos en bandera

Con `textAlign: 'left'` cada verso arranca en el margen y los dos poemas conservan solo el contorno derecho; el resto del texto de cuerpo, del subtítulo al colofón, también pasa a la izquierda, y solo los títulos siguen centrados.

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

## Errores frecuentes

- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **En el texto en bandera no se evitan las líneas cortas.** optimalLineBreaking, avoidRunts, runtPenalty y runtMinCharacters actúan sobre el algoritmo de Knuth–Plass, que postext 1.4.1 solo aplica al texto justificado. Un párrafo en bandera se corta línea a línea y puede terminar en una sola palabra corta, digan lo que digan esos ajustes. Revisa las últimas líneas del texto en bandera y reescribe el párrafo que acabe en una línea corta.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.

- Un verso más largo que la medida pasa a una segunda línea, y las dos mitades se centran por separado. Mide el verso más ancho antes de fijar el cuerpo y los márgenes. Para verso en bandera, con sangría francesa en los versos que no caben, consulta [Poemas compuestos verso a verso](https://postext.dev/es/cookbook/poetry-collection.md).
- Un párrafo pierde los espacios iniciales, así que no puedes dar forma a un verso rellenándolo de espacios. El ancho de cada verso depende solo de sus palabras, y una traducción tiene que buscar versos de un ancho parecido al del original, como hace la edición castellana.
- Al centrar, cada verso conserva su ancho natural, así que la losa y la base de *El altar* no quedan a escuadra: el tercer y el cuarto verso de la losa son más estrechos que los dos primeros. Justificar tampoco las iguala, porque la 1.4.1 deja la última línea de un párrafo justificado con su ancho natural, y cada uno de estos versos es un párrafo.
- La portada y la última página llegan hasta la última línea de la rejilla. Una línea más en la portada, por un epígrafe más largo, un texto que ya no cabe en una línea o un `:::space` mayor, lleva el rótulo del pie y las dos líneas que lo siguen a una página propia; una línea más en la última página manda sola la última línea del colofón a una quinta página. Si añades una línea a cualquiera de las dos, quita otra de un `:::space` de encima.
- Postext no compone texto de lado, como los impresores de 1633 compusieron *Easter Wings*: solo giran figuras y tablas enteras. Aquí las estrofas van derechas, y la nota de la página 4 lo dice.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: «The Altar» y «Easter wings», el título, el subtítulo, el nombre del autor y el epígrafe de la portada, y el lema de Herbert con una frase del prólogo de los impresores, de la primera edición de The Temple (Cambridge, 1633) según la transcripción de EEBO-TCP (CC0 1.0); se suplen las letras y los signos que esa transcripción da por ilegibles o desconocidos en seis finales de verso (cuatro de The Altar y dos de Easter wings): George Herbert; Text Creation Partnership ([fuente](https://github.com/textcreationpartnership/A03058)), dominio público
- Texto: El versículo 9 del salmo 29 en la edición castellana, de la Biblia Reina-Valera de 1909: Reina-Valera 1909 ([fuente](https://es.wikisource.org/wiki/Biblia_Reina-Valera_1909/Salmos/29)), dominio público
- Texto: Las versiones castellanas de los dos poemas, del subtítulo, del lema y de la frase de los impresores, y el pie de imprenta de la portada, la nota sobre el texto y el colofón de las dos ediciones, escritos para esta receta: Ignacio Ferro, CC-BY-4.0
- Tipografías: IM Fell English (OFL-1.1), IM Fell Great Primer (OFL-1.1), IM Fell English SC (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 015 · Poemas compuestos verso a verso](https://postext.dev/es/cookbook/poetry-collection.md): Cada verso es un párrafo, y el que no cabe sigue con 4 em de sangría francesa. Los espacios eme guardan las sangrías de 1918; :::space separa las estrofas. · Nivel 2 (Intermedio) · Poesía
- [N.º 043 · Traducción enfrentada, estrofa a estrofa](https://postext.dev/es/cookbook/bilingual-facing-verse.md): Cada poema es un recuadro sin marco con dos columnas: un corte fijo abre la inglesa en su título y :::space deja cada estrofa a la altura de su original. · Nivel 2 (Intermedio) · Poesía
- [N.º 044 · Edición crítica: numeración de versos y notas por verso](https://postext.dev/es/cookbook/critical-edition-line-numbers.md): El Lycidas de Milton, texto de 1645: una función cuenta los versos, pone el número cada cinco en un recuadro lateral y abre cada nota con el número del verso. · Nivel 3 (Avanzado) · Poesía
