En bref
Un petit manuel d’apiculture écrit en fichiers séparés, un par chapitre. Postext les réunit en un seul livre, et les numéros de page, de chapitre et d’image continuent d’un fichier à l’autre.
Ce que vous allez composer
A Beekeeper’s Year est un petit manuel en quatre chapitres, un par saison. Il s’ouvre sur « Autumn », puisque l’année de l’apiculteur commence quand on retire le dernier miel. Chaque chapitre est un fichier Markdown séparé qui ne contient aucun numéro de page, de chapitre ni de figure. buildBundle reporte ces compteurs d’un fichier au suivant et compose les chapitres, avec un cinquième fichier pour la couverture et la table des matières, en un seul livre de onze pages. Chaque chapitre s’ouvre sur une belle page, sous un coin de rayon de miel qui porte son numéro. La table des matières, sur le verso en face d’« Autumn », reflète cette ouverture avec son titre contre le petit fond, et donne le folio de chaque chapitre. Un cadre de couvain, dessiné par le code, paraît une fois dans chaque chapitre, des figures 1.1 à 4.1. La page 6 reste blanche parce que « Winter » remplit une seule page et que « Spring » doit s’ouvrir sur un recto.
Cette recette répond à
- Comment mettre en page un livre de plusieurs chapitres pour que les numéros de page, de figure et de chapitre se suivent d’un chapitre à l’autre ?
- Comment ajouter une table des matières qui se met à jour seule (points de conduite, numéros de page, auteurs, lignes de partie) ?
- Comment forcer un saut de page ou de colonne, et faire commencer chaque chapitre sur une page de droite ?
- Comment masquer les titres courants sur les ouvertures et les pages blanches, ou peindre un verso blanc dans la couleur de la partie ?
La réponse courte
// buildBundle lays the documents out in order with one config and carries state from each to
// the next: the pages already set (so parity goes on), the folio, the chapter and figure counts.
const book = () => buildBundle({ chapters, config: config(), resources });
const config = () => ({
headings: { ...display, levels: [
// Every chapter opens on a recto: after a chapter that ends on one, the next document
// starts with a blank verso of its own.
{ level: 1, breakBefore: { enabled: true, parity: 'odd' },
// '{1}' puts the number in the PDF bookmarks ('1 Autumn'); the contents and
// {chapterNumber} count the chapters in order either way.
numberingTemplate: '{1}', advancedDesign: opener,
span: 'page' }, // in the column a design stops at the column's foot: the cover would too
subhead,
] },
// The cover and the contents are headings that take no number, no contents entry and no
// running heads, so Autumn is still chapter 1. Both inherit span 'page', which starts
// each on a page of its own, and parity 'odd', which the contents turn off: it would
// leave page 2 blank (gotcha: style-inherits-break).
headingStyles: [
{ id: 'cover', numbered: false, toc: false, advancedDesign: cover, ...bare },
{ id: 'contents', numbered: false, toc: false, breakBefore: { enabled: false },
advancedDesign: contentsOpener, ...bare },
],
// :::toc in the first document lists the whole book with the folio each chapter lands on:
// buildBundle lays the book out again (three passes at most) until those folios settle.
toc: contents,
// The locale picks the hyphenation patterns and names the figures Figure or Figura. They
// number {h1}.{n}, and the counters carry on from document to document (Winter's is 2.1).
locale: t({ en: 'en-us', es: 'es' }),
colorPalette, page, layout: { layoutType: 'single' }, bodyText, captionStyle, calloutStyles,
header, footer, // the look: above, and in the regions below
});
Ingrédients
- Fonctionnalités
- Livres construits chapitre par chapitreTable des matièresChapitres qui commencent en belle pageLégendes numérotéesDes citations qui placent les figuresTitres numérotésStyles de titreChapitres non numérotésTitres courants par sectionTitres courants et foliosTitres courants selon le rôle de la pageOuvertures composéesAttributs de titreImages dans les maquettes de pageMarges en miroirEncadrés flottantsExport PDFSignets PDFFigure et Tableau dans votre langueCouvertures, pages de titre et colophons
- Utilise aussi
- EncadrésBandeau de chapitre sur toute la largeurCouleur du papierPolices intégrées au PDFFigures et tableaux comme ressourcesSauts de ligne dans les titres
- Typographie
- Andada Pro, Rozha One, Figtree (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Un fichier par chapitre
// Nothing in a chapter says where it lands: buildBundle works that out from the order.
const chapters = [front, autumn, winter, spring, summer].map((markdown) => ({ markdown }));
Chaque saison est un document à part : vous pouvez réécrire un chapitre sans ouvrir les autres. Le premier document contient le frontmatter, la couverture et la table des matières. Ses métadonnées sont celles du livre : {title} et {author} les impriment dans chaque chapitre, et le PDF en tire son titre et son auteur.
#2 · Des règles qui passent d’un document à l’autre
Le code de cette étape est la réponse courte plus haut. buildBundle met en page les documents dans l’ordre avec une même configuration et transmet à chacun une continuation issue des précédents : le nombre de pages physiques déjà composées, pour que la parité, les marges en miroir et les titres courants pairs et impairs se poursuivent ; le folio suivant ; et les compteurs de chapitres et de figures. Avec parity: 'odd', un chapitre qui suit un chapitre terminé sur un recto commence par un verso blanc, et ce blanc appartient à son propre document (saut avant). La couverture et la table des matières sont des styles de titre avec numbered: false : « Autumn » reste donc le chapitre 1. Toutes deux héritent span: 'page' du niveau des chapitres, ce qui place chacune sur sa propre page sans :::pagebreak. Le style de la table désactive la parité héritée, qui laisserait sinon la page 2 blanche.
#3 · Une table des matières pour tout le livre
const contents = {
levels: [
{ level: 1, fontFamily: 'Rozha One', fontSize: pt(16), lineHeight: pt(18),
numberFontFamily: 'Figtree', numberFontSize: pt(11), numberFontWeight: 700,
numberColor: col('accent'), numberWidth: mm(7), numberGap: mm(4), marginTop: pt(8) },
// Sections: 9.3 pt in the muted colour, indented 11 mm (number 7 + gap 4) to the titles.
{ level: 2, fontSize: pt(9.3), lineHeight: pt(13.5), indent: mm(11), color: col('muted') },
],
pageNumber: { fontFamily: 'Figtree', fontSize: pt(8.5), fontWeight: 600, width: mm(7) },
leader: { char: '. ', gap: mm(2) },
// A second line under each chapter, from its {months="…"} heading attribute.
subtitle: { enabled: true, attr: 'months', fontFamily: 'Andada Pro', fontSize: pt(9),
color: col('muted') }, // italic by default
};
:::toc se trouve dans le premier document mais liste les chapitres et les sections de tout le livre. buildBundle met le livre en page, recueille chaque titre avec le libellé de page qu’il a reçu, et remet le livre en page avec ce plan jusqu’à ce que les libellés ne bougent plus, en trois passes au plus (table des matières). Les mois sous chaque titre viennent d’un attribut du titre, {months="…"}, que l’ouverture imprime aussi.
#4 · Une ouverture, reflétée pour la table des matières
const corner = (id, edge) => ({ kind: 'image', id, resourceId: id,
placement: { anchor: { to: 'bleed', edge }, size: { width: mm(CORNER.width) } } });
const months = { kind: 'text', id: 'months', content: '{attr.months}', ...label,
color: col('accent'), placement: at('top-left', 0, 14, 'container') };
const title = { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(42),
lineHeight: 1.05, align: 'left', overflow: 'wrap', placement: below('months', 1.5, 100) };
const opener = { enabled: true, minHeight: mm(52), slot: { elements: [
corner('cells', 'top-right'),
// The number's box is centred on its cell. Comb and number both hang from the bleed's top
// right, so a bleed (page.cutLines) moves them together.
{ kind: 'text', id: 'number', content: '{chapterNumber}', ...display, fontSize: pt(40),
lineHeight: 1, align: 'center', placement: { size: { width: mm(NUMBER.box) },
...at('top-right', NUMBER_CELL.x + NUMBER.box / 2 - CORNER.width,
NUMBER_CELL.y - NUMBER.rise, 'bleed') } },
months, title,
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'Andada Pro', italic: true,
fontSize: pt(11.5), lineHeight: 1.35, color: col('ink'), align: 'left', overflow: 'wrap',
placement: below('title', 3, 88) },
] } };
// The contents mirror it on the verso: the comb in the outer corner, the book's subtitle and
// the title set flush right against the spine, on the same lines as Autumn's across the spread.
const flushRight = (element, placement) => ({ ...element, align: 'right',
placement: { ...placement, size: { width: 'fill' } } });
const contentsOpener = { ...opener, minHeight: mm(40), slot: { elements: [
corner('comb', 'top-left'),
flushRight({ ...months, content: '{subtitle}' }, months.placement),
flushRight(title, below('months', 1.5)),
] } };
{chapterNumber} poursuit le compte des documents mis en page avant : l’ouverture de « Spring » imprime 3 alors que son fichier ne contient aucun numéro. La boîte du numéro est calculée à partir du centre de son alvéole, et le numéro comme le rayon pendent du coin supérieur droit du fond perdu : changer le format ou ajouter un fond perdu pour l’impression les déplace ensemble. Sur la page de la table, le surtitre, lu dans {subtitle}, et le titre sont composés au fer à droite contre le petit fond, et le rayon passe au coin extérieur du verso. Son titre se place alors à la même hauteur que celui d’« Autumn » sur la page en vis-à-vis.
#5 · Des titres courants dans chaque document
const head = (id, content, parity, placement, extra = {}) => ({
kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
...label, color: col('muted'), placement, ...extra,
});
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('accent') };
// Only the first document has frontmatter, and its {title} is the book's in every chapter.
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio),
head('verso-title', '{title}', 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
// {chapterTitle} and {pageNumber} are worked out page by page, in every document.
head('recto-title', '{chapterTitle}', 'odd',
at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)),
head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio),
] };
// Openers carry a drop folio instead, 8 mm under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all',
at('top', 0, 8, 'container'), { pages: 'opener', fontWeight: 700 })] };
Seul le premier document a un frontmatter, et buildBundle le transmet à chaque chapitre : le titre courant du verso imprime donc le {title} du livre dans les cinq documents. {chapterTitle} et {pageNumber} sont résolus page par page et s’impriment dans chaque document ; la page 11, la seule belle page de texte, porte « SUMMER » à côté de son folio. pages: 'body' tient tous les titres courants à l’écart des ouvertures et de la page blanche.
#6 · Des doubles pages à l’écran, un seul PDF pour le livre
const text = chapters.map((chapter) => chapter.markdown).join('\n');
await prepareFonts(text, config(), kitFonts(FONTS));
const art = { cover: coverArt(), cells: cornerArt(true), comb: cornerArt(false) };
for (const [season, plan] of Object.entries(SEASONS)) art[`${season}-frame`] = frameArt(plan);
for (const [id, markup] of Object.entries(art)) await loadSvg(`${id}.svg`, markup);
// One VDTDocument per Markdown document.
const docs = await withLoadedFonts(book, { ...kitFonts(FONTS), text });
showPages(docs, { title: t({ en: 'A Beekeeper’s Year', es: 'Un año de colmenar' }) });
// renderToPdf takes the array: one file for the book, with a bookmark per chapter.
offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`);
buildBundle renvoie un tableau de documents, un par fichier Markdown. showPages apparie les pages selon pageIndexOffset + page.index : la page 1 est seule, en recto, et les pages 2 et 3, 4 et 5, et ainsi de suite, se font face. renderToPdf prend le même tableau et écrit un seul fichier avec les libellés de pages du livre et un signet pour chaque chapitre et chaque section.
La recette complète
// ═══ Postext Cookbook · Nº 007 · One book from separate chapters ══════════════════════════ // https://postext.dev/en/cookbook/book-from-chapters // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Andada Pro, Rozha One, Figtree (SIL OFL 1.1) · Needs postext ≥ 1.25.0 // A handbook in four seasons written as five Markdown documents, laid out by buildBundle as // one book: parity, folios, chapter and figure numbers and the contents run straight through. import { buildBundle, prepareFonts, withLoadedFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'book-from-chapters'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these, so the book can be retinted ink: '#2a2218', // text and display type honey: '#d99a1e', // the drawings only: the cover and the comb cells accent: '#7a4e12', // the text accent (7:1 on paper): numbers, labels, subheads; main-color too pollen: '#c4692b', comb: '#f7e7c4', rule: '#d8c8a8', // figures: pollen, wax, wood and walls muted: '#6e634f', paper: '#fffdf7', // running heads, colophon, contents sections; the page }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // The geometry, in mm. The drawings, the opener and the running heads are derived from it. const TRIM = { width: 150, height: 200 }; // a small handbook const MARGIN = { top: 22, bottom: 22, inner: 19, outer: 15 }; // mirrored const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // the text width: 116 mm const SQRT3 = Math.sqrt(3); // comb cells of radius r sit √3·r apart, their rows 1.5·r apart const CORNER = { width: 84, height: 70, r: 9.5 }; // the openers' comb, and its cell radius const NUMBER_CELL = { x: 4 * SQRT3 * CORNER.r, y: 2 * 1.5 * CORNER.r }; // row 2, cell 4 // The chapter number's box: 20 mm wide, its top 8 mm above the cell's centre, which is where // a 40 pt Rozha One figure sits optically centred in the cell. const NUMBER = { box: 20, rise: 8 }; const FRAME = { width: MEASURE, height: 50 }; // the brood frame figures, at the text width const HEAD = { y: 12, gap: 8 }; // running heads 12 mm from the top edge, 8 mm folio to text const LEAD = 14.2; // body leading in pt: the baseline grid // The look: a small handbook, mirrored, justified Andada Pro on the grid; Rozha One titles. const page = { // mirror: left is the inner margin, right the outer sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true }, }; const bodyText = { // justified, hyphenated, optimal line breaks and widow control: by default fontFamily: 'Andada Pro', fontSize: pt(10.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4.5), indentAfterHeading: false, // Spaces stretch to 1.7× at most (the default is 2): the line breaker then takes the // hyphens it would otherwise avoid, and no line gapes. maxWordSpacing: 1.7, }; const display = { fontFamily: 'Rozha One', fontWeight: 400, color: col('ink') }; // titles // Subheads: the line box itself is a line and a half deep, so the gap to the text under it // is the same everywhere. A margin above would not do it: it drops at the head of a page. const subhead = { level: 2, fontFamily: 'Andada Pro', fontWeight: 700, fontSize: pt(12.5), lineHeight: pt(LEAD * 1.5), color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(0) }; const captionStyle = { fontFamily: 'Figtree', fontSize: pt(7.6), gap: mm(2.5), labelColor: col('accent') }; // The colophon floats to the foot of the last page: a box with no background, placed // 'bottom', with one statement per paragraph. const calloutStyles = [{ id: 'colophon', placement: 'bottom', backgroundEnabled: false, padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginBottom: pt(0), body: { fontFamily: 'Figtree', fontSize: pt(7), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0) } }]; // #region answer: five Markdown documents, one book: buildBundle and the rules they share // buildBundle lays the documents out in order with one config and carries state from each to // the next: the pages already set (so parity goes on), the folio, the chapter and figure counts. const book = () => buildBundle({ chapters, config: config(), resources }); const config = () => ({ headings: { ...display, levels: [ // Every chapter opens on a recto: after a chapter that ends on one, the next document // starts with a blank verso of its own. { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // '{1}' puts the number in the PDF bookmarks ('1 Autumn'); the contents and // {chapterNumber} count the chapters in order either way. numberingTemplate: '{1}', advancedDesign: opener, span: 'page' }, // in the column a design stops at the column's foot: the cover would too subhead, ] }, // The cover and the contents are headings that take no number, no contents entry and no // running heads, so Autumn is still chapter 1. Both inherit span 'page', which starts // each on a page of its own, and parity 'odd', which the contents turn off: it would // leave page 2 blank (gotcha: style-inherits-break). headingStyles: [ { id: 'cover', numbered: false, toc: false, advancedDesign: cover, ...bare }, { id: 'contents', numbered: false, toc: false, breakBefore: { enabled: false }, advancedDesign: contentsOpener, ...bare }, ], // :::toc in the first document lists the whole book with the folio each chapter lands on: // buildBundle lays the book out again (three passes at most) until those folios settle. toc: contents, // The locale picks the hyphenation patterns and names the figures Figure or Figura. They // number {h1}.{n}, and the counters carry on from document to document (Winter's is 2.1). locale: t({ en: 'en-us', es: 'es' }), colorPalette, page, layout: { layoutType: 'single' }, bodyText, captionStyle, calloutStyles, header, footer, // the look: above, and in the regions below }); // #endregion const bare = { header: { elements: [] }, footer: { elements: [] } }; // no running heads // #region art: honeycomb drawn in code, seeded so that every run draws the same cells let seed = 2026; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const paint = (id, opacity = 1) => `fill="${palette[id]}" fill-opacity="${opacity}"`; // A w × h mm sheet (10 px per mm) of hexagonal cells of radius r; cell(x, y) paints each one. function comb(w, h, r, cell, under = '') { let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}">${under}`; for (let row = 0, y = 0; y < h + r; y = ++row * 1.5 * r) { for (let x = (row % 2) * SQRT3 / 2 * r; x < w + 2 * r; x += SQRT3 * r) { const corner = (a) => `${(x + 0.88 * r * Math.sin(a)).toFixed(2)} ` + (y - 0.88 * r * Math.cos(a)).toFixed(2); const hexagon = [0, 1, 2, 3, 4, 5].map((i) => corner(i * Math.PI / 3)).join('L'); const fill = cell(x, y); if (fill) svg += `<path d="M${hexagon}Z" ${fill}/>`; } } return `${svg}</svg>`; } // Comb that thins out with the distance d from a corner: solid cells with some open ones up to // d = 0.78, faint open cells up to 1, nothing beyond. const fade = (d, full, open, k = 1) => (d > 1 ? '' : d > 0.78 ? paint(open, 0.14 * k) : rand() < 0.3 ? paint(open, 0.28 * k) : paint(full, (d < 0.5 ? 0.95 : 0.6) * k)); const coverArt = () => comb(TRIM.width, TRIM.height, 7.5, (x, y) => fade(Math.hypot( (TRIM.width - x) / TRIM.width, y / (TRIM.height - 10)) + rand() * 0.22, 'comb', 'accent'), `<rect width="${TRIM.width}" height="${TRIM.height}" ${paint('honey')}/>`); // A paler cluster from the outer top corner; on a recto the cell under the number is solid. const cornerArt = (recto) => comb(CORNER.width, CORNER.height, CORNER.r, (x, y) => (recto && Math.hypot(x - NUMBER_CELL.x, y - NUMBER_CELL.y) < 1 ? paint('honey') : fade(Math.hypot(((recto ? CORNER.width : 0) - x) / (CORNER.width - 4), y / (CORNER.height - 6)) + rand() * 0.25, 'honey', 'honey', 0.85))); // One brood frame through the year. Per season: the brood nest (centre v, half-width, // half-height, what fills it) and where the honey sits, on a frame that runs −1…1 each way. const SEASONS = { autumn: [0.5, 0.3, 0.42, 'accent', () => true], winter: [0.4, 0.42, 0.62, 'ink', (u, v) => v < 0.25 - 0.4 * (1 - u * u)], // ink: the cluster spring: [0.2, 0.62, 0.8, 'accent', (u, v) => v < -0.3 && Math.abs(u) > 0.45], summer: [0.45, 0.5, 0.6, 'accent', (u, v) => v < 0.35 || Math.abs(u) > 0.7], }; function frameArt([cv, ru, rv, nest, honey]) { const { width: w, height: h } = FRAME; // a top bar with 4 mm lugs, slim side bars const wood = `<path d="M0 0H${w}V4.5H${w - 4}V${h}H4V4.5H0Z" ${paint('rule')}/>` + `<rect x="6.5" y="4.5" width="${w - 13}" height="${h - 7}" ${paint('rule', 0.5)}/>`; return comb(w, h, 2.5, (x, y) => { const [u, v] = [(x - w / 2) / (w / 2 - 7.5), (y - h / 2 - 1) / (h / 2 - 4)]; const d = Math.hypot(u / ru, (v - cv) / rv) + rand() * 0.12; if (x < 7 || x > w - 7 || y < 6 || y > h - 3.5) return ''; return paint(d < 1 ? nest : d < 1.3 && nest === 'accent' ? 'pollen' : honey(u, v) ? 'honey' : 'comb', d < 1 && nest === 'ink' ? 0.8 : 1); }, wood); } // #endregion // The cover: comb over a honey page, the title low on the inner side where the comb runs out. const label = { fontFamily: 'Figtree', fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase' }; const at = (edge, x, y, to = 'page') => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const below = (id, y, width) => ({ ...at('below', 0, y, `#${id}`), ...(width && { size: { width: mm(width) } }) }); // No minHeight: the contents heading, which inherits span 'page', starts the next page. const cover = { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'text', id: 'kicker', content: '{subtitle}', ...label, fontSize: pt(8.5), color: col('ink'), placement: at('top-left', 17, 116) }, { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(54), lineHeight: 0.98, align: 'left', overflow: 'wrap', placement: below('kicker', 4, 125) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(2), color: col('ink'), placement: { ...below('title', 6), size: { width: mm(16) } } }, { kind: 'text', id: 'author', content: '{author}', ...label, fontSize: pt(9), fontWeight: 700, color: col('ink'), placement: below('rule', 5) }, ] } }; // #region opener: each chapter's opener: the number in a honey cell, months, title and lead const corner = (id, edge) => ({ kind: 'image', id, resourceId: id, placement: { anchor: { to: 'bleed', edge }, size: { width: mm(CORNER.width) } } }); const months = { kind: 'text', id: 'months', content: '{attr.months}', ...label, color: col('accent'), placement: at('top-left', 0, 14, 'container') }; const title = { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(42), lineHeight: 1.05, align: 'left', overflow: 'wrap', placement: below('months', 1.5, 100) }; const opener = { enabled: true, minHeight: mm(52), slot: { elements: [ corner('cells', 'top-right'), // The number's box is centred on its cell. Comb and number both hang from the bleed's top // right, so a bleed (page.cutLines) moves them together. { kind: 'text', id: 'number', content: '{chapterNumber}', ...display, fontSize: pt(40), lineHeight: 1, align: 'center', placement: { size: { width: mm(NUMBER.box) }, ...at('top-right', NUMBER_CELL.x + NUMBER.box / 2 - CORNER.width, NUMBER_CELL.y - NUMBER.rise, 'bleed') } }, months, title, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'Andada Pro', italic: true, fontSize: pt(11.5), lineHeight: 1.35, color: col('ink'), align: 'left', overflow: 'wrap', placement: below('title', 3, 88) }, ] } }; // The contents mirror it on the verso: the comb in the outer corner, the book's subtitle and // the title set flush right against the spine, on the same lines as Autumn's across the spread. const flushRight = (element, placement) => ({ ...element, align: 'right', placement: { ...placement, size: { width: 'fill' } } }); const contentsOpener = { ...opener, minHeight: mm(40), slot: { elements: [ corner('comb', 'top-left'), flushRight({ ...months, content: '{subtitle}' }, months.placement), flushRight(title, below('months', 1.5)), ] } }; // #endregion // #region contents: what :::toc prints: numbers in the accent, dotted leaders, folios const contents = { levels: [ { level: 1, fontFamily: 'Rozha One', fontSize: pt(16), lineHeight: pt(18), numberFontFamily: 'Figtree', numberFontSize: pt(11), numberFontWeight: 700, numberColor: col('accent'), numberWidth: mm(7), numberGap: mm(4), marginTop: pt(8) }, // Sections: 9.3 pt in the muted colour, indented 11 mm (number 7 + gap 4) to the titles. { level: 2, fontSize: pt(9.3), lineHeight: pt(13.5), indent: mm(11), color: col('muted') }, ], pageNumber: { fontFamily: 'Figtree', fontSize: pt(8.5), fontWeight: 600, width: mm(7) }, leader: { char: '. ', gap: mm(2) }, // A second line under each chapter, from its {months="…"} heading attribute. subtitle: { enabled: true, attr: 'months', fontFamily: 'Andada Pro', fontSize: pt(9), color: col('muted') }, // italic by default }; // #endregion // #region running-heads: the book on the verso, the chapter on the recto, folios outside const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages ...label, color: col('muted'), placement, ...extra, }); const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('accent') }; // Only the first document has frontmatter, and its {title} is the book's in every chapter. const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio), head('verso-title', '{title}', 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)), // {chapterTitle} and {pageNumber} are worked out page by page, in every document. head('recto-title', '{chapterTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)), head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio), ] }; // Openers carry a drop folio instead, 8 mm under the text block. const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', at('top', 0, 8, 'container'), { pages: 'opener', fontWeight: 700 })] }; // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const front = String.raw`---Exemple Markdown · 10 lignes · content.en.md
title: "A Beekeeper’s Year" subtitle: "A handbook in four seasons" author: "Clara Ibarrola" --- # A Beekeeper’s \\ Year {style="cover"} # Contents {style="contents"} :::toc`; // frontmatter, cover and contents (content.<lang>.md) const autumn = String.raw`# Autumn {months="September · October · November" lead="The beekeeper’s year begins when the last honey comes off. What you do in the next eight weeks decides whether there is a colony to wake in March."}Exemple Markdown · 16 lignes · content.autumn.en.md
Most calendars start in January, and most beekeeping books in spring, with the first warm day and the first bees on the crocuses. This one starts in September, because that is where the bees start. The colony that flies next April is being raised now: the workers born in autumn live five or six months instead of five or six weeks, and they are the ones that carry the colony through the winter. A colony that goes into October with a young queen, twenty kilos of stores and few mites will usually come out strong in spring. ## Taking the harvest Take the last supers off by the end of August, when at least four fifths of each frame is capped. Clear the bees with a board the evening before and lift the boxes early, before the robbers are up. Leave the brood box alone; the honey in it is the bees’ winter food. Then weigh what is left. A colony in a single brood box needs about twenty kilos of stores to see out a winter like ours, which means frames like the one in :ref{id="autumn-frame"}, capped honey from the top bar almost to the bottom one. If the hive is lighter than that, feed. ## Stores for winter Feed thick syrup, two kilos of sugar to a litre of water, in a feeder over the crownboard, and feed it fast: the bees must take it down, dry it and cap it while the days are still warm enough to fly. By the middle of October it is too late for syrup, and what they have then is what they will eat. A hive that covers fewer than five frames at the end of September seldom lasts the winter on its own. Unite it with a stronger one over a sheet of newspaper: the bees chew through the paper in a day or two and mix without fighting. Treat for varroa as soon as the supers are off, before the winter bees are raised. Narrow the entrance to one bee’s width against wasps, and fit a mouse guard before the first frost. Then close the hive, and do not open it again until the first warm day of spring.`; // content.autumn.<lang>.md, and so on const winter = String.raw`# Winter {months="December · January · February" lead="Below fourteen degrees the bees draw together into a ball on the comb and live on their autumn stores."}Exemple Markdown · 2 lignes · content.winter.en.md
The cluster in :ref{id="winter-frame"} is how the colony gets through the cold. The bees on the outside pack tight, heads in, and hold the heat in; those inside shiver their flight muscles to keep the core warm, and the whole ball moves slowly up the frames as it eats. Do not open the hive. Once a month, heft it from the back to feel the weight and clear the dead bees from the entrance.`; const spring = String.raw`# Spring {months="March · April · May" lead="The colony wakes before you do. Wait for the first warm afternoon to open the hive and see what the winter has left behind."}Exemple Markdown · 14 lignes · content.spring.en.md
On a still day above fifteen degrees, when the bees are flying freely and coming home with pollen, you can open the hive for the first time. Work quickly and quietly, with a little smoke at the entrance, and ask only three questions: is there a laying queen, is there enough food, and is there room? Keep the visit to ten minutes at most, and close up before the brood can chill. ## The first inspection Look for eggs rather than for the queen. Eggs in the cells mean she was there within the last three days, and an egg standing upright on the base of the cell was laid yesterday. A good frame at the end of April looks like :ref{id="spring-frame"}: a solid oval of capped brood with few gaps, a band of pollen round it and the last of the winter honey in the upper corners. Patchy brood, or eggs laid two and three to a cell, means trouble with the queen; find out now, while there is still time to replace her. If the frames are light, feed. More colonies starve in March and April than in the depth of winter, because the brood is growing fast and the flowers are not yet out. Give a light colony a frame of stores from a strong one, or a block of fondant over the feed hole; syrup can wait for warmer nights. ## Room to grow By May the colony can double in a few weeks. When the bees cover seven or eight frames of the brood box, add a super, and add the next one before the first is full. A crowded colony prepares to swarm, and from now until July you should look through the brood box every seven days for queen cells. Spring is also the time to renew the comb. Early in the season, move three or four of the darkest frames to the edge of the brood box; once the queen has left them, take them out and put in frames of fresh foundation. With nectar coming in, the bees draw new comb eagerly, and in three years every frame in the box has been replaced.`; const summer = String.raw`# Summer {months="June · July · August" lead="The colony is at its largest and the main flow is on. Miss a week in June and the bees may swarm into the apple tree."}Exemple Markdown · 32 lignes · content.summer.en.md
In summer the brood box fills with honey from the top down, as :ref{id="summer-frame"} shows, and the supers fill after it. Add supers before the bees need them and look through the brood box for queen cells every week. Leave full frames on the hive until they are capped. In June a missed week can cost you half the colony and most of the crop, so write down what you find at every visit. ## Swarm season Swarming is how a colony reproduces. When the old queen leaves with half the workers, the bees that stay raise a new one, and you have two colonies where you had one, or one colony and a cluster of bees hanging from the nearest branch. The signs are queen cells along the bottom of the frames, a crowded brood box and a queen who has slimmed down to fly. If you find queen cells with larvae in them and the old queen is still at home, make an artificial swarm. Move the old queen to a new box on the old site with one frame of brood, and move the parent colony, with its queen cells, a metre or two away. The flying bees return to the old site and to their queen, and the colony behaves as if it had already swarmed. Within ten days or so a new queen emerges in the parent box, and within a month she is laying. ## The main flow In a good year the flow comes in late June and July, from lime, bramble and clover, and a strong colony can bring in two or three kilos of nectar a day. The supers fill faster than you expect, so keep adding them. Harvest when the frames are capped and the honey no longer shakes out of the cells: it is ripe below eighteen per cent water. Take the supers off at the end of July or in August, clearing the bees as the first chapter describes, and carry the boxes indoors before the robbers find them. ## From comb to jar Uncap the frames over a tray with a knife or a fork, spin them in the extractor, and run the honey through a coarse sieve and then a fine one. Leave it in a settling tank for two or three days in a warm room: the wax and the air bubbles rise, you skim the froth off the top, and what runs from the tap at the bottom is clear. Jar it then, and label each batch with the month and the flowers it came from. Honey keeps for years if it is dry, but it draws water from the air, so seal the jars and store them somewhere cool and dark. Most of it sets in time: spring honey from oilseed rape turns solid within weeks, lime honey stays runny for months, and neither has gone off. A jar that has set runs clear again in a warm water bath, never above forty degrees. Put the wet frames back on the hives in the evening, when the robbers have stopped flying, and let the bees clean them. By September the workers have driven the drones out and the colony is shrinking; the next jobs, feeding and treating for varroa, are in the first chapter. :::callout{type="colophon"} Set in Andada Pro, Rozha One and Figtree (SIL Open Font License). Text and drawings: original, CC BY 4.0. The front matter and the four seasons are five Markdown documents; Postext sets them as one book. An imaginary handbook by an imaginary author. :::`; // #region chapters: five Markdown documents in reading order: the front matter, then a year // Nothing in a chapter says where it lands: buildBundle works that out from the order. const chapters = [front, autumn, winter, spring, summer].map((markdown) => ({ markdown })); // #endregion const svg = (id, [width, height], caption) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption, altText: caption, svg: { fileId: `${id}.svg`, width: width * 10, height: height * 10 } }); // as comb() draws const CAPTIONS = t({ en: { autumn: 'A frame in October: honey (gold) round the last brood (brown) and pollen (russet).', winter: 'The same frame in January: the cluster (dark) eats its way up from empty comb (pale).', spring: 'The same frame in late April: brood across the middle, the winter honey nearly gone.', summer: 'The same frame in July: an arch of new honey presses down on the brood.', }, es: { autumn: 'Un cuadro en octubre: la miel (dorada) rodea la última cría (marrón) ' + 'y el polen (rojizo).', winter: 'El mismo cuadro en enero: el racimo (oscuro) deja la cera vacía (clara) y sube.', spring: 'El mismo cuadro a finales de abril: cría en el centro; queda poca miel del invierno.', summer: 'El mismo cuadro en julio: un arco de miel nueva aprieta la cría hacia abajo.', } }); const resources = [svg('cover', [TRIM.width, TRIM.height]), svg('cells', [CORNER.width, CORNER.height]), svg('comb', [CORNER.width, CORNER.height]), ...Object.keys(SEASONS).map((season) => svg(`${season}-frame`, [FRAME.width, FRAME.height], CAPTIONS[season]))]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build. // Rozha One ships one face, the 400 the titles set. const FONTS = { // text, display and labels 'Andada Pro': ['400', '400i', '700'], 'Rozha One': ['400'], Figtree: ['400', '600', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: draw, lay the book out, show it as spreads, offer one PDF of all chapters const text = chapters.map((chapter) => chapter.markdown).join('\n'); await prepareFonts(text, config(), kitFonts(FONTS)); const art = { cover: coverArt(), cells: cornerArt(true), comb: cornerArt(false) }; for (const [season, plan] of Object.entries(SEASONS)) art[`${season}-frame`] = frameArt(plan); for (const [id, markup] of Object.entries(art)) await loadSvg(`${id}.svg`, markup); // One VDTDocument per Markdown document. const docs = await withLoadedFonts(book, { ...kitFonts(FONTS), text }); showPages(docs, { title: t({ en: 'A Beekeeper’s Year', es: 'Un año de colmenar' }) }); // renderToPdf takes the array: one file for the book, with a bookmark per chapter. offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #endregionKit · core, fonts, viewer, pdf, images : identique dans toutes les recettes · 315 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 · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── 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
#Imprimer un chapitre avec ses folios du livre
Passez un seul document à renderToPdf pour épreuver « Spring » seul, toujours numéroté de 6 à 8 comme dans le livre. La page blanche 6 vient avec lui, parce qu’un verso blanc devant une ouverture appartient au chapitre qui s’ouvre après lui.
-offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
+offerPdf(() => renderToPdf(docs[3], { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),#Numéroter les figures en continu dans tout le livre
Un compteur de figures qui ne se remet jamais à zéro numérote les quatre cadres de 1 à 4 d’un document à l’autre, au lieu de 1.1 à 4.1.
buildBundle, prepareFonts, withLoadedFonts, renderPageToCanvas, registerResourceImage,
+ defaultResourceTypes,
@@
locale: t({ en: 'en-us', es: 'es' }),
+ resourceTypes: defaultResourceTypes(LANG)
+ .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' })),Erreurs fréquentes
Erreur fréquente
Un style de titre hérite du saut de page de son niveau
Une entrée headingStyles reprend de son niveau de titre chaque champ qu’elle omet, breakBefore compris. Une page de table des matières ou un colophon mis en forme sur un H1 après un :::pagebreak hérite de la parité 'odd' et atterrit derrière une page blanche. Donnez à un tel style breakBefore: { enabled: false }. Styles de titre →
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 →
Vérification du Sandbox · chapterFrontmatterIgnored
Front matter ignoré
Pourquoi. Seul le front matter du premier chapitre compte ; un chapitre ultérieur commence par un bloc de front matter qui est ignoré.
Correction. Gardez les métadonnées du livre dans le premier chapitre, et utilisez les attributs de titre pour les valeurs propres à chaque chapitre. Docs →
- En haut d’une page, et sous une figure qui en occupe la tête, un intertitre perd sa
marginTop. Un intertitre qui tire son espace de la marge se tient une ligne au-dessus de son texte à ces deux endroits et une ligne et demie partout ailleurs. Ici, l’espace est dans la ligne même de l’intertitre :lineHeightvaut une ligne et demie et la marge une ligne, si bien que chaque intertitre du livre se tient à la même distance de son texte. - Par défaut, une ligne justifiée peut étirer ses espaces jusqu’au double de leur largeur, et l’algorithme de coupure préfère cela à une césure. Avec cette valeur par défaut, l’édition anglaise n’avait aucune césure, et des lignes aux espaces presque deux fois plus larges que la normale côtoyaient des lignes serrées à 0,7.
maxWordSpacing: 1.7fait couper l’algorithme à la place. Après l’avoir modifié, vérifiez la dernière ligne de chaque paragraphe : une limite plus stricte peut y laisser un mot seul, et reformuler quelques mots du paragraphe règle le problème.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Polices
- Andada Pro (SIL OFL 1.1) · Rozha One (SIL OFL 1.1) · Figtree (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)


