Aller au contenu principal
Recette numéro 16

Recettes · Chapitre 2 · Caractères et texte

De l’espagnol justifié dans un roman de poche

Le début du chapitre I de Marianela en poche : césure espagnole, espaces sous 1,7×, aucune fin trop courte, lettrine montante et Figura 1 sur le plan.

p. 8–9 · 1–2 sur 5

  • Exemple en espagnol : pas encore d’édition française
  • Format 115 × 180 mm
  • 1 colonne
  • Gentium Book Plus 10/13,8
  • Libre Bodoni
  • Marcellus SC
  • 5 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 63 ms
  • 177 lignes de code

En bref

Les premières pages d’un roman espagnol de 1878 en livre de poche. On y voit comment garder des espaces réguliers entre les mots, coupés aux bonnes syllabes espagnoles.

Ce que vous allez composer

Le début de Marianela de Galdós (1878) dans une édition de poche de 11,5 × 18 cm, avec un plan conjectural des mines de Socartes face au chapitre I. Au-dessus du titre du chapitre en Libre Bodoni italique, capítulo primero est écrit en toutes lettres en petites capitales ; le texte, en Gentium Book Plus, commence au quart de la hauteur par une initiale montante couleur sang-de-bœuf. Les mots se coupent aux syllabes espagnoles (hie-rro, esta-blecimiento) et les espaces entre les mots restent sous 1,7 fois leur largeur normale ; aucun paragraphe ne se termine sur la queue d’un mot coupé. Le dialogue est composé avec des rayas, les tirets cadratins qui ouvrent et ferment aussi les incisos (incises) du narrateur. Le mot de passe du voyageur est entre « comillas latinas », et entre guillemets anglais doubles là où la note de l’édition le cite à l’intérieur d’une citation. Le plan est la Figura 1, citée dans le texte sous la forme [fig. 1]. Des titres courants en Marcellus SC nomment l’auteur sur les versos et le titre sur les rectos.

Cette recette répond à

  • Comment obtenir une bonne justification et une bonne césure pour un texte en espagnol, en français ou en allemand ?
  • Comment éviter veuves, orphelines et dernières lignes d’un seul mot, et garder un titre avec son texte ?
  • Comment écrire des tirets de dialogue, des années en début de paragraphe, des prix et des symboles littéraux sans que le Markdown les interprète mal ?
  • Comment obtenir les étiquettes « Figure » et « Tableau » dans la langue de mon document ?
  • Comment ajouter à une ouverture une ligne d’auteur, un chapeau ou une attaque avec lettrine ?

La réponse courte

script.js · lignes 47–62dans le code complet
const LOCALE = 'es'; // config().locale: Spanish hyphenation, and Spanish caption labels
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // a paragraph after a heading starts flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes: the built-in types, 'Figura' and 'Fig.' in Spanish, renumbered
// through the book (Figura 1) where the default counts by chapter (Figura 1.1).
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));

Ingrédients

Typographie
Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1)
Ressources
  • The conjectural map of the Socartes mines, drawn in code in the page’s palette (Ignacio Ferro, MIT)

Préparation

#1 · Syllabes espagnoles, espaces contenues

Le code est dans la réponse courte plus haut. locale: 'es' choisit les motifs de césure espagnols, comme le fait une étiquette régionale telle que 'es-ES' ou 'es-MX' ; les règles américaines de la locale par défaut couperaient cabal-los, hi-erro et establec-imiento (langues prises en charge). La justification, Knuth–Plass et les règles de veuves et d’orphelines sont actives par défaut. La justification de 86 mm demande aussi de contenir ses espaces : avec le maxWordSpacing: 2 par défaut, huit lignes s’ouvrent au-delà de 1,6× et la plus large atteint 1,89× ; à 1,7, la plus large est à 1,65×, car l’algorithme de coupure coupe plutôt hie-rro et ca-lles (bornes de l’espacement des mots). Au-delà de la limite, une ligne coûte bien plus cher mais reste permise : sur une justification plus étroite, sans meilleure coupure, elle peut donc encore la dépasser. runtMinCharacters: 26 attrape les bouts de mots coupés que laisse passer la valeur par défaut de 20 largeurs d’espace : siem- / pre adelante. devient Adelante, / siempre adelante., et So- / cartes [fig. 1]. devient de / Socartes [fig. 1]. (fins de paragraphe trop courtes).

