Aller au contenu principal
Recette numéro 17

Recettes · Chapitre 3 · Titres et ouvertures

Cinq ouvertures de chapitre dans un même livre

Une ouverture sur le niveau 1 et quatre styles de titre nommés dans le Markdown ; chaque style change l’accent, certains aussi les marges, colonnes ou folios.

Sur cette page
Sortie
Canvas
Niveau
Avancé
Postext
Testé avec Postext 1.31.0
Nécessite ≥ 1.25.0
Licence
Mis à jour le 10 oct. 2026
Code MIT · Texte CC BY 4.0

p. 4–5 sur 7

  • Exemple en anglais : pas encore d’édition française
  • Format 180 × 240 mm
  • 1 colonne
  • Lora 10,8/14,5
  • Fraunces
  • Geist
  • Geist Mono
  • 7 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 113 ms
  • 249 lignes de code

En bref

Un petit livre sur les façons d’ouvrir un chapitre, où chaque chapitre commence dans un style différent. Il montre comment donner à un chapitre ses propres couleurs, marges et mise en page avec un seul réglage.

Ce que vous allez composer

Five Ways to Begin est un livre de sept pages sur les ouvertures de chapitre, au format 180 × 240 mm, et chaque chapitre est composé dans l’ouverture qu’il décrit. Le premier chapitre est celle d’un roman : un surtitre espacé, un titre en italique maigre et une lettrine rouge sur cinq lignes, avec des titres courants sur les pages suivantes. Le deuxième chapitre place son titre et un fleuron dans un cartouche crème tracé de deux filets bleus. Le troisième empile son titre en Fraunces Black blanc sur un aplat magenta à fond perdu, au-dessus d’une lettrine en chasse fixe défoncée dans une boîte. Le quatrième, le manuel, a des titres courants en chasse fixe, une pastille de numéro orange à côté de son titre et deux colonnes en Geist. La coda pose son titre à mi-hauteur d’une page vide, au-dessus d’une lettrine montante. Le premier chapitre emploie l’ouverture propre au niveau 1 ; les quatre autres nomment un style de titre sur leur ligne de titre.

Cette recette répond à

  • Comment donner à chaque chapitre sa propre photo, sa propre couleur ou sa propre variante d’ouverture ?
  • Comment ajouter à une ouverture une ligne d’auteur, un chapeau ou une attaque avec lettrine ?
  • Comment composer un titre de chapitre sur toute la largeur de la page, avec le texte en deux colonnes en dessous ?
  • Comment construire à côté du titre un badge numéroté qui s’élargit quand le numéro grandit (9 → 10) ?

La réponse courte

script.js · lignes 16–52dans le code complet
// A heading line picks a style by id, and its attributes feed the opener's {attr.…} texts:
//   # The Frame {style="framed" kicker="Chapter the Second"}
// A style overrides the level fields it names (opener, drop cap) and, until the next level-1
// heading, the section fields it names; the rest comes from the level and the document.
const level1 = () => ({ level: 1,
  // span 'page' opens each chapter at the head of a new page and runs its opener across
  // every column (chapter four has two). By default an H1 waits for a recto, with a blank
  // page before it; parity 'any' lets these short chapters open on either side.
  span: 'page',
  breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), // each opener ends where its first paragraph starts
  advancedDesign: literary, // chapter one, unstyled: the book's own opener
  dropCap: { lines: 5, fontFamily: 'Fraunces', fontWeight: 400, color: col('accent'),
    gap: mm(2.5) } }); // a five-line drop in rubric red
const headingStyles = () => [ // factories, called by config() once every design exists
  // Each style brings its opener and swaps 'accent' for a colour of its own, on its pages.
  { id: 'framed', advancedDesign: framed, palette: { accent: palette.lapis },
    margins: { left: mm(EVEN), right: mm(EVEN) }, // a centred text block; 'accent' is blue:
    dropCap: { lines: 3, fontFamily: 'Fraunces', fontWeight: 600, color: col('accent'),
      gap: mm(2) } },
  { id: 'stacked', advancedDesign: stacked, palette: { accent: palette.magenta }, // white, on
    dropCap: { lines: BOXED, fontFamily: 'Geist Mono', fontWeight: 700, color: col('paper'),
      gap: mm(2.2) } }, // the square the opener draws for it
  { id: 'spec', advancedDesign: spec, palette: { accent: palette.signal }, dropCap: false,
    marginBottom: pt(0), // the hairline under the title is the separator
    margins: { left: mm(SPEC), right: mm(SPEC) }, // a 144 mm text block
    layout: { layoutType: 'double', gutterWidth: mm(7) },
    bodyStyle: { fontFamily: 'Geist', fontSize: pt(9.8), textAlign: 'left' }, // on the grid
    header: { elements: [...specHead('even'), ...specHead('odd')] },
    footer: { elements: [] } }, // no folio at the foot: the head gives the page
  { id: 'quiet', numbered: false, // a coda: it advances no chapter counter
    advancedDesign: quiet, palette: { accent: palette.graphite }, footer: tinyFolio,
    dropCap: { lines: 1, fontFamily: 'Fraunces', fontWeight: 300, fontSize: pt(46), // raised:
      color: col('accent'), gap: mm(1.2) }, // on the first baseline, rising into the white
    margins: { left: mm(QUIET.inner), right: mm(QUIET.outer) }, // a 106 mm measure
    bodyStyle: { fontSize: pt(QUIET.size), lineHeight: pt(QUIET.lead), textAlign: 'left' } },
]; // hook-up in config(): headings: { levels: [level1(), …] }, headingStyles()

Ingrédients

