# Vers figurés : Easter Wings de Herbert, centré vers à vers

> Easter Wings et The Altar de George Herbert composés en texte courant centré, un paragraphe par vers : la longueur des lignes dessine les deux figures.

- Version HTML: https://postext.dev/fr/cookbook/shaped-verse
- Recette Nº 062 · Caractères et texte · Niveau 1 (Débutant) · Sorties: Canvas
- Genres: Poésie
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/shaped-verse/en/p01.webp?v=f9f3fdde), [2](https://postext.dev/cookbook/shaped-verse/en/p02.webp?v=f9f3fdde), [3](https://postext.dev/cookbook/shaped-verse/en/p03.webp?v=f9f3fdde), [4](https://postext.dev/cookbook/shaped-verse/en/p04.webp?v=f9f3fdde)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=shaped-verse&lang=en (.postext: https://postext.dev/cookbook/shaped-verse/en/shaped-verse.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/shaped-verse.md), [es](https://postext.dev/es/cookbook/shaped-verse.md), [ca](https://postext.dev/ca/cookbook/shaped-verse.md), [pt](https://postext.dev/pt/cookbook/shaped-verse.md), [zh](https://postext.dev/zh/cookbook/shaped-verse.md), [ja](https://postext.dev/ja/cookbook/shaped-verse.md), [ar](https://postext.dev/ar/cookbook/shaped-verse.md)

## En bref

Deux vieux poèmes anglais dont les vers sont imprimés pour que chaque poème ressemble à un dessin : un autel et une paire d’ailes. Chaque vers est simplement centré, et sa longueur dessine la forme.

## Ce que vous allez composer

Un livret de quatre pages A5, une feuille A4 pliée en deux, qui contient deux poèmes figurés de *The Temple* de George Herbert dans l’orthographe de la première édition de 1633. La page de titre suit celle de 1633, jusqu’à son épigraphe tirée du psaume 29. À l’intérieur, *The Altar* fait face à *Easter Wings*. Aucun vers de l’un ou l’autre poème n’est en retrait ni complété par des espaces ; chacun est centré, si bien que l’autel repose sur un pilier étroit et que chaque strophe des ailes se resserre jusqu’à deux mots puis s’élargit à nouveau. Sur la dernière page, sous la devise de Herbert, une note raconte comment les imprimeurs de 1633 composèrent les ailes de côté, et le colophon raccourcit ligne après ligne. Tout est composé en texte courant ou dans l’un des trois niveaux de titre, avec les caractères IM Fell, sans style nommé ni maquette de page. L’édition espagnole compose une version vers à vers qui garde les deux formes.

**Cette recette répond à:**

- Comment espacer la page de titre et les strophes d’un poème, si les lignes vides du Markdown n’ajoutent rien ?
- Comment composer de la poésie : un vers par ligne, blancs entre les strophes, retraits suspendus pour les vers renvoyés, sans césure ?
- Comment composer une épigraphe, une dédicace, une signature ou une citation en exergue avec un grand guillemet ?
- Comment forcer un saut de page ou de colonne, et faire commencer chaque chapitre sur une page de droite ?

## La réponse courte

```js
// script.js, lignes 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; only justified text is hyphenated by default
  firstLineIndent: pt(0), // the default 1.5 em moves every line 3.3 mm off the titles' axis
};
```

## Ingrédients

**Enseigne**

- [Retraits, alignement et espacement des paragraphes](https://postext.dev/fr/docs/configuration-text.md#texte-courant): Texte justifié ou en drapeau, alinéas en retrait de première ligne avec le premier paragraphe sans retrait après un titre, ou paragraphes en bloc séparés par un espace.
- [Niveaux de titre](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau): Caractère, échelle de corps, couleur, graisse, marges, italique et capitales pour chaque niveau de titre, de H1 à H6.
- [Espace vertical explicite](https://postext.dev/fr/docs/document-format.md#space): Ajoute des lignes d’espace entières ou fractionnaires entre deux blocs, là où les lignes vides n’ajoutent rien ; supprimé en haut de colonne.

**Utilise aussi**

- [Sauts de page et de colonne](https://postext.dev/fr/docs/document-format.md#pagebreak)
- [Chapitres qui commencent en belle page](https://postext.dev/fr/docs/configuration-text.md#saut-avant)
- [Texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Gras, italique et leurs couleurs](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Format de la page](https://postext.dev/fr/docs/configuration-page-layout.md#formats-de-page-prédéfinis)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Une ou deux colonnes](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

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

## Préparation

### 1 · Un vers est un paragraphe centré

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/shaped-verse.md#la-réponse-courte) plus haut. Postext réunit les lignes d’un paragraphe Markdown en une seule : chaque vers est donc un paragraphe, suivi d’une ligne vide. `textAlign: 'center'` compose chaque vers à sa largeur naturelle sur l’axe de la justification, et comme seul le texte justifié est coupé tant que `hyphenation.ragged` n’est pas activé, aucun mot n’est coupé ([texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant)). Chaque vers est la première ligne de son paragraphe : le retrait de première ligne par défaut, 1,5 em, le décalerait de 3,3 mm à droite de l’axe des titres ; `firstLineIndent: pt(0)` garde poèmes et titres sur un même axe.

### 2 · La justification prend le vers le plus long

```js
// script.js, lignes 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 vers qui ne tient pas passe à la ligne, et les deux moitiés sont centrées : le contour reçoit alors une marche qui n’est pas dans la figure de Herbert. Avec une justification de 75 mm, trois vers de *The Altar* et deux d’*Easter Wings* passent à la ligne, et le livret s’étend sur six pages. Le vers le plus large ici, le premier d’*Easter Wings*, mesure 80,7 mm en anglais et 80,0 mm en espagnol : la justification de 110 mm de la page A5 prend donc chaque vers entier.

### 3 · Des titres sobres, centrés

```js
// script.js, lignes 51–71
// 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; a second # title
    // would open on a recto.
    { 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.
    // A heading that opens a page drops its top margin, 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) },
  ],
};
```

Aucun niveau n’a de maquette, seulement des réglages typographiques, et `headings.textAlign` centre les trois ([titres](https://postext.dev/fr/docs/configuration-text.md#titres)). La parité `'any'` ouvre chaque poème à la page suivante, recto ou verso : l’autel de la page 2 fait donc face aux ailes de la page 3. Un `marginTop` ne peut pas abaisser ces titres, car l’espace au-dessus d’un titre qui ouvre une page est supprimé. C’est la boîte de ligne du niveau 2, profonde de trois lignes, qui abaisse chaque titre d’environ 5 mm, et les deux poèmes commencent à la ligne 5 du bloc de texte. Le niveau 1 ouvre le document et n’a pas besoin de saut ici ; il en porte un quand même, pour qu’un second titre `#` s’ouvre sur un recto. Le niveau 3 compose les quatre étiquettes des pages 1 et 4 dans les petites capitales d’IM Fell English SC, au corps du texte.

### 4 · Le Markdown espace et découpe les pages

```js
// script.js, lignes 75–92
// 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 = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  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
  bodyText: verse, // a :ref added to these pages takes main-color, the ink, like bold and italic
  headings: titles,
  header: { elements: [] }, // a folded keepsake of four pages: no running heads,
  footer: { elements: [] }, // and no folios
});
```

Une ligne vide entre deux paragraphes n’ajoute aucun blanc : chaque espace de ces pages vient donc de `:::space`, une ligne entre les strophes d’*Easter Wings* et, sur la page de titre, cinq lignes au-dessus de l’étiquette du psaume et dix sous l’épigraphe ([`:::space`](https://postext.dev/fr/docs/document-format.md#space)). Avec ces valeurs, la mention d’éditeur de la page 1 se termine sur la dernière ligne du bloc de texte, et les étiquettes au-dessus du psaume et de la note se tiennent toutes deux sur la ligne 13, à 94 mm du haut de la page. `:::pagebreak` ouvre la dernière page ([`:::pagebreak`](https://postext.dev/fr/docs/document-format.md#pagebreak)). Le colophon est lui aussi composé à raison d’un paragraphe par ligne, coupé là où finit une expression : ses quatre lignes s’effilent ainsi en cul-de-lampe.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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.25.0
import { buildDocumentWithFonts, renderPageToCanvas } 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; only justified text is hyphenated by default
  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; a second # title
    // would open on a recto.
    { 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.
    // A heading that opens a page drops its top margin, 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 = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  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
  bodyText: verse, // a :ref added to these pages takes main-color, the ink, like bold and italic
  headings: titles,
  header: { elements: [] }, // a folded keepsake of four pages: no running heads,
  footer: { elements: [] }, // and no folios
});
// #endregion

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

# The Temple

*Sacred Poems and Private Ejaculations*

:::space

By Mr. George Herbert

:::space{lines=5}

### Psal. 29.

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

:::space{lines=10}

### Two shaped poems

in the text of the first edition

Cambridge, 1633

## The Altar

A broken ALTAR, Lord, thy servant reares,

Made of a heart, and cemented with teares:

Whose parts are as thy hand did frame;

No workmans tool hath touch’d the same.

A HEART alone

Is such a stone,

As nothing but

Thy pow’r doth cut.

Wherefore each part

Of my hard heart

Meets in this frame,

To praise thy name.

That if I chance to hold my peace,

These stones to praise thee may not cease.

O let thy blessed SACRIFICE be mine,

And sanctifie this ALTAR to be thine.

## Easter Wings

Lord, who createdst man in wealth and store,

Though foolishly he lost the same,

Decaying more and more,

Till he became

Most poore:

With thee

O let me rise

As larks, harmoniously,

And sing this day thy victories:

Then shall the fall further the flight in me.

:::space

My tender age in sorrow did beginne:

And still with sicknesses and shame

Thou didst so punish sinne,

That I became

Most thinne.

With thee

Let me combine,

And feel this day thy victorie:

For, if I imp my wing on thine,

Affliction shall advance the flight in me.

:::pagebreak

### Herbert’s motto

*Lesse then the least of Gods mercies.*

:::space

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

:::space{lines=7}

### A note on the text

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

:::space{lines=5}

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

Igino Marini’s revivals of the types

John Fell gathered at Oxford.

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

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

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown }, config(), kitFonts(FONTS));
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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

### Ouvrir chaque poème sur une page de droite

Avec la parité `'odd'`, *The Altar* et *Easter Wings* s’ouvrent aux pages 3 et 5, deux rectos, et le livret passe à six pages, les pages 2 et 4 restant blanches.

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

### Composer les vers au fer à gauche

Avec `textAlign: 'left'`, chaque vers commence à la marge et les deux poèmes ne gardent que leur contour droit ; le reste du texte courant, du sous-titre au colophon, passe aussi au fer à gauche, et seuls les titres restent centrés.

```diff
-  textAlign: 'center', // ragged on both sides; only justified text is hyphenated by default
+  textAlign: 'left', // ragged right; only justified text is hyphenated by default
```

## Erreurs fréquentes

- Un vers trop long pour la justification passe à la ligne, et ses deux moitiés sont centrées chacune de leur côté. Mesurez le vers le plus large avant de choisir le corps et les marges. Pour des vers au fer à gauche, avec des rejets en retrait, voyez [Des poèmes composés vers à vers](https://postext.dev/fr/cookbook/poetry-collection.md).
- Les espaces en début de paragraphe sont supprimées : vous ne pouvez pas donner sa forme à une ligne en la complétant par des espaces. La largeur de chaque vers vient de ses seuls mots, et une traduction doit trouver des vers à peu près aussi larges que ceux de l’original, comme le fait l’édition espagnole.
- Le centrage garde chaque vers à sa largeur naturelle : la table et la base de *The Altar* ne sortent donc pas d’équerre, les troisième et quatrième vers de la table étant plus étroits que les deux premiers. La justification ne peut pas les équarrir non plus, car la dernière ligne d’un paragraphe justifié garde sa largeur naturelle et chacun de ces vers est un paragraphe à lui seul.
- La page de titre et la dernière page sont remplies jusqu’à la dernière ligne de la grille. Une ligne de plus sur la page de titre, venue d’une épigraphe plus longue, d’un vers qui passe à la ligne ou d’un `:::space` plus grand, envoie l’étiquette du pied et les deux lignes qui la suivent sur une page à part ; une ligne de plus sur la dernière page envoie la dernière ligne du colophon, seule, sur une cinquième page. Pour ajouter une ligne à l’une ou l’autre page, retirez-en une d’un `:::space` placé au-dessus.
- Postext ne compose pas de texte de côté, comme les imprimeurs de 1633 l’ont fait pour *Easter Wings* : seuls les figures et les tableaux entiers pivotent. Ici, les strophes restent droites, et la note de la page 4 le précise.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: “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 ([source](https://github.com/textcreationpartnership/A03058)), domaine public
- Texte: Psalm 29:9 in the Spanish edition, from the Reina-Valera Bible of 1909: Reina-Valera 1909 ([source](https://es.wikisource.org/wiki/Biblia_Reina-Valera_1909/Salmos/29)), domaine public
- Texte: 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
- Caractères: IM Fell English (OFL-1.1), IM Fell Great Primer (OFL-1.1), IM Fell English SC (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 015 · Des poèmes composés vers à vers](https://postext.dev/fr/cookbook/poetry-collection.md): Chaque poème est un bloc :::verse écrit tel qu’imprimé : retraits de 1918 en espaces de tête, renvois pendant 2 em plus loin, ligne blanche entre strophes. · Niveau 2 (Intermédiaire) · Poésie
- [Nº 043 · Traduction en regard, strophe par strophe](https://postext.dev/fr/cookbook/bilingual-facing-verse.md): Chaque poème est un encadré sans cadre à deux colonnes : un saut de colonne fixe ouvre l’anglais sur son titre, et :::space aligne les strophes. · Niveau 2 (Intermédiaire) · Poésie
- [Nº 044 · Édition critique : numéros de vers et notes associées](https://postext.dev/fr/cookbook/critical-edition-line-numbers.md): Le Lycidas de Milton (texte de 1645) : lineNumbers numérote un vers sur cinq dans la colonne latérale, et chaque note s’ouvre sur ce numéro en pastille. · Niveau 3 (Avancé) · Poésie