#2 · Une page de poche en lignes entières

script.js · lignes 35–43dans le code complet
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};

La marge du bas se calcule à partir du format, de la marge du haut et de LINES : le bloc de texte contient exactement 30 lignes de 13,8 pt et chaque page pleine se termine sur la même ligne de base, comme le font les pages 10 et 11. Avec mirror: true, la marge intérieure de 15 mm reste au dos et la marge extérieure de 14 mm du côté de la tranche sur les deux pages d’une double page (marges en miroir), et la justification de 86 mm contient environ 57 caractères de Gentium Book Plus en 10 pt.

#3 · Le chapitre en toutes lettres et une initiale montante

script.js · lignes 66–95dans le code complet
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the text: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
const RISE = 2; // lines of LEAD the raised initial keeps clear above the first line
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // The ordinal is written in the heading, # Perdido {ordinal="primero"}: the plate before
    // the chapter is a level-1 heading too, so the level carries no counter.
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // a longer title takes a second line
      placement: centred(TITLE_Y) },
  ] },
  // The text starts SINK lines down; the initial's rise takes the last RISE of them.
  minHeight: pt((SINK - RISE) * LEAD),
};
// parity 'any': each chapter opens on the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own. The first paragraph
// of the chapter opens with a raised initial: lines: 1 stands it on the first baseline,
// three leads tall, and the paragraph keeps its rise clear above it.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener,
  dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
    color: col('oxblood') } };

capítulo primero vient de l’attribut ordinal du titre lui-même (attributs des titres). Un niveau numéroté avec '{1:ordinal}' écrirait le mot de lui-même, mais la planche qui précède le chapitre est aussi un titre de niveau 1 : ce niveau ne porte donc pas de compteur. L’initiale appartient au niveau des chapitres : son dropCap ouvre le premier paragraphe après chaque titre de niveau 1, et le paragraphe est écrit dans le Markdown tel qu’il se lit (lettrines). lines: 1 avec un fontSize de trois interlignes en fait une initiale montante, posée sur la première ligne de base ; la première ligne est raccourcie de sa largeur et de l’écart par défaut, puis justifiée avec le reste du paragraphe. La lettre monte au-dessus de la première ligne, et le paragraphe garde cette hauteur libre au-dessus de lui en lignes entières : deux ici (RISE), si bien que minHeight termine l’ouverture à SINK - RISE lignes et que la première ligne de texte tombe toujours sur la ligne SINK, au quart de la hauteur. Le marginBottom: 0 du niveau des chapitres remplace la marge par défaut, qui ajouterait une ligne blanche. Un paragraphe avec lettrine abandonne son retrait de première ligne.

Page 9 : l’étiquette et le titre du chapitre, et l’initiale montante à côté de la première ligne du texte justifié.

#4 · Le plan face au chapitre, en Figura 1

script.js · lignes 120–141dans le code complet
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };

position: 'here' place le plan là où se trouve ::resource{id="plano"}, sous un titre à lui. Ce titre fait de la page 8 du livre une ouverture, et les titres courants sautent les ouvertures. Son style vide le pied de page et règle son propre saut, ce qui garde la planche en face du chapitre I, sans titre courant ni folio, même quand les chapitres s’ouvrent sur des rectos (styles de titre). Les types espagnols de la réponse courte impriment Figura 1. sous le plan (sans resourceTypes, on lit Figura 1.1, numéroté par chapitre) et Fig. 1 dans le texte, que case="lower" dans le :ref du Markdown transforme en fig. 1. Le renvoi est entre crochets, ce qu’utilisent les éditions espagnoles pour marquer ce que l’éditeur ajoute au texte de l’auteur.

#5 · L’auteur au verso, le titre au recto

script.js · lignes 99–116dans le code complet
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };

parity: 'even' place l’auteur sur les versos et 'odd' le titre sur les rectos, et pages: 'body' les écarte tous deux de l’ouverture, où le seul élément de page est le folio en pied réglé avec pages: 'opener'. Chaque titre courant est ancré à la page et déplacé de SHIFT sur le centre du bloc de texte, qui se trouve à un demi-millimètre du centre de la page parce que les marges intérieure et extérieure diffèrent. Le folio en pied existe en deux parités pour la même raison. Les folios sont composés en Gentium, car Marcellus SC dessine le 1 et le 0 comme un I et un O.