Typographie
Lora, Fraunces, Geist, Geist Mono (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Placer l’ouverture du livre sur le niveau

script.js · lignes 97–105dans le code complet
// The first paragraph, in :::paragraphs{style="lead"}, starts 17 mm under the title.
const literary = design([
  text('kicker', '{attr.kicker}', 'Geist', 8, centred(22), { ...caps(1.7), align: 'center' }),
  text('title', '{titleText}', 'Fraunces', 32, at('#kicker', 'below', 0, 4, 'fill'),
    { fontWeight: 300, italic: true, lineHeight: 1.05, align: 'center' }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'),
    placement: at('#title', 'below', (MEASURE - 18) / 2, 6, 18) }, // an 18 mm rule, centred
  room('#title', 17),
]);

Le premier chapitre n’a pas de style : il prend la maquette propre au niveau 1, dont part chaque style (la réponse courte montre le niveau et les styles). Le premier paragraphe du chapitre est dans le Markdown, dans un groupe :::paragraphs{style="lead"} d’un corps plus grand que le texte (12/17 pt contre 10,8/14,5), et la dropCap du niveau l’ouvre (lettrines) : le M repose sur la cinquième ligne de base et, sans fontSize, son sommet s’aligne sur les capitales de la première ligne, les deux hauteurs de capitale étant mesurées dans Fraunces et Lora. L’ouverture se termine par une boîte vide, room(), à 17 mm sous le titre, là où commence le chapeau ; un :::space sous le chapeau laisse une ligne et fait partir le texte sans retrait.

#2 · Encadrer le titre dans deux boîtes

script.js · lignes 109–127dans le code complet
const FRAME = 62; // mm: the outer box's height
const INSET = 3; // mm: the hairline box runs this far inside it
const FLEURON = 44; // mm: the ornament's width, centred under the title
const framed = design([ // array order is paint order: the boxes first, the type on top
  { kind: 'box', id: 'frame', placement: { ...at('container', 'top-left', 0, 0),
    size: { width: 'fill', height: mm(FRAME) } },
  style: { backgroundColor: col('tint'), borderColor: col('accent'), borderWidth: pt(1.6) } },
  { kind: 'box', id: 'hairline', placement: { ...at('#frame', 'align-top', INSET, INSET),
    size: { width: mm(MEASURE - 2 * INSET), height: mm(FRAME - 2 * INSET) } },
  style: { borderColor: col('accent'), borderWidth: pt(0.5) } },
  text('kicker', '{attr.kicker}', 'Geist', 7.5, centred(14), { ...caps(1.6), align: 'center' }),
  text('title', '{titleText}', 'Fraunces', 34, at('#kicker', 'below', 0, 3.5, 'fill'),
    { fontWeight: 600, lineHeight: 1.05, align: 'center' }),
  // An image element draws a registered resource, never numbered or captioned. Its resourceId
  // may be '{attr.photo}' (postext ≥ 1.8): then each heading names its own photo.
  { kind: 'image', id: 'fleuron', resourceId: 'fleuron',
    placement: at('#title', 'below', (MEASURE - FLEURON) / 2, 4.5, FLEURON) },
  room('#frame', 9), // the text starts 9 mm under the frame
]);

L’ordre du tableau est l’ordre de peinture : les boîtes viennent d’abord et le texte par-dessus ; l’ornement est un élément image, qui dessine une ressource SVG enregistrée, jamais numérotée ni citée. C’est aussi par un élément image que chaque chapitre reçoit sa propre photo : depuis postext 1.8, son resourceId accepte des marqueurs, si bien qu’avec resourceId: '{attr.photo}' un seul style dessine la photo que nomme chaque titre, # The Frame {style="framed" photo="lake"}, comme le font les plats de la fiche de recette (éléments image). Le style framed de la réponse courte définit palette pour passer accent en bleu sur les pages de ce chapitre : les filets, le surtitre et le folio en pied s’impriment en bleu, et ses margins centrent le bloc de texte sous le cadre. La dropCap propre au style nomme col('accent'), que la palette passe aussi en bleu : le L sur trois lignes s’accorde au cadre.

#3 · Empiler le titre, encadrer la lettrine

script.js · lignes 131–160dans le code complet
const FIELD = 130; // mm from the trim: the foot of the colour field
const S_BEARING = 1.2; // mm: the white left of the title's S (0.0365 em at 94 pt)
// The boxed initial: the style's drop cap sets the white letter; the opener draws the square,
// fitted by hand in pt. The text starts on the grid line 8 mm under the field (from the trim).
const TEXT_TOP = Math.ceil(((FIELD - TOP + 8) * 72) / 25.4 / LEAD) * LEAD; // pt
const BASE = 0.8; // a line's baseline stands 0.8 down its line box
const CAPS = 0.7; // the height of a capital in Lora, em
// Geist Mono, em: cap height, the advance every capital shares, the white either side of an M
const MONO = { caps: 0.71, advance: 0.6, side: 0.044 };
const BOXED = 3; // lines the initial spans
const capsTop = TEXT_TOP + BASE * LEADIN.lead - CAPS * LEADIN.size; // the first line's capitals
const foot = TEXT_TOP + (BOXED - 1 + BASE) * LEADIN.lead; // the third baseline: the letter's foot
// The M runs from the first line's capitals to its foot; the square runs past both as far as
// the M's sides sit in, so the colour is as wide on all four sides.
const letter = (foot - capsTop) / MONO.caps, pad = MONO.side * letter;
const stacked = design([
  { kind: 'box', id: 'field', style: { backgroundColor: col('accent') },
    placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(FIELD) } } },
  text('kicker', '{attr.kicker}', 'Geist', 8, at('container', 'top-left', 0, 4),
    { ...caps(1.8), color: col('paper') }),
  // {titleText} keeps the \\ breaks from the Markdown, so each line ends at the writer's \\.
  // Moved left by the S's side bearing, so its curve meets the text edge.
  text('title', '{titleText}', 'Fraunces', 94,
    at('container', 'top-left', -S_BEARING, 15, 'fill'),
    { fontWeight: 900, lineHeight: 0.84, color: col('paper') }),
  { kind: 'box', id: 'initial', style: { backgroundColor: col('accent') }, reserve: false,
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(capsTop - pad) },
      size: { width: pt(MONO.advance * letter), height: pt(foot - capsTop + 2 * pad) } } },
  room('container', (TEXT_TOP * 25.4) / 72, 'top-left'), // the opener ends where the text starts
]);

