Skip to main content
Recipe number 62

Cookbook · Chapter 2 · Type & text

Shaped verse: Herbert’s Easter Wings, centred line by line

George Herbert’s Easter Wings and The Altar set as centred body text, a paragraph to each line of verse, so the lengths of the lines draw the two figures.

On this page
Genre
Poetry
Output
Canvas
Level
Basic
Postext
Tested with Postext 1.4.1
Needs ≥ 1.4.1
Licence
Updated 26 Sept 2026
Code MIT · Text CC BY 4.0

pp. 2–3 of 4

  • Trim 148 × 210 mm
  • 1 column
  • IM Fell English 12.4/17
  • IM Fell English SC
  • IM Fell Great Primer
  • 4 pages
  • Level
  • Postext 1.4.1
  • Laid out in 4 ms
  • 102 lines of code

What you'll build

A keepsake of four A5 pages, one A4 sheet folded once, holding two shaped poems from George Herbert’s The Temple in the spelling of the 1633 first edition. The title page follows the 1633 one, down to its Psalm 29 epigraph. Inside, The Altar faces Easter Wings. No line of either poem is indented or padded with spaces; each is centred, so the altar stands on a narrow pillar and each stanza of the wings narrows to two words and widens again. On the last page, under Herbert’s motto, a note tells how the 1633 printers set the wings sideways, and the colophon gets shorter line by line. Everything is set as body text or as one of three heading levels in the IM Fell types, with no named styles and no page designs. The Spanish edition sets a line-for-line version that keeps both shapes.

This recipe answers

  • How do I add extra vertical space between two blocks, when blank lines do nothing?
  • How do I set poetry: one line per verse, stanza gaps, hanging indents for wrapped lines, no hyphenation?
  • How do I set an epigraph, a dedication, a signature, or a pull quote with a big quote mark?
  • How do I force a page or column break, and start every chapter on a right-hand page?

The short answer

script.js · lines 26–38in full code
// 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
};

Centred text, one line of verse to a paragraph

Ingredients

Type
IM Fell English, IM Fell Great Primer, IM Fell English SC (SIL OFL 1.1)
Assets
None: every picture is drawn in code

Method

#1 · A line of verse is a centred paragraph

The code is the short answer above. Postext joins the lines of a Markdown paragraph into one, so every line of verse is a paragraph, with a blank line after it. textAlign: 'center' sets each line at its natural width on the axis of the measure, and since 1.4.1 hyphenates justified text only, no word is broken (body text). Each line is the first of its paragraph, so the default first-line indent of 1.5 em would push it 3.3 mm right of the titles’ axis; firstLineIndent: pt(0) keeps poems and titles on one axis.

#2 · The measure takes the longest line

script.js · lines 42–47in full code
// 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

A line that does not fit turns over, and both halves are centred, so the outline gets a step that is not in Herbert’s figure. With a 75 mm measure, three lines of The Altar and two of Easter Wings turn over, and the keepsake runs to six pages. The widest line here, the first of Easter Wings, is 80.7 mm in English and 80.0 mm in Spanish, so the 110 mm measure of the A5 page takes every line whole.

#3 · Plain headings, centred

script.js · lines 51–72in full code
// 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) },
  ],
};

No level has a design, only type settings, and headings.textAlign centres all three (headings). Parity 'any' opens each poem on the next page, recto or verso, so the altar on page 2 faces the wings on page 3. A marginTop cannot lower those titles, because 1.4.1 drops the space above a heading that opens a page. The line box of level 2, three lines deep, lowers each title about 5 mm instead, and both poems start on line 5 of the text block. Level 1 opens the document and needs no break here; its break is restated so that a second # title would still open on a recto. Level 3 sets the four labels of pages 1 and 4 in the small capitals of IM Fell English SC, at the body size.

#4 · The Markdown spaces and breaks the pages

script.js · lines 76–94in full code
// 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
});

A blank line between paragraphs adds no space, so every gap on these pages comes from :::space: one line between the stanzas of Easter Wings, and on the title page five lines above the Psalm label and ten under the epigraph (:::space). With those counts the imprint on page 1 ends on the last line of the text block, and the labels over the Psalm and over the note both stand on line 13, 94 mm from the top of the page. :::pagebreak opens the last page (:::pagebreak). The colophon is also set a paragraph to each line, broken where a phrase ends, so its four lines taper to a tailpiece.

The whole recipe

// ═══ 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`---
Markdown sample · 129 lines · content.en.mdtitle: "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 · core, fonts, viewer: the same in every recipe · 235 lines// ─── 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 ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗

Variations

#Open each poem on a right-hand page

With parity 'odd' The Altar and Easter Wings open on pages 3 and 5, both rectos, and the keepsake runs to six pages with pages 2 and 4 blank.

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

#Set the verse flush left

With textAlign: 'left' every line starts at the margin and the two poems keep only their right-hand outline; the rest of the body text, from the subtitle to the colophon, goes flush left too, and only the headings stay centred.

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

Pitfalls

Pitfall

Any headings object switches off the H1 page break

By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →

Pitfall

Ragged text is never checked for runts

optimalLineBreaking, avoidRunts, runtPenalty and runtMinCharacters act on the Knuth–Plass line breaker, which postext 1.4.1 runs for justified text only. A ragged paragraph is broken line by line and can end on one short word whatever those settings say. Read the last lines of ragged text and reword a paragraph that ends on a runt. Widows, orphans and runts →

Pitfall

Load every face before layout

Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →

  • A line of verse too long for the measure turns over, and both halves are centred on their own. Measure the widest line before you choose the type size and the margins. For verse set flush left, with turnovers that hang, see Poems set line by line.
  • Leading spaces are trimmed from a paragraph, so you cannot pad a line into shape with spaces. The width of each line comes from its words alone, and a translation has to find lines about as wide as the original’s, as the Spanish edition does.
  • Centring keeps each line at its natural width, so the slab and the base of The Altar do not come out square: the third and fourth lines of the slab are narrower than the first two. Justifying cannot square them either, because 1.4.1 leaves the last line of a justified paragraph at its natural width and each of these lines is a paragraph of its own.
  • The title page and the last page are filled to the last line of the grid. One more line on the title page, from a longer epigraph, a line that turns over or a larger :::space, moves the label at its foot and the two lines under it onto a page of their own; one more line on the last page moves the colophon’s last line alone onto a fifth page. To add a line to either page, take one out of a :::space above it.
  • Postext does not set text sideways, as the 1633 printers set Easter Wings: only whole figures and tables rotate. Here the stanzas stand upright, and the note on page 4 says so.

Credits

Text
  • “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 · public domain
  • Psalm 29:9 in the Spanish edition, from the Reina-Valera Bible of 1909 · Reina-Valera 1909 · public domain
  • 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
Fonts
IM Fell English (SIL OFL 1.1) · IM Fell Great Primer (SIL OFL 1.1) · IM Fell English SC (SIL OFL 1.1)