En bref
Cinq pages d’un petit recueil d’essais. On y voit comment imprimer le titre du livre en haut des pages de gauche et le titre de l’essai sur les pages de droite.
Ce que vous allez composer
Les pages 29 à 33 d’une édition de poche des Essais de Montaigne dans l’anglais de Charles Cotton : tout le chapitre IV et le début du chapitre V. Les versos portent le titre du livre en capitales espacées. Les rectos portent le titre de l’essai en italique, abrégé par des points de suspension, car le titre du chapitre IV compte 81 caractères. Chaque folio se loge dans un onglet sépia de la marge extérieure, à 3 mm d’un filet de 0,5 pt posé sur le bord du bloc de texte. Une ouverture n’a pas de titre courant, seulement un folio en pied. Le texte est en Baskervville sur une page blanc cassé chaud, les titres en Libre Caslon Display, les folios et le titre du livre en Alegreya SC. Chaque titre courant est défini dans la configuration : le titre courant intégré du moteur, en Open Sans bleu de 8 pt, ne s’imprime jamais.
Cette recette répond à
- Comment régler les titres courants : titre du livre sur la page de gauche, titre du chapitre sur celle de droite, numéro de page à l’extérieur ?
- Comment masquer les titres courants sur les ouvertures et les pages blanches, ou peindre un verso blanc dans la couleur de la partie ?
- Comment empêcher les titres, les mots en gras et les puces de sortir en bleu ?
La réponse courte
const GAP = 3; // mm from the tab to the divider, which lands on the text block's edge
const HEAD = 12; // mm from the top edge of the page to the top of the tab
const CAP = t({ en: 60.5, es: 66.8 }); // mm: the head's widest line, set per edition so
// essay IV's is cut after 'false' and 'objetos'; a short running title suits any title
const page = (edge) => ({ to: 'page', edge }); // the trim box (gotcha: negative-offsets)
const header = { elements: [
// Verso (even): tab | divider | THE BOOK'S TITLE from the frontmatter
folio({ id: 'folio-even', parity: 'even', pages: 'body', anchor: page('top-left'),
x: OUTER - GAP - TAB, y: HEAD }),
divider({ id: 'rule-even', parity: 'even', from: 'folio-even', edge: 'right-of', x: GAP }),
{ kind: 'text', id: 'book', content: '{title}', parity: 'even', pages: 'body',
fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), lineHeight: LINE / FOLIO_PT,
color: col('muted'), textTransform: 'uppercase', letterSpacing: pt(1.3),
placement: { anchor: { to: '#rule-even', edge: 'right-of' }, // top: the tab's top
offset: { x: mm(2.5), y: pt(PAD.top) } } }, // PAD.top down: on the folio's baseline
// Recto (odd): the essay's title, cut short with an ellipsis | divider | tab.
folio({ id: 'folio-odd', parity: 'odd', pages: 'body', anchor: page('top-right'),
x: -(OUTER - GAP - TAB), y: HEAD }), // from the right edge, a negative x runs inwards
divider({ id: 'rule-odd', parity: 'odd', from: 'folio-odd', edge: 'left-of', x: -GAP }),
{ kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
fontFamily: 'Baskervville', italic: true, fontSize: pt(TITLE_PT),
lineHeight: LINE / TITLE_PT, color: col('muted'),
overflow: 'ellipsis-end', // stated, though a running head's default
placement: { anchor: { to: '#rule-odd', edge: 'left-of' },
offset: { x: mm(-2.5), y: pt(PAD.top) }, size: { maxWidth: mm(CAP) } } },
] };
// Openers: no head, only a drop folio HEAD above the page's foot, centred under the text block.
const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
Ingrédients
- Fonctionnalités
- Titres courants et foliosTitres courants selon le rôle de la pageAncrer les éléments de maquetteTexte, filets et boîtes dans les maquettes de pageOuvertures composéesAttributs de titreSauts de ligne dans les titresChapitres qui commencent en belle pageMarges en miroirFormat de la pageCouleur du papierMétadonnées du documentGras, italique et leurs couleursPalette de couleurs sémantiqueEncadrésStyles de paragrapheVeuves, orphelines et fins de paragraphe trop courtesPages sur un canvasLivres construits chapitre par chapitre
- Utilise aussi
- Bandeau de chapitre sur toute la largeur
- Typographie
- Baskervville, Libre Caslon Display, Alegreya SC (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Remplacer le titre courant bleu intégré
const palette = {
ink: '#1f1b16', // text: a warm near-black, never #000
sepia: '#8a5a2b', // the one accent: folio tabs, dividers, opener plates (5.3:1 on paper)
muted: '#6f6558', // running heads, verse glosses, the colophon (5.2:1 on paper)
paper: '#f8f4ec', // a warm off-white page; also the type reversed out of the sepia
};
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' } })),
// Text-style defaults (headings, bold, italic, lists, boxes) link to 'main-color':
// pointed at the sepia, none of them prints in the engine's blue.
{ id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
Chaque couleur de la configuration est liée à l’une de ces entrées. Si vous laissez header non défini, le moteur dessine son titre courant intégré : {title} sur les rectos et {chapterTitle} sur les versos au-dessus d’un filet de 1 pt, en Open Sans 600 de 8 pt et en bleu #295AA3 (en-têtes et pieds de page). Faire pointer main-color sur le sépia recolore les valeurs par défaut du texte et ce titre courant avec elles, mais son caractère, son filet et le côté où s’imprime chaque titre restent ceux du moteur. Chaque titre courant est donc écrit ici en entier, chaque couleur liée à une entrée de palette. Le gras, l’italique et les renvois suivent aussi main-color par défaut, et bodyText les ramène à l’encre.
#2 · Construire chaque titre courant à partir d’un onglet
const FOLIO_PT = 7.5; // pt: the folio, and the book's title in capitals beside it
const TITLE_PT = 8.8; // pt: the essay's title, whose italic lowercase reads small at 7.5
const LINE = FOLIO_PT * 1.2; // pt: the folio and both titles share this line box and baseline
const PAD = { top: 1, bottom: 1.4 }; // pt: 0.4 pt more below for the descending 3, 5, 7, 9
const TAB = 7.5; // mm: the tab's width
const TAB_H = LINE + PAD.top + PAD.bottom; // pt: the tab's height, which the divider matches
const folio = ({ id, parity, pages, anchor, x = 0, y }) => ({
kind: 'text', id, content: '{pageNumber}', parity, pages, fontFamily: 'Alegreya SC',
fontSize: pt(FOLIO_PT), fontWeight: 700, lineHeight: LINE / FOLIO_PT, color: col('paper'),
box: { backgroundColor: col('sepia'), borderRadius: mm(2), // a pill; the number is centred
padding: { top: pt(PAD.top), bottom: pt(PAD.bottom) } }, // by default in both directions
placement: { anchor, offset: { x: mm(x), y: mm(y) }, size: { width: mm(TAB) } },
});
const divider = ({ id, parity, from, edge, x }) => ({
kind: 'rule', id, parity, pages: 'body', direction: 'vertical', thickness: pt(0.5),
color: col('sepia'), placement: { anchor: { to: `#${from}`, edge }, offset: { x: mm(x) },
size: { height: pt(TAB_H) } },
});
La réponse courte ancre l’onglet de chaque titre courant à 'page' : ses décalages se mesurent donc depuis le bord de coupe. Depuis le coin top-right, un x positif sort de la page, d’où le décalage négatif de l’onglet du recto. Le filet de séparation est accroché à l’onglet, et le titre au filet (placement des éléments). Les dimensions de l’onglet sont des constantes nommées, parce que les autres pièces se mesurent à partir d’elles. Le filet mesure TAB_H de haut. Le folio et les deux titres partagent une même boîte de ligne de 9 pt, et la ligne de base d’un texte de maquette se place aux 80 % de la hauteur de sa boîte de ligne : les trois reposent donc sur une même ligne de base, quel que soit le corps.


#3 · Marges en miroir et lignes entières
const TRIM = { width: 132, height: 198 }; // mm
const TOP = 22; // mm: the top margin, which holds the heads (gotcha: header-paints-over-text)
const INNER = 15; // mm
const OUTER = 21; // mm: the outer margin, where the folio tabs hang
const LINES = 31; // whole lines of LEAD in the text block, so full pages end level
const MEASURE = TRIM.width - INNER - OUTER; // the text block's width: 96 mm
const geometry = { // left is the inner margin on a recto; mirror swaps it on the versos
width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, // 150 dpi is for the screen
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 },
};
Avec mirror: true, la marge right est la marge extérieure sur un recto et passe à gauche sur un verso : OUTER est donc toujours la marge extérieure, côté tranche, où pendent les onglets. La marge du bas se déduit du format, de la marge du haut et de LINES, ce qui maintient le bloc de texte à exactement LINES lignes de LEAD (31 × 14 pt ici), quels que soient le format ou la marge du haut. Chaque page pleine se termine sur la 31e ligne, si bien que des pages en vis-à-vis comme 30 et 31 finissent au même niveau.
#4 · Laisser l’ouverture montrer le titre entier
const PLATE = 26; // mm: a square plate at the head of the text block
const opener = {
enabled: true,
minHeight: mm(58), // the text of every essay starts on the same line
slot: { elements: [
{ kind: 'box', id: 'plate', style: { backgroundColor: col('sepia') },
placement: { anchor: { to: 'container', edge: 'top-left' },
size: { width: mm(PLATE), height: mm(PLATE) } } },
{ kind: 'text', id: 'numeral', content: '{attr.num}', fontFamily: 'Libre Caslon Display',
fontSize: pt(48), lineHeight: 1, color: col('paper'),
placement: { anchor: { to: '#plate', edge: 'align-top' }, // the plate's own box, where
size: { width: mm(PLATE), height: mm(PLATE) } } }, // the text centres by default
{ kind: 'text', id: 'book', content: '{subtitle}', fontFamily: 'Alegreya SC',
fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sepia'), align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(PLATE + 4.5), y: mm(PLATE - 2.9) } } }, // its baseline on the plate's foot
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display',
fontSize: pt(20), lineHeight: 1.12, color: col('ink'), align: 'left',
overflow: 'wrap', // an opener shows the whole title; the running head cuts it
placement: { anchor: { to: '#plate', edge: 'below' }, offset: { y: mm(6) },
size: { width: mm(MEASURE) } } },
] },
};
L’ouverture compose le titre en entier avec overflow: 'wrap' et le coupe à chaque \\ de la ligne de titre. Seule une ouverture sur toute la largeur de la page respecte ces coupures : le niveau porte donc span: 'page' (sauts de ligne dans les titres). Dans le titre courant, {chapterTitle} donne toujours le titre sur une seule ligne, et ce sont les points de suspension qui coupent cette ligne. Le chiffre romain vient de l’attribut {num="IV"} du titre, et les petites capitales à côté de la plaque viennent du subtitle du frontmatter.
#5 · Ouvrir chaque essai sur un recto
headings: { // 400, the face's only weight: the default 700 would ask for one that is missing
fontFamily: 'Libre Caslon Display', fontWeight: 400, color: col('ink'),
levels: [
// 'odd' puts every opener on a recto, adding a blank verso when one is needed;
// span: 'page' makes the design an opener, where a \\ in the title breaks the line.
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
],
},
Avec 'odd', un essai qui se termine sur un recto est suivi d’un verso blanc. L’édition espagnole en a un, car son chapitre IV se termine à la page 31 ; ici, le chapitre IV se termine à la page 32, un verso, et le chapitre V s’ouvre à la page 33. Le pages: 'body' des titres courants les écarte des pages blanches et des ouvertures (les pages dont le premier bloc est ce titre). Le pages: 'opener' du pied de page n’imprime le folio en pied que sur les ouvertures (éléments de texte).
#6 · Commencer les folios à 29
// 28 pages come before this one, so recto and verso, the mirrored margins and the odd/even
// heads follow the book page (gotcha: parity-page1-recto); the folios start at 29.
const continuation = { pageIndexOffset: 28, pageNumbering: { startAt: 29 } };
const doc = await buildDocumentWithFonts({ markdown, continuation }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Running heads by parity', es: 'Cabeceras según la paridad' }) });
parity suit la place de la page dans le livre et ignore son folio. pageIndexOffset: 28 ajoute 28 à l’index de chaque page : recto et verso, les marges en miroir et les titres courants pairs et impairs suivent donc la page du livre. pageNumbering.startAt numérote ensuite ces pages de 29 à 33. Sans ce décalage, la première page produite est un recto, quel que soit le folio qu’elle imprime. Cela tombe bien pour la page 29, mais avec startAt: 30 le titre du livre arriverait page 31 et celui de l’essai page 32, chacun du mauvais côté de la double page.
La recette complète
// ═══ Postext Cookbook · Nº 005 · Running heads by parity ═══════════════════════════════ // https://postext.dev/en/cookbook/running-heads-by-parity // Code: MIT · Text: Montaigne, tr. Cotton (PD, Gutenberg #3600); es: new translation (MIT) // Fonts: Baskervville, Libre Caslon Display, Alegreya SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0 // Pages 29 to 33 of a pocket Montaigne. Versos carry the book's title and rectos the essay's, // the folios sit in tabs in the outer margin, and an opener prints only a folio at the foot. import { buildDocumentWithFonts, renderPageToCanvas } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'running-heads-by-parity'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: four colours with semantic ids; main-color points at the sepia const palette = { ink: '#1f1b16', // text: a warm near-black, never #000 sepia: '#8a5a2b', // the one accent: folio tabs, dividers, opener plates (5.3:1 on paper) muted: '#6f6558', // running heads, verse glosses, the colophon (5.2:1 on paper) paper: '#f8f4ec', // a warm off-white page; also the type reversed out of the sepia }; 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' } })), // Text-style defaults (headings, bold, italic, lists, boxes) link to 'main-color': // pointed at the sepia, none of them prints in the engine's blue. { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } }, ]; // #endregion const LEAD = 14; // pt: the body leading, the pitch of the baseline grid // #region tab: the folio tab and its divider, the two pieces every head is built from const FOLIO_PT = 7.5; // pt: the folio, and the book's title in capitals beside it const TITLE_PT = 8.8; // pt: the essay's title, whose italic lowercase reads small at 7.5 const LINE = FOLIO_PT * 1.2; // pt: the folio and both titles share this line box and baseline const PAD = { top: 1, bottom: 1.4 }; // pt: 0.4 pt more below for the descending 3, 5, 7, 9 const TAB = 7.5; // mm: the tab's width const TAB_H = LINE + PAD.top + PAD.bottom; // pt: the tab's height, which the divider matches const folio = ({ id, parity, pages, anchor, x = 0, y }) => ({ kind: 'text', id, content: '{pageNumber}', parity, pages, fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), fontWeight: 700, lineHeight: LINE / FOLIO_PT, color: col('paper'), box: { backgroundColor: col('sepia'), borderRadius: mm(2), // a pill; the number is centred padding: { top: pt(PAD.top), bottom: pt(PAD.bottom) } }, // by default in both directions placement: { anchor, offset: { x: mm(x), y: mm(y) }, size: { width: mm(TAB) } }, }); const divider = ({ id, parity, from, edge, x }) => ({ kind: 'rule', id, parity, pages: 'body', direction: 'vertical', thickness: pt(0.5), color: col('sepia'), placement: { anchor: { to: `#${from}`, edge }, offset: { x: mm(x) }, size: { height: pt(TAB_H) } }, }); // #endregion // #region page: a pocket trim; the margins mirror, the text block holds 31 whole lines const TRIM = { width: 132, height: 198 }; // mm const TOP = 22; // mm: the top margin, which holds the heads (gotcha: header-paints-over-text) const INNER = 15; // mm const OUTER = 21; // mm: the outer margin, where the folio tabs hang const LINES = 31; // whole lines of LEAD in the text block, so full pages end level const MEASURE = TRIM.width - INNER - OUTER; // the text block's width: 96 mm const geometry = { // left is the inner margin on a recto; mirror swaps it on the versos width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, // 150 dpi is for the screen 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 }, }; // #endregion // #region answer: running heads by parity: book on the verso, essay on the recto, folios outside const GAP = 3; // mm from the tab to the divider, which lands on the text block's edge const HEAD = 12; // mm from the top edge of the page to the top of the tab const CAP = t({ en: 60.5, es: 66.8 }); // mm: the head's widest line, set per edition so // essay IV's is cut after 'false' and 'objetos'; a short running title suits any title const page = (edge) => ({ to: 'page', edge }); // the trim box (gotcha: negative-offsets) const header = { elements: [ // Verso (even): tab | divider | THE BOOK'S TITLE from the frontmatter folio({ id: 'folio-even', parity: 'even', pages: 'body', anchor: page('top-left'), x: OUTER - GAP - TAB, y: HEAD }), divider({ id: 'rule-even', parity: 'even', from: 'folio-even', edge: 'right-of', x: GAP }), { kind: 'text', id: 'book', content: '{title}', parity: 'even', pages: 'body', fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), lineHeight: LINE / FOLIO_PT, color: col('muted'), textTransform: 'uppercase', letterSpacing: pt(1.3), placement: { anchor: { to: '#rule-even', edge: 'right-of' }, // top: the tab's top offset: { x: mm(2.5), y: pt(PAD.top) } } }, // PAD.top down: on the folio's baseline // Recto (odd): the essay's title, cut short with an ellipsis | divider | tab. folio({ id: 'folio-odd', parity: 'odd', pages: 'body', anchor: page('top-right'), x: -(OUTER - GAP - TAB), y: HEAD }), // from the right edge, a negative x runs inwards divider({ id: 'rule-odd', parity: 'odd', from: 'folio-odd', edge: 'left-of', x: -GAP }), { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body', fontFamily: 'Baskervville', italic: true, fontSize: pt(TITLE_PT), lineHeight: LINE / TITLE_PT, color: col('muted'), overflow: 'ellipsis-end', // stated, though a running head's default placement: { anchor: { to: '#rule-odd', edge: 'left-of' }, offset: { x: mm(-2.5), y: pt(PAD.top) }, size: { maxWidth: mm(CAP) } } }, ] }; // Openers: no head, only a drop folio HEAD above the page's foot, centred under the text block. const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener', anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] }; // #endregion // #region opener: the essay's numeral on a sepia plate; the title below it, set in full const PLATE = 26; // mm: a square plate at the head of the text block const opener = { enabled: true, minHeight: mm(58), // the text of every essay starts on the same line slot: { elements: [ { kind: 'box', id: 'plate', style: { backgroundColor: col('sepia') }, placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: mm(PLATE), height: mm(PLATE) } } }, { kind: 'text', id: 'numeral', content: '{attr.num}', fontFamily: 'Libre Caslon Display', fontSize: pt(48), lineHeight: 1, color: col('paper'), placement: { anchor: { to: '#plate', edge: 'align-top' }, // the plate's own box, where size: { width: mm(PLATE), height: mm(PLATE) } } }, // the text centres by default { kind: 'text', id: 'book', content: '{subtitle}', fontFamily: 'Alegreya SC', fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sepia'), align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(PLATE + 4.5), y: mm(PLATE - 2.9) } } }, // its baseline on the plate's foot { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display', fontSize: pt(20), lineHeight: 1.12, color: col('ink'), align: 'left', overflow: 'wrap', // an opener shows the whole title; the running head cuts it placement: { anchor: { to: '#plate', edge: 'below' }, offset: { y: mm(6) }, size: { width: mm(MEASURE) } } }, ] }, }; // #endregion const config = () => ({ locale: t({ en: 'en-us', es: 'es' }), // the language of the hyphenation patterns colorPalette, page: geometry, layout: { layoutType: 'single' }, bodyText: { // bold, italic and references default to main-color (the sepia): set to the ink fontFamily: 'Baskervville', fontSize: pt(10.2), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, // Hyphenation, optimal breaking and widow, orphan and runt control are on by default; a band // of 0.7–1.65 (default 0.6–2) evens the grey from line to line. At the default runt length, // 20 space widths, English essay IV ends on 'minds.' alone; at 16 it ends on 'our minds.'. minWordSpacing: 0.7, maxWordSpacing: 1.65, runtMinCharacters: 16, }, // #region levels: every essay opens on a recto; the heading draws the opener headings: { // 400, the face's only weight: the default 700 would ask for one that is missing fontFamily: 'Libre Caslon Display', fontWeight: 400, color: col('ink'), levels: [ // 'odd' puts every opener on a recto, adding a blank verso when one is needed; // span: 'page' makes the design an opener, where a \\ in the title breaks the line. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, ], }, // #endregion // Montaigne's quotations: a box with no fill keeps verse and gloss together. Its two 10.5 pt // margins and a two-line gloss (2 × 10.5 pt) make three whole lines, so the grid adds no space. calloutStyles: [{ id: 'quote', backgroundEnabled: false, padding: { top: pt(0), right: mm(8), bottom: pt(0), left: mm(8) }, marginTop: pt(LEAD * 0.75), marginBottom: pt(LEAD * 0.75) }], paragraphStyles: [ { id: 'verse', fontSize: pt(9.6), textAlign: 'center', firstLineIndent: pt(0) }, { id: 'gloss', fontSize: pt(7.6), lineHeight: pt(LEAD * 0.75), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0) }, // Markdown has no horizontal rule (--- prints as text; gap: markdown-extras), so the rule // over the colophon is eight em dashes of Alegreya SC that overlap into a 0.5 pt line. { id: 'end', fontFamily: 'Alegreya SC', fontSize: pt(8), color: col('sepia'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0) }, ], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 70 lignes · content.en.md
title: "The Essays of Montaigne" subtitle: "Book the First" author: "Michel de Montaigne" --- # That the soul expends its passions \\ upon false objects, \\ where the true are wanting {num="IV"} A gentleman of my country, marvellously tormented with the gout, being importuned by his physicians totally to abstain from all manner of salt meats, was wont pleasantly to reply, that in the extremity of his fits he must needs have something to quarrel with, and that railing at and cursing, one while the Bologna sausages, and another the dried tongues and the hams, was some mitigation to his pain. But, in good earnest, as the arm when it is advanced to strike, if it miss the blow, and goes by the wind, it pains us; and as also, that, to make a pleasant prospect, the sight should not be lost and dilated in vague air, but have some bound and object to limit and circumscribe it at a reasonable distance. :::callout{type="quote"} :::paragraphs{style="verse"} *Ventus ut amittit vires, nisi robore densae* *Occurrant sylvae, spatio diffusus inani.* ::: :::paragraphs{style="gloss"} As the wind loses its force diffused in void space, unless it in its strength encounters the thick wood. — Lucan, III, 362 ::: ::: So it seems that the soul, being transported and discomposed, turns its violence upon itself, if not supplied with something to oppose it, and therefore always requires an object at which to aim, and whereon to act. Plutarch says of those who are delighted with little dogs and monkeys, that the amorous part that is in us, for want of a legitimate object, rather than lie idle, does after that manner forge and create one false and frivolous. And we see that the soul, in its passions, inclines rather to deceive itself, by creating a false and fantastical subject, even contrary to its own belief, than not to have something to work upon. After this manner brute beasts direct their fury to fall upon the stone or weapon that has hurt them, and with their teeth even execute revenge upon themselves for the injury they have received from another: :::callout{type="quote"} :::paragraphs{style="verse"} *Pannonis haud aliter, post ictum saevior ursa,* *Cui jaculum parva Lybis amentavit habena,* *Se rotat in vulnus, telumque irata receptum* *Impetit, et secum fugientem circuit hastam.* ::: :::paragraphs{style="gloss"} So the she-bear, fiercer for the Libyan’s dart, turns upon the wound and, attacking the spear, twists it as she flies. — Lucan, VI, 220 ::: ::: What causes of the misadventures that befall us do we not invent? what is it that we do not lay the fault to, right or wrong, that we may have something to quarrel with? It is not those beautiful tresses you tear, nor is it the white bosom that in your anger you so unmercifully beat, that with an unlucky bullet have slain your beloved brother; quarrel with something else. Livy, speaking of the Roman army in Spain, says that for the loss of the two brothers, their great captains, *flere omnes repente, et offensare capita*, “all at once wept and tore their hair.” ’Tis a common practice. And the philosopher Bion said pleasantly of the king, who by handfuls pulled his hair off his head for sorrow, “Does this man think that baldness is a remedy for grief?” Who has not seen peevish gamesters chew and swallow the cards, and swallow the dice, in revenge for the loss of their money? Xerxes whipped the sea, and wrote a challenge to Mount Athos; Cyrus employed a whole army several days at work, to revenge himself of the river Gyndas, for the fright it had put him into in passing over it; and Caligula demolished a very beautiful palace for the pleasure his mother had once enjoyed there. I remember there was a story current, when I was a boy, that one of our neighbouring kings, having received a blow from the hand of God, swore he would be revenged, and in order to it, made proclamation that for ten years to come no one should pray to Him, or so much as mention Him throughout his dominions, or, so far as his authority went, believe in Him; by which they meant to paint not so much the folly as the vainglory of the nation of which this tale was told. They are vices that always go together, but in truth such actions as these have in them still more of presumption than want of wit. Augustus Caesar, having been tossed with a tempest at sea, fell to defying Neptune, and in the pomp of the Circensian games, to be revenged, deposed his statue from the place it had amongst the other deities. Wherein he was still less excusable than the former, and less than he was afterwards when, having lost a battle under Quintilius Varus in Germany, in rage and despair he went running his head against the wall, crying out, “O Varus! give me back my legions!” for these exceed all folly, forasmuch as impiety is joined therewith, invading God Himself, or at least Fortune, as if she had ears that were subject to our batteries; like the Thracians, who when it thunders or lightens, fall to shooting against heaven with Titanian vengeance, as if by flights of arrows they intended to bring God to reason. Though the ancient poet in Plutarch tells us: :::callout{type="quote"} :::paragraphs{style="verse"} *Point ne se faut couroucer aux affaires,* *Il ne leur chault de toutes nos choleres.* ::: :::paragraphs{style="gloss"} We must not trouble the gods with our affairs; they take no heed of our angers and disputes. — Plutarch ::: ::: But we can never enough decry the disorderly sallies of our minds. # Whether the governor of a place \\ besieged ought himself \\ to go out to parley {num="V"} Quintus Marcius, the Roman legate in the war against Perseus, King of Macedon, to gain time wherein to reinforce his army, set on foot some overtures of accommodation, with which the king being lulled asleep, concluded a truce for some days, by this means giving his enemy opportunity and leisure to recruit his forces, which was afterwards the occasion of the king’s final ruin. Yet the elder senators, mindful of their forefathers’ manners, condemned this proceeding as degenerating from their ancient practice, which, they said, was to fight by valour, and not by artifice, surprises, and night-encounters; neither by pretended flight nor unexpected rallies to overcome their enemies; never making war till having first proclaimed it, and very often assigned both the hour and place of battle. :::paragraphs{style="end"} ———————— ::: :::paragraphs{style="colophon"} Chapters IV and V of Book the First of Montaigne’s essays, in Charles Cotton’s translation as revised by W. C. Hazlitt (1877), Project Gutenberg eBook #3600; notes shortened or omitted, misprints corrected. Set in Baskervville, Libre Caslon Display and Alegreya SC (SIL Open Font License). :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build. const FONTS = { // text, display and label faces (Libre Caslon Display has no italic) Baskervville: ['400', '400i'], 'Libre Caslon Display': ['400'], 'Alegreya SC': ['400', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: pages 29 to 33 of the book // 28 pages come before this one, so recto and verso, the mirrored margins and the odd/even // heads follow the book page (gotcha: parity-page1-recto); the folios start at 29. const continuation = { pageIndexOffset: 28, pageNumbering: { startAt: 29 } }; const doc = await buildDocumentWithFonts({ markdown, continuation }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Running heads by parity', es: 'Cabeceras según la paridad' }) }); // #endregionKit · core, fonts, viewer : identique dans toutes les recettes · 224 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 ───────────────────────────────────────────────────────────────────────
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
#Donner un titre courant abrégé aux titres longs
Écrivez le titre courant sur la ligne du titre, {num="IV" short="Of false objects"}, et imprimez l’attribut au lieu du titre entier ; un chapitre sans short n’imprime rien à cet endroit.
- { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
+ { kind: 'text', id: 'essay', content: '{attr.short}', parity: 'odd', pages: 'body',#Ouvrir les essais d’un côté ou de l’autre
Avec 'any', un essai commence à la page suivante, recto ou verso, et aucune page blanche n’est jamais ajoutée. La plaque est accrochée au coin supérieur gauche du bloc de texte et le folio en pied est centré dessous : les deux se placent donc de la même façon sur un verso que sur un recto.
- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },#Peindre le verso blanc dans la couleur d’accent
Une boîte avec pages: 'blank' ne s’imprime que sur les pages qu’ajoute le saut au recto : dans l’édition espagnole, elle remplit la page 32 de sépia, et l’édition anglaise, qui n’a pas de page blanche, ne change pas.
const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
- anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
+ anchor: { to: 'container', edge: 'bottom' }, y: -HEAD }),
+ { kind: 'box', id: 'blank-leaf', pages: 'blank', style: { backgroundColor: col('sepia') },
+ placement: { anchor: page('top-left'),
+ size: { width: mm(TRIM.width), height: mm(TRIM.height) } } }] };Erreurs fréquentes
Erreur fréquente
Des décalages négatifs relatifs au conteneur n’affichent rien
Le texte de maquette à largeur automatique est limité à son conteneur : un décalage négatif depuis le conteneur le pousse donc dehors et rien ne s’affiche. Ancrez de tels éléments à la page ou au fond perdu avec des décalages explicites en mm, ou donnez-leur une largeur fixe. Ancrer les éléments de maquette →
Erreur fréquente
Les éléments d’en-tête et de pied de page se peignent par-dessus le texte
Les éléments d’en-tête et de pied de page sont peints par-dessus la page et la zone de texte ne leur fait pas de place. Gardez-les dans les marges, qui sont ce qui leur réserve de l’espace. Titres courants et folios →
Erreur fréquente
La page 1 est un recto : planifiez les pages avec les numéros physiques
La page 1 est une page de droite et la page 2 le premier verso : planifiez donc les doubles pages avec les numéros de page physiques. Une ouverture sur une page paire fait face à la page impaire qui la suit. Sauts de page et de colonne →
CAPest réglé dans chaque édition pour le titre de l’essai IV, le seul qu’un titre courant affiche ici. Les points de suspension suivent le dernier mot entier qui tient, et ce mot peut être une préposition : à 64 mm, le titre courant espagnol finirait par « …sus pasiones sobre… ». Un livre aux titres longs a besoin du titre courant abrégé décrit dans les variantes.- Dans l’édition anglaise, faire passer
minWordSpacingde 0,7 à 0,75 laisse la première ligne de l’essai V (« Quintus Marcius, the Roman legate… ») sans coupure possible dans la bande, et ses espaces s’étirent à 2,4 fois leur largeur normale. L’édition espagnole n’a pas une telle ligne. Après tout changement, vérifiez les lignes les plus lâches que liste la capture.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Essays, Book I, chapters IV and V, in Charles Cotton’s translation edited by William Carew Hazlitt (1877) · Michel de Montaigne; Charles Cotton; William Carew Hazlitt · domaine public
- Essais, livre I, chapitres IV et V: the 1595 French text in the Général Michaud edition (Firmin-Didot, 1907) · Michel de Montaigne · domaine public
- Spanish translation, with its verse glosses · Postext Cookbook · original
- Polices
- Baskervville (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Alegreya 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)