L’empilement s’écrit dans le Markdown : # Say it \\ out \\ loud. coupe {titleText} à chaque \\, tandis que les titres courants et les signets du PDF lisent le titre sur une ligne (sauts de ligne dans les titres). L’aplat pend du haut du fond perdu, sur toute la largeur et 130 mm de haut, et le chapeau commence sur la première ligne de grille 8 mm dessous (TEXT_TOP). La lettrine encadrée tient en deux pièces. La dropCap du style compose le M en blanc sur trois lignes du chapeau, et l’ouverture peint dessous une boîte magenta, placée à partir de TEXT_TOP, de l’interlignage du chapeau et des métriques de Geist Mono, pour que la couleur au-dessus et au-dessous du M soit aussi large que ses approches latérales. La boîte ne prend aucune place dans l’ouverture (reserve: false) ; une boîte room vide termine l’ouverture là où commence le chapeau.

#4 · Donner au manuel une pastille, deux colonnes et ses propres titres courants

script.js · lignes 181–208dans le code complet
const SPEC = 18; // mm: both side margins, for a 144 mm text block in two columns
const PAD = 1.6; // mm of colour over the badge's figures
const spec = design([
  { kind: 'rule', id: 'top-rule', direction: 'horizontal', thickness: pt(2.4), color: col('ink'),
    placement: at('container', 'top-left', 0, 0, 'fill') },
  text('kicker', '{attr.kicker}', 'Geist Mono', 7.5, at('container', 'top-left', 0, 3.2),
    { ...caps(1.2, 500), color: col('graphite') }),
  // The badge's width follows its text, with padding on both sides, so 9 → 10 widens it. Its
  // line box holds 1 mm more under the figures than over them, so the foot pads 1 mm less.
  text('badge', '{chapterNumber}', 'Geist Mono', 26, at('container', 'top-left', 0, 12),
    { fontWeight: 700, lineHeight: 1, box: { backgroundColor: col('accent'), padding: {
      top: mm(PAD), right: mm(2.4), bottom: mm(PAD - 1), left: mm(2.4) } } }),
  // Same size and line height as the badge, dropped by its top padding, so the baselines meet.
  text('title', '{titleText}', 'Geist', 26, at('#badge', 'right-of', 4, PAD),
    { fontWeight: 700, lineHeight: 1 }),
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('ink'),
    placement: at('container', 'top-left', 0, 31, 'fill') }, // room for a one-line title
]);
const specHead = (parity) => { // mono labels over the opener's rule, on every page of the section
  const [edge, s] = parity === 'even' ? ['top-left', 1] : ['top-right', -1];
  const mono = { ...caps(1.1, 500), color: col('graphite'), parity, pages: 'all' };
  return [
    text(`sp-page-${parity}`, '{pageNumber} / {totalPages}', 'Geist Mono', 7,
      at('container', edge, 0, HEAD_Y), mono),
    text(`sp-chap-${parity}`, '§ {chapterNumber} · {chapterTitle}', 'Geist Mono', 7,
      at('container', edge, s * 16, HEAD_Y), mono),
  ];
};

La pastille est un élément texte avec un fond et une marge intérieure mais sans largeur : sa largeur suit {chapterNumber}, et le titre est placé right-of elle. Au chapitre 10, la pastille grandit d’un chiffre, 5,5 mm, et le titre se déplace avec elle. Sa marge intérieure du bas fait 1 mm de moins que celle du haut, parce que la boîte de ligne contient d’autant plus sous les chiffres : le 4 se tient ainsi centré. Dans le style spec, layout compose la section en deux colonnes et le span: 'page' du niveau étend l’ouverture sur les deux. Ses margins élargissent le bloc de texte à 144 mm, bodyStyle compose du Geist en drapeau sur la grille de 14,5 pt du livre, header remplace les titres courants du livre et un footer vide supprime le folio.

#5 · Laisser de la place à la coda

script.js · lignes 212–221dans le code complet
const quiet = design([ // the kicker is in the accent, which this style turns graphite
  text('kicker', '{attr.kicker}', 'Geist', 7.5, at('container', 'top-left', 0, 92),
    caps(1.6, 500)),
  text('title', '{titleText}', 'Fraunces', 26, at('#kicker', 'below', 0, 3),
    { fontWeight: 300, lineHeight: 1.1 }),
  room('#title', 3), // and the initial's rise, in whole lines: the text 16 mm under the title
]);
const tiny = (parity, edge) => text(`tiny-${parity}`, '{pageNumber}', 'Geist', 6.5,
  at('container', edge, 0, -12), { fontWeight: 500, color: col('muted'), parity });
const tinyFolio = { elements: [tiny('even', 'bottom-left'), tiny('odd', 'bottom-right')] };

