Saltar al contenido principal
Receta número 62

Recetario · Capítulo 2 · Texto y tipografía

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.

En esta página
Género
Poesía
Salida
Canvas
Nivel
Básico
Postext
Probada con Postext 1.4.1
Requiere ≥ 1.4.1
Licencia
Actualizada el 26 sept 2026
Código MIT · Texto CC BY 4.0

pp. 2–3 de 4

  • Formato 148 × 210 mm
  • 1 columna
  • IM Fell English 12,4/17
  • IM Fell English SC
  • IM Fell Great Primer
  • 4 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 4 ms
  • 102 líneas de código

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 añado espacio vertical entre dos bloques, si las líneas en blanco no hacen nada?
  • ¿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

script.js · líneas 26–38en el código completo
// 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

Tipografía
IM Fell English, IM Fell Great Primer, IM Fell English SC (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Cada verso es un párrafo centrado

El código está en 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). 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

script.js · líneas 42–47en el código completo
// 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

script.js · líneas 51–72en el código completo
// 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). 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

script.js · líneas 76–94en el código completo
// 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). 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). 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

// ═══ 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`---
Muestra en Markdown · 131 líneas · content.es.mdtitle: "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 · core, fonts, viewer: igual en todas las recetas · 235 líneas// ─── 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 ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗

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.

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

-  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

Error frecuente

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. Capítulos que abren en página impar →

Error frecuente

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. Viudas, huérfanas y líneas cortas →

Error frecuente

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. Fuentes antes de componer →

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

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 · dominio público
  • El versículo 9 del salmo 29 en la edición castellana, de la Biblia Reina-Valera de 1909 · Reina-Valera 1909 · dominio público
  • 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
Fuentes
IM Fell English (SIL OFL 1.1) · IM Fell Great Primer (SIL OFL 1.1) · IM Fell English SC (SIL OFL 1.1)