En bref
Trois pages d’un essai sur la mise en page des livres anciens. On y voit comment placer chaque ligne de texte à la même hauteur dans les deux colonnes et sur les deux pages en vis-à-vis.
Ce que vous allez composer
Les pages 9 à 11 de La page réglée, un recueil consacré au livre, contiennent « Le canon secret », un essai sur les proportions que les copistes médiévaux donnaient à leurs pages. Ses marges suivent le canon qu’il décrit, 2:3:4:6 et en miroir, sur un format de 210 × 280 mm autour de deux colonnes de Vollkorn. Une grille de ligne de base est tracée sur les pages en rouge pâle, et le texte courant des deux colonnes et des deux pages en vis-à-vis repose dessus. Le titre en Playfair Display remplit la première colonne de l’ouverture et le dessin du canon coiffe la seconde. Les intertitres se placent à une demi-ligne de la grille, et le texte qui les suit y revient. Une note d’atelier en plus petit corps suit son propre interlignage de 10 pt. À la page 11, l’essai se termine sous une figure sur toute la largeur de la page, sur deux colonnes coupées à la même longueur, et le colophon ferme la seconde.
Cette recette répond à
- Comment régler une page de livre : format, marges intérieure et extérieure en miroir, deux colonnes et une gouttière ?
- Comment caler le texte sur une grille de ligne de base pour que les lignes s’alignent entre colonnes et pages en regard ?
- Comment obtenir des colonnes alignées en pied et une dernière page équilibrée (justification verticale) ?
- Comment éviter veuves, orphelines et dernières lignes d’un seul mot, et garder un titre avec son texte ?
La réponse courte
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
backgroundColor: col('paper'),
margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
// The layout keeps to the grid whether or not the overlay draws it.
baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
Ingrédients
- Fonctionnalités
- Marges en miroirGrille de ligne de baseÉquilibrage des colonnesSortir de la grille volontairementPlacement des figuresVeuves, orphelines et fins de paragraphe trop courtesStyles de paragrapheFormat de la pageUne ou deux colonnesFilet de colonneUnités et dimensionsTexte courantListes à puces et listes de contrôleListes numérotéesNiveaux de titreTitres courants et foliosTitres courants selon le rôle de la pageFigures et tableaux comme ressourcesPages sur un canvas
- Typographie
- Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1)
- Ressources
- Les deux dessins, réalisés en code avec la palette de la page (Postext Cookbook, CC BY 4.0)
Préparation
#1 · Les titres peuvent quitter la grille, les listes jamais
headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
levels: [
// parity 'odd': the essay opens on a recto.
// Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
{ level: 1, breakBefore: { enabled: true, parity: 'odd' },
fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
marginBottom: pt(LEAD) },
// A lead and a half above puts the head between two grid lines. The margin below
// needs no fitting: the engine snaps the text under a head to the next grid line.
{ level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
] },
// Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
L’espace au-dessus d’un titre est conservé tel qu’il est réglé : 1,5 interligne place donc le titre entre deux lignes de la grille, et le texte qui le suit est calé sur la ligne suivante (grille de ligne de base). Une liste n’est pas calée avant son premier élément : la réponse courte met donc ses marges à zéro (n’importe quel nombre entier d’interlignes conviendrait aussi) ; avec la valeur par défaut de 1,5 em, les trois éléments de la page 10 tomberaient 1,3 pt sous les lignes de la colonne voisine. Les deux lignes de 46 pt du titre prennent chacune quatre interlignes et sa marge inférieure un de plus : l’essai commence donc à la ligne 10.