Le surtitre pend 92 mm sous le haut du bloc de texte, et la lettrine montante est la lettrine sur une ligne du style quiet : son pied repose sur la première ligne de base et, à 46 pt, elle s’élève dans le blanc au-dessus au lieu de s’enfoncer dans le paragraphe. Le paragraphe garde cette montée dégagée au-dessus de lui en lignes entières : le room de l’ouverture sous le titre ne fait donc que 3 mm. Dans le style quiet, margins réduit la justification à 106 mm, bodyStyle compose le texte en drapeau à 12,5/17,5 pt, footer remplace le folio en pied par un petit folio à l’angle extérieur et palette passe le surtitre en gris. Rien sur cette page n’imprime de numéro de chapitre : numbered: false ne se verrait que dans un chapitre ajouté après la coda, qui serait toujours le cinquième.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 017 · Five chapter openers in one book ═════════════════
// https://postext.dev/en/cookbook/five-chapter-openers
// Code: MIT · Text: original (CC BY 4.0) · Ornament: generated in code (CC BY 4.0)
// Fonts: Lora, Fraunces, Geist, Geist Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A small book about openers, each chapter set in the one it describes. Every heading style
// brings its opener and palette; three move the margins, two bring their own type and folios.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: one opener per chapter: the level's own, then heading styles by name
// A heading line picks a style by id, and its attributes feed the opener's {attr.…} texts:
//   # The Frame {style="framed" kicker="Chapter the Second"}
// A style overrides the level fields it names (opener, drop cap) and, until the next level-1
// heading, the section fields it names; the rest comes from the level and the document.
const level1 = () => ({ level: 1,
  // span 'page' opens each chapter at the head of a new page and runs its opener across
  // every column (chapter four has two). By default an H1 waits for a recto, with a blank
  // page before it; parity 'any' lets these short chapters open on either side.
  span: 'page',
  breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), // each opener ends where its first paragraph starts
  advancedDesign: literary, // chapter one, unstyled: the book's own opener
  dropCap: { lines: 5, fontFamily: 'Fraunces', fontWeight: 400, color: col('accent'),
    gap: mm(2.5) } }); // a five-line drop in rubric red
const headingStyles = () => [ // factories, called by config() once every design exists
  // Each style brings its opener and swaps 'accent' for a colour of its own, on its pages.
  { id: 'framed', advancedDesign: framed, palette: { accent: palette.lapis },
    margins: { left: mm(EVEN), right: mm(EVEN) }, // a centred text block; 'accent' is blue:
    dropCap: { lines: 3, fontFamily: 'Fraunces', fontWeight: 600, color: col('accent'),
      gap: mm(2) } },
  { id: 'stacked', advancedDesign: stacked, palette: { accent: palette.magenta }, // white, on
    dropCap: { lines: BOXED, fontFamily: 'Geist Mono', fontWeight: 700, color: col('paper'),
      gap: mm(2.2) } }, // the square the opener draws for it
  { id: 'spec', advancedDesign: spec, palette: { accent: palette.signal }, dropCap: false,
    marginBottom: pt(0), // the hairline under the title is the separator
    margins: { left: mm(SPEC), right: mm(SPEC) }, // a 144 mm text block
    layout: { layoutType: 'double', gutterWidth: mm(7) },
    bodyStyle: { fontFamily: 'Geist', fontSize: pt(9.8), textAlign: 'left' }, // on the grid
    header: { elements: [...specHead('even'), ...specHead('odd')] },
    footer: { elements: [] } }, // no folio at the foot: the head gives the page
  { id: 'quiet', numbered: false, // a coda: it advances no chapter counter
    advancedDesign: quiet, palette: { accent: palette.graphite }, footer: tinyFolio,
    dropCap: { lines: 1, fontFamily: 'Fraunces', fontWeight: 300, fontSize: pt(46), // raised:
      color: col('accent'), gap: mm(1.2) }, // on the first baseline, rising into the white
    margins: { left: mm(QUIET.inner), right: mm(QUIET.outer) }, // a 106 mm measure
    bodyStyle: { fontSize: pt(QUIET.size), lineHeight: pt(QUIET.lead), textAlign: 'left' } },
]; // hook-up in config(): headings: { levels: [level1(), …] }, headingStyles()
// #endregion

const palette = { // every colour in the config links to one of these by id
  ink: '#16181c', // text: a cool near-black
  accent: '#8c1c13', // the one accent of a section; the book's own is a rubric red
  tint: '#f6f0e3', // the frame's cream
  muted: '#6b6760', // running heads and the colophon
  paper: '#ffffff', // type on colour
  lapis: '#24427a', magenta: '#a3155e', signal: '#ff5a1f', graphite: '#4a4f57',
};
// col(id): a palette-linked colour, so a style's palette can swap 'accent' on its own pages.
// The hex is the value the entry holds in the book's palette.
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' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
const TRIM = { w: 180, h: 240 }; // mm
const TOP = 22, INNER = 20, OUTER = 36; // margins, mm: a wide outer margin keeps 124 mm lines
const MEASURE = TRIM.w - INNER - OUTER; // mm
const EVEN = (TRIM.w - MEASURE) / 2; // mm: equal side margins, for the framed chapter
const LEAD = 14.5; // body leading, pt: the baseline grid
const LEADIN = { size: 12, lead: 17 }; // pt: a chapter's opening paragraph, a size larger
const QUIET = { size: 12.5, lead: 17.5, inner: 30, outer: 44 }; // the coda: pt, and mm margins

// Design-slot shorthands. at(): a placement from an anchor's edge, x and y in mm.
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width !== undefined && { size: { width: width === 'fill' ? 'fill' : mm(width) } }) });
// Every design text wraps: an opener's would anyway, a running head's would end in '…'.
// A number lineHeight multiplies the size.
const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id,
  content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'left', overflow: 'wrap',
  placement, ...extra });