#6 · La ponctuation espagnole dans le Markdown

script.js · lignes 145–153dans le code complet
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink: a muted note would need italicColor too, for its italic Marianela.
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];

La page 12, la dernière, se termine sur trois styles de paragraphe. Les astérisques, en Bodoni sang-de-bœuf, sont centrés après un :::space{lines=2}, puisque les lignes vides n’ajoutent aucun blanc. La note de l’édition est en 8,3 pt sur un interlignage de 11 pt, et le colophon en 7,5 pt dans le gris des titres courants. La note reste à l’encre ; passez-la en gris et le style demande aussi un italicColor, sinon le Marianela en italique s’imprime plus foncé que les mots qui l’entourent. Dans le Markdown, le dialogue s’ouvre par une raya (—), jamais par un trait d’union, car un trait d’union suivi d’une espace en début de ligne ouvre une liste. 1878. au début de la note ouvrirait une liste numérotée : un gluon de mots (U+2060) se place donc devant l’année. Les astérisques sont échappés (\*) ; sans échappement, le premier ouvrirait une liste à puces et les deux autres un passage en italique, et la ligne disparaîtrait.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 016 · Justified Spanish in a pocket novel ═════════════════
// https://postext.dev/en/cookbook/spanish-pocket-novel
// Code: MIT · Text: B. Pérez Galdós, Marianela, 1878 (PD, Gutenberg #17340) · Map: drawn in code
// Fonts: Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, defaultResourceTypes, registerResourceImage,
  inlineSvgFonts,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'spanish-pocket-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1e1b18', // text: a warm near-black
  oxblood: '#8b2e2a', // the one accent: chapter label, rule, initial, asterisks, route
  muted: '#6d645a', // running heads, the map's paths, the caption's credit
  paper: '#f7f2e8', // an ivory book paper
  slag: '#c8553d', moss: '#6f7a4f', water: '#58707f', // the map: mined earth, woods, river
};
// col(id): a colour linked to its palette entry, in text styles and design slots alike.
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' } })),
  { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const TEXT = 'Gentium Book Plus'; // the text face
const DISPLAY = 'Libre Bodoni'; // the chapter's title and the initial
const LABEL = 'Marcellus SC'; // small capitals: chapter label, running heads, map names
const [BODY, LEAD] = [10, 13.8]; // pt: the body size, and its leading: the grid's pitch
const smallCaps = { fontFamily: LABEL, fontSize: pt(9), letterSpacing: pt(1.6), // 2 labels
  color: col('oxblood'), align: 'center' };

// #region page: a pocket paperback; the text block holds 30 whole lines
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
// #endregion

// #region answer: Spanish syllables, word spaces under 1.7×, captions that say Figura
const LOCALE = 'es'; // config().locale: Spanish hyphenation, and Spanish caption labels
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // a paragraph after a heading starts flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes: the built-in types, 'Figura' and 'Fig.' in Spanish, renumbered
// through the book (Figura 1) where the default counts by chapter (Figura 1.1).
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
// #endregion

// #region opener: the chapter spelled out, its title, and a raised initial on the text
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the text: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
const RISE = 2; // lines of LEAD the raised initial keeps clear above the first line
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // The ordinal is written in the heading, # Perdido {ordinal="primero"}: the plate before
    // the chapter is a level-1 heading too, so the level carries no counter.
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // a longer title takes a second line
      placement: centred(TITLE_Y) },
  ] },
  // The text starts SINK lines down; the initial's rise takes the last RISE of them.
  minHeight: pt((SINK - RISE) * LEAD),
};
// parity 'any': each chapter opens on the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own. The first paragraph
// of the chapter opens with a raised initial: lines: 1 stands it on the first baseline,
// three leads tall, and the paragraph keeps its rise clear above it.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener,
  dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
    color: col('oxblood') } };
// #endregion

// #region heads: the author on the verso, the title on the recto; a drop folio on the opener
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
// #endregion

// #region figure: the map faces chapter I as Figura 1; the text cites it as [fig. 1]
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
// #endregion