#2 · Quitter la grille exprès, puis y revenir
paragraphStyles: [
{ id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
{ id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
],
La note est un conteneur :::paragraphs composé en 7,8 sur 10 pt, hors grille. Après son dernier paragraphe, la position est arrondie à la ligne de grille suivante, si bien que le titre qui vient ensuite se place là où il serait après du texte courant (le conteneur :::paragraphs). Le colophon utilise le même conteneur en 7,5 sur 10 pt : ses quatre lignes prennent 40 pt, 0,2 pt de moins que trois interlignes, et la dernière colonne de l’essai se termine ainsi au même niveau que sa voisine.
#3 · Placer les figures là où la grille peut les absorber
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
// A float never lands above the paragraph that cites it. A column 'top' float cited in
// the first column can still take the head of the second, on the same page.
figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
// A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
// Each band is rounded up to whole leads, so the text under it stays on the grid.
figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
Un flottant ne se place jamais au-dessus du paragraphe qui le cite (placement). Le canon, cité dans la première colonne, peut encore prendre la tête de la seconde à la page 9 ; le dessin comparatif sur toute la largeur, cité à la page 10, attend la page 11. Chaque bande est arrondie à un nombre entier d’interlignes : le texte sous un dessin commence donc sur une ligne de la grille, au niveau de la colonne voisine et de la page en vis-à-vis. Dimensionnez un dessin en fonction de cet arrondi : à 61,8 mm, la bande du canon, légende comprise, prend 17 interlignes ; à 62,5 mm, elle en prendrait 18 et laisserait presque une ligne de blanc sous la légende.

#4 · Laisser l’équilibrage mettre les colonnes d’équerre
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
const balancing = { enabled: true };
Chaque colonne pleine des pages 9 et 10 se termine à la ligne 44, parce que le bloc de texte mesure exactement 44 interlignes et que le texte a été calibré pour lui. Le maintien avec le paragraphe suivant et les règles contre les veuves, les orphelines et les fins de paragraphe trop courtes sont actifs par défaut. Quand l’une d’elles termine une colonne avec une ligne de moins, l’équilibrage place l’espace manquant au-dessus d’un titre précédent (équilibrage des colonnes), où il se voit comme un trou : calibrez donc le texte jusqu’à ce que l’équilibrage n’ait plus rien à combler. Sur la dernière page, il répartit les lignes restantes pour que les deux colonnes se terminent sur la même ligne.
#5 · Mettre les marges en miroir et numéroter à partir de la page 9
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
color: col('rubric') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
La réponse courte calcule la marge de pied : le format de 280 mm, moins la marge de tête de 24 mm et un bloc de texte d’exactement LINES interlignes (44 × 13,4 pt = 208 mm), laisse 48 mm, soit six unités, comme l’exige la progression 2:3:4:6. Avec mirror: true, les 16 mm réglés en left forment la marge intérieure, à gauche d’un recto et à droite d’un verso (marges en miroir) : les titres courants ancrés à OUTER millimètres du bord de la page s’arrêtent donc là où s’arrête le bloc de texte, du côté de la tranche. pages: 'body' les tient à l’écart de l’ouverture. startAt: 9 imprime les folios 9 à 11, et pageIndexOffset: 8 compte les huit pages qui les précèdent : le miroir, la parité des titres courants et breakBefore: 'odd' suivent ainsi le livre relié. Avec un décalage de 7, la première page compterait comme un verso, et le saut sur page impaire placerait une page blanche avant l’essai.
La recette complète
// ═══ Postext Cookbook · Nº 013 · A book page on a baseline grid ═════════════════════ // https://postext.dev/en/cookbook/baseline-grid-book-page // Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0) // Fonts: Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0 // An essay on the canon of page proportions, on a page that shows its geometry and grid. import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'fr'; // @lang: the language of the sample document ('en' | 'es' | 'pt' | 'fr') const RECIPE = 'baseline-grid-book-page'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // A scribe's colours: ink, one rubric red and the pale red of a ruled page. const palette = { ink: '#211d1a', // text: a warm near-black rubric: '#b3261e', // the one accent: title, numbers, folios, the drawings' lines grid: '#efc6bd', // the baseline grid, as pale as a manuscript's ruling rule: '#d5cbbb', // the column rule and the drawings' hairlines muted: '#716860', // running heads, the workshop note, the colophon (5.1:1 on paper) tint: '#ede4d3', // the ground of the drawings paper: '#fbf8f1', }; 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' } })), // Defaults this config does not restate link to main-color: point it at the rubric. { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } }, ]; // #region answer: one grid for the whole spread: trim, mirrored margins, two columns, leading const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's const LINES = 44; // lines per column: the text block is a whole number of leads tall const TRIM = { width: 210, height: 280 }; // mm const MM_PER_PT = 25.4 / 72; const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units // The layout keeps to the grid whether or not the overlay draws it. baselineGrid: { enabled: true, color: col('grid') }, }; const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } }; const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) }; // Lists add no space of their own (any whole number of leads would do). The grid never // absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items // 1.3 pt off the lines of the column beside them, until the list reaches a new column. const onGrid = { marginTop: pt(0), marginBottom: pt(0) }; // #endregion // #region heads: the book's title on versos, the essay's on rectos, folios from page 9 const OUTER = 4 * UNIT; // mm: the heads end where the text block does const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below const TAB = 7; // mm from a folio to the title beside it const onPage = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(RISE) } }); const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8), color: col('muted'), ...extra, }); const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0), color: col('rubric') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio), head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)), head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))), head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio), ] }; // The opener drops its folio into the foot margin, centred under the text block. const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } }; const footer = { elements: [ head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }), ] }; // startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so // mirroring, parity and breakBefore follow the book (7 would add a blank page). const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } }; // #endregion // #region balance: full columns end flush, and the last page ends level // On by default: it fills a column a keep rule leaves short, and cuts the last page level. const balancing = { enabled: true }; // #endregion const config = () => ({ locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr' }), // hyphenation and the caption labels colorPalette, page, layout, bodyText: { ...body, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rubric'), referenceBold: false, firstLineIndent: mm(4), indentAfterHeading: false }, // hyphenation and widow rules are on // #region flow: heads that leave the grid and come back to it; lists that never leave it headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing, levels: [ // parity 'odd': the essay opens on a recto. // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10. { level: 1, breakBefore: { enabled: true, parity: 'odd' }, fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'), marginBottom: pt(LEAD) }, // A lead and a half above puts the head between two grid lines. The margin below // needs no fitting: the engine snaps the text under a head to the next grid line. { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) }, ] }, // Lists add no space of their own (onGrid, in the short answer): items stay on the grid. unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 }, orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink // #endregion // #region note: small type leaves the grid on purpose, and the flow snaps back after it paragraphStyles: [ { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'), boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) }, { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], // #endregion captionStyle: { fontSize: pt(8), labelColor: col('rubric'), descriptionItalic: true }, header, footer, }); // #region art: the two drawings, in the page's palette (lines only: no text, no filters) const BLOCK_W = TRIM.width - 6 * UNIT; // mm: the text block, 162 mm const COLUMN_W = (BLOCK_W - GUTTER) / 2; // mm: one column, 78 mm const svgFile = (w, h, markup) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"` + ` width="${w * 10}" height="${h * 10}">` + `<rect width="${w}" height="${h}" fill="${palette.tint}"/>${markup}</svg>`; const ln = (x1, y1, x2, y2, stroke, w = 0.4) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"` + ` stroke="${stroke}" stroke-width="${w}" stroke-linecap="round"/>`; const leaf = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"` + ` fill="${palette.paper}" stroke="${palette.rule}" stroke-width="0.3"/>`; const ruling = (x, y, w, h, n) => Array.from({ length: n }, (_, i) => // a ruled text block ln(x, y + (h * (i + 0.8)) / n, x + w, y + (h * (i + 0.8)) / n, palette.rule, 0.25)).join(''); const dot = (x, y) => `<circle cx="${x}" cy="${y}" r="0.8" fill="${palette.rubric}"/>`; // Figure 1: the canon constructed on an open spread of 2:3 pages (sizes in mm). function canonSvg(w, h) { const W = (w - 8) / 2, H = 1.5 * W, X = 4, Y = 3.5; // two 2:3 pages, the ninths under them const at = (fx, fy) => [X + fx * W, Y + fy * H]; // a point in page widths and heights const line = (a, b) => ln(...at(...a), ...at(...b), palette.rubric); let s = leaf(X, Y, W, H) + leaf(X + W, Y, W, H); s += ruling(...at(2 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // verso text block s += ruling(...at(10 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // recto text block s += `<circle cx="${X + W / 2}" cy="${Y + (4 * H) / 9}" r="${W / 2}" fill="none"` + ` stroke="${palette.muted}" stroke-width="0.35"/>`; // a page wide, a block tall s += ln(X + W, Y, X + W, Y + H, palette.muted, 0.5); // the spine s += line([0, 1], [2, 0]) + line([0, 0], [2, 1]); // 1 · the spread's diagonals s += line([1, 0], [0, 1]) + line([1, 0], [2, 1]); // 2 · each page's diagonal s += line([2 / 3, 1 / 3], [2 / 3, 0]) + line([4 / 3, 1 / 3], [4 / 3, 0]); // 3 · the verticals s += line([2 / 3, 0], [4 / 3, 1 / 3]) + line([4 / 3, 0], [2 / 3, 1 / 3]); // 4 · across the spine for (let i = 0; i <= 18; i++) { // the ninths, ticked under the spread const [x, y] = at(i / 9, 1); s += ln(x, y + 1.2, x, y + (i % 9 ? 2.8 : 4), palette.muted, 0.3); } for (const c of [[2 / 9, 1 / 9], [8 / 9, 1 / 9], [2 / 9, 7 / 9], [10 / 9, 1 / 9], [16 / 9, 1 / 9], [16 / 9, 7 / 9]]) s += dot(...at(...c)); // 5 · the corners the lines fix return svgFile(w, h, s); } // Figure 2: this book's page twice, at a quarter of its size. function pagesSvg(w, h) { const k = (h - 8) / TRIM.height, W = TRIM.width * k, H = TRIM.height * k, Y = 4, GAP = 20; const X1 = (w - 2 * W - GAP) / 2, X2 = X1 + W + GAP; let s = leaf(X1, Y, W, H) + leaf(X2, Y, W, H); for (let i = 1; i < 9; i++) { // the canon's ninths s += ln(X1 + (i * W) / 9, Y, X1 + (i * W) / 9, Y + H, palette.grid, 0.3); s += ln(X1, Y + (i * H) / 9, X1 + W, Y + (i * H) / 9, palette.grid, 0.3); } s += ruling(X1 + W / 9, Y + H / 9, (6 * W) / 9, (6 * H) / 9, 32); s += `<rect x="${X1 + W / 9}" y="${Y + H / 9}" width="${(6 * W) / 9}" height="${(6 * H) / 9}"` + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`; const colW = COLUMN_W * k, top = Y + 3 * UNIT * k, tall = LINES * LEAD * MM_PER_PT * k; const left = X2 + 2 * UNIT * k; for (const x of [left, left + colW + GUTTER * k]) s += ruling(x, top, colW, tall, LINES); s += `<rect x="${left}" y="${top}" width="${BLOCK_W * k}" height="${tall}"` + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`; s += ln(X1, Y, X1, Y + H, palette.muted, 0.6) + ln(X2, Y, X2, Y + H, palette.muted, 0.6); return svgFile(w, h, s); } // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 63 lignes · content.fr.md
title: "La page réglée" subtitle: "Essais sur la fabrication des livres" --- # Le canon secret Avant qu’un scribe médiéval écrive un seul mot, la page avait déjà été mesurée. Les feuillets d’un cahier étaient piqués sur leurs bords à l’alêne, puis réglés de piqûre en piqûre à la pointe sèche ou à la mine de plomb, afin que chaque ligne d’écriture ait une ligne où se poser et que chaque feuillet du livre ait le même cadre. Dans bien des manuscrits, la réglure se voit encore, discrète comme un filigrane. Elle garde la trace de deux décisions que les scribes n’ont jamais expliquées : la taille de la surface écrite, et sa place sur le feuillet. Le dessin de la :ref{id="canon" style="full" case="lower"} donne la réponse que trois hommes, travaillant chacun de leur côté au vingtième siècle, ont retrouvée en mesurant les livres anciens eux-mêmes. En 1946, J. A. van de Graaf publia dans une revue néerlandaise une construction qui trouve le bloc de texte d’une page à la seule règle, sans rien mesurer. Un an plus tard, le typographe argentin Raúl Rosarivo soutint dans *Divina proporción tipográfica* que Gutenberg, Peter Schöffer et Nicolas Jenson avaient divisé leurs pages en neuvièmes. Et Jan Tschichold, qui dans sa jeunesse avait défendu la mise en page asymétrique, consacra ses dernières années à montrer pourquoi les proportions anciennes étaient si difficiles à améliorer. Leurs résultats concordent de près. La méthode porte aujourd’hui le nom de canon secret, parce que personne ne l’a jamais mise par écrit ; on peut pourtant la mesurer dans un manuscrit après l’autre, et dans les premiers livres imprimés. ## Tracer le canon Il suffit pour la construction d’une double page ouverte et d’une règle : 1. Tracez les deux diagonales de la double page. 2. Sur chaque page, tracez la diagonale qui va du haut du dos au coin inférieur extérieur. 3. Là où elle croise une diagonale de la double page, élevez une verticale jusqu’en tête de page. 4. Joignez son sommet au croisement de la page d’en face. 5. La nouvelle ligne coupe la diagonale de la page au coin supérieur intérieur du bloc de texte. Le coin supérieur extérieur se trouve sur la diagonale de la double page, le coin inférieur extérieur sur la diagonale de la page, et le rectangle se ferme de lui-même. Ce rectangle est le bloc de texte des scribes. La marge de petit fond vaut un neuvième de la largeur de la page, celle de grand fond deux neuvièmes ; la marge de tête vaut un neuvième de sa hauteur, celle de pied deux neuvièmes. Sur une page de deux sur trois, les quatre marges suivent le rapport 2:3:4:6, dans l’ordre petit fond, tête, grand fond et pied. Le bloc de texte a les proportions de la page et il est aussi haut que la page est large, comme le montre le cercle. ## La figure de Villard Les dessinateurs médiévaux savaient aussi diviser une ligne sans règle graduée. Le carnet de Villard de Honnecourt, trente-trois feuillets de dessins du treizième siècle conservés aujourd’hui à la Bibliothèque nationale de France, contient une figure qui divise une ligne en parties égales à la seule règle : quelques diagonales tracées sur un rectangle, et chaque croisement marque le tiers, le quart ou le cinquième de son côté, aussi loin que le dessinateur veut aller. Tschichold a montré que la même figure trouve les neuvièmes d’une page. Les feuillets du carnet, tels qu’ils nous sont parvenus, mesurent environ 235 sur 155 millimètres, soit à peu près deux sur trois. Rosarivo arriva aux neuvièmes par l’autre bout. En travaillant au compas et à la règle sur des livres imprimés au quinzième siècle, il conclut que leur surface de composition avait été obtenue en divisant la page en neuf parties dans chaque sens, le texte en occupant six, et il appela « nombre secret » la proportion 2:3 de ces pages. Des experts du musée Gutenberg de Mayence examinèrent ses conclusions, que le *Gutenberg-Jahrbuch* publia de nouveau par la suite. :::paragraphs{style="note"} **Note d’atelier.** Sur une page de 160 sur 240 millimètres, le canon donne des marges de 17,8 millimètres au petit fond, 26,7 en tête, 35,6 au grand fond et 53,3 en pied, et un bloc de texte de 106,7 sur 160 millimètres, aussi haut que la page est large. Divisez la largeur et la hauteur en neuf et comptez : une part à l’intérieur, une au-dessus, deux à l’extérieur et deux au-dessous. ::: ## Des lignes où se poser La réglure fixait les lignes autant que le cadre. Jusqu’au début du treizième siècle, la plupart des scribes écrivaient leur première ligne au-dessus de la première réglure ; en une génération, ils se mirent à la suspendre au-dessous, un changement assez régulier pour que les paléographes s’en servent pour dater les manuscrits. N. R. Ker, qui le décrivit en 1960, le situa vers 1230. La réglure dessinait aussi les colonnes : des paires de lignes verticales bornaient chaque colonne d’un livre à deux colonnes, et le filet qui sépare les colonnes de cette page en est la dernière trace. Les imprimeurs gardèrent la même discipline. La Bible de Gutenberg est composée sur deux colonnes de quarante lignes dans ses premières pages, de quarante et une à la page dix et de quarante-deux sur presque toutes les suivantes, d’où le nom de Bible à 42 lignes que lui donnent les bibliographes. Les lignes furent resserrées dans la même surface de composition, si bien que le cadre contint plus de texte et que la Bible entière demanda moins de papier. ## Ce que garde une page de travail Le canon a été conçu pour de grands livres faits pour durer, avec dans leurs marges de la place pour les notes d’un lecteur. Peu de livres peuvent se l’offrir aujourd’hui. Sur cette page de 210 sur 280 millimètres, il exigerait un bloc de texte de 140 sur 187, et plus de la moitié du papier resterait blanche. La page que vous lisez garde plutôt trois choses du canon : - la progression de ses marges, toujours 2:3:4:6, désormais par pas de huit millimètres : 16 au petit fond, 24 en tête, 32 au grand fond et 48 en pied ; - la double page comme unité de la maquette : deux petits fonds réunis ont la largeur d’un grand fond, si bien que le blanc autour de chaque bloc de texte se répartit également sur le livre ouvert ; - la réglure elle-même, devenue une grille de ligne de base de 13,4 points sur laquelle reposent les deux colonnes, sur cette page comme sur celle d’en face. Elle renonce aux neuvièmes. Son bloc de texte, 162 sur 208 millimètres, est bien plus grand que celui du canon, et il est divisé en deux colonnes de 78 millimètres séparées par une gouttière de six. Composée sur toute la largeur du bloc, une ligne de ce caractère dépasserait cent signes, trop longue pour que l’œil retrouve le début de la suivante ; une colonne en contient une cinquantaine, et exactement 44 lignes. La :ref{id="pages" style="full" case="lower"} montre les deux pages côte à côte. Une page qui compte ses lignes peut aussi égaliser le pied de ses colonnes : chaque colonne pleine de cet essai se termine sur la quarante-quatrième ligne. Quand les règles qui gardent un titre avec son texte, ou qui empêchent un paragraphe de laisser une ligne seule derrière lui, feraient finir une colonne une ligne trop tôt, la ligne manquante devient un peu plus d’espace au-dessus d’un titre placé plus haut, et la colonne atteint quand même la dernière ligne. Sur la dernière page d’un essai, les colonnes sont au contraire coupées à la même longueur, et le texte se termine en une bande égale. Une grille de ligne de base fixe la place de chaque ligne avant qu’un seul mot soit composé, comme le faisaient l’alêne et la pointe sèche sur le parchemin. Les listes de cet essai n’ajoutent aucun espace qui leur soit propre, si bien que leurs éléments tombent sur les mêmes lignes que les paragraphes de la colonne voisine. Un titre peut se placer entre deux lignes de la grille, avec une ligne et demie au-dessus et une demi-ligne au-dessous, mais le texte qui le suit revient aussitôt sur la grille. La note d’atelier, en plus petit corps, suit un interlignage plus serré qui lui est propre et retrouve la grille là où elle s’achève. Une figure est traitée de la même façon : sa bande, légende comprise, est arrondie à un nombre entier de lignes (dix-sept pour la :ref{id="canon" style="full" case="lower"}), de sorte que le texte sous un dessin placé en tête de page s’aligne sur celui de la page d’en face. Sur ces pages, la grille est tracée en rouge pâle pour que le lecteur puisse y vérifier le texte. Ouvrez le livre à n’importe quelle double page : chaque ligne de la page de gauche se prolonge, par-delà le dos, en une ligne de la page de droite, comme la réglure des scribes courait de piqûre en piqûre sur les deux moitiés de la feuille ouverte. :::paragraphs{style="colophon"} Composé en Vollkorn, corps 9,8 sur 13,4 points, avec Playfair Display et Vollkorn SC (SIL Open Font License 1.1) Texte et dessins : Postext Cookbook, CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // Captions and alt text in every sample language: [caption, altText]. const CAPTIONS = { canon: { en: ['The canon on an open spread of 2:3 pages. The diagonals fix the corners of both text ' + 'blocks; the circle, as wide as a page, is exactly as tall as its block.', 'Two facing pages crossed by red construction lines, a ruled text block where they cross ' + 'on each page, and a circle as wide as the left page.'], es: ['El canon sobre un pliego abierto de páginas 2:3. Las diagonales fijan las esquinas de ' + 'las dos cajas; el círculo, tan ancho como la página, es tan alto como su caja.', 'Dos páginas enfrentadas cruzadas por líneas rojas de construcción, una caja pautada donde ' + 'se cortan en cada página y un círculo tan ancho como la página izquierda.'], pt: ['O cânone sobre uma página dupla aberta de páginas 2:3. As diagonais fixam os cantos ' + 'das duas manchas; o círculo, da largura da página, tem exatamente a altura da mancha.', 'Duas páginas lado a lado cruzadas por linhas vermelhas de construção, uma mancha pautada ' + 'onde elas se cruzam em cada página e um círculo da largura da página esquerda.'], fr: ['Le canon sur une double page ouverte de pages 2:3. Les diagonales fixent les angles ' + 'des deux blocs de texte ; le cercle, aussi large que la page, est exactement aussi haut ' + 'que son bloc.', 'Deux pages en regard traversées de lignes de construction rouges, un bloc de texte réglé ' + 'là où elles se croisent sur chaque page, et un cercle aussi large que la page de gauche.'], }, pages: { en: ['This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a ' + 'text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 ' + 'mm and two columns of 44 lines.', 'Two identical pages: the left one divided into a nine-by-nine grid around its text block, ' + 'the right one holding two ruled columns.'], es: ['La página de 210 × 280 mm de este libro, dos veces. A la izquierda, dividida en ' + 'novenos por el canon, con una caja de 140 × 187 mm. A la derecha, tal como la compone ' + 'el libro: márgenes de 16, 24, 32 y 48 mm y dos columnas de 44 líneas.', 'Dos páginas iguales: la izquierda, dividida en una cuadrícula de nueve por nueve alrededor ' + 'de su caja; la derecha, con dos columnas pautadas.'], pt: ['A página de 210 × 280 mm deste livro, duas vezes. À esquerda, dividida em nonos pelo ' + 'cânone, com uma mancha de 140 × 187 mm. À direita, como o livro a compõe: margens de ' + '16, 24, 32 e 48 mm e duas colunas de 44 linhas.', 'Duas páginas iguais: a da esquerda dividida numa grade de nove por nove em volta da ' + 'mancha, a da direita com duas colunas pautadas.'], fr: ['La page de 210 × 280 mm de ce livre, deux fois. À gauche, divisée en neuvièmes par le ' + 'canon, avec un bloc de texte de 140 × 187 mm. À droite, telle que le livre la compose : ' + 'marges de 16, 24, 32 et 48 mm et deux colonnes de 44 lignes.', 'Deux pages identiques : celle de gauche divisée en une grille de neuf sur neuf autour de ' + 'son bloc de texte, celle de droite avec deux colonnes réglées.'], }, }; // #region figures: one figure heads the next column, the other the next page // mm: a column wide and just short enough for a 17-lead band with its caption; a block wide const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64]; const figure = (id, [width, height], placement, [caption, altText]) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, placement, caption, altText, createdAt: 0, updatedAt: 0, }); const resources = [ // A float never lands above the paragraph that cites it. A column 'top' float cited in // the first column can still take the head of the second, on the same page. figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)), // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page). // Each band is rounded up to whole leads, so the text under it stays on the grid. figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build. const FONTS = { // text, display and label faces (Vollkorn SC ships no italic) Vollkorn: ['400', '400i', '700'], 'Playfair Display': ['700', '700i'], 'Vollkorn SC': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('canon.svg', canonSvg(...CANON)); await loadSvg('pages.svg', pagesSvg(...PAGES)); const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'A book page on a baseline grid', es: 'Una página de libro sobre una rejilla base', pt: 'Uma página de livro sobre a grade de linhas de base', fr: 'Une page de livre sur une grille de ligne de base' }) });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
#Masquer la grille pour l’imprimeur
enabled ne contrôle que les lignes tracées sur les pages finies ; la mise en page reste sur la grille de 13,4 pt dans les deux cas, et aucune ligne ne bouge.
- baselineGrid: { enabled: true, color: col('grid') },
+ baselineGrid: { enabled: false },#Voir ce que fait l’équilibrage
Sans équilibrage, la page 11 fait descendre ses huit dernières lignes et le colophon dans la première colonne et laisse la seconde vide.
-const balancing = { enabled: true };
+const balancing = { enabled: false };Erreurs fréquentes
Erreur fréquente
Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite
Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite. Placement des figures →
- La règle selon laquelle un flottant
topne se place jamais sur la page qui le cite vaut pour les flottants sur toute la largeur de la page. Un flottanttopde colonne peut se placer sur sa page de citation, en tête d’une colonne suivante, comme le fait la figure 1.1 à la page 9. - Les formats de page, les marges et la gouttière se règlent en unités absolues (mm, pt, cm, in). Un
emn’y a aucun corps de référence, et la composition s’arrête sur « dimensionToPx: baseFontSizePx is required for unit "em" ». - L’espace-mot du Vollkorn est étroit, 0,2 em : dans une colonne d’une cinquantaine de caractères, certaines lignes justifiées sortent lâches ou serrées. L’exemple a été calibré ligne à ligne d’après le rapport des lignes lâches de la capture ; vérifiez-le de nouveau après toute modification du texte ou de la longueur de ligne.
Crédits
- Recette
- Ignacio Ferro
- Texte
- L’essai « The Secret Canon » et ses versions espagnole, portugaise et française, « El canon secreto », « O cânone secreto » et « Le canon secret », écrits pour cette recette · Postext Cookbook · CC BY 4.0
- Images
- Les deux dessins, réalisés en code avec la palette de la page · Postext Cookbook · CC BY 4.0
- Polices
- Vollkorn (SIL OFL 1.1) · Playfair Display (SIL OFL 1.1) · Vollkorn SC (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