const caps = (tracking, weight = 600) => ({ fontWeight: weight, letterSpacing: pt(tracking),
  textTransform: 'uppercase', color: col('accent') });
const centred = (y) => at('container', 'top', 0, y, 'fill'); // a full-width line, centred
// No minHeight: the text starts under the lowest element of each opener.
const design = (elements) => ({ enabled: true, slot: { elements } });
// An empty box reserves room: the text starts y mm under the element it hangs from.
const room = (anchor, y, edge = 'below') => ({ kind: 'box', id: 'room', style: {},
  placement: { ...at(anchor, edge, 0, 0, 'fill'), size: { width: 'fill', height: mm(y) } } });

// #region literary: chapter one, the level's own opener: a five-line drop in rubric red
// The first paragraph, in :::paragraphs{style="lead"}, starts 17 mm under the title.
const literary = design([
  text('kicker', '{attr.kicker}', 'Geist', 8, centred(22), { ...caps(1.7), align: 'center' }),
  text('title', '{titleText}', 'Fraunces', 32, at('#kicker', 'below', 0, 4, 'fill'),
    { fontWeight: 300, italic: true, lineHeight: 1.05, align: 'center' }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'),
    placement: at('#title', 'below', (MEASURE - 18) / 2, 6, 18) }, // an 18 mm rule, centred
  room('#title', 17),
]);
// #endregion

// #region framed: a cartouche of two boxes around the title, an ornament, a three-line initial
const FRAME = 62; // mm: the outer box's height
const INSET = 3; // mm: the hairline box runs this far inside it
const FLEURON = 44; // mm: the ornament's width, centred under the title
const framed = design([ // array order is paint order: the boxes first, the type on top
  { kind: 'box', id: 'frame', placement: { ...at('container', 'top-left', 0, 0),
    size: { width: 'fill', height: mm(FRAME) } },
  style: { backgroundColor: col('tint'), borderColor: col('accent'), borderWidth: pt(1.6) } },
  { kind: 'box', id: 'hairline', placement: { ...at('#frame', 'align-top', INSET, INSET),
    size: { width: mm(MEASURE - 2 * INSET), height: mm(FRAME - 2 * INSET) } },
  style: { borderColor: col('accent'), borderWidth: pt(0.5) } },
  text('kicker', '{attr.kicker}', 'Geist', 7.5, centred(14), { ...caps(1.6), align: 'center' }),
  text('title', '{titleText}', 'Fraunces', 34, at('#kicker', 'below', 0, 3.5, 'fill'),
    { fontWeight: 600, lineHeight: 1.05, align: 'center' }),
  // An image element draws a registered resource, never numbered or captioned. Its resourceId
  // may be '{attr.photo}' (postext ≥ 1.8): then each heading names its own photo.
  { kind: 'image', id: 'fleuron', resourceId: 'fleuron',
    placement: at('#title', 'below', (MEASURE - FLEURON) / 2, 4.5, FLEURON) },
  room('#frame', 9), // the text starts 9 mm under the frame
]);
// #endregion

// #region stacked: a colour field, the title's words stacked, a boxed initial
const FIELD = 130; // mm from the trim: the foot of the colour field
const S_BEARING = 1.2; // mm: the white left of the title's S (0.0365 em at 94 pt)
// The boxed initial: the style's drop cap sets the white letter; the opener draws the square,
// fitted by hand in pt. The text starts on the grid line 8 mm under the field (from the trim).
const TEXT_TOP = Math.ceil(((FIELD - TOP + 8) * 72) / 25.4 / LEAD) * LEAD; // pt
const BASE = 0.8; // a line's baseline stands 0.8 down its line box
const CAPS = 0.7; // the height of a capital in Lora, em
// Geist Mono, em: cap height, the advance every capital shares, the white either side of an M
const MONO = { caps: 0.71, advance: 0.6, side: 0.044 };
const BOXED = 3; // lines the initial spans
const capsTop = TEXT_TOP + BASE * LEADIN.lead - CAPS * LEADIN.size; // the first line's capitals
const foot = TEXT_TOP + (BOXED - 1 + BASE) * LEADIN.lead; // the third baseline: the letter's foot
// The M runs from the first line's capitals to its foot; the square runs past both as far as
// the M's sides sit in, so the colour is as wide on all four sides.
const letter = (foot - capsTop) / MONO.caps, pad = MONO.side * letter;
const stacked = design([
  { kind: 'box', id: 'field', style: { backgroundColor: col('accent') },
    placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(FIELD) } } },
  text('kicker', '{attr.kicker}', 'Geist', 8, at('container', 'top-left', 0, 4),
    { ...caps(1.8), color: col('paper') }),
  // {titleText} keeps the \\ breaks from the Markdown, so each line ends at the writer's \\.
  // Moved left by the S's side bearing, so its curve meets the text edge.
  text('title', '{titleText}', 'Fraunces', 94,
    at('container', 'top-left', -S_BEARING, 15, 'fill'),
    { fontWeight: 900, lineHeight: 0.84, color: col('paper') }),
  { kind: 'box', id: 'initial', style: { backgroundColor: col('accent') }, reserve: false,
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(capsTop - pad) },
      size: { width: pt(MONO.advance * letter), height: pt(foot - capsTop + 2 * pad) } } },
  room('container', (TEXT_TOP * 25.4) / 72, 'top-left'), // the opener ends where the text starts
]);
// #endregion

// The book's running heads and folios, anchored to the text block, whatever its margins.
const HEAD_Y = 14; // mm from the trim to the running heads
const label = (id, content, parity, edge, x, extra = {}) => text(id, content, 'Geist', 7.5,
  at('container', edge, x, HEAD_Y), { ...caps(1.4, 500), color: col('muted'), parity,
    pages: 'body', ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0.4) };