// #region styles: the asterisks that close the excerpt, the edition's note, the colophon
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink: a muted note would need italicColor too, for its italic Marianela.
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
// #endregion

const config = () => ({
  locale: LOCALE,
  resourceTypes,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    fontFamily: DISPLAY, // the designs paint the titles; this keeps Open Sans unloaded
    levels: [chapter],
  },
  headingStyles: [frontispiece],
  paragraphStyles,
  captionStyle,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Exemple Markdown · 45 lignes · content.es.mdtitle: "Marianela" author: "Benito Pérez Galdós" --- # Las minas de Socartes {style="lamina"} ::resource{id="plano"} # Perdido {ordinal="primero"} Se puso el sol. Tras el breve crepúsculo vino tranquila y oscura la noche, en cuyo negro seno murieron poco a poco los últimos rumores de la tierra soñolienta, y el viajero siguió adelante en su camino, apresurando su paso a medida que avanzaba la noche. Iba por angosta vereda, de esas que sobre el césped traza el constante pisar de hombres y brutos, y subía sin cansancio por un cerro en cuyas vertientes se alzaban pintorescos grupos de guinderos, hayas y robles. (Ya se ve que estamos en el Norte de España.) Era un hombre de mediana edad, de complexión recia, buena talla, ancho de espaldas, resuelto de ademanes, firme de andadura, basto de facciones, de mirar osado y vivo, ligero a pesar de su regular obesidad, y (dígase de una vez aunque sea prematuro) excelente persona por doquiera que se le mirara. Vestía el traje propio de los señores acomodados que viajan en verano, con el redondo sombrerete, que debe a su fealdad el nombre de hongo, gemelos de campo pendientes de una correa, y grueso bastón que, entre paso y paso, le servía para apalear las zarzas cuando extendían sus ramas llenas de afiladas uñas para atraparle la ropa. Detúvose, y mirando a todo el círculo del horizonte, parecía impaciente y desasosegado. Sin duda no tenía gran confianza en la exactitud de su itinerario y aguardaba el paso de algún aldeano que le diese buenos informes topográficos para llegar pronto y derechamente a su destino. —No puedo equivocarme —murmuró—. Me dijeron que atravesara el río por la pasadera… así lo hice. Después que marchara adelante, siempre adelante. En efecto, allá, detrás de mí queda esa apreciable villa, a quien yo llamaría *Villafangosa* por el buen surtido de lodos que hay en sus calles y caminos… De modo que por aquí, adelante, siempre adelante (me gusta esta frase, y si yo tuviera escudo no le pondría otra divisa) he de llegar a las famosas minas de Socartes [:ref{id="plano" case="lower"}]. Después de andar largo trecho, añadió: —Me he perdido, no hay duda de que me he perdido… Aquí tienes, Teodoro Golfín, el resultado de tu «adelante, siempre adelante». Estos palurdos no conocen el valor de las palabras. O han querido burlarse de ti, o ellos mismos ignoran dónde están las minas de Socartes. Un gran establecimiento minero ha de anunciarse con edificios, chimeneas, ruido de arrastres, resoplido de hornos, relincho de caballos, trepidación de máquinas, y yo no veo, ni huelo, ni oigo nada… Parece que estoy en un desierto… ¡qué soledad! Si yo creyera en brujas, pensaría que mi destino me proporcionaba esta noche el honor de ser presentado a ellas… ¡Demonio!, ¿pero no hay gente en estos lugares?… Aún falta media hora para la salida de la luna. ¡Ah!, bribona, tú tienes la culpa de mi extravío… Si al menos pudiera conocer el sitio donde me encuentro… ¿Pero qué más da? (Al decir esto, hizo un gesto propio del hombre esforzado que desprecia los peligros). Golfín, tú que has dado la vuelta al mundo, ¿te acobardarás ahora?… ¡Ah!, los aldeanos tenían razón: adelante, siempre adelante. La ley universal de la locomoción no puede fallar en este momento. Y puesta denodadamente en ejecución aquella osada ley, recorrió un kilómetro, siguiendo a capricho las veredas que le salían al paso y se cruzaban y se quebraban en ángulos mil, cual si quisiesen engañarle y confundirle más. Por grande que fuera su resolución e intrepidez, al fin tuvo que pararse. Las veredas, que al principio subían, luego empezaron a bajar, enlazándose; y al fin bajaron tanto, que nuestro viajero hallose en un talud, por el cual solo habría podido descender echándose a rodar. —¡Bonita situación! —exclamó sonriendo y buscando en su buen humor lenitivo a la enojosa contrariedad—. ¿En dónde estás, querido Golfín? Esto parece un abismo. ¿Ves algo allá abajo? Nada, absolutamente nada… pero el césped ha desaparecido, el terreno está removido. Todo es aquí pedruscos y tierra sin vegetación, teñida por el óxido de hierro… Sin duda estoy en las minas… pero ni alma viviente, ni chimeneas humeantes, ni ruido, ni un tren que murmure a lo lejos, ni siquiera un perro que ladre… ¿Qué haré?, hay por aquí una vereda que vuelve a subir. ¿Seguirela? ¿Desandaré lo andado?… ¡Retroceder! ¡Qué absurdo! O yo dejo de ser quien soy, o llegaré esta noche a las famosas minas de Socartes y abrazaré a mi querido hermano. Adelante, siempre adelante. Dio un paso y hundiose en la frágil tierra movediza. —¿Esas tenemos, señor planeta?… ¿Conque quiere usted tragarme?… Si ese holgazán satélite quisiera alumbrar un poco, ya nos veríamos las caras usted y yo… Y a fe que por aquí abajo no hemos de ir a ningún paraíso. Parece esto el cráter de un volcán apagado… Hay que andar suavemente por tan delicioso precipicio. ¿Qué es esto? ¡Ah! Una piedra; magnífico asiento para echar un cigarro, esperando a que salga la luna. El discreto Golfín se sentó tranquilamente como podría haberlo hecho en el banco de un paseo; y ya se disponía a fumar, cuando sintió una voz… sí, indudablemente era una voz humana que lejos sonaba, un quejido patético, mejor dicho, melancólico canto, formado de una sola frase, cuya última cadencia se prolongaba apianándose en la forma que los músicos llamaban *morendo*, y que se apagaba al fin en el plácido silencio de la noche, sin que el oído pudiera apreciar su vibración postrera. :::space{lines=2} :::paragraphs{style="asterismo"} \*  \*  \* ::: :::paragraphs{style="nota"} ⁠1878. Galdós publica *Marianela* en Madrid, en la Imprenta y Litografía de La Guirnalda. Seguimos el comienzo de su capítulo primero en el texto de dominio público de Project Gutenberg (eBook 17340), con la ortografía y la puntuación actuales: raya en los diálogos y comillas latinas para la consigna del viajero, que se vuelven inglesas cuando la cita va dentro de otra: «el resultado de tu “adelante, siempre adelante”». Los corchetes señalan lo que añade el editor. ::: :::paragraphs{style="colofon"} Compuesto en Gentium Book Plus, Libre Bodoni y Marcellus SC (SIL OFL) :::
`; // content.es.md, inlined by the Cookbook // #region art: the map of Socartes, in millimetres at its printed size, in the palette function mulberry32(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const n2 = (v) => +v.toFixed(2); // A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed. function smooth(list, close = false) { const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)]; let d = `M${n2(p[1][0])} ${n2(p[1][1])}`; for (let i = 1; i < p.length - 2; i++) { const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]]; d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} ` + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`; } return close ? `${d}Z` : d; } // The map's names are set in the label face, named on the root element: loadSvg embeds it. function drawMap(W, H) { // drawn for W 86 × H 112: the mines above, the town below const rnd = mulberry32(1878); const C = { ground: mix(palette.paper, palette.moss, 0.16), hill: mix(palette.moss, palette.paper, 0.45), wood: palette.moss, shade: mix(palette.moss, palette.ink, 0.35), earth: mix(palette.slag, palette.paper, 0.3), bench: palette.slag, deep: mix(palette.slag, palette.ink, 0.2), pit: mix(palette.slag, palette.ink, 0.45), edge: mix(palette.slag, palette.ink, 0.5), water: palette.water, ink: palette.ink, path: palette.muted, route: palette.oxblood, stream: mix(palette.water, palette.ink, 0.3), // the river's name: 5.7:1 on the ground }; const out = [`<rect width="${W}" height="${H}" fill="${C.ground}"/>`]; const fill = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`); const stroke = (d, color, w, extra = '') => out.push(`<path d="${d}" fill="none" ` + `stroke="${color}" stroke-width="${w}" stroke-linecap="round" ` + `stroke-linejoin="round"${extra}/>`); // The names, set first so that no tree grows over them. const labels = [[33, 30.5, 'minas de', 'end'], [33, 34, 'Socartes', 'end'], [34.4, 19, 'talleres', 'end'], [43.6, 52.4, 'talud', 'end', C.route], [12, 70.5, 'cerro', 'middle'], [25.5, 96.4, 'pasadera'], [70, 94.5, 'río', 'middle', C.stream], [3, 109.6, 'Villafangosa'], [5, 11.4, 'N', 'middle']]; const box = ([x, y, text, anchor = 'start']) => { const w = text.length * 1.45; const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x; return [left - 0.8, y - 2.6, left + w + 0.8, y + 0.8]; }; const clear = (x, y) => labels.map(box).every(([a, b, c, d]) => x < a || x > c || y < b || y > d); // The mines: iron-stained earth cut in benches down to the pit, the "extinct crater". const mine = [[40, 6], [58, 3], [76, 5], [84, 17], [83, 35], [74, 47], [58, 52], [45, 50], [36, 40], [35, 20]]; [[1, C.earth], [0.76, C.bench], [0.52, C.deep], [0.28, C.pit]].forEach(([k, color], i) => { const [cx, cy] = [60 + i * 1.6, 27 - i * 1.4]; // each bench deeper, the pit off-centre const ring = mine.map(([x, y]) => [cx + (x - 60) * k + (rnd() - 0.5) * 2.4 * k, cy + (y - 27) * k + (rnd() - 0.5) * 2.4 * k]); out.push(`<path d="${smooth(ring, true)}" fill="${color}" stroke="${C.edge}" ` + 'stroke-width="0.25"/>'); }); fill('M28.4 11.6h6.5v3.2h-6.5zM33 7h0.9v4.7h-0.9z', C.ink); // workshops, chimney, by the rim // The hill of cherry, beech and oak, and the paths that cross it at a thousand angles. out.push(`<path d="${smooth([[19, 73], [23, 61], [36, 56], [50, 59], [55, 72], [49, 85], [35, 90], [23, 84]], true)}" fill="${C.hill}" stroke="${C.shade}" stroke-width="0.25"/>`); for (let i = 0; i < 100; i++) { const a = rnd() * Math.PI * 2; const r = Math.sqrt(rnd()) * 16; const [x, y] = [37 + Math.cos(a) * r, 73 + Math.sin(a) * r]; const [size, shade] = [0.55 + rnd() * 0.55, rnd() < 0.3 ? C.shade : C.wood]; if (clear(x, y)) { out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(size)}" fill="${shade}"/>`); } } [[[22, 80], [30, 74], [28, 64], [38, 58]], [[26, 62], [36, 70], [46, 64], [52, 70]], [[27, 87], [33, 79], [42, 82], [50, 76]], [[38, 57], [40, 67], [33, 76], [37, 89]]] .forEach((path) => stroke(smooth(path), C.path, 0.3, ' stroke-dasharray="1 0.8"')); // The river, the footbridge, and the town the traveller left behind. stroke(smooth([[0, 92], [12, 93], [22, 99], [34, 100], [50, 96], [66, 98], [W + 1, 102]]), C.water, 2.4); stroke('M20.4 95.2L18.6 101.2', C.ink, 0.7); for (let i = 0; i < 18; i++) { const [x, y] = [3 + (i % 6) * 2.2 + rnd() * 0.5, 98.6 + Math.floor(i / 6) * 2.3 + rnd() * 0.5]; fill(`M${n2(x)} ${n2(y)}h1.5v1.2h-1.5z`, C.ink); } fill('M5.2 94.4h1v2.4h-1zM4 96.2h3.4v2h-3.4z', C.ink); // the church // Golfín's walk: over the footbridge, up the hill by its paths, down to the edge. stroke(smooth([[12, 101], [17, 99.4], [21, 94], [25, 88], [31, 84], [27, 78], [33, 72], [40, 75], [45, 68], [40, 62], [45.3, 53.6]]), C.route, 0.75, ' stroke-dasharray="0.05 1.3"'); out.push(`<circle cx="46.4" cy="51.6" r="1.4" fill="none" stroke="${C.route}" ` + 'stroke-width="0.6"/>'); // North, up: a half-inked arrowhead over an N. fill('M5 3.2L6.6 8.2L5 7.2Z', C.ink); out.push(`<path d="M5 3.2L3.4 8.2L5 7.2Z" fill="none" stroke="${C.ink}" stroke-width="0.2"/>`); labels.forEach(([x, y, text, anchor = 'start', color = C.ink]) => out.push(`<text x="${x}" ` + `y="${y}" text-anchor="${anchor}" fill="${color}">${text}</text>`)); out.push(`<rect x="0.2" y="0.2" width="${W - 0.4}" height="${H - 0.4}" fill="none" ` + `stroke="${C.ink}" stroke-width="0.4"/>`); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" ` + `viewBox="0 0 ${W} ${H}" font-family="${LABEL}" font-size="2.35">${out.join('')}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Gentium Book Plus': ['400', '400i', '700'], // text, captions (700: 'Figura 1.'), the note 'Libre Bodoni': ['400', '400i', '700'], // asterisks, the chapter's title, the initial 'Marcellus SC': ['400'], // chapter label, plate name, running heads }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('plano.svg', drawMap(MEASURE, MAP_H)); // The map is book page 8, a verso, facing chapter I: folios and parity follow the book. const continuation = { pageIndexOffset: 7, pageNumbering: { startAt: 8 } }; const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(), kitFonts(FONTS)); showPages(doc, { title: 'Español justificado en una novela de bolsillo' });
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