// Title on the verso, chapter on the recto, folios at the outer corners.
const header = { elements: [
  label('verso-folio', '{pageNumber}', 'even', 'top-left', 0, folio),
  label('verso-title', '{title}', 'even', 'top-left', 9),
  label('recto-title', '{chapterTitle}', 'odd', 'top-right', -9),
  label('recto-folio', '{pageNumber}', 'odd', 'top-right', 0, folio),
] };
// Openers carry a drop folio instead, centred under the text block, in the section's accent.
const footer = { elements: [text('drop-folio', '{pageNumber}', 'Geist', 7.5,
  at('container', 'top', 0, 9), { ...folio, color: col('accent'), pages: 'opener' })] };

// #region spec: a ruled title, a number badge that widens with the number, two columns
const SPEC = 18; // mm: both side margins, for a 144 mm text block in two columns
const PAD = 1.6; // mm of colour over the badge's figures
const spec = design([
  { kind: 'rule', id: 'top-rule', direction: 'horizontal', thickness: pt(2.4), color: col('ink'),
    placement: at('container', 'top-left', 0, 0, 'fill') },
  text('kicker', '{attr.kicker}', 'Geist Mono', 7.5, at('container', 'top-left', 0, 3.2),
    { ...caps(1.2, 500), color: col('graphite') }),
  // The badge's width follows its text, with padding on both sides, so 9 → 10 widens it. Its
  // line box holds 1 mm more under the figures than over them, so the foot pads 1 mm less.
  text('badge', '{chapterNumber}', 'Geist Mono', 26, at('container', 'top-left', 0, 12),
    { fontWeight: 700, lineHeight: 1, box: { backgroundColor: col('accent'), padding: {
      top: mm(PAD), right: mm(2.4), bottom: mm(PAD - 1), left: mm(2.4) } } }),
  // Same size and line height as the badge, dropped by its top padding, so the baselines meet.
  text('title', '{titleText}', 'Geist', 26, at('#badge', 'right-of', 4, PAD),
    { fontWeight: 700, lineHeight: 1 }),
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('ink'),
    placement: at('container', 'top-left', 0, 31, 'fill') }, // room for a one-line title
]);
const specHead = (parity) => { // mono labels over the opener's rule, on every page of the section
  const [edge, s] = parity === 'even' ? ['top-left', 1] : ['top-right', -1];
  const mono = { ...caps(1.1, 500), color: col('graphite'), parity, pages: 'all' };
  return [
    text(`sp-page-${parity}`, '{pageNumber} / {totalPages}', 'Geist Mono', 7,
      at('container', edge, 0, HEAD_Y), mono),
    text(`sp-chap-${parity}`, '§ {chapterNumber} · {chapterTitle}', 'Geist Mono', 7,
      at('container', edge, s * 16, HEAD_Y), mono),
  ];
};
// #endregion

// #region quiet: the coda: a title far down the page, a raised initial, a folio at the side
const quiet = design([ // the kicker is in the accent, which this style turns graphite
  text('kicker', '{attr.kicker}', 'Geist', 7.5, at('container', 'top-left', 0, 92),
    caps(1.6, 500)),
  text('title', '{titleText}', 'Fraunces', 26, at('#kicker', 'below', 0, 3),
    { fontWeight: 300, lineHeight: 1.1 }),
  room('#title', 3), // and the initial's rise, in whole lines: the text 16 mm under the title
]);
const tiny = (parity, edge) => text(`tiny-${parity}`, '{pageNumber}', 'Geist', 6.5,
  at('container', edge, 0, -12), { fontWeight: 500, color: col('muted'), parity });
const tinyFolio = { elements: [tiny('even', 'bottom-left'), tiny('odd', 'bottom-right')] };
// #endregion

const flush = { textAlign: 'left', firstLineIndent: pt(0) };
const config = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the hyphenation patterns of each edition
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, // 150 dpi: a screen edition
    margins: { top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Lora', fontSize: pt(10.8), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    minWordSpacing: 0.8, maxWordSpacing: 1.6 }, // an even texture on 124 mm lines
  headings: { fontFamily: 'Geist', color: col('ink'), levels: [level1(),
    { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), fontWeight: 700, // the manual's
      numberingTemplate: '{1}.{2}', marginTop: pt(LEAD), marginBottom: pt(0) }] },
  headingStyles: headingStyles(),
  paragraphStyles: [
    // A chapter's opening paragraph, a size larger and ragged (the coda's in its own size);
    // a :::space under it in the Markdown leaves a line and starts the text flush.
    { id: 'lead', fontSize: pt(LEADIN.size), lineHeight: pt(LEADIN.lead), ...flush },
    { id: 'coda-lead', fontSize: pt(QUIET.size), lineHeight: pt(QUIET.lead), ...flush },
    { id: 'colophon', fontFamily: 'Geist', fontSize: pt(7), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(2 * LEAD) }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Exemple Markdown · 93 lignes · content.en.mdtitle: "Five Ways to Begin" subtitle: "Notes on the chapter opener" --- # The Voice {kicker="Chapter One"} :::paragraphs{style="lead"} Most books begin quietly. A novel opens each chapter with a number, sometimes a title, some white space and then the first sentence, set in the same face as the rest of the text. The arrangement is meant to go unnoticed. It tells a reader who has finished one chapter that the next one has started, and lets them go on reading without stopping to look at the page. ::: :::space The one ornament the novel has kept is the initial. It goes back to the rubricator, the scribe who went through a finished manuscript adding the headings and the capital letters in red ink; the word *rubric* comes from *rubrica*, the Latin for red ochre. The custom survived into print. Many early printed books leave a blank square where a chapter begins, sometimes with a small guide letter printed in it, so that a rubricator could paint the initial by hand once the sheets came off the press. Some copies were finished in gold and colour and others never were; in those the squares are still empty but for the guide letter. A drop cap is positioned from the lines beside it. Its top aligns with the capitals of the first line, and its foot stands on the baseline of the last line it spans, so the letter sits inside the text block. The lines it displaces all start the same distance from its right edge. Set above the capitals, the letter looks pasted on; set below them, it seems to sink into the paragraph. Five lines is a deep drop, usually kept for the first chapter of a long book. Two or three lines are more common, and many novels have no initial at all and mark the start of a chapter with the white space above the first line. A plain opener still follows exact measurements. The white space above the title, which printers call the sinkage, is the same depth on every opener, so the titles sit at one height as the pages turn, whether a title is one word long or eight. The first line of text falls on the same grid line as in the rest of the book, so that the lines on facing pages align when the book lies open. A sinkage that changes by a few millimetres from one chapter to the next shows as soon as you flick through the book. The chapter number can be spelled out, as it is here, printed in figures or, for an older look, set in roman numerals, and a book keeps to the form it chooses. Some novels leave out the word *Chapter* and print the number alone above the text, which suits a book of many short chapters. Others give each chapter a title and no number. The title is then the only element of the opener, so it is set a size larger, with more space above it. After the initial, many books set the rest of the first line, or the first phrase, in small capitals, which eases the step down from the large letter to the size of the text; others continue in roman. The first paragraph is not indented. An indent separates a paragraph from the one before it, and the first paragraph of a chapter has none before it. Chapters of a novel usually start on a new page, and in a long book often on a right-hand page, even when that leaves the page before it blank. In a novel of twenty chapters this can add about ten blank pages. Publishers accept them because every opener then falls on the same side of the book, and a reader looking for a chapter can find it by riffling the pages. The opener has no running head. The chapter title is already on the page and a head above it would repeat it, so the page number moves to the foot of the page instead, centred under the text. Printers call this a drop folio. Many books leave it out as well, and the opener is then counted but carries no printed number, a blind folio. On the chapter’s second page the running heads come back, the book’s title on the left-hand page and the chapter’s on the right, and the folios return to the outer corners. From there to the end of the chapter the text keeps to the same grid. A chapter that ends on its first page never shows the running heads at all. This book sets each chapter in the opener it describes, and so far it has followed the conventions of the novel. The next four chapters depart from them: a framed title for a gift edition, a title stacked on a colour field for a magazine, a numbered and ruled opener for a manual, and a title set low on an empty page for a coda. # The Frame {style="framed" kicker="Chapter the Second"} :::paragraphs{style="lead"} Long before books had title pages, the start of a text was marked in the manuscript itself, with a painted border round the first leaf and a large coloured initial, often under a band of leaves or knotwork. ::: :::space The first printers took the practice over from the manuscripts they imitated and made it mechanical. They kept cases of cast ornaments in the shape of flowers, leaves and knots, which the trade calls fleurons, and built borders of any size from them, piece by piece, as a mason builds a wall. Initials were woodcuts, each letter cut into a block of foliage. A thrifty shop kept a few blocks with an empty centre, into which any capital from the case could be set; printers called such a block a factotum, from the Latin for “do everything”. A frame now appears mostly on reissued classics and anniversary editions, books bought to be given. Here a heavy rule outside and a hairline 3 mm inside it hold the title the way a mount holds a print. The type inside the frame is centred, with a single ornament below it. Colour goes on the frame, the kicker and the initial, and the text stays black. Below the frame the text has the face, size and measure of the rest of the book. Only the margins change, so that the text block is centred under the frame. # Say it \\ out \\ loud. {style="stacked" kicker="Chapter Three · Magazines, posters, manifestos"} :::paragraphs{style="lead"} Magazines, posters and manifestos give their first page to the title, set as large as the page allows and often with no picture at all. ::: :::space A stacked title has few words, one phrase to each line, and the writer decides where the lines break. Left to wrap wherever the measure runs out, a title can split an article from its noun and end a line on *the*; broken where a speaker would pause, it reads as a headline. It needs a heavy face and empty space around it. Here the colour field gives it a background that bleeds off the top and sides of the page. Below the field the text returns to reading size, and only its first letter, reversed out in white from a magenta square, repeats the colour. # Number Everything {style="spec" kicker="Manuals · Standards · Datasheets"} Few people read a manual from the first page to the last. They open it at the section they need, often with the machine it describes in the other hand, and close it once the question is answered. The chapter number is what they write in a margin or quote in an email, so the opener sets it large, in figures of equal width, at the top left, where the eye starts. ## Numbers before names Readers cite a section by its number. A reference to section 4.2 still works after the section’s title has been reworded, which a reference to “the section on columns” may not. Headings are numbered down to the second level, and the number of a withdrawn section is never given to a new one, so an old reference cannot lead to different text. The badge that carries the chapter number widens with it. One- and two-figure numbers get the same padding on either side, and the title moves along to make room, so chapter 10 has the same gap between badge and title as chapter 9. At this size each extra figure adds 5.5 mm. ## Two columns, short lines Technical text is often set in two columns because a short line is easier to scan. Here each column is 68.5 mm wide, about forty characters of this text. A reader looking for one value can run down a narrow column, and at the end of each line the eye finds the start of the next without losing its place. The text is ragged right, so every word space keeps its natural width, and a long string such as a part number never stretches the spaces of the line before it. The title runs across both columns. It has to be read before anything else on the page, and in the first column alone it would look like one more paragraph. ## Rules and colour The rules divide the page. A heavy rule across the top of the text block marks the start of the chapter, and a hairline separates the title from the text. Both run the full width of the text block, across the two columns. The running head above them gives the chapter and the page number over the total, so a reader can see how far into the document they are without turning to the contents. The only colour is the orange of the badge, and the figure printed on it is black. Everything else a reader has to read is black or dark grey, so a black-and-white photocopy of the page loses no information. # Leave Room {style="quiet" kicker="Coda"} :::paragraphs{style="coda-lead"} Some books need no signal at all. A collection of poems or a memoir can use its openers to slow the reader down. ::: :::space This coda does it with white space: its title starts more than 90 mm down the text block, and its initial rises above the first line into the empty page. :::paragraphs{style="colophon"} *Five Ways to Begin* is set in Lora, Fraunces, Geist and Geist Mono (SIL Open Font License). Text and ornament: CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the fleuron, a printer's flower drawn in paths in the frame's blue function fleuron(ink) { // two scrolls with a leaf above and below, a rosette between them const scroll = 'M51 14.2C42 9.5 31 8.6 22 11.4C16 13.2 11.6 13 9.6 10.2C8.2 7.8 10.2 5.3 13 5.8' + 'C11 6.8 10.6 8.8 12 9.9C14.6 11.4 18 10.2 21.6 9.1C30.6 6.4 42 7.6 51 12.4Z' + 'M36 11.4C33.4 15.6 28.6 18.4 23.4 18.8C26.4 15 31 12.4 36 11.4Z' // the leaf below + 'M31 8.3C29.6 5.2 26.4 3.4 23 3.5C25.2 5.8 27.8 7.6 31 8.3Z'; // the leaf above const petal = 'M60 13.4C58.2 10.6 58.2 7.2 60 4.4C61.8 7.2 61.8 10.6 60 13.4Z'; const turn = (a) => `transform="rotate(${a} 60 13.4)"`; const rosette = [0, 90, 180, 270].map((a) => `<path d="${petal}" ${turn(a)}/>`).join('') + [45, 135, 225, 315].map((a) => `<circle cx="60" cy="9.6" r="1.1" ${turn(a)}/>`).join(''); return '<svg xmlns="http://www.w3.org/2000/svg" width="720" height="162" viewBox="0 0 120 27">' + `<g fill="${ink}"><path d="${scroll}"/>` // the right half is the left one mirrored + `<path d="${scroll}" transform="translate(120 0) scale(-1 1)"/>${rosette}` + '<circle cx="4.5" cy="10.5" r="1.5"/><circle cx="115.5" cy="10.5" r="1.5"/></g></svg>'; } // #endregion const resources = [{ id: 'fleuron', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'fleuron.svg', width: 720, height: 162 }, altText: t({ en: 'A printer’s flower: a four-petalled rosette between two leafy scrolls.', es: 'Un florón de imprenta: una roseta de cuatro pétalos entre dos volutas con hojas.' }) }]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display, label and figure faces Lora: ['400', '400i'], Fraunces: ['300', '300i', '400', '600', '900'], Geist: ['400', '400i', '500', '600', '700'], 'Geist Mono': ['500', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('fleuron.svg', fleuron(palette.lapis)); // a picture's colours are fixed when drawn const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Five chapter openers', es: 'Cinco aperturas de capítulo' }) });
Kit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes// ─── 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 · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Envoyer chaque chapitre sur un recto

Avec 'odd', chaque chapitre s’ouvre sur une page de droite et les chapitres deux à cinq reçoivent chacun un verso blanc avant eux : le livre passe de 7 à 11 pages.

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

#Réduire la lettrine à trois lignes

Sans fontSize, le corps suit le nombre de lignes : une lettrine sur trois lignes s’aligne donc toujours sur les capitales de la première ligne.

-  dropCap: { lines: 5, fontFamily: 'Fraunces', fontWeight: 400, color: col('accent'),
+  dropCap: { lines: 3, fontFamily: 'Fraunces', fontWeight: 400, color: col('accent'),

#Ajouter des petites capitales après la lettrine

leadIn compose les premiers mots après la lettre en petites capitales, comme le font beaucoup de romans ; words: 'line' prend toute la première ligne.

-    gap: mm(2.5) } });
+    gap: mm(2.5), leadIn: { words: 'line' } } });