#Ouvrir chaque chapitre sur un recto

Avec 'odd', un chapitre qui commencerait sur un verso reçoit une page blanche avant lui, comme dans une édition plus aérée. Le chapitre I s’ouvre déjà sur un recto, et la planche garde sa page parce que son style règle son propre saut.

-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },

#Numéroter les figures par chapitre

Laissés tels quels, les types espagnols intégrés numérotent les figures au sein de chaque titre de premier niveau : le plan devient Figura 1.1 et le renvoi fig. 1.1.

-const resourceTypes = defaultResourceTypes(LOCALE)
-  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);

Erreurs fréquentes

Erreur fréquente

'1998. ' ou '- ' en début de paragraphe ouvre une liste

Un paragraphe qui commence par un nombre, un point et une espace, ou par un trait d’union et une espace, devient un élément de liste. Mettez un word joiner (U+2060) devant le nombre, et écrivez les dialogues avec un tiret cadratin. Échappements et caractères littéraux →

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 →

Erreur fréquente

Un style de titre hérite du saut de page de son niveau

Une entrée headingStyles reprend de son niveau de titre chaque champ qu’elle omet, breakBefore compris. Une page de table des matières ou un colophon mis en forme sur un H1 après un :::pagebreak hérite de la parité 'odd' et atterrit derrière une page blanche. Donnez à un tel style breakBefore: { enabled: false }. Styles de titre →

  • Un abaissement de neuf lignes au lieu de huit laissait la page 11 courte d’une ligne, parce que avoidWidows refusait de commencer un paragraphe sur la dernière ligne de la page. Après tout changement des réglages ou du texte, vérifiez le pied de chaque page.
  • RISE est le nombre de lignes que l’initiale montante garde libres au-dessus du texte. Un autre caractère ou un autre corps change cette hauteur : une initiale plus petite peut n’avoir besoin que d’une ligne, et le texte commence alors une ligne plus haut, à moins que minHeight ne change avec elle.
  • Aucun de ces caractères n’a l’astérisme ⁂ (U+2042) dans son fichier latin Fontsource : le navigateur le prendrait donc dans une police système. Les livres espagnols marquent de toute façon la pause par trois astérisques espacés.

Crédits

Texte
  • Marianela (1878), the opening of chapter I, “Perdido”, with today’s spelling and punctuation · Benito Pérez Galdós · domaine public
  • The edition’s note and the map’s caption · Postext Cookbook · original
Images
  • The conjectural map of the Socartes mines, drawn in code in the page’s palette · Ignacio Ferro · MIT
Polices
Gentium Book Plus (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Marcellus SC (SIL OFL 1.1)
Sandbox