Erreurs fréquentes

Erreur fréquente

Valeurs d’attribut : pas de { ni de } ; une valeur avec " entre apostrophes simples

Une valeur d’attribut s’arrête à l’accolade fermante : elle ne peut donc contenir ni { ni }. Une valeur qui contient un guillemet double se met entre apostrophes simples ; un signe dollar ne pose pas de problème. Attributs de titre →

  • Les chapeaux sont composés en drapeau, comme dans une ouverture de magazine ; un chapeau justifié à côté d’une lettrine sur cinq lignes peut ouvrir grand ses espaces sur une ligne de peu de mots.
  • La boîte magenta est ajustée à la main au Lora 12/17, au M de Geist Mono et à la ligne de base, à 0,8 de la hauteur de sa boîte de ligne (BASE). Si vous changez la police, le corps ou l’interlignage du chapeau, la lettre, le nombre de lignes ou la hauteur de l’aplat, vérifiez de nouveau la boîte par rapport aux capitales de la première ligne.
  • Les titres ne pivotent jamais et ne se composent jamais à la verticale : le texte de maquette est toujours horizontal. Empilez plutôt les mots avec \\, comme le fait le troisième chapitre.

Crédits

Texte
Texte original, CC BY 4.0
Polices
Lora (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Geist (SIL OFL 1.1) · Geist Mono (SIL OFL 1.1)
Sandbox