Chapitre 12 · Partie II · Le métier
Configuration : utilisation programmatique
Appeler Postext depuis le code : buildDocument, Web Workers, la visionneuse HTML, les fichiers PDF, le livre en 3D, l’EPUB et les paquets .postext
En bref
Cette page s’adresse aux personnes qui écrivent du code. Elle montre comment composer un livre avec un seul appel de fonction et comment lire les avertissements qu’il signale. Elle explique comment faire le travail en arrière-plan, pour que la page reste rapide. Elle montre comment produire une vue web, un fichier PDF, un livre en 3D et un livre numérique EPUB. La dernière section présente le fichier qui contient un livre entier avec ses polices et ses images.
#Utilisation programmatique
Voie recommandée : utilisez le Web Worker. Dans un navigateur, la très grande majorité des intégrations devraient piloter la chaîne de mise en page par
createLayoutWorker()depostext/worker, et non en appelantbuildDocumentdirectement sur le thread principal. Le worker garde l’interface réactive pendant les constructions, met en cache les mesures de texte d’une reconstruction incrémentale à l’autre et met en place une annulation où la dernière demande l’emporte, de sorte qu’une nouvelle frappe interrompt toute construction périmée déjà en cours. Passez directement à Mise en page dans un Web Worker pour la recette de référence. Tout le reste de cette section (buildDocumenten direct, résolveurs, fonctions d’élagage, caches) reste utile, puisque le worker expose exactement les mêmes entrées et sorties, mais pour du code d’interface, l’enveloppe du worker est le bon point de départ. N’appelezbuildDocumentsur le thread principal que pour des exports ponctuels, le rendu côté serveur (Node) ou des tests.
#Construire un document
La fonction buildDocument exécute toute la chaîne de mise en page et renvoie un arbre de document virtuel (VDT) avec les coordonnées précises de chaque élément. C’est le point d’entrée de plus bas niveau ; le code d’interface doit lui préférer l’enveloppe Web Worker, qui appelle buildDocument dans un thread de worker dédié avec les mêmes arguments.
import { buildDocument } from 'postext';
const content = {
markdown: '# Chapter One\n\nThe story begins here...',
};
const config = {
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt overrides the 8 pt default
};
// Build the layout — produces a VDT with one entry per page in `vdt.pages`
const vdt = buildDocument(content, config);
console.log(`Document has ${vdt.pages.length} pages`);#Avertissements dans le document
buildDocument ne s’arrête pas sur une référence erronée ou sur un style inconnu : il applique une solution de repli et consigne ce qu’il a fait dans doc.contentWarnings. Les encadrés que la mise en page a dû forcer se trouvent dans doc.warnings, qui garde la forme qu’il avait dans postext 1.4 : chaque entrée y est un calloutOverflow avec ses pageIndex, columnIndex et overflowPx. Chaque champ est absent lorsqu’il n’y a rien à signaler. Chaque entrée a un kind. Les types de contenu portent la plage source de l’élément de syntaxe concerné (sourceStart / sourceEnd, des positions dans le markdown que vous avez passé, frontmatter compris) et, lorsque cet élément est tombé sur une page, son pageIndex.
| Type | Levé quand | Ce que fait la sortie |
|---|---|---|
calloutOverflow | Un encadré :::callout ne tient dans aucune colonne et aucune coupure ne peut le scinder. Un encadré span: 'side' plus haut qu’une colonne latérale vide l’est aussi (depuis postext 1.25). | Il est placé malgré tout et déborde de sa colonne de overflowPx (sur pageIndex / columnIndex). C’est le seul type listé dans doc.warnings ; les types suivants se trouvent dans doc.contentWarnings. |
invalidFrontmatter | Le frontmatter n’est pas du YAML valide (un guillemet non fermé, du texte après une valeur entre guillemets). message donne la raison fournie par l’analyseur, avec sa ligne et sa colonne. | Le document est composé sans ses métadonnées ; le corps qui suit le --- fermant est mis en page normalement. |
unknownResourceId | Une insertion ::resource (usage: 'embed'), un :ref en ligne ('ref') ou l’image d’une cellule de tableau ('cellImage') nomme un identifiant qu’aucune ressource ne porte. | L’insertion est omise, le renvoi imprime ? (ou son libellé text=) sans numéro ni lien, la cellule reste en texte seul. inResource nomme la ressource dont la légende, la note ou la cellule contient la référence. |
unknownDirective | Une ligne :::name dont le nom n’est ni une directive ni un conteneur. | La ligne est composée comme du texte. |
malformedEmbed | Une ligne ::name qui n’est pas une insertion bien formée et isolée : ::resource avec un identifiant sans guillemets ou entre apostrophes simples, ou avec un autre attribut, ou une ligne collée sous un paragraphe sans ligne vide. | La ligne est composée comme du texte. |
fullwidthMarkup | Une ligne contient un balisage saisi avec une méthode de saisie chinoise ou japonaise : une clôture :::, un titre #, un appel de note [^…], des attributs {…} après une clôture ou un titre, ou du gras **…**. typed est le balisage tel qu’il a été saisi, ascii la forme à saisir. Un par ligne. | La ligne est composée comme du texte ; rien n’est converti. |
attributeKeyInvalid | Une clé d’attribut contient des lettres hors ASCII (作者=曹雪芹) ; pointe sur la clé. | L’attribut est ignoré. |
unknownParagraphStyle | :::paragraphsstyle ne nomme aucun style de paragraphe. | Les paragraphes sont composés comme du texte courant. |
unknownCalloutType | :::callouttype ne nomme aucun des calloutStyles ; levé seulement une fois que certains sont configurés. | L’encadré prend le premier style d’encadré. |
columnsFlowUnknown | :::columnsflow ne vaut ni snake ni parallel ; value est la valeur indiquée. | Le groupe prend la valeur par défaut : parallel avec breaks, snake sans. |
unknownChipStyle | :chip[…]style ne nomme aucun style de pastille. | La pastille prend le premier style de pastille. |
undefinedFootnote | Un appel de note [^id] qu’aucun paragraphe [^id]: ne définit (id est celui de la note). | Le numéro s’imprime ; la note est vide. |
unusedFootnote | Une définition de note [^id]: qu’aucun appel ne cite. | La note n’est pas composée. |
indexMarkInvalid | Une marque d’index sans terme : :index, ou des attributs sans term sur une marque sans texte entre crochets. | La marque n’indexe rien. |
indexSeeUnknown | Une cible see ou seealso (target) qui n’est pas une entrée de son index (index, '' pour l’index principal). Pointe sur la ligne :::index. | Le renvoi s’imprime malgré tout. |
indexRangeUnclosed | Une marque range="start" sans range="end" correspondant, ou l’inverse (missing indique quelle extrémité manque ; term nomme l’entrée). Pointe sur la ligne :::index. | La plage n’imprime que sa page. |
unknownHeadingStyle | Le style="…" d’un titre ne nomme aucun style de titre (level est celui du titre). | Le titre et sa section gardent les réglages propres à leur niveau. |
unknownTableStyle | Le table.styleId d’une ressource tableau ne nomme aucune entrée de tableStyles. | Le tableau est composé dans tableStyle. |
raggedTableGrid | La grille d’un tableau n’est pas rectangulaire une fois ses fusions comptées (voir Construire des modèles de tableau). | Des cellules glissent sur une fusion ou laissent un trou. reason ('spanOverlap' / 'missingCells'), row et col situent le premier problème ; count indique combien il y en a. |
lineNumberOverlap | Avec lineNumbers.position: 'side', un numéro de ligne chevauche un encadré latéral, une légende latérale ou une figure de la colonne latérale. Pointe sur la ligne numérotée ; number est le numéro tel qu’il est imprimé. | Le numéro est peint malgré tout, et ni l’un ni l’autre ne bouge. |
dropCap | Un paragraphe ouvert par une lettrine qui ne peut pas la recevoir telle qu’elle est configurée. reason : 'shortParagraph' (moins de lignes que la lettrine n’en occupe ; handling indique ce qu’a fait shortParagraph, lines les lignes que couvre une lettrine réduite), 'split' (il se coupe avant la dernière ligne de la lettrine, seul dans une colonne trop courte), 'joiningScript' (sa première lettre se lie à la suivante), 'verticalText' ou 'noLetter' (il s’ouvre sur une référence, une formule ou un appel de note). text est sa première ligne. | La place est gardée, la lettrine est réduite ou omise, comme l’indique l’avertissement. |
codeOverflow | Un listing de code a des lignes plus larges que sa boîte. mode indique ce qu’a fait codeStyle.overflow ('wrap', 'shrink', 'clip'), lines combien de lignes source étaient trop larges, scale le corps auquel un listing réduit a été composé (une fraction de fontSize), lang le langage du bloc de code. Pointe sur le listing. | Les lignes sont renvoyées à la ligne, composées plus petit ou coupées, comme l’indique mode. |
floatShrunk | Une image flottante a été composée plus petite que sa taille pour tenir dans la place de son emplacement (placement.shrink). resourceId la nomme, scale est la part de sa largeur qu’elle garde, et overflowPx, s’il est présent, la distance dont elle dépasse encore le pied du bloc de texte à son échelle minimale (placement.minScale), sur une nouvelle page où elle n’avait nulle part ailleurs où aller. Pointe sur le paragraphe qui la cite en premier. | L’image est imprimée à cette échelle ; au-delà du bloc de texte seulement lorsque overflowPx l’indique. |
textWrap | Une ressource ou un encadré réglé pour que le texte l’habille (placement.wrap, le wrap d’un encadré) et qui ne le fait pas comme demandé. reason : 'tooNarrow' (le texte à côté serait plus étroit que layout.wrap.minTextWidth), 'fewLines' (il fait moins de layout.wrap.minLinesBeside lignes), 'moved' (un élément en ligne trop haut pour la place restante dans sa colonne est passé à la suivante, avec son ancre) ou 'verticalText'. resourceId nomme une ressource, box le style d’un encadré. Pointe sur l’insertion ou sur l’encadré. | L’élément occupe toute sa bande, ou se place dans la colonne suivante, selon la raison. |
columnsTooNarrow | Les sous-colonnes d’un groupe :::columns sont plus étroites que six cadratins de leur texte : columns sous-colonnes, de widthPx chacune. Pointe sur la clôture du groupe. | Le groupe est composé comme demandé, avec peu de mots par ligne. |
afterText | Un encadré span: 'side', ou une figure ou un tableau de la colonne latérale (resourceId), placé sur une page qui ne contient pas de texte : le texte de son chapitre, ou du document, s’est terminé alors qu’il attendait encore de la place dans la colonne latérale. Un par encadré ou par flottant ; pointe sur l’encadré (sur le bloc qui cite le flottant), avec sa page. Depuis postext 1.25. | Il se place dans la colonne latérale d’une page ouverte après le texte, les encadrés dans l’ordre de leurs clôtures. |
unplaced | Un encadré ou une ressource flottante (resourceId) qui attend encore un emplacement à la fin de la mise en page : les pages ouvertes pour l’accueillir n’ont pas pu le faire (un encadré latéral alors que ces pages n’ont pas de colonne latérale, par exemple). Pointe sur l’encadré ou sur le bloc qui cite la ressource ; pas de page. Depuis postext 1.25. | Il ne figure sur aucune page. Jusqu’à postext 1.24, il disparaissait sans avertissement. |
shortListSplit | Une liste de items éléments, courte au sens de bodyText.shortListMaxItems, coupée entre deux colonnes ou deux pages. Avec bodyText.shortListSplit: 'warn', toute liste de ce genre est signalée ; avec 'avoid', seulement celle qu’aucune colonne ne pouvait contenir entière. Un par liste ; pointe sur la liste, avec la page de son premier élément. Depuis postext 1.28. | La liste est coupée là où la colonne se termine. |
openerFloat | Un flottant placé sur une page d’ouverture avec layout.openerFloats: 'warn' : une figure, un tableau ou une bande (resourceId), ou un encadré flottant. Un par flottant ; pointe sur le bloc qui le cite (sur l’encadré), avec sa page. Depuis postext 1.28. | Le flottant reste sur la page d’ouverture ; 'avoid' le déplace vers le premier emplacement qui la suit. |
fontFallback | Une police dans laquelle le texte a été composé (family, weight, style) et que le jeu de polices ne pouvait pas fournir au moment de la construction : reason: 'missing', aucune police de la famille n’était chargée ni installée, ou celle qui répond pour cette graisse et cette inclinaison n’était pas encore chargée ; 'synthesized', la famille n’a pas de police de cette graisse ou de cette inclinaison et le navigateur la tire d’une autre, telle quelle ou dessinée plus grasse ou penchée (un 600 tiré du 700, un 700 italique demandé à une famille qui n’a qu’un 400 romain). Vérifié là où il existe un jeu de polices (document.fonts, le self.fonts d’un worker, ou BuildDocumentOptions.fontSet), derrière debug.warnings.missingFont. Ni page ni plage source. | Le texte est mesuré et dessiné avec la police de repli, ou avec une autre police de la famille, telle quelle ou rendue plus grasse ou penchée ; ses coupures de ligne changent quand la police arrive. Voir Charger les polices avant la mise en page. |
Les avertissements qui concernent une ressource (son style de tableau, sa grille, une référence dans sa légende, sa note ou ses cellules) pointent sur la première insertion ou la première référence de la ressource dans le texte, et chacun n’est listé qu’une fois par ressource. Seules les ressources que le document utilise sont vérifiées : un chapitre d’un livre signale les tableaux qu’il cite, pas tous les tableaux du livre.
import { buildDocument, formatWarning } from 'postext';
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.\n\n:::sidebar\nNotes.' }, config);
for (const w of [...(doc.warnings ?? []), ...(doc.contentWarnings ?? []), ...(doc.configWarnings ?? [])]) console.warn(formatWarning(w));
// Unknown resource id "fig-map" in :ref — it prints "?" (or its text= label), with no number or link (page 1, offset 4)
// Unknown directive ":::sidebar" — the line is set as text (page 1, offset 25)
// Narrow on `kind` to read the fields of a kind.
const missing = (doc.contentWarnings ?? []).flatMap((w) => (w.kind === 'unknownResourceId' ? [w.resourceId] : []));formatWarning(w) renvoie une description d’une ligne en anglais. Un hôte qui localise ses messages s’appuie plutôt sur kind, en gardant une branche par défaut, car des versions mineures peuvent ajouter des types. collectContentWarnings(markdown, config, resources) renvoie les avertissements de contenu sans rien mettre en page (la liste qu’ajoute la construction, sans pageIndex), pour un éditeur qui vérifie le texte pendant la saisie. collectHeadingDesignCuts(doc) cherche dans une mise en page terminée les designs de titre dont le texte dépasse le pied de leur page ou de leur colonne (kind: 'headingDesignCut' ; voir Hauteur réservée), ce que la mise en page elle-même ne signale pas, et formatWarning décrit aussi ses résultats. Le panneau Vérifications du Sandbox les liste tous.
Les moteurs de rendu signalent ce qu’ils ne peuvent pas peindre comme demandé par une option onWarning : renderPageToCanvas, renderPage et renderToCanvas (RenderPageOptions), renderToHtml et renderToHtmlIndexed (RenderHtmlOptions), et renderToPdf (RenderToPdfOptions, aussi par le worker PDF). Le principal type d’avertissement de rendu est missingImage : une image (une figure, l’image d’une cellule de tableau, une icône d’encadré, une image de design) qui n’a rien à dessiner est peinte comme un espace réservé neutre et signalée, une fois par fileId et par appel de rendu, avec son pageIndex, le resourceId quand le code de peinture le connaît (figures et images de cellule) et, dans le PDF, le documentIndex d’un rendu de plusieurs documents. « Rien à dessiner » signifie : aucun registerResourceImage pour le fileId dans le canvas, aucune URL renvoyée par resourceImageUrl en HTML, et aucun octet fourni par resourceBytes (ou des octets qui ne se décodent pas) dans le PDF. Une ressource bitmap ou SVG qui ne nomme aucun fileId n’a rien à demander : elle est dessinée comme un espace réservé, sans signalement. Deux autres types viennent des hôtes qui incorporent des polices dans des images SVG (registerSvgImage, registerBundleImages, bundleImageUrl, renderToHtml avec inlineSvgFonts, postext-epub ; voir Polices dans le texte SVG), avec le fileId et le resourceId de l’image : svgFontUnavailable (family, weight, style), une famille que nomme son texte sans police à incorporer, si bien que l’image compose ce texte dans une police de repli ; et svgFontsTooLarge (bytes, maxBytes), des polices au-delà du plafond de taille, dont aucune n’est incorporée. Les avertissements de rendu ne sont pas stockés dans le VDT : ce qu’un hôte peut fournir change après la mise en page.
import { buildDocument, renderPage, type RenderWarning, type Resource } from 'postext';
const map: Resource = {
id: 'fig-map', typeId: 'figure', kind: 'bitmap', caption: 'The route.', createdAt: 0, updatedAt: 0,
bitmap: { fileId: 'map-file', format: 'png', width: 1200, height: 800 },
};
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.', resources: [map] }, config);
const warnings: RenderWarning[] = [];
const canvas = renderPage(doc.pages[0], doc, { onWarning: (w) => warnings.push(w) });
// Until 'map-file' is registered with registerResourceImage:
// [{ kind: 'missingImage', fileId: 'map-file', resourceId: 'fig-map', pageIndex: 0 }]#Rendre une page en bitmap
Chaque page peut être rastérisée indépendamment. Utilisez renderPage(page, doc) pour obtenir un HTMLCanvasElement pour un numéro de page donné : le canvas est un bitmap aux dimensions exactes de la page en pixels (à la résolution configurée), que vous pouvez afficher, exporter ou passer à n’importe quelle chaîne de traitement d’images :
import { buildDocument, renderPage } from 'postext';
const vdt = buildDocument(content, config);
// Render page 3 (zero-indexed) to a bitmap canvas
const pageNumber = 2;
const page = vdt.pages[pageNumber];
if (!page) throw new Error(`Page ${pageNumber} does not exist`);
const canvas = renderPage(page, vdt);
// canvas.width / canvas.height are the page bitmap size in pixels
// Show it in the DOM
document.body.appendChild(canvas);
// …or export it as a PNG data URL
const pngDataUrl = canvas.toDataURL('image/png');
// …or get a Blob for download / upload
canvas.toBlob((blob) => {
if (blob) saveAs(blob, `page-${pageNumber + 1}.png`);
}, 'image/png');
// …or grab raw RGBA pixels
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);Si vous préférez dessiner dans un canvas que vous possédez déjà (par exemple un canvas attaché au DOM avec une mise en page précise), utilisez renderPageToCanvas(page, doc, canvas) : il redimensionne le canvas que vous lui passez et y peint, au lieu d’en créer un nouveau.
Pour rendre toutes les pages, parcourez vdt.pages :
const bitmaps = vdt.pages.map((page) => renderPage(page, vdt));Exemple en direct : une page en image
Tout ce qui précède, exécuté dans le navigateur. Le pen importe la dernière version de postext depuis un CDN, attend les polices web, met en page un court document sur deux colonnes, peint sa première page dans un canvas et propose ce bitmap en PNG. Appuyez sur Run on CodePen pour charger l’éditeur et modifier le markdown ou la configuration ; la page est repeinte à chaque modification.
import { buildDocument, renderPage } from 'https://esm.sh/postext';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
// 150 dpi: crisp enough for a preview, light enough to paint instantly.
page: { sizePreset: '17x24', dpi: 150 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
// The whole layout: one entry per page in doc.pages, with exact coordinates.
const doc = buildDocument({ markdown }, config);
// Rasterise the first page. The canvas is sized to the page at the configured dpi.
const canvas = renderPage(doc.pages[0], doc);
document.getElementById('page').replaceChildren(canvas);
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · page 1 is ${canvas.width} × ${canvas.height} px`;
// The same bitmap as a PNG file.
canvas.toBlob((blob) => {
const link = document.getElementById('download');
link.href = URL.createObjectURL(blob);
link.hidden = false;
}, 'image/png');index.html
<p id="status">Laying out…</p>
<a id="download" download="page-1.png" hidden>Download page 1 as PNG</a>
<div id="page"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
margin-top: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Charge un éditeur interactif depuis codepen.io. L’exemple importe la dernière version de postext depuis un CDN.
#React
postext/react exporte createLayout(content, config?) : un composant qui met le document en page une seule fois, au montage, et affiche chaque page sous forme de <canvas> dans une <div>.
import { createLayout } from 'postext/react';
const Article = createLayout(
{ markdown: '# Hello\n\nThe first paragraph of the article.' },
{ page: { sizePreset: '17x24' } },
);
export function ArticlePage() {
return <Article className="pages" style={{ maxWidth: 480 }} />;
}- Sur le thread principal, une seule fois. Les pages sont peintes à la résolution du document et mises à l’échelle à la largeur du conteneur.
contentetconfigsont fixés au moment où vous appelezcreateLayout; créez un autre composant pour afficher autre chose. Pour un aperçu en direct, construisez dans le Web Worker et peignez avecrenderPageToCanvas, comme dans l’exemple React de cette section. - Les polices et les images d’abord. Chargez les polices web du document avant le montage du composant, et enregistrez ses images avec
registerResourceImage. Lorsque le markdown contient un$, le composant démarre lui-même le moteur mathématique. - React reste hors du point d’entrée principal.
postextn’importe jamais React ; seulpostext/reactle fait.createLayoutest toujours exporté depuispostextpour que le code existant continue de fonctionner, mais il est déprécié : il chargepostext/reactquand vous l’appelez, et le composant se suspend jusqu’à son arrivée (React le rend de nouveau de lui-même). Importez-le depuispostext/react. - Le composant déprécié se suspend. Tant que
postext/reactn’est pas arrivé, lecreateLayoutdepostexta besoin d’une racine concurrente (createRoot) ou d’une frontière<Suspense>au-dessus de lui. Dans une racineReactDOM.renderhistorique, ou dansrenderToString, sans frontière, React signale une erreur à la place.reactreste une dépendance pair obligatoire, afin que les bundlers puissent résoudre cet import différé.
#Résoudre les valeurs par défaut
Les fonctions de résolution complètent les objets de configuration partiels avec les valeurs par défaut. C’est utile lorsque vous avez besoin d’une configuration complète pour l’inspecter ou la comparer :
import { resolvePageConfig, resolveBodyTextConfig } from 'postext';
const fullPage = resolvePageConfig({ sizePreset: '21x28' });
// => { sizePreset: '21x28', width: { value: 21, unit: 'cm' }, height: { value: 28, unit: 'cm' },
// margins: { top: { value: 2, unit: 'cm' }, ... }, dpi: 300, cutLines: { enabled: false, ... }, ... }
const fullBody = resolveBodyTextConfig({ fontFamily: 'Inter' });
// => { fontFamily: 'Inter', fontSize: { value: 8, unit: 'pt' }, lineHeight: { value: 1.5, unit: 'em' }, ... }Résolveurs disponibles, un par section de premier niveau : resolvePageConfig, resolveLayoutConfig, resolveBodyTextConfig, resolveHeadingsConfig, resolveHeadingStylesConfig, resolveTocConfig, resolvePartsConfig, resolveUnorderedListsConfig, resolveOrderedListsConfig, resolveMathConfig, resolveTableStyleConfig, resolveCaptionStyleConfig, resolveDiagramStyleConfig, resolveParagraphStylesConfig, resolveCalloutStylesConfig, resolveHeaderFooterConfig, resolveDebugConfig, resolveHtmlViewerConfig, resolvePdfGenerationConfig, plus resolveDesignSlot pour un seul emplacement de design. Les palettes de couleurs s’appliquent séparément par applyPaletteToConfig(config), applyPaletteToResolvedConfig(resolved, palette) et resolveColorValue(value, palette, fallback) : voir Palette de couleurs.
Les résolveurs dont les valeurs par défaut découlent d’une autre section prennent cette section, déjà résolue, comme argument supplémentaire. resolveUnorderedListsConfig et resolveOrderedListsConfig prennent le texte courant résolu, car les valeurs par défaut des listes pour fontFamily et color en découlent ; resolveCalloutStylesConfig prend le texte courant, les titres et les listes à puces résolus (voir l’exemple dans Styles d’encadré), et resolveHeadingStylesConfig la page, le texte courant et les deux sections de listes résolus. Consultez les déclarations de types du paquet npm pour la signature exacte de chacun :
import { resolveBodyTextConfig, resolveUnorderedListsConfig } from 'postext';
const body = resolveBodyTextConfig({ fontFamily: 'Inter' });
const lists = resolveUnorderedListsConfig({ bulletChar: '—' }, body);
// => lists.fontFamily === 'Inter' (inherited)Les jeux de valeurs par défaut statiques (les valeurs utilisées quand aucune cascade n’intervient) sont aussi exportés : DEFAULT_PAGE_CONFIG, DEFAULT_CUT_LINES, DEFAULT_PAGE_NUMBERING, PAGE_SIZE_PRESETS, DEFAULT_LAYOUT_CONFIG, DEFAULT_COLUMN_RULE, DEFAULT_COLUMN_BALANCING, DEFAULT_BODY_TEXT_CONFIG, DEFAULT_HYPHENATION_CONFIG, DEFAULT_HEADINGS_CONFIG, DEFAULT_UNORDERED_LISTS_STATIC, DEFAULT_ORDERED_LISTS_STATIC, DEFAULT_PARAGRAPH_STYLES, DEFAULT_CALLOUT_STYLES, DEFAULT_CALLOUT_STYLE_STATIC, DEFAULT_PARTS_CONFIG, DEFAULT_HEADING_STYLES, DEFAULT_TOC_CONFIG, DEFAULT_MATH_CONFIG, DEFAULT_DIAGRAM_STYLE_CONFIG, DEFAULT_DEBUG_CONFIG, DEFAULT_HTML_VIEWER_CONFIG, DEFAULT_PDF_GENERATION_CONFIG, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX, plus les valeurs par défaut des éléments d’en-tête et de pied de page (DEFAULT_HEADER_FOOTER_SLOT, DEFAULT_HEADER_SLOT, DEFAULT_FOOTER_SLOT, DEFAULT_TEXT_ELEMENT, DEFAULT_RULE_ELEMENT, DEFAULT_BOX_ELEMENT) et defaultResourceTypes(locale), qui dépend de la langue (voir Types de ressources).
#Élaguer les valeurs par défaut
Lorsque vous enregistrez une configuration (par exemple dans localStorage ou dans un fichier), utilisez stripConfigDefaults pour retirer les valeurs égales aux valeurs par défaut. Les configurations stockées restent ainsi minimales, seules les modifications voulues étant enregistrées :
import { stripConfigDefaults } from 'postext';
const minimal = stripConfigDefaults(fullConfig);
// Only properties that differ from defaults remainDes fonctions d’élagage individuelles existent aussi, une par résolveur : stripPageDefaults, stripLayoutDefaults, stripBodyTextDefaults, stripHeadingsDefaults, stripHeadingStylesDefaults, stripTocDefaults, stripPartsDefaults, stripUnorderedListsDefaults, stripOrderedListsDefaults, stripMathDefaults, stripTableStyleDefaults, stripCaptionStyleDefaults, stripDiagramStyleDefaults, stripParagraphStylesDefaults, stripCalloutStylesDefaults, stripHeaderFooterDefaults, stripDesignSlotDefaults, stripDebugDefaults, stripHtmlViewerDefaults, stripPdfGenerationDefaults.
Certaines valeurs par défaut dépendent du reste de la configuration : l’équilibrage des colonnes est désactivé sur une grille de caractères et en texte vertical, bodyText.avoidWidows est désactivé sur une grille de caractères (et bodyText.avoidOrphans aussi dans la région japan), et les notes de bas de page, les légendes et l’index suivent la langue du document. stripConfigDefaults compare chaque valeur à la valeur par défaut de la configuration qu’on lui donne : headings.balancing.enabled: true est donc conservé là où l’équilibrage est désactivé par défaut, et false y est supprimé. bodyText.avoidWidows: true est conservé de la même façon sur une grille de caractères. Une fonction d’élagage appelée seule reçoit ce contexte en arguments : stripHeadingsDefaults(headings, balancingOnByDefault(config)), stripIndexDefaults(index, locale), stripCaptionStyleDefaults(captionStyle, locale), stripFootnotesDefaults(footnotes, locale, writingMode), stripBodyTextDefaults(bodyText, locale, avoidWidowsByDefault(config), avoidOrphansByDefault(config)).
Une valeur qui signifie « rien » est conservée partout où « rien » n’est pas la valeur par défaut. Un style de titre reprend l’en-tête et le pied de page du document lorsqu’il n’en définit pas : un style qui les vide (footer: { elements: [] } sur une couverture) garde donc l’emplacement vide, et ses margins, layout et bodyStyle sont conservés même vides. Un niveau de titre ramené à sa valeur par défaut alors que les valeurs communes à tous les titres diffèrent garde sa valeur. calloutStyles: [] et chipStyles: [] restent des listes vides : omis, le style intégré reviendrait. Dans tous les cas, resolveAllConfig(stripConfigDefaults(config)) donne la même résolution que resolveAllConfig(config).
#Analyse
Le moteur expose son analyseur de markdown et son lecteur de frontmatter. Utilisez-les pour inspecter un document avant de le construire, ou pour alimenter d’autres outils avec la même structure de blocs que celle que voit Postext :
import { parseMarkdown, extractFrontmatter } from 'postext';
const source = '---\ntitle: Chapter One\n---\n\n# Opening\n\nThe story begins here.';
const { metadata, content } = extractFrontmatter(source);
// metadata.title === 'Chapter One'
const blocks = parseMarkdown(content);
// => [ { type: 'heading', level: 1, text: 'Opening', … },
// { type: 'paragraph', text: 'The story begins here.', … } ]Voir la page Format du document pour la liste complète des éléments de syntaxe markdown que Postext reconnaît.
#Charger les polices avant la mise en page
La mise en page mesure le texte avec les polices dont dispose le jeu de polices au moment où elle s’exécute. prepareFonts charge, avant la première construction, toutes les polices que demandent une configuration et son texte : le texte courant, les titres, les listes, les titres et le corps des encadrés, les tableaux, les textes de design, les titres courants, la table des matières, le code et le lettrage de bande dessinée, dans chaque graisse et chaque inclinaison que définit la configuration (les quatre de la famille du texte courant, puisque ** et * y composent le gras et l’italique). Chaque police est chargée pour les caractères que le document compose, si bien qu’une famille servie en tranches unicode-range (latin étendu, grec, arabe, les tranches CJK de Google Fonts et de Fontsource) apporte les fichiers dont le texte a besoin.
import { prepareFonts, buildDocument, buildDocumentWithFonts } from 'postext';
// The host's files: the PDF font provider's contract, so one function serves both.
async function resolve(family: string, weight: number, style: 'normal' | 'italic') {
const id = family.toLowerCase().replace(/\s+/g, '-');
return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
}
const report = await prepareFonts(content, config, { resolve });
// report.loaded, report.missing, report.synthesized: { family, weight, style }[]
const doc = buildDocument(content, config);
// Or in one call: prepare, build, load any face the pages used that was not there, build again.
const same = await buildDocumentWithFonts(content, config, { resolve });- Les polices que la page déclare (une règle
@font-face, uneFontFacedéjà ajoutée) sont chargées par le jeu de polices (document.fonts.load, ouself.fontsdans un worker). Celles qu’elle ne déclare pas sont demandées àresolve(family, weight, style, { text, codePoints }), qui répond par un fichier (des octets, ou une URL), par plusieurs fichiers (les tranches d’une police), par{ source, unicodeRange, weight, style }pour une tranche ou une plage variable, ou parnull. Le moteur les ajoute commeFontFaceune fois que toutes sont chargées, dans l’ordre de la configuration et de chaque réponse (latin, puis latin-ext, puis greek quand le résolveur répond ainsi), si bien que le jeu de polices est le même quel que soit l’ordre d’arrivée des fichiers. Il les enregistre aussi dans le registre de polices que lisent les images SVG et les workers de mise en page. Un résolveur peut déclarer la police lui-même (en ajoutant une feuille de style) et répondrenull. - Le rapport liste les polices couvertes par une police chargée (ou par une famille installée), celles qui restent
missing, et celles que le navigateur devraitsynthesizeà partir d’une autre graisse ou inclinaison.timeoutMs(10 000 par défaut) borne l’attente ; une police encore en cours de chargement compte alors comme manquante. Là où il n’y a pas de jeu de polices (Node),prepareFontsne fait rien et signale toutes les polices comme chargées. buildDocumentWithFonts(content, config, options)prépare, construit avecbuildDocumentAsync, lit les polices dans lesquelles les pages composent réellement du texte, charge celles que le jeu de polices n’a pas pu fournir (une graisse que seules les pages révèlent est demandée àresolvemême si une autre graisse de la famille pourrait la remplacer), puis reconstruit (deux constructions supplémentaires au plus).withLoadedFonts(build, options)fait de même autour de n’importe quelle fonction de construction, pour un livre construit chapitre par chapitre ou pour un paquet (buildBundle) qui renvoie plusieurs documents.options.onFontsreçoit le rapport final.- Après la construction, chaque police dans laquelle le texte a été composé et que le jeu de polices n’a pas pu fournir figure dans
doc.contentWarningssous la formefontFallback(voir Avertissements dans le document).
Les polices qui arrivent plus tard sont prises en compte d’elles-mêmes. Le moteur garde, pour chaque jeu de polices, la liste des polices de chaque famille qui étaient chargées lors de sa dernière vérification ; une construction vérifie de nouveau à son début (seulement si le jeu a grandi ou diminué, est en cours de chargement ou vient de finir de charger une police) et abandonne ce qui a été mesuré dans les familles dont les polices ont changé. watchFonts(fontSet) fait de même au fil du chargement des polices, au plus une fois par image d’animation quel que soit le nombre de tranches qu’apporte une rafale, et onFontsChanged(listener) indique à l’hôte quelles familles ont changé, pour qu’il puisse refaire la mise en page :
import { watchFonts, onFontsChanged } from 'postext';
const stop = watchFonts(); // document.fonts by default
const off = onFontsChanged((families) => relayout());prepareFonts lance la surveillance sur le jeu dans lequel il charge les polices (watch: false la laisse désactivée).
#Cache de mesures
La mesure du texte est l’étape coûteuse de la mise en page. Deux sortes de cache la gardent bon marché :
- Un cache de blocs qui vous appartient.
createMeasurementCache()renvoie unMeasurementCachequi mémorise chaque paragraphe mesuré, indexé par son texte, ses polices, sa largeur, ses options de coupure des lignes et le dictionnaire de césure actif. Passez-le comme troisième argument debuildDocument(ou debuildDocumentAsync) pour réutiliser les mesures entre les passes de placement d’une construction et d’une construction à l’autre : un éditeur qui met le document en page à chaque frappe ne mesure alors que les paragraphes qui ont changé. Sans lui, chaque passe mesure de nouveau chaque bloc. Un paragraphe lu dans le cache est identique à un paragraphe mesuré à neuf : une construction avec cache compose donc chaque ligne comme une construction sans cache ; dans postext 1.4.1, un paragraphe en cache perdait la marque d’une dernière ligne trop courte, et le resserrement des fins de paragraphe trop courtes ainsi que l’équilibrage des colonnes pouvaient alors le couper autrement. Le cache porte la génération de mesure sous laquelle il a été rempli : lorsque des polices d’une famille arrivent ou disparaissent, sa consultation suivante abandonne les blocs composés dans cette famille, si bien qu’un cache conservé pendant des chargements de polices ne sert jamais de lignes mesurées avec une police de repli. - Les caches globaux de largeurs. Les largeurs de mots sont mises en cache par chaîne de police dans un état de module que partagent toutes les constructions de la page, et pretext tient son propre cache. Le moteur abandonne les largeurs d’une famille lorsque ses polices changent (au début d’une construction, depuis
watchFonts, depuisprepareFontsetloadBundleFonts) ; le cache de pretext n’a pas d’index par famille et il est vidé en entier.
import { buildDocument, createMeasurementCache, evictFontFamilies, clearMeasurementCache } from 'postext';
const cache = createMeasurementCache();
let doc = buildDocument(content, config, cache);
// A face of "EB Garamond" was added to document.fonts: the next build sees it,
// with the same cache, and measures that family again.
doc = buildDocument(content, config, cache);
// A host that changes faces the engine cannot see (a font set of its own) says so:
evictFontFamilies(['EB Garamond']); // that family's widths and cached blocks
clearMeasurementCache(); // every familyPour les applications qui mesurent le texte morceau par morceau, cachedMeasureBlock(text, font, maxWidthPx, lineHeightPx, options, cache) et cachedMeasureRichBlock(spans, normalFont, boldFont, italicFont, boldItalicFont, maxWidthPx, lineHeightPx, options, cache) prennent les arguments de measureBlock et de measureRichBlock, plus le cache, en dernier.
#Le module de coupure des lignes (WebAssembly)
Les paragraphes sont coupés en lignes par Knuth–Plass. Depuis postext 1.33, le moteur l’exécute dans un module de coupure des lignes écrit en Rust et compilé en WebAssembly (30 Ko, inclus dans le paquet npm). Il choisit les mêmes coupures que le module TypeScript, avec la même arithmétique dans le même ordre, et un livre entier se met en page environ 15 % plus vite. Rien n’est à configurer :
- Dans un worker, dans Node et dans Bun, le module est compilé au premier paragraphe.
- Sur le thread principal d’une page, où un navigateur ne compile un module de cette taille que de façon asynchrone, il se compile en arrière-plan et le module TypeScript répond en attendant.
await initLineBreaker()avant la première mise en page le met au travail dès le premier paragraphe ; la promesse se résout àfalselà où WebAssembly n’est pas disponible. - Là où WebAssembly manque, ou là où une Content Security Policy le refuse (
script-srcsans'wasm-unsafe-eval'), le module TypeScript répond pour chaque paragraphe.
Les deux choisissent les mêmes coupures : le module qui a répondu ne change donc jamais une mise en page. useWasmLineBreaker(false) garde le module TypeScript, pour comparer les deux.
import { initLineBreaker, buildDocument } from 'postext';
await initLineBreaker(); // optional: the first layout already uses it
const doc = buildDocument({ markdown }, config);#État global partagé dans une page
Une partie de l’état de Postext vit dans des variables de niveau module. Tout ce qui importe postext dans le même domaine d’exécution JavaScript (realm) le partage : une page et ses scripts partagent une copie, tandis que chaque iframe et chaque worker a la sienne. Avec un seul document par page, on ne s’en aperçoit jamais. Avec plusieurs documents dans une page (deux aperçus en direct, une galerie d’exemples), on s’en aperçoit :
- Images de ressources.
registerResourceImage(fileId, image)remplit un registre unique indexé parfileId, que lisentrenderPageetrenderPageToCanvas. Deux documents qui enregistrent tous deuxfigure.svgpartagent cette entrée : le dernier enregistrement l’emporte, pour les deux. Donnez aux identifiants de fichier un préfixe propre à chaque document, et appelezunregisterResourceImage(fileId)ouclearResourceImages()quand un document disparaît. Les rastérisations que le moteur de rendu canvas met en cache sont indexées de la même façon et abandonnées avec l’image. - Mesures de texte. Les largeurs mesurées sont mises en cache par chaîne de police et par texte pour tout le domaine d’exécution. Lorsque des polices d’une famille arrivent ou disparaissent, le moteur abandonne les largeurs de cette famille pour tous les documents (voir Charger les polices avant la mise en page) ;
clearMeasurementCache()les abandonne toutes. - Configurations résolues. Chaque objet de configuration est résolu une fois et le résultat est mis en cache pour cet objet. Chaque construction compare d’abord l’objet au texte qu’il avait lors de sa résolution (un
JSON.stringify, environ 0,1 ms pour une configuration de livre de 55 Ko et 1,5 ms pour une configuration de 240 Ko), si bien qu’une configuration modifiée sur place, à n’importe quelle profondeur (config.bodyText.fontSize = …, une couleur de palette), est résolue de nouveau.invalidateConfig(config)abandonne la résolution à la main.stableStringifyethashStringdonnent une clé de contenu indépendante de l’ordre des clés, pour les hôtes qui mettent en cache des mises en page par configuration. - Langue de césure. Chaque construction règle la langue de césure globale du processus sur le
bodyText.hyphenation.localede son document. Les fonctions exportéeshyphenateText(text)etlayoutDesignSlotutilisent la langue de la dernière construction, sauf si vous en passez une : appelezhyphenateText(text, 'es'). - Moteur mathématique. Il y a un seul moteur MathJax et un seul cache de formules rendues pour le domaine d’exécution ;
initMathEngine()le démarre pour tout le monde. - Module de coupure des lignes. Il y a un seul module WebAssembly de coupure des lignes pour le domaine d’exécution, compilé une fois ;
useWasmLineBreaker(false)le désactive pour tout le monde.
L’isolation la plus simple consiste à donner un domaine d’exécution à chaque document : une iframe pour chaque exemple en direct (un embed CodePen en est une), ou un worker de mise en page par document pour les mesures et la césure (les images restent enregistrées sur la page).
#Mise en page dans un Web Worker
C’est la façon recommandée d’utiliser Postext dans le navigateur. Si vous construisez quoi que ce soit d’interactif (un aperçu en direct, un éditeur, une visionneuse qui suit le redimensionnement, un bac à sable dans le style du Sandbox), pilotez la chaîne par createLayoutWorker() de postext/worker. N’appelez pas buildDocument directement sur le thread principal pour du code d’interface.
Appeler buildDocument sur le thread principal exécute toute la chaîne (analyse, mesure, placement et chaque nouvelle exécution de la passe de placement dont la mise en page a besoin) sur le thread qui l’a appelée. Pour un export ponctuel, cela convient. Pour une interface interactive, c’est le mauvais thread : une mise en page de 150 ms bloque les événements d’entrée, les frappes s’accumulent et le défilement saccade. Le worker déplace chacune de ces millisecondes vers un thread d’arrière-plan.
Postext fournit un point d’entrée Web Worker dédié, postext/worker, qui sort la chaîne du thread principal. C’est la voie que nous attendons de la majorité des intégrations : les vues Canvas, HTML et PDF du Sandbox partagent toutes le même handle createLayoutWorker() par un unique hook useLayoutWorker (packages/postext-sandbox/src/worker/useLayoutWorker.ts) et le pilotent avec une annulation où la dernière demande l’emporte : une nouvelle frappe interrompt la construction en cours avant même qu’elle se termine.
D’un coup d’œil, l’intégration de référence se présente ainsi :
- Créez un worker une fois par vue avec
createLayoutWorker(). - Enregistrez les polices une fois par famille en postant des
ArrayBuffertransférables parregisterFonts(payloads). - Construisez avec
build(content, config, { signal }), en passant un nouvelAbortSignalà chaque appel pour pouvoir annuler les constructions périmées. - Remplacez toute construction précédente en interrompant son signal avant de lancer la suivante : c’est le modèle « la dernière l’emporte ».
- Libérez le worker lorsque le composant qui le possède est démonté.
Le même VDTDocument que renvoie build(...) alimente tous les moteurs de rendu en aval : renderPage/renderPageToCanvas pour le canvas, renderToHtmlIndexed pour le HTML et renderToPdf (de postext-pdf) pour le PDF. Vous construisez une fois dans le worker et vous rastérisez sur le thread principal autant de fois que l’interface en a besoin.
#Ce que le worker vous apporte
- Le thread principal reste libre. L’analyse, la mesure et chaque passe de placement s’exécutent toutes dans le worker. Le thread principal n’est sollicité que lorsque le
VDTDocumentterminé lui est renvoyé. - Annulation où la dernière demande l’emporte.
build(content, config, { signal })transmet unAbortSignalau worker. Une interruption avant la fin lève uneAbortErrorcôté principal ; dans le worker, la chaîne lève uneBuildCancelledErrorau point de contrôle d’annulation suivant (il y en a un par bloc) et s’arrête immédiatement. - Cache de mesures par worker. Le worker garde un seul
MeasurementCachependant toute sa durée de vie. Les constructions suivantes qui partagent police, texte et largeur réutilisent les mesures de lignes en cache : taper un seul caractère dans un long document ne remesure que les blocs dont l’entrée a réellement changé. - Des métriques identiques à celles du thread principal. Les polices sont envoyées dans le worker sous forme d’
ArrayBuffertransférables et enregistrées parnew FontFace(...)dans le propreFontFaceSetdu worker. Le worker mesure avec les mêmes métriques de police canvas que celles qu’utiliserait le thread principal : les coupures de ligne et les hauteurs de colonne sont donc identiques à l’octet près. - Le cache de rastérisation des formules survit aux constructions du worker. Le moteur de rendu mathématique fournit un cache de rastérisation indexé par contenu à côté de celui indexé par identité : sans lui, le clonage structuré d’un
MathRenderà travers la frontière du worker manquerait le cache par identité à chaque reconstruction. - Les mêmes lignes que sur la page. Un canvas créé dans un worker n’a pas de page dont tirer une langue, et une police répond avec d’autres glyphes selon la langue. Le moteur fixe la langue du document (
locale) sur chaque contexte avec lequel il mesure, dans le worker comme sur la page, si bien quebuild()renvoie les lignes quebuildDocumentdonne sur une page, dans n’importe quelle langue (bodyText.shapingLanguage: 'document', la valeur par défaut depuis postext 1.30). Avec'host', que garde une configuration enregistrée avant 1.30, un worker mesure sans langue et une page avec la sienne, comme jusqu’à postext 1.28.
#API publique
Le client du worker se trouve dans le sous-chemin postext/worker et tient en quelques noms :
createLayoutWorker(opts?): LayoutWorkerHandle: lance un worker dédié (ou enveloppe celui que vous passez paropts.worker, ou démarre le point d’entrée du worker situé àopts.url) et renvoie un handle typé. Voir Charger le worker depuis un CDN.LayoutWorkerHandle.registerFonts(faces: FontPayload[]): Promise<void>: envoie les octets des polices dans le worker. Les buffers sont transférés : gardez une copie neuve sur le thread principal si vous devez les renvoyer plus tard.LayoutWorkerHandle.build(content, config?, { signal? }): Promise<VDTDocument>: exécute la chaîne. Interrompre le signal annule la construction en cours.LayoutWorkerHandle.dispose(): void: termine le worker et rejette toute construction en attente avecAbortError.FontPayload:{ family, weight, style, unicodeRange?, buffer: ArrayBuffer }.weightest une chaîne de graisse CSS ('700','bold') ;registerFontsl’accepte aussi sous forme de nombre (700). Lebufferest transféré au worker lorsque vous appelezregisterFonts.BuildCancelledError(réexportée depuispostext) : ce quebuildDocumentlève en interne lorsqueoptions.shouldCancelrenvoietrue. Vous ne la voyez généralement pas sur le thread principal : le protocole du worker la convertit enAbortErroravant qu’elle n’atteigne votre code.
Le paquet npm publie aussi un chemin postext/worker/entry qui pointe vers le script compilé du worker. createLayoutWorker() résout cette URL automatiquement ; vous n’avez besoin de la référencer explicitement que si votre bundler exige un appel new Worker(new URL(...), { type: 'module' }) construit à la main, ou si vous servez le point d’entrée vous-même (opts.url).
#Intégration minimale
import { createLayoutWorker } from 'postext/worker';
import type { FontPayload, LayoutWorkerHandle } from 'postext/worker';
import type { PostextConfig, VDTDocument } from 'postext';
// 1. Create the worker once and keep the handle for the lifetime of your viewport.
const layout: LayoutWorkerHandle = createLayoutWorker();
// 2. Register fonts once per family (transferable ArrayBuffers).
// getConfigFontFamilies(config) is a helper that lists the families your config will render.
const payloads: FontPayload[] = await collectFontPayloadsForFamilies([
'EB Garamond',
'Open Sans',
]);
await layout.registerFonts(payloads);
// 3. Drive builds with last-wins cancellation: abort the previous signal
// before starting a new build. A stale build is thrown away inside the worker.
let pending: AbortController | null = null;
async function rebuild(
markdown: string,
config: PostextConfig,
): Promise<VDTDocument | null> {
pending?.abort();
pending = new AbortController();
try {
return await layout.build({ markdown }, config, { signal: pending.signal });
} catch (err) {
if ((err as { name?: string } | null)?.name === 'AbortError') return null;
throw err;
}
}
// 4. Dispose when the component that owns the worker unmounts.
// Pending builds reject with AbortError.
layout.dispose();Enveloppé dans un composant React, cela donne :
import { useEffect, useRef } from 'react';
import { createLayoutWorker } from 'postext/worker';
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderPageToCanvas } from 'postext';
import type { PostextConfig } from 'postext';
export function CanvasPreview({
markdown,
config,
}: {
markdown: string;
config: PostextConfig;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const workerRef = useRef<LayoutWorkerHandle | null>(null);
const pendingRef = useRef<AbortController | null>(null);
// Mount: spin up the worker and ship the fonts once.
useEffect(() => {
const handle = createLayoutWorker();
workerRef.current = handle;
(async () => {
const payloads = await collectFontPayloadsForFamilies(
getConfigFontFamilies(config),
);
await handle.registerFonts(payloads);
})();
return () => {
pendingRef.current?.abort();
handle.dispose();
};
}, []); // fonts registered once; re-register only when the family set changes
// Every keystroke or config change: supersede the in-flight build and kick a new one.
useEffect(() => {
const handle = workerRef.current;
if (!handle) return;
pendingRef.current?.abort();
const ac = new AbortController();
pendingRef.current = ac;
(async () => {
try {
const vdt = await handle.build({ markdown }, config, { signal: ac.signal });
const canvas = canvasRef.current;
if (!canvas || !vdt.pages[0]) return;
renderPageToCanvas(vdt.pages[0], vdt, canvas); // rasterise on the main thread
} catch (err) {
if ((err as { name?: string } | null)?.name !== 'AbortError') throw err;
}
})();
}, [markdown, config]);
return <canvas ref={canvasRef} />;
}Le schéma est toujours le même : créer une fois, enregistrer les polices une fois, construire avec un AbortSignal autant de fois que nécessaire, libérer au démontage.
#Charger le worker depuis un CDN
Un script de worker doit provenir de l’origine de la page : une copie de postext/worker servie par un CDN ne peut donc pas démarrer le fichier layout.worker.js placé à côté d’elle. createLayoutWorker() s’en charge :
- esm.sh, sans option. Lorsque
postext/workera lui-même été chargé depuis esm.sh (l’URL de son module ressemble àhttps://esm.sh/postext@1.5.0/es2022/worker.mjs), le client démarre lehttps://esm.sh/postext@1.5.0/worker/entrycorrespondant par un module blob d’une ligne, de même origine, qui l’importe. Il en va de même pour les imports qui ajoutent?deps=,?external=ou?alias=, et pour la formehttps://esm.sh/*postext@1.5.0/worker. Le worker reçoit toujours le build simple de cette version, car un worker n’a pas d’import map pour résoudre des dépendances externes. - Tout autre serveur, avec
url. Les autres CDN, comme jsDelivr (/+esm) ou unpkg, ne sont pas détectés.createLayoutWorker({ url })démarre le module d’entrée du worker situé àurl: directement pour une URL de même origine, par la même enveloppe blob pour une URL d’une autre origine. Ce serveur doit autoriser les requêtes cross-origin (CORS). - Les bundlers ne changent rien. Avec Vite, webpack ou Next.js, continuez d’appeler
createLayoutWorker()sans option : ils émettent le worker comme un chunk de votre application.
import { createLayoutWorker } from 'https://esm.sh/postext/worker';
const layout = createLayoutWorker();
const face = async (weight, style) => ({
family: 'EB Garamond',
weight,
style,
buffer: await (await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/eb-garamond@5/files/eb-garamond-latin-${weight}-${style}.woff2`)).arrayBuffer(),
});
await layout.registerFonts(await Promise.all([face(400, 'normal'), face(700, 'normal'), face(400, 'italic')]));
const doc = await layout.build({ markdown }, { bodyText: { fontFamily: 'EB Garamond' } });Un worker ne voit pas les polices de la page. Il a son propre jeu de polices, qui ne contient que les polices envoyées par registerFonts et les polices installées sur le système. Lorsqu’une construction compose du texte dans une famille que le worker ne trouve pas, ce texte est mesuré avec une police de repli, et ses coupures de ligne ne correspondront pas à celles de la page. Le client affiche alors un avertissement dans la console par famille ("EB Garamond" is not available inside the layout worker…) et liste les familles dans BuildStats.missingFonts, que reçoit le callback onStats de build. Le document lui-même porte les mêmes polices sous forme d’avertissements de contenu fontFallback.
handle.prepareFonts(content, config, options) exécute prepareFonts sur la page, puis envoie au worker les fichiers de chaque police trouvée que contient le registre de polices (les fichiers du résolveur, les polices d’un paquet, les règles @font-face lisibles de la page), en se limitant aux tranches qui contiennent les caractères du document. Lorsque des polices arrivent dans le worker, il abandonne uniquement les mesures de ces familles, ainsi que son cache de documents terminés.
Des polices peuvent aussi atteindre le worker après une construction : une graisse dont les pages se révèlent avoir besoin, une autre tranche unicode-range d’une police, un fichier téléversé de nouveau sous le même nom de famille. La construction suivante mesure avec elles. Le canvas d’un worker ne le ferait pas de lui-même : dans Chrome, il résout une police la première fois qu’une notation abrégée de police est définie et ne consulte plus le jeu de polices pour cette notation, si bien que le texte garderait les largeurs de la police qui a répondu en premier. Le worker nomme donc une famille une fois de plus dans les notations abrégées qu’il définit, dès qu’une police de cette famille est arrivée en retard (Geist, Geist) : une autre liste de familles pour le canvas, la même police et les mêmes largeurs. Les documents qu’il renvoie gardent les chaînes de police telles que le moteur les écrit, et un worker qui a reçu ses polices avant sa première construction définit chaque notation abrégée telle qu’elle est écrite.
#Collecte des polices (Fontsource / Google Fonts)
registerFonts prend des octets de police bruts. Le thread principal est le bon endroit pour les récupérer, car un cache central permet à plusieurs instances de worker de partager les mêmes octets.
Le collectFontPayloadsForFamilies du Sandbox (packages/postext-sandbox/src/controls/fontLoader.ts) sert d’implémentation de référence. Il :
- Interroge
https://api.fontsource.org/v1/fonts/{family-id}pour connaître les graisses et les styles de la famille. - Lit les sous-ensembles de la famille et leur
unicode-rangedans son paquet sur jsDelivr (@fontsource/{family-id}@5/400.css), une fois par famille. Les fichiers sont ensuite demandés à la version exacte depuis laquelle cette feuille de style a été servie (lex-jsd-versionde jsDelivr), si bien qu’une famille n’arrive jamais de deux versions de son paquet. - Télécharge chaque fichier du romain, du gras et de leurs italiques pour chaque famille (
files/{family-id}-{subset}-{weight}-{style}.woff2) ;collectFontPayloadsForConfig(config, markdown)prend plutôt les graisses et les styles que demande une configuration. Ce sont des fichiers statiques, ceux que le PDF incorpore, si bien que le worker mesure avec ce que dessine le PDF. Un fichier variable donne d’autres chasses entre deux de ses masters, et une ligne à une fraction de pixel de sa justification se coupe ailleurs. - Renvoie un
FontPayload[]dont lebufferest unArrayBufferneuf à chaque appel. C’est important, carregisterFontstransfère le buffer et laisse la copie de l’expéditeur détachée.
Les fichiers d’une police s’ajoutent dans l’ordre où un pen les déclare, latin en premier et les fichiers propres à l’écriture en dernier (arabic, les tranches numérotées d’une famille chinoise ou japonaise), car le fichier ajouté en dernier répond pour un caractère que deux fichiers contiennent. Une famille pour laquelle Fontsource n’a pas de fichiers est lue dans la feuille de style CSS2 de Google, qui ne renvoie du WOFF2 qu’aux chaînes User-Agent de type navigateur.
Associez-le à getConfigFontFamilies(config) pour obtenir la liste des familles qu’une PostextConfig donnée rendra réellement (texte courant, titres, puces de liste, numéros de liste ordonnée).
#Annulation coopérative dans le moteur
Si vous pilotez buildDocument vous-même (par exemple dans un worker personnalisé), la chaîne expose un hook shouldCancel que vous pouvez utiliser directement :
import { buildDocument, BuildCancelledError } from 'postext';
let superseded = false;
try {
const vdt = buildDocument(content, config, cache, {
shouldCancel: () => superseded,
});
} catch (err) {
if (err instanceof BuildCancelledError) return; // a newer build took over
throw err;
}shouldCancel est appelé une fois par bloc de premier niveau pendant le placement. Le hook est volontairement coopératif : il ne peut pas arrêter l’appel de mise en page de pretext au milieu d’une ligne, mais il garde une granularité d’annulation assez fine (quelques millisecondes) pour qu’un utilisateur qui tape vite n’attende jamais une construction périmée.
#Piloter l’export PDF depuis le worker
Le moteur de rendu PDF prend un VDTDocument prêt et le transforme en octets PDF. Il ne relance pas la mise en page. Le flux PDF de référence dans le navigateur s’associe donc proprement au worker : construisez le VDT dans le worker (hors du thread principal, annulable, avec réutilisation du cache), puis appelez renderToPdf sur le thread principal avec ce même VDT.
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderToPdf } from 'postext-pdf';
import type { PostextConfig } from 'postext';
import { createPdfFontProvider } from './pdfFontProvider';
const fontProvider = createPdfFontProvider();
export async function exportPdf(
layout: LayoutWorkerHandle,
markdown: string,
config: PostextConfig,
): Promise<Uint8Array> {
// 1. Build the VDT in the worker — UI stays responsive during the layout passes.
const vdt = await layout.build({ markdown }, config);
// 2. Rasterise to PDF on the main thread. renderToPdf is fast once the VDT exists
// because it is walking precomputed coordinates, not remeasuring text.
return renderToPdf(vdt, {
fontProvider,
// pdfGeneration config on `vdt.config` is honoured automatically.
});
}Si vous gérez déjà un handle de worker pour l’aperçu en direct, réutilisez-le pour l’export au lieu de lancer un second worker : grâce au cache de mesures du worker, un export PDF qui suit un aperçu à l’écran ne coûte presque rien.
Pour un long livre, l’écriture du PDF lui-même prend aussi quelques secondes ; postext-pdf/worker exécute cette étape dans un worker à part (voir Rendre le PDF dans un worker).
#Quand utiliser le worker, et quand s’en passer
Utilisez le worker pour :
- Les aperçus en direct, les éditeurs et les bacs à sable. Tout ce qui reconstruit le document en réponse à une saisie de l’utilisateur.
- Les visionneuses HTML qui suivent le redimensionnement et relancent la mise en page à chaque tick de
ResizeObserver. - L’export PDF dans le navigateur déclenché depuis une interface qui a déjà un aperçu en direct : réutilisez le handle de worker existant pour que l’export profite du cache de mesures.
- Plusieurs onglets de sortie qui ont tous besoin du même VDT (les vues Canvas / HTML / PDF du Sandbox partagent un handle de worker par montage de vue).
Passez-vous du worker pour :
- La génération côté serveur : Node n’a pas de
FontFaceSetde navigateur, et vous contrôlez de toute façon le thread. - Les exports ponctuels isolés (une CLI, un script d’export sans interface, une Cloud Function) où aucune interface interactive ne risque d’être bloquée. Appeler
buildDocumentdirectement est plus simple et évite le coût du transfert initial des polices.
#Intégrer la visionneuse HTML
La visionneuse HTML est le moteur de rendu de Postext conçu d’abord pour l’écran. Au lieu de rastériser les pages en image bitmap, elle produit des nœuds DOM positionnés en absolu, dont la géométrie vient du même pipeline que la sortie imprimée. C’est donc le bon choix quand vous voulez, dans un navigateur, une typographie lisible, sélectionnable et qui suit le redimensionnement (une application de lecture, un aperçu intégré à un produit, une documentation embarquée) sans charger de visionneuse PDF.
Les pièces essentielles de l’API publique :
buildDocument(content, config, cache?): exécute tout le pipeline de mise en page et renvoie unVDTDocument.renderToHtmlIndexed(doc, options): transforme le VDT en une seule chaîne HTML accompagnée d’un découpage par page et par bloc. Ce découpage permet de patcher le DOM à peu de frais quand seuls quelques blocs ont changé d’un rendu à l’autre.resolveHtmlViewerConfig(partial): complète la configuration avec les valeurs par défaut de la visionneuse HTML (maxCharsPerLine,columnGap,optimalLineBreaking).buildFontString+measureGlyphWidth+dimensionToPx: primitives de mesure qui servent à déduire une largeur de colonne réelle, en pixels, d’un nombre de caractères visé.createMeasurementCache/clearMeasurementCache: caches interchangeables qui permettent de réutiliser les mesures d’une mise en page à l’autre.prepareFonts/buildDocumentWithFonts/watchFonts/onFontsChanged: chargent les fontes du document avant la mise en page et relancent la mise en page quand des fontes arrivent (voir Charger les polices avant la mise en page).
#Exemple en direct : une chaîne HTML
L’aller-retour complet en JavaScript simple, avant l’intégration React plus bas : construisez le document, passez le VDTDocument à renderToHtml et déposez la chaîne dans un conteneur. mode: 'single' empile les pages verticalement ; background leur donne une couleur, car les pages sont transparentes par défaut. Le pen affiche aussi le balisage généré, ce qui vous laisse voir les lignes positionnées en absolu que produit le moteur de rendu : le navigateur les peint mais ne les recompose jamais.
import { buildDocument, renderToHtml } from 'https://esm.sh/postext';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
// 96 dpi: page pixels are CSS pixels, so the HTML shows at its real size.
page: { sizePreset: '17x24', dpi: 96 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
// One HTML string for the whole document. Every line is an absolutely
// positioned element, so the browser never reflows the text.
const html = renderToHtml(doc, { mode: 'single', background: '#ffffff' });
document.getElementById('viewer').innerHTML = html;
document.getElementById('source').textContent = html;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(html.length / 1024).toFixed(1)} KB of HTML`;index.html
<p id="status">Laying out…</p>
<div id="viewer"></div>
<details>
<summary>Generated HTML</summary>
<pre id="source"></pre>
</details>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
/* The page is wider than this pane: let it scroll instead of clipping it.
The renderer centres pages with an inline style, hence the !important. */
#viewer {
overflow: auto;
}
#viewer .pt-doc {
align-items: flex-start !important;
}
/* Each page is a .pt-page block; the renderer positions every line inside it. */
#viewer .pt-page {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
details {
margin-top: 16px;
}
#source {
max-height: 240px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
}Charge un éditeur interactif depuis codepen.io. L’exemple importe la dernière version de postext depuis un CDN.
#Intégration minimale
L’extrait ci-dessous est l’intégration utile la plus courte : construire le document à la taille actuelle de la fenêtre, le rendre dans un conteneur et recommencer à chaque redimensionnement.
import { useEffect, useRef } from 'react';
import {
buildDocument,
renderToHtmlIndexed,
resolveHtmlViewerConfig,
buildFontString,
measureGlyphWidth,
dimensionToPx,
createMeasurementCache,
watchFonts,
onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
// Screen-friendly DPI: at 144 DPI, an 8pt body size resolves to 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
// Prose sample used to measure the target column width. Proportional fonts
// make "N × average width" unreliable, so we measure a representative string.
const SAMPLE =
'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';
function sampleForChars(n: number): string {
let s = SAMPLE;
while (s.length < n) s += ' ' + SAMPLE;
return s.slice(0, n);
}
export function PostextHtmlViewer({
markdown,
config,
mode = 'multi',
}: {
markdown: string;
config: PostextConfig;
mode?: 'single' | 'multi';
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const cacheRef = useRef<MeasurementCache>(createMeasurementCache());
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const relayout = () => {
const rect = host.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const viewer = resolveHtmlViewerConfig(config.htmlViewer);
const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
const fontWeight = config.bodyText?.fontWeight ?? 400;
const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
const fontSizePx = dimensionToPx(fontSize, HTML_DPI);
// Measure the *actual* column width for N characters of body prose.
const targetColumnPx = measureGlyphWidth(
sampleForChars(viewer.maxCharsPerLine),
buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
);
const inner = Math.max(rect.width - PADDING_PX * 2, 100);
let columnWidthPx: number;
if (mode === 'single') {
columnWidthPx = Math.min(targetColumnPx, inner);
} else {
// Fit as many columns as we can at the target width.
const count = Math.max(
1,
Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
);
columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
}
columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);
// Single mode uses one very tall page; multi mode uses the viewport
// height so each VDT "page" becomes one column.
const pageHeightPx =
mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);
const override: PostextConfig = {
...config,
page: {
...config.page,
dpi: HTML_DPI,
width: { value: columnWidthPx, unit: 'px' },
height: { value: pageHeightPx, unit: 'px' },
margins: {
top: { value: 0, unit: 'px' },
bottom: { value: 0, unit: 'px' },
left: { value: 0, unit: 'px' },
right: { value: 0, unit: 'px' },
},
},
layout: { ...config.layout, layoutType: 'single' },
bodyText: {
...config.bodyText,
optimalLineBreaking: viewer.optimalLineBreaking,
},
};
const doc = buildDocument({ markdown }, override, cacheRef.current);
const { html } = renderToHtmlIndexed(doc, {
mode,
columnGap: viewer.columnGap,
padding: PADDING_PX,
background: 'transparent',
});
host.innerHTML = html;
};
relayout();
const ro = new ResizeObserver(() => relayout());
ro.observe(host);
// Re-measure when web fonts land so glyph widths aren't taken from fallbacks.
const stopWatching = watchFonts();
const off = onFontsChanged(() => relayout());
return () => {
ro.disconnect();
off();
stopWatching();
};
}, [markdown, config, mode]);
return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}Quelques remarques sur ce que fait cet exemple :
- Mesurer la colonne plutôt que l’estimer. Comme
maxCharsPerLineest une cible exprimée en caractères, la largeur réelle en pixels dépend de la police du texte courant.measureGlyphWidthdonne une vraie mesure dans la police choisie, ce qui garde la longueur de ligne constante quand on change de police. - Réécrire la page. La visionneuse HTML traite chaque « page » du VDT comme une colonne à l’écran. L’exemple remplace
page.widthpar la largeur de colonne mesurée, met les marges à zéro (la marge intérieure se trouve hors de la page, dans le div.pt-docqui l’enveloppe) et utiliseHTML_DPI = 144pour qu’un texte courant de8ptdonne16px. - Tenir compte du chargement des polices.
watchFontsécoutedocument.fontset, une fois par image, oublie ce qui a été mesuré dans les familles dont des fontes sont arrivées ;onFontsChangedrelance ensuite la mise en page de la colonne. Sans cette relance, le premier rendu utilise les métriques d’une police de secours et saute quand la vraie police arrive. - Réutiliser le cache de mesures. Créer le cache une seule fois par composant permet aux redimensionnements et aux changements d’échelle de police de reprendre les mesures du rendu précédent au lieu de remesurer chaque paragraphe.
#Aller plus loin
L’exemple ci-dessus est volontairement dépouillé. Les intégrations en production ajoutent en général :
- L’isolation par Shadow DOM : rendre dans
host.attachShadow({ mode: 'open' })pour qu’aucune règle CSS de la page extérieure ne déborde sur la visionneuse. - Le patch incrémental :
renderToHtmlIndexedrenvoiepages[i].blocks, chacun avec unidstable et le HTML extérieur du bloc. Quand seuls quelques blocs diffèrent entre deux rendus, vous pouvez remplacer ces enveloppes de blocs sur place au lieu de reconstruireinnerHTML. - Les calques : superposer un SVG positionné en absolu sur chaque
.pt-pagepour les curseurs, les sélections ou les grilles de ligne de base. - Les liens : les mots d’un lien Markdown sont enveloppés dans
<a href="…" rel="noopener noreferrer">, qui prend la couleur du texte et n’est pas souligné ; voir Format du document › Liens. Dans une visionneuse de type éditeur, interceptez les clics sur lesa[href]qui ne commencent pas par#et ouvrez-les dans un nouvel onglet (les ancres de:refrenvoient à l’intérieur du document). - Les images à encre unique : avec
diagramStyle.singleInkactivé, les<img>SVG reçoivent un filtre CSS, sauf si vous passezsingleInk: falsepour des URL déjà recolorées ; voir Encre unique sur le canvas et en HTML.
Le composant HtmlPreview du Sandbox (packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx) met tout cela en œuvre sur la même API que ci-dessus et peut servir de référence. Il fait aussi passer chaque construction par un worker de mise en page partagé (voir Exécuter la mise en page dans un Web Worker), si bien que les modifications en direct et les redimensionnements ne bloquent jamais le thread principal : remplacez l’appel direct buildDocument(...) de l’extrait ci-dessus par layoutWorker.build(...) quand vous êtes prêt à sortir la mise en page du thread principal.
#En quoi la sortie HTML diffère du canvas et du PDF
renderToHtml place chaque ligne, chaque figure et chaque élément de maquette exactement là où les placent le canvas et le PDF, mais peint moins de choses autour :
| Fonction | Canvas (renderPage) | HTML (renderToHtml) | PDF (renderToPdf) |
|---|---|---|---|
| Fond de page | Blanc, avec page.backgroundColor sur le format rogné et le fond perdu. | Transparent, sauf si vous passez background ou définissez page.backgroundColor (qui remplit alors toute la boîte de la page, zone de repères comprise). | Blanc, avec page.backgroundColor sur le format rogné et le fond perdu. |
Grille de ligne de base (page.baselineGrid) | Dessinée | Non dessinée | Dessinée |
Filet entre colonnes (layout.columnRule) | Dessiné | Non dessiné | Dessiné |
Traits de coupe (page.cutLines) | Dessinés | Non dessinés ; la boîte de la page inclut toujours la zone de repères autour du format rogné. | Dessinés |
| Page en négatif | Option pageNegative | Non disponible | Option pageNegative |
| Texte | Pixels | Texte sélectionnable dans des éléments positionnés en absolu, composé dans les familles de polices CSS : la page doit charger les mêmes fontes. | Polices incorporées fournies par votre fontProvider ; texte sélectionnable, consultable par recherche et balisé. |
Texte vertical (layout.writingMode: 'vertical-rl') | Caractères peints une cellule à la fois, remis debout ; formes verticales obtenues par une fonte jumelle (loadVerticalAlternates). | Le flux tient dans une boîte tournée d’un quart de tour ; chaque ligne est remise debout et composée avec writing-mode: vertical-rl, si bien que le navigateur prend les formes verticales et redresse les caractères ; les nombres courts sont en text-combine-upright: all ; un tiret, des points de suspension, un point médian ou un tiret ondulé occupent une boîte de leur cellule (le navigateur les ferait avancer de leur largeur horizontale), un tiret étant étiré pour la remplir selon flow.dashAdvances. | Caractères debout grâce à un jumeau Identity-V de chaque police ; voir Texte vertical dans le PDF. |
| Images | registerResourceImage | L’option resourceImageUrl(fileId) ; sans elle, une boîte grise en guise d’image. | L’option resourceBytes(fileId). |
| Formules | Tracés vectoriels | <svg> en ligne | Tracés vectoriels |
| Liens | Aucun | Les renvois :ref pointent vers leur ressource ; les lignes de la table des matières, non. | Les renvois :ref et les lignes de la table des matières, plus les signets (outline). |
La page transparente compte sur un site sombre : un aperçu sans background affiche du texte noir sur le fond sombre du site. Passez renderToHtml(doc, { background: '#ffffff' }), ou donnez au document une page.backgroundColor.
Les styles de texte de la page hôte restent dehors. Chaque ligne est composée aux largeurs que le moteur a mesurées : un letter-spacing, un word-spacing, un text-transform ou un font-variant que la sortie hériterait de la page environnante élargirait les suites de glyphes et ferait se chevaucher les lignes. La racine .pt-doc réinitialise donc les propriétés de texte héritées (espacement des lettres et des mots, casse, retrait, espaces blancs, style, variante, graisse et chasse de la police, fonctionnalités OpenType et crénage, hauteur de ligne, alignement, ombre et emphase du texte, césure, direction, mode d’écriture, contour et remplissage du texte, agrandissement automatique du texte sur mobile) avant ses propres déclarations de mise en page, si bien que la sortie a le même aspect dans une racine fantôme (shadow root) ou sous un élément stylé. Cette liste est exportée sous le nom HTML_TEXT_RESET, une chaîne de déclarations CSS : un hôte qui monte le innerHtml des pages (issu de renderToHtmlIndexed) dans ses propres conteneurs l’applique à leur racine. Jusqu’à postext 1.4, la racine ne réinitialisait rien ; le contournement consistait à l’envelopper dans un élément avec all: initial.
#Générer des PDF
La sortie PDF se trouve dans un paquet séparé, postext-pdf, afin que les intégrations purement web ne paient pas le coût de pdf-lib et de @pdf-lib/fontkit. Le moteur PDF ne remesure pas le texte : il consomme exactement le même VDTDocument que celui que vous passeriez à renderToCanvas ou à renderToHtml, et convertit ses coordonnées en pixels en points PDF. Les trois sorties s’accordent donc forcément sur les coupures de lignes, les hauteurs de colonnes et la position des ressources.
Dans le navigateur, construisez le VDT au moyen du Web Worker.
renderToPdfest rapide une fois le VDT disponible : la partie coûteuse est le pipeline de mise en page qui l’a produit. Exécuter ce pipeline dans le worker garde l’interface réactive et permet à un export PDF de réutiliser le cache de mesures que l’aperçu en direct a déjà rempli. Voir Piloter l’export PDF depuis le worker pour la démarche recommandée. Les exemples sur le thread principal ci-dessous servent de référence pour ce que signifient les arguments : dans le code d’interface, construisez d’abord le VDT dans le worker et n’appelez directement querenderToPdf.
#Installation
npm install postext postext-pdfpostext est une dépendance pair (peer dependency) de postext-pdf. Chaque version de postext-pdf a besoin du postext avec lequel elle est sortie, ou d’une version ultérieure de la même version majeure (sa plage pair est ^ cette version, ^1.5.0 pour la 1.5.0), car elle importe des fonctions que postext a ajoutées dans cette version. Mettez les deux à jour ensemble et, sur un CDN, épinglez-les à la même version.
#API publique
Le paquet expose un seul point d’entrée et quelques types :
renderToPdf(doc, options): Promise<Uint8Array>: prend unVDTDocument(ou les chapitres d’un livre sous forme de tableau de documents) et renvoie les octets bruts du PDF.PdfFontProvider: la signature de rappel(family, weight, style, request?) => Promise<Uint8Array | Uint8Array[]>querenderToPdfutilise pour demander les octets d’une police quand il doit incorporer une nouvelle combinaison famille/graisse/style.request.codePointscontient les caractères que les pages composent dans cette fonte ; la réponse est un fichier, ou plusieurs qui forment ensemble la fonte (voir Polices chinoises, japonaises et coréennes).declaredFontFile(bytes, { unicodeRange?, order? }): PdfFontFile: un fichier de fonte qui indique comment la page l’a déclaré, c’est-à-dire l’unicode-rangede sa règle@font-faceet sa place dans l’ordre des déclarations. UnPdfFontFileest unUint8Arraydoté de ces deux propriétés facultatives (voir Polices chinoises, japonaises et coréennes). Depuis postext-pdf 1.30.RenderToPdfOptions:{ fontProvider, resourceBytes?, outlines?, accessible?, colorSpace?, pageNegative?, characterGrid?, onProgress?, onWarning?, rasterizeSvg?, harfbuzzWasm?, print?, outputProfile?, profileBaseUrl?, date? }. Omis,outlines,accessibleetcolorSpacereprennent lepdfGenerationdu document (voir Génération PDF (configuration)).resourceBytesest décrit dans Octets des ressources et originaux d’impression ;onWarningdans Quelles fontes sont demandées au fournisseur et dans Avertissements dans le document.characterGrid: trueimprime la grille quecjk.grid.showdessine à l’écran et que le PDF omet sinon (voir Grille de caractères).harfbuzzWasmindique où charger leharfbuzz.wasmde HarfBuzz (une URL, relative à la page, ou les octets du fichier) pour un document contenant du texte de droite à gauche ou à liaisons ; omis, c’est la copie placée à côté du module de postext-pdf, puis la même version de harfbuzzjs sur jsDelivr et esm.sh.printreçoit les réglages de production pour l’impression (norme PDF/X, profil de sortie, noir, contrôle en amont) et reprend sinon leprintdu document ; avec une norme PDF/X, oucolorSpace: 'cmyk', chaque couleur est séparée au moyen du profil ICC de sortie, dontoutputProfilefournit les octets (sinon il est téléchargé depuisprofileBaseUrl, par défaut la copie du dossiericc/de postext sur le CDN de npm).date(uneDate, un temps en millisecondes ou une chaîne de date, comme lemtimedecreateBundle) est la date de création et de modification du fichier et, dans un fichier PDF/X, la date de son paquet XMP et la source de son identifiant de document : le même document rendu deux fois avec une mêmedatedonne les mêmes octets. Omise, le fichier porte l’heure de l’appel. Une date invalide lève une exception.PdfWarning: un problème non bloquant signalé paronWarning; distinguez-le parkind:'fontFallback'(PdfFontFallbackWarning), une fonte composée dans une autre coupe de sa famille ;'missingGlyph'(PdfMissingGlyphWarning), des caractères pour lesquels aucun fichier d’une fonte n’a de glyphe ;'variableFontDefaultInstance'(PdfVariableFontWarning), une police variable demandée à une graisse autre que son instance par défaut ;'cffEmbeddedWhole'(PdfCffEmbeddedWholeWarning), une fonte CFF de plus de 2 Mo incorporée en entier ;'complexShapingUnavailable'(PdfComplexShapingWarning), du texte de droite à gauche ou à liaisons dessiné sans HarfBuzz, qui n’a pas pu être chargé (reasonliste où il a été cherché), si bien que les signes diacritiques arabes sont mal placés ;'outputProfileUnavailable'(PdfPrintWarning), un rendu CMJN dont le profil de sortie n’a pas pu être chargé, converti avec la formule naïve ;'pageNegativeIgnored'(PdfPrintWarning), la page en négatif omise d’un fichier PDF/X-1a ; ou'missingImage', une image sans octets dessinée sous forme d’emplacement vide (signalé uniquement à unonWarningque vous passez ; sans lui, les avertissements de polices vont àconsole.warn).decompressWoff2(bytes): Uint8Array: fonction utilitaire qui transforme un fichier WOFF2 en octets TTF, le format quepdf-libsait incorporer directement.createPdfWorker(options?), depuispostext-pdf/worker: le même rendu dans un Web Worker ; voir Rendre le PDF dans un worker.
#Exemple minimal
import { buildDocument } from 'postext';
import { renderToPdf } from 'postext-pdf';
const vdt = buildDocument(
{ markdown: '# Chapter One\n\nThe story begins here…' },
{
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt overrides the 8 pt default
},
);
const pdfBytes = await renderToPdf(vdt, {
fontProvider: async (family, weight, style) => {
// Return TTF bytes for this family/weight/style.
// See the "Font provider" section below for a real implementation.
const res = await fetch(`/fonts/${family}-${weight}${style === 'italic' ? 'i' : ''}.ttf`);
return new Uint8Array(await res.arrayBuffer());
},
});
// `pdfBytes` is a Uint8Array — save, download, or stream it.
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url);#Pourquoi un fournisseur de polices ?
pdf-lib incorpore de vrais fichiers de polices dans le PDF : les polices installées dans le navigateur ne sont pas disponibles au moment du rendu, et une police chargée uniquement pour la mesure à l’écran ne suffit pas, à elle seule, à produire un PDF autonome. renderToPdf parcourt les pages à la recherche de chaque fonte qu’elles peignent (une par combinaison family|weight|style ; voir Quelles fontes sont demandées au fournisseur) et appelle votre fournisseur une fois par combinaison distincte. Le fournisseur renvoie un Uint8Array d’octets TTF ou OTF, ou une liste de ceux-ci pour une fonte servie en plusieurs fichiers (voir Polices chinoises, japonaises et coréennes) ; pdf-lib sous-ensemble les contours TrueType et incorpore les fichiers CFF (.otf) en entier. Les fontes auxquelles le fournisseur répond avec le même fichier, par exemple une coupe romaine qui remplace le gras dont une famille est dépourvue, partagent une seule police incorporée. Une fonte qu’aucune page n’utilise finalement pour dessiner, comme celle du texte d’une figure SVG quand la figure est dessinée sous forme d’image, est laissée hors du fichier.
Utilisez des polices statiques par graisse, pas une seule police variable. Google Fonts sert souvent un unique WOFF2 variable par famille, qui couvre tout l’axe des graisses. pdf-lib ne peut incorporer que l’instance par défaut d’un fichier variable, si bien qu’un paragraphe en gras serait rendu en graisse normale. Fontsource publie des fichiers WOFF2 statiques par graisse qui règlent proprement le problème : c’est la méthode qu’utilise le Sandbox. Un fichier variable demandé à une graisse autre que son instance par défaut est signalé par un avertissement variableFontDefaultInstance.
Chaque mot du texte arrive là où la mise en page l’a placé. Dans les paragraphes, les éléments de liste, les citations, les encadrés et les autres textes courants, chaque mot commence à la position mesurée par le VDT, si bien qu’un écart entre les largeurs du navigateur et celles de la fonte incorporée ne s’accumule jamais le long d’une ligne. Une ligne sans mise en forme en ligne est peinte comme un seul objet texte qui déplace la plume entre les mots ; les lignes justifiées, centrées ou comportant de la mise en forme sont peintes mot par mot. Un caractère pour lequel la fonte n’a pas de glyphe, que le navigateur a mesuré dans une autre police et que le PDF peint avec la boîte de glyphe manquant de la fonte, ne déplace aucun des mots qui le suivent, et il est signalé une fois par fonte par un avertissement missingGlyph. Une espace absente de la fonte, comme une espace fine insécable ou une espace tabulaire, prend la largeur que le navigateur lui a donnée, et les caractères invisibles comme le gluon de mots (word joiner) et l’espace sans chasse ne sont pas dessinés. Un trait d’union insécable (U+2011) absent de la fonte est dessiné avec le trait d’union de la fonte (U+2010), ou avec son trait d’union-signe moins quand elle n’a pas non plus de trait d’union, comme l’affiche le navigateur ; Open Sans et Outfit, entre autres, n’ont ni l’un ni l’autre. Aucun de ces deux cas ne compte comme un glyphe manquant. Il y a deux exceptions. Une ligne qui contient des lettres de droite à gauche est toujours peinte d’un seul tenant (voir Langues et écritures). Le texte placé par une maquette (titres courants et pieds de page, ouvertures, titres d’encadrés et autres éléments de maquette) est composé avec les largeurs propres de la fonte incorporée, si bien qu’un glyphe absent de la fonte y décale toujours le reste de sa ligne.
#Quelles fontes sont demandées au fournisseur
renderToPdf parcourt les pages comme il les peint et ne demande au fournisseur que les fontes qu’elles dessinent :
- la fonte romaine d’un bloc qui compose au moins une ligne, et la fonte grasse, italique ou grasse italique de chaque suite de texte réellement composée ainsi ;
- les suites de pastilles, les puces et numéros de liste, et le texte des emplacements de maquette (titres courants, folios, bandeaux d’ouverture et de partie) ;
- le texte des légendes, des notes et des cellules de tableau de chaque ressource ;
- les fontes que nomme le
<text>d’une figure SVG, quand la figure est incorporée. Une famille que le fournisseur ne peut pas fournir du tout cède la place à la famille suivante de la listefont-familydu SVG.
Une famille de titres que personne ne met en italique n’est donc jamais demandée en italique, et une figure sans note n’a jamais besoin des fontes des notes.
Quand le fournisseur refuse une fonte, le rendu continue. Une autre fonte de la même famille est incorporée à sa place et un PdfWarning est signalé. La remplaçante est la première fonte qui se charge, en essayant les neuf graisses standard (100 à 900) dans l’ordre qu’utilise la correspondance des polices CSS, qui est aussi la fonte que le navigateur affiche dans l’aperçu :
- d’abord le même style. Pour une graisse de 400 à 500, les graisses jusqu’à 500 passent en premier, puis les plus maigres de la plus proche vers le bas, puis les plus grasses à partir de 600. Pour une graisse inférieure à 400, les graisses plus maigres passent en premier, de la plus proche vers le bas, puis les plus grasses. Pour une graisse supérieure à 500, les graisses plus grasses passent en premier, puis les plus maigres ;
- ensuite l’autre style, italique pour le romain et romain pour l’italique, à la graisse demandée puis aux autres graisses dans le même ordre.
Une famille sans italique compose donc ses suites italiques en romain, une famille qui ne propose que 400 et 700 prend un 600 pour un 700, et une famille qui n’a qu’une coupe compose tout dans celle-ci. Le fournisseur est sollicité une fonte à la fois, dans cet ordre, et jamais deux fois pour la même fonte, si bien qu’une fonte que rien n’utilise n’est jamais incorporée. Une famille que le fournisseur ne peut pas servir du tout est demandée pour chacune de ces 18 fontes avant que le rendu échoue.
const bytes = await renderToPdf(doc, {
fontProvider,
onWarning: (w) => {
// { kind: 'fontFallback', family: 'Oswald', weight: 700, style: 'italic',
// fallback: { weight: 700, style: 'normal' }, reason: '…', message: '…' }
console.info(w.message);
},
});Sans onWarning, le message va à console.warn. Le texte garde ses positions, qui viennent du VDT et ont été mesurées avec les fontes dont disposait le navigateur : une remplaçante aux largeurs différentes peut donc paraître serrée ou lâche. Fournissez la vraie fonte pour corriger cela. Un rendu n’échoue encore (postext-pdf: failed to load font(s): …) que lorsque le fournisseur ne peut fournir aucune fonte d’une famille à une graisse standard, en romain ou en italique.
#Fournisseur de polices dans le navigateur (Fontsource + WOFF2)
Le Sandbox fournit createPdfFontProvider() (packages/postext-sandbox/src/viewport/pdfFontProvider.ts), que vous pouvez copier dans n’importe quelle application web. L’essentiel :
import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
const bytesCache = new Map<string, Promise<Uint8Array>>();
function fontsourceId(family: string): string {
return family.toLowerCase().replace(/\s+/g, '-');
}
function fontsourceWoff2Url(
family: string,
weight: number,
style: 'normal' | 'italic',
): string {
const id = fontsourceId(family);
return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
}
export function createPdfFontProvider(): PdfFontProvider {
return async (family, weight, style) => {
const key = `${family}|${weight}|${style}`;
const cached = bytesCache.get(key);
if (cached) return cached;
const promise = (async (): Promise<Uint8Array> => {
const url = fontsourceWoff2Url(family, weight, style);
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
// pdf-lib needs TTF bytes, so decompress the WOFF2 wrapper client-side.
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
})();
bytesCache.set(key, promise);
return promise;
};
}Une version de production devrait aussi :
- Interroger les graisses disponibles (via
https://api.fontsource.org/v1/fonts/{id}) et ramener la graisse demandée à la plus proche que la famille propose réellement, afin qu’une demande deweight: 600sur une famille qui n’a que{400, 700}aboutisse quand même. - Se rabattre de l’italique sur le romain quand une famille n’a pas de coupe italique pour la graisse demandée, au lieu de faire échouer tout le rendu.
- Réutiliser le cache d’un rendu à l’autre (garder
bytesCacheau niveau du module, pas par appel), de sorte que régénérer le PDF après un changement de configuration ne coûte pratiquement rien.
#Polices chinoises, japonaises et coréennes
Une fonte CJK ne se présente pas comme un seul petit fichier. Fontsource livre Noto Serif SC en une centaine de fichiers par graisse, chacun contenant une partie des caractères et déclaré dans la feuille de style de la famille avec son unicode-range (@fontsource/noto-serif-sc/400.css) ; le navigateur télécharge les fichiers que touche le texte d’une page. Le fichier latin que télécharge le fournisseur ci-dessus ne contient aucun caractère han, et les sous-ensembles nommés sont incomplets : le chinese-simplified de Noto Serif SC n’a pas 釵, et le chinese-traditional de Noto Serif TC n’a aucun des signes pleine chasse (),!?:;.
Un fournisseur peut donc répondre pour une fonte avec plusieurs fichiers. renderToPdf lui passe les caractères que les pages composent dans cette fonte (request.codePoints), rassemblés dans tous les chapitres avant que quoi que ce soit ne soit dessiné, et le fournisseur renvoie les fichiers qui les contiennent, dans l’ordre où le navigateur les consulte. Chaque fichier est incorporé en sous-ensemble distinct, et chaque caractère est dessiné à partir du premier fichier qui a un glyphe pour lui : un chapitre qui touche 60 tranches incorpore 60 petits sous-ensembles. Une fonte demandée à nouveau, pour le texte d’une figure SVG par exemple, n’est demandée que pour les caractères qui manquent à ses fichiers. Un fournisseur qui renvoie un seul Uint8Array fonctionne comme avant. Le fournisseur du Sandbox lit la feuille de style Fontsource de la graisse et du style, et télécharge les fichiers dont les plages contiennent le texte ; en voici le cœur :
import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
type Slice = { url: string; ranges: Array<[number, number]> };
async function fontsourceSlices(family: string, weight: number, style: 'normal' | 'italic'): Promise<Slice[]> {
const id = family.toLowerCase().replace(/\s+/g, '-');
const cssUrl = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
const css = await (await fetch(cssUrl)).text();
return [...css.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => ({
url: new URL(/url\(\.?\/?([^)]+\.woff2)\)/.exec(rule)![1], cssUrl).href,
ranges: /unicode-range:\s*([^;]+);/.exec(rule)![1].split(',').map((part) => {
const [lo, hi = lo] = part.trim().slice(2).split('-');
return [parseInt(lo, 16), parseInt(hi, 16)] as [number, number];
}),
}));
}
export const sliceFontProvider: PdfFontProvider = async (family, weight, style, request) => {
// Where ranges overlap, the browser tries the last rule first.
const slices = (await fontsourceSlices(family, weight, style)).reverse();
const picked = new Set<Slice>();
for (const cp of request?.codePoints ?? []) {
const slice = slices.find((s) => s.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
if (slice) picked.add(slice);
}
if (picked.size === 0) picked.add(slices[0]!);
return Promise.all(slices.filter((s) => picked.has(s)).map(async (s) =>
decompressWoff2(new Uint8Array(await (await fetch(s.url)).arrayBuffer()))));
};Une famille latine passe par le même code : un texte anglais reçoit son seul fichier latin, un texte tchèque reçoit latin et latin-ext.
Un navigateur ne s’arrête pas au premier fichier qui a un glyphe. Il compose un caractère dans le fichier déclaré en dernier parmi ceux dont l’unicode-range le contient, et les fichiers d’une famille se chevauchent : Fontsource répète les lettres ASCII de Noto Serif JP dans son fichier latin et dans ses tranches numérotées. Deux caractères composés dans deux fichiers ne sont pas crénés entre eux, si bien que le fichier détermine la largeur : Chrome 154 compose Shift_JIS sur 4,25 em avec les tranches déclarées après latin, et sur 4,32 em avec le seul fichier latin, qui crène t_ et _J. Depuis postext-pdf 1.30, un fournisseur peut indiquer comment la page a déclaré chaque fichier : declaredFontFile(bytes, { unicodeRange, order }) renvoie les mêmes octets accompagnés de l’unicode-range de la règle du fichier, tel que l’écrit le CSS, et de la place de cette règle parmi les règles de la fonte, n’importe quel nombre qui croît avec l’ordre de déclaration (le plus grand est déclaré en dernier).
import { declaredFontFile } from 'postext-pdf';
declaredFontFile(bytes, { unicodeRange: 'U+0000-00FF,U+0131,U+0152-0153', order: 0 });Quand les fichiers d’une fonte portent leurs plages, dans un document façonné dans sa propre langue (bodyText.shapingLanguage: 'document', la valeur par défaut), un caractère est dessiné à partir du fichier déclaré en dernier dont la plage le contient et qui a un glyphe pour lui, comme un navigateur choisit parmi les règles @font-face, et une lettre et ses signes diacritiques combinants restent dans un même fichier. Un caractère qu’aucune plage ne contient est dessiné à partir du premier fichier qui a un glyphe. Les fichiers simples, et tous les fichiers d’un document en 'host', sont lus dans l’ordre donné, comme avant : un fournisseur qui décrit ses fichiers place donc toujours en tête celui déclaré en dernier. Une fonte décrite ainsi est sollicitée à nouveau pour chaque caractère qui ne lui avait pas encore été demandé, puisqu’un fichier déclaré plus tard peut le contenir. Le fournisseur du Sandbox décrit ses fichiers.
Les caractères pour lesquels aucun fichier de la fonte n’a de glyphe sont dessinés avec le glyphe .notdef de la police (une boîte vide dans la plupart des polices), et renderToPdf les signale une fois par fonte après avoir dessiné les pages :
// { kind: 'missingGlyph', family: 'Noto Serif TC', weight: 400, style: 'normal',
// characters: [',', '!', '?'], message: '…' }Le Sandbox les liste, ainsi que les deux avertissements ci-dessous, dans son panneau Vérifications après chaque PDF qu’il génère. Quand le livre, ses réglages ou ses ressources changent, ils sont marqués comme provenant d’un PDF antérieur jusqu’à ce que le suivant les remplace ; ouvrir un autre livre les efface.
- Le gras exige un fichier statique par graisse. Fontsource sert chaque graisse de Noto Serif SC et TC sous forme de fichiers statiques distincts, si bien que le gras fonctionne dans le Sandbox. Les fichiers de Google Fonts (
NotoSerifSC[wght].ttf, 25 Mo) sont des polices variables : pdf-lib incorpore leur instance par défaut, si bien qu’une fonte 700 s’imprime en 400, etrenderToPdfle signale parvariableFontDefaultInstance. Pour un paquet, découpez une instance statique par graisse avec fontTools (fonttools varLib.instancer NotoSerifSC[wght].ttf wght=700) et réduisez-la aux caractères du livre avecpyftsubset. - Utilisez les versions TrueType. Les fichiers
.otfde Source Han Serif et de Noto Serif CJK ont des contours CFF, que postext-pdf incorpore en entier, de 8 à 25 Mo par graisse ; une fonte CFF de plus de 2 Mo est signalée parcffEmbeddedWhole. Les versions TrueType (Google Fonts, Fontsource) sont réduites aux glyphes utilisés. Pour le japonais, Noto Serif JP et Noto Sans JP (Google Fonts, ou les tranches numérotées de Fontsource), Shippori Mincho, Zen Old Mincho et BIZ UDMincho existent en TrueType ; Source Han Serif JP et les fichiers.otfJPde Noto Serif CJK sont en CFF. - Formes japonaises d’une fonte pan-CJK. Un même point de code han, de ponctuation ou de guillemet peut se dessiner d’une façon au Japon et d’une autre en Chine, et une fonte pan-CJK (Source Han, Noto CJK) contient les deux. Le PDF façonne un document japonais (
locale: 'ja'), et un isolat en japonais (:ltr[…]{lang=ja}) dans n’importe quel document, avec le système de langue OpenTypeJAN, si bien que la fonctionnalitéloclde la fonte imprime les formes japonaises que le canvas et le HTML impriment grâce àlang. Un isolat dans une autre langue à l’intérieur d’un livre japonais est façonné avec les formes de cette langue (les formes par défaut de la police pour le chinois en'host') et balisé comme unSpanavec son/Lang. AvecbodyText.shapingLanguage: 'document', la valeur par défaut depuis postext 1.30, chaque document et chaque isolat est façonné dans sa propre langue (ZHSpour le chinois simplifié), comme la mise en page l’a mesuré ; avec'host', les documents chinois et les autres sont façonnés avec les formes par défaut de la police, comme avant. Une fonte conçue pour le japonais comme Noto Serif JP a des formes par défaut japonaises ; elle compose tout de même les “ ” d’un texte japonais dans leurs formesJAN.
Un caractère absent d’une famille n’est pas pris dans une autre : Noto Serif TC n’emprunte rien à Noto Serif SC. Réglez la couverture au moment de construire les fichiers de polices ; la vitrine 红楼梦 copie les glyphes qui manquent à ses sous-ensembles TC depuis la fonte SC.
#Fournisseur de polices côté serveur (Node, fichiers locaux)
Sous Node, vous pouvez sauter entièrement l’étape WOFF2 et lire des fichiers TTF/OTF sur le disque :
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { PdfFontProvider } from 'postext-pdf';
const FONT_DIR = '/path/to/fonts';
function filename(family: string, weight: number, style: 'normal' | 'italic'): string {
const slug = family.replace(/\s+/g, '');
const styleSuffix = style === 'italic' ? 'Italic' : '';
const weightName =
weight >= 700 ? 'Bold'
: weight >= 600 ? 'SemiBold'
: weight >= 500 ? 'Medium'
: weight >= 300 ? 'Light'
: 'Regular';
return `${slug}-${weightName}${styleSuffix}.ttf`;
}
export const localFontProvider: PdfFontProvider = async (family, weight, style) => {
const buf = await readFile(join(FONT_DIR, filename(family, weight, style)));
return new Uint8Array(buf);
};#Octets des ressources et originaux d’impression
resourceBytes(fileId) renvoie les octets bruts d’une image, et le moteur en détecte le format :
- PNG, JPEG, GIF et WebP sont incorporés comme images ;
- le balisage SVG est dessiné en tracés vectoriels, son
textcomposé en texte réel dans les polices incorporées du document, ou bien rastérisé à 600 dpi dans le navigateur lorsqu’il emploie des fonctions hors du sous-ensemble vectoriel. Un<style>qui ne contient que des règles@font-face(des fontes qu’un auteur a incorporées) est ignoré et laisse la figure vectorielle (depuis postext-pdf 1.25) ; toute autre feuille de style en fait une image matricielle, produite avec les fontes que nomme son texte, insérées depuisfontProvider(sauf sidiagramStyle.inlineFontsou lesvg.inlineFontsde la ressource vautfalse) ; - un PDF voit sa première page incorporée telle quelle, sous forme de XObject de formulaire.
Chaque image est stockée une seule fois dans le fichier, quel que soit le nombre de fois où elle est dessinée. Un SVG dessiné en tracés vectoriels devient un XObject de formulaire que chaque page peint : un cadre ou un logo dans la maquette de page d’un document de trente pages est écrit une fois, et non trente ; chaque page supplémentaire ajoute quelques centaines d’octets. Jusqu’à postext-pdf 1.4, chaque page portait sa propre copie des tracés.
Une figure SVG peut désigner un original d’impression dans svg.pdfFileId : un PDF d’une page de la même figure, en général l’original dont le SVG a été exporté. renderToPdf demande d’abord à resourceBytes l’identifiant de l’original. Il incorpore cette page à la place du SVG, avec ses polices, ses dégradés et ses espaces colorimétriques intacts, partout où le SVG est dessiné : comme figure, comme image d’une cellule de tableau (TableCell.image), comme image de maquette ou comme icône d’encadré (son marker aussi). Le VDT porte l’identifiant de l’original à chacun de ces usages (svg.pdfFileId sur la ressource de la figure, pdfFileId sur une image de cellule et sur un bloc d’image de maquette), si bien que, dans un livre, chaque chapitre reçoit l’original. Les moteurs canvas et HTML continuent de dessiner le SVG. Les octets du SVG lui-même sont utilisés à la place dans trois cas : l’original est absent, ce n’est pas un PDF, ou l’encre unique est activée (diagramStyle.singleInk ne recolore que le balisage SVG).
const resources: Resource[] = [{
id: 'map', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'map.svg', width: 800, height: 600, pdfFileId: 'map.pdf' },
}];
const files = new Map([['map.svg', svgBytes], ['map.pdf', masterPdfBytes]]);
const pdf = await renderToPdf(buildDocument({ markdown, resources }, config), {
fontProvider,
resourceBytes: (fileId) => files.get(fileId),
});Un hôte peut aussi renvoyer les octets de l’original pour l’identifiant propre du SVG, comme le fait bundleResourceBytes ; les deux façons fonctionnent.
#Texte vertical dans le PDF
Une page verticale (layout.writingMode: 'vertical-rl') est dessinée dans un repère tourné d’un quart de tour, comme le canvas la peint, et son texte est composé de haut en bas de la colonne :
- Les caractères debout sont affichés au moyen d’une seconde police Type0 tirée du même fichier incorporé : même CIDFont, mêmes largeurs et même table ToUnicode, avec
Encoding /Identity-V(mode vertical). Une suite de caractères forme un seul objet texte dont les glyphes avancent d’eux-mêmes d’un cadratin vers le bas de la colonne (DW2 [880 −1000]), si bien que les lecteurs sélectionnent et extraient une colonne comme une seule ligne. Les glyphes sont façonnés avec les fonctionnalités OpenTypevertetfwid, qui donnent leurs formes verticales aux parenthèses, aux guillemets, aux virgules d’énumération du continent, aux points de suspension et aux tirets ; un caractère qui se tient debout tel quel garde son glyphe horizontal. Rien de la police n’est incorporé deux fois. - Les mots latins et les nombres longs sont couchés sur le côté avec la police horizontale ; un nombre dans une cellule se tient debout, comprimé en largeur jusqu’au cadratin s’il est plus large ; un signe pour lequel la police n’a pas de forme verticale est tourné ou déplacé, comme sur le canvas.
- L’approche entre les caractères est écrite sous forme de nombres
TJ, qui, en mode vertical, déplacent la plume vers le bas de la colonne. - Chaque ligne verticale est marquée d’un
/ActualTextcontenant son texte, si bien que la copie et l’extraction de texte la lisent telle qu’elle est écrite.pdftotextet pdf.js lisent les colonnes de haut en bas et de droite à gauche ; pdf.js commence une nouvelle ligne à chaque nombre composé dans une cellule. - Les liens, signets et destinations sont reportés sur la feuille : un lien sur une ligne verticale est un rectangle haut et étroit, et un signet ouvre la page en haut de la colonne de son titre.
- Un PDF balisé déclare le mode d’écriture sur son élément
Document(l’attribut de mise en pageWritingMode /TbRl, dont hérite chaque élément) ; la validation PDF/UA-1 (veraPDF) réussit sur un chapitre vertical. - Visionneuses : Acrobat, Aperçu, Chrome (PDFium), pdf.js et Poppler affichent les polices verticales. Un livre relié à droite (
page.binding) demande aussi aux visionneuses de disposer ses doubles pages de droite à gauche (/Direction /R2L,/PageLayout /TwoPageRight) ; Acrobat et Foxit le suivent, Chrome non.
Un chapitre de 43 pages composé en Noto Serif TC (les caractères du livre, en TrueType) pèse environ 820 Ko, dont la plus grande partie revient aux deux sous-ensembles de polices.
#Liens dans le PDF
Les mots d’un lien Markdown (voir Format du document › Liens) deviennent des annotations de lien URI, une par suite de mots liés sur une ligne. Chacune couvre la boîte de la ligne et n’a pas de bordure. Dans un rendu accessible, chaque suite est un élément Link dont le /Contents est son texte. Seules les cibles absolues http:, https:, mailto:, tel: et ftp: sont liées, car une URL relative n’a pas de base à l’intérieur d’un PDF. Les caractères hors de l’ASCII imprimable sont encodés en pourcentage. Les renvois :ref et les lignes de la table des matières gardent leurs liens à l’intérieur du document.
#Exemple complet dans le navigateur : construire, rendre, télécharger
Tout assembler : construire le VDT, le rendre en PDF et déclencher un téléchargement depuis le navigateur :
import { buildDocument, createMeasurementCache } from 'postext';
import { renderToPdf } from 'postext-pdf';
import { createPdfFontProvider } from './pdfFontProvider';
const fontProvider = createPdfFontProvider();
export async function downloadPdf(markdown: string, config: PostextConfig) {
const cache = createMeasurementCache();
const vdt = buildDocument({ markdown }, config, cache);
const bytes = await renderToPdf(vdt, { fontProvider });
const blob = new Blob([bytes.slice().buffer], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}Important : appelez ensureConfigFontsLoaded(config) (ou l’équivalent) avant buildDocument quand votre configuration fait référence à des polices web. La mise en page est mesurée avec les métriques dont dispose le navigateur à ce moment pour la famille : si la vraie police n’est pas encore arrivée, le VDT est mesuré avec une police de secours et le PDF ne correspondra pas à la sortie canvas ou HTML. Le Sandbox le fait explicitement avant chaque rendu (voir packages/postext-sandbox/src/viewport/PdfViewport.tsx).
#Exemple en direct : un PDF dans le navigateur
Le déroulé complet ci-dessus, exécuté dans le navigateur : le pen importe postext et postext-pdf depuis un CDN, charge les polices web, construit le document, incorpore les coupes Fontsource au moyen du fournisseur de polices et transmet les octets à un lien qui ouvre le fichier dans un nouvel onglet, ainsi qu’à un lien de téléchargement. Le PDF obtenu a les mêmes coupures de lignes que la sortie canvas et HTML, de vraies polices incorporées et des signets.
import { buildDocument } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// The PDF embeds real font files. Fontsource publishes one static WOFF2 per
// weight and style; decompress it to the TTF bytes pdf-lib can embed.
const fontProvider = async (family, weight, style) => {
const id = family.toLowerCase().replace(/\s+/g, '-');
const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
const res = await fetch(url);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
};
// Layout is measured with the browser's fonts, so load them before building:
// otherwise the PDF would not match the canvas or HTML output.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
// Same VDT, now translated to PDF points: identical line breaks and placement.
const bytes = await renderToPdf(doc, { fontProvider });
// A PDF viewer cannot run inside this sandboxed result frame,
// so hand the file to a new tab and to a download link.
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(bytes.length / 1024).toFixed(0)} KB PDF`;index.html
<p id="status">Rendering…</p>
<p id="links" hidden>
<a id="open" target="_blank" rel="noopener">Open lantern.pdf in a new tab</a> ·
<a id="download" download="lantern.pdf">Download it</a>
</p>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
}Charge un éditeur interactif depuis codepen.io. L’exemple importe la dernière version de postext depuis un CDN.
#Rendre le PDF dans un worker
postext-pdf/worker sort renderToPdf du thread principal. Le worker écrit le texte, les figures vectorielles, l’arbre de structure et le fichier lui-même. Deux tâches ont besoin de la page, et le worker les demande donc au thread principal : télécharger les polices et rastériser un SVG au moyen d’un <img>. Pour un livre de plusieurs centaines de pages, le rendu prend des secondes qui figeraient sinon la page ; pour quelques pages, appeler directement renderToPdf est plus simple.
import { createPdfWorker } from 'postext-pdf/worker';
const pdfWorker = createPdfWorker();
const bytes = await pdfWorker.render(docs, {
fontProvider, // runs on this thread
resourceBytes: new Map([['map.svg', svgBytes]]), // a Map; its buffers move to the worker
onProgress: ({ phase, pages, totalPages }) => showProgress(phase, pages, totalPages),
onWarning: (w) => console.info(w.message),
});
pdfWorker.dispose();render(docs, options)prend un document, ou le tableau des documents de chapitres d’un livre. Il accepte les options derenderToPdf, avec deux différences.resourceBytesest uneMap<string, Uint8Array>dont les tampons sont transférés : passez des copies des octets que vous conservez.rasterizeSvg, s’il est fourni, s’exécute sur le thread principal ; par défaut, ce sont l’Imageet le canvas de la page qui s’en chargent.- Un même objet ne rend qu’un document à la fois.
dispose()termine le worker et rejette tout rendu encore en attente. createPdfWorker({ worker })accepte unWorkerque vous créez vous-même, pour les outils de build qui contrôlent les URL des workers. Ce worker doit exécuterpostext-pdf/worker/entry.
Depuis un CDN. Par défaut, le script du worker est chargé depuis l’URL propre du paquet (new URL('./pdf.worker.js', import.meta.url)). Une page d’une autre origine peut ne pas pouvoir le démarrer : importé depuis esm.sh, createPdfWorker() lève Failed to construct 'Worker': Script at 'https://esm.sh/postext-pdf@…/pdf.worker.js' cannot be accessed from origin …. Démarrez plutôt un worker de module de même origine qui importe le point d’entrée (si vous épinglez une version, épinglez la même dans les deux URL) :
import { createPdfWorker } from 'https://esm.sh/postext-pdf/worker';
const entry = URL.createObjectURL(new Blob(
["import 'https://esm.sh/postext-pdf/worker/entry';"],
{ type: 'text/javascript' },
));
const pdfWorker = createPdfWorker({ worker: new Worker(entry, { type: 'module' }) });Le worker de mise en page de postext/worker a besoin de la même enveloppe autour de postext/worker/entry (voir Exécuter la mise en page dans un Web Worker).
#PDF prêts pour l’impression
Pour une chaîne de production imprimée, réglez ces options de configuration avant le rendu :
page.cutLines.enabled: true: ajoute la zone de fond perdu et les traits de coupe autour du format rogné, et donne à chaque page une TrimBox et une BleedBox. Voir Traits de coupe.print: { standard: 'pdfx4', outputProfile: 'fogra51' }(ou'pdfx1a') : un fichier PDF/X avec l’intention de sortie (output intent), l’identification et les boîtes que vérifie un imprimeur ; chaque couleur et chaque image RVB séparées au moyen du profil ICC, le noir 100 % K en surimpression et les grands aplats noirs en noir enrichi. Voir Production pour l’impression (configuration).colorSpace: 'cmyk'(oupdfGeneration: { forceColorSpace: true, colorSpace: 'cmyk' }) : la même séparation sans l’identification PDF/X (les traits de coupe sont toujours en couleur de repérage). Les originaux d’impression PDF sont incorporés tels quels.page.dpi: 300: les px par pouce de la mise en page ; un bitmap sans résolution propre s’imprime à cette résolution à sa taille naturelle. Le contrôle en amont signale les images de moins de 300 ppp à leur taille imprimée.ColorValue.cmyk: une couleur définie en CMJN s’imprime avec ses valeurs exactes.{ pageNegative: true }dansRenderToPdfOptions: inverse la zone rognée au moyen d’un mode de fusion Différence (les traits de coupe ne sont pas inversés). Utile pour les contrôles en amont d’une typographie sombre sur fond clair.
#Implémentation de référence
Le composant PdfViewport du Sandbox (packages/postext-sandbox/src/viewport/PdfViewport.tsx) relie les pièces ci-dessus dans un aperçu en direct doté de boutons de régénération, de téléchargement et d’impression, et constitue un bon point de départ pour toute intégration PDF dans le navigateur. Il construit le VDT au moyen du worker de mise en page partagé (voir Exécuter la mise en page dans un Web Worker), si bien qu’un clic sur Recalculer ne fige pas l’interface pendant l’exécution du pipeline : le thread principal ne s’occupe que de renderToPdf (déjà rapide une fois le VDT disponible).
#Un livre en 3D (postext-folio)
postext-folio présente à l’écran un document mis en page comme un livre imprimé ouvert sur un bureau : des doubles pages selon la règle du recto, et des feuillets que le lecteur tourne avec les boutons ‹ ›, les flèches du clavier, un balayage, un clic sur une page, ou en saisissant une page par son bord pour la faire passer de l’autre côté. Chaque feuillet se courbe dans three.js selon son papier et projette une ombre réelle sur les pages qu’il recouvre. Le canvas WebGL dessine le livre de la même façon, immobile ou en train de tourner ses pages, si bien qu’une page ne change jamais d’aspect en se posant. C’est la visionneuse des recettes du livre de recettes et de l’onglet Folio du Sandbox.
npm install postext postext-folio threeimport { buildDocument } from 'postext';
import { createFolioFromDocument } from 'postext-folio';
const doc = buildDocument({ markdown }, config);
const book = createFolioFromDocument(document.getElementById('book')!, doc, {
onChange: ({ pages }) => console.log('showing pages', pages),
});
// After an edit: the same viewer, on the same page.
book.setDocument(buildDocument({ markdown: edited }, config));- Les pages sont peintes au fur et à mesure.
createFolioFromDocumentpeint chaque page avecrenderPageToCanvasexactement aux pixels physiques d’un emplacement de page (WebGL l’affiche alors texel pour pixel, aussi net que l’aperçu canvas), et seulement les doubles pages autour de celle qui est ouverte (window, trois de chaque côté par défaut). Les pages qui sortent de cette fenêtre sont libérées, si bien qu’un livre de mille pages ne coûte que la mémoire de quelques-unes. Un saut vers une page lointaine peint d’abord cette double page. Jusqu’à dix pages d’écart, les feuillets tournent un par un ; au-delà, le bloc de pages intermédiaire se soulève d’un seul tenant, aussi épais que ces pages (leurs épaisseurs additionnées), et se pose de l’autre côté.setDocumentconserve la peinture de chaque page qui reste identique dans la nouvelle mise en page ({ repaint: true }les repeint toutes, après l’arrivée d’une image). - Le document définit le livre. Sa première page s’ouvre seule à droite quand c’est un recto (
pageIndexOffsetpair), un livre relié à droite (page.binding: 'right', ou un document vertical) est disposé en miroir et tourne vers la gauche, les pages blanches prennent la couleur de fond de la page, et la largeur rognée de la page (pageWidthMm) met à l’échelle l’épaisseur du papier et les plats. Un chapitre mis en page avec unecontinuationcompte les autres pages du livre (pageIndexOffsetavant lui,bookPageCountaprès lui) dans l’épaisseur des deux blocs de pages, sans les dessiner (extraPages). - Le document définit l’aspect. Le papier, la reliure, le bureau et l’éclairage sont les réglages
foliodu document (doc.config.folio). Une page composée à l’intérieur d’une suite:::paperporte son propre papier (VDTPage.paper), et son feuillet est dessiné avec la couleur, la surface, l’épaisseur et la rigidité de ce papier. Avecbinding.cover: 'pages', la première page est le plat avant et la dernière, quand c’est un verso, le plat arrière (covers). Un format de journal ('broadsheet','berliner','tabloid','compact') dont les réglages ne nomment ni papier ni reliure se présente comme du papier journal plié, y compris quand l’hôte passe son proprefolio. - Le conteneur définit la taille. Le livre le remplit, avec les boutons et le nombre de pages dans les marges : donnez donc une hauteur au conteneur ; un redimensionnement repeint les pages à la nouvelle taille. Sous 560 px de large, il affiche une page à la fois (
mode: 'auto';'single'et'double'imposent l’un ou l’autre) : le dos longe le bord intérieur de la page et le feuillet tourne autour de lui ; faire glisser vers le dos tourne la page en avant, un balayage dans l’autre sens revient en arrière, un tapotement tourne la page. - Ce que fait le pointeur.
interaction(puissetInteraction) définit ce que font sur le livre le bouton gauche, un doigt ou un stylet :'hand'(la valeur par défaut) saisit et tourne les pages,'orbit'fait pivoter la vue comme un glissement du bouton droit (pour les pavés tactiles et les tablettes),'select'laisse le pointeur à l’hôte, pour sélectionner du texte par exemple.pageAt(event)donne la page sous un pointeur et l’endroit visé ({ page, x, y }, en fractions de la page depuis son coin supérieur gauche), sur le livre tel qu’on le voit, incliné ou pivoté ;pointOnScreen(point)fait le chemin inverse, pour dessiner un curseur ou une sélection sur la page.refreshPage(src)réaffiche un canvas de page que l’hôte a redessiné sur place. Le Sandbox les utilise tous pour sélectionner du texte et suivre le curseur de l’éditeur sur les pages en 3D. - Une loupe pour les petits caractères.
interaction: 'magnify'tient une loupe ronde cerclée de noir au-dessus du livre, là où se trouve le pointeur (un doigt la tient au-dessus de lui tant qu’il touche l’écran). Elle montre le livre tel que le voit l’œil du lecteur, éclairé et courbé comme il est posé, grossi au maximum au centre et s’incurvant vers le bord. La molette,+et−changent le grossissement (setMagnification(zoom), de 1,5 à 10 ; la valeur par défaut montre la page à environ 5,5 px CSS par millimètre) et Échap range la loupe ;magnifier: { zoom, diameter }règle les deux dès le départ.createFolioFromDocumentrepeint les pages sous la loupe, assez nettes pour son centre, si bien que le texte courant d’un journal se lit ;createFolioprend ces peintures dansdetail: { paint(index, deviceWidth), release() }. Le Sandbox la place sur le bouton Loupe (M). La loupe sélectionne aussi du texte : au-dessus des pages, le curseur est un curseur de texte,pageAtrenvoie le point situé sous le centre de la loupe (au-dessus du doigt sur un écran tactile) et, dans le Sandbox, un clic à cet endroit place le curseur et un glissement sélectionne. - Le lecteur peut en faire le tour. Un glissement du bouton droit fait tourner la vue autour du livre (jusqu’à 70° de la verticale), y compris pendant que des feuillets tournent ;
resetView()la ramène en douceur aux valeurstiltetyawdes réglages, etgetView()donne la vue telle qu’on la voit à cet instant ({ tilt, yaw }, en degrés) pour l’enregistrer dans ces réglages. Le Sandbox les place sur deux boutons, Réinitialiser la vue et Enregistrer comme vue par défaut. - Les vidéos se lisent sur les pages. Un clic sur l’affiche d’une vidéo la lit sur la page, dans tous les modes d’
interaction, et elle continue pendant que son feuillet tourne ; un autre clic la met en pause, et elle s’arrête quand le livre s’immobilise sur une double page qui ne la montre pas. Une vidéo avecplayer.autoplaydémarre d’elle-même la première fois que sa double page s’affiche ; une vidéo qui se lit aussi en même temps que les autres (player.exclusive: false) démarre, sans le son, à chaque fois et s’arrête quand on tourne la double page, plusieurs à la fois. Optionsvideos,videoUrletonVideo, etstopVideo()sur la visionneuse ; voir Format du document › Vidéos sur les pages de Folio. - D’abord les polices et les images. Comme pour
renderPage, les fontes qu’utilise le document doivent être chargées dansdocument.fontset ses images de ressources enregistrées avecregisterResourceImageavant que les pages ne soient peintes. - Accessible. La visionneuse est un groupe focalisable qui répond à ←/→ (inversées pour un livre relié à droite), Page précédente/suivante, Début et Fin ; ses boutons et son nombre de pages sont étiquetés (
labelsles traduit), et chaque canvas de page porte un textealt(alt: (index) => …). - Sans WebGL2, ou quand le lecteur demande des animations réduites, les doubles pages changent simplement. Les livres WebGL sont lourds pour un téléphone (une texture par face de page) : le Sandbox ne propose son onglet Folio que là où WebGL2 est disponible et où le petit côté de l’écran mesure au moins 600 px.
canFlip()indique si les feuillets tourneront en 3D à cet endroit : WebGL2, et pas d’animations réduites.
#Apparence
L’option appearance, puis setAppearance, l’emportent sur ce que dit le document. Tout ce qui est omis garde la valeur du document :
const book = createFolioFromDocument(container, doc, {
appearance: {
folio: {
tilt: 22,
paper: { type: 'bookWove', texture: 'laid' },
binding: { type: 'hardcover', coverColor: { hex: '#5a1f1f', model: 'hex' } },
surface: { type: 'walnut' },
lighting: { environment: 'lamp' },
},
// Photographed desks: a folder laid out as postext.dev's /folio/textures/
// (manifest.json and one folder per desk). Without it, procedural maps.
textureBaseUrl: '/folio/textures',
// The picture for `folio.binding.spineImage`: the resource's URL,
// or a drawn canvas or image.
spineImage: spineUrl,
},
});
// A settings panel: the book redrawn in place, nothing painted again.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();| Champ | Effet |
|---|---|
folio | Les réglages folio : inclinaison, papier, reliure, surface, éclairage. Remplace ceux du document lorsqu’il est fourni. |
pageWidthMm | La largeur rognée d’une page en mm, par rapport à laquelle l’épaisseur du papier et les plats sont mis à l’échelle. Tirée du document : la page rognée à sa résolution en dpi. 150 par défaut pour createFolio. |
extraPages | : des pages du livre en dehors de celles fournies, comptées dans l’épaisseur des blocs de pages et jamais dessinées. |
covers | : la première page fournie est la couverture, la dernière la quatrième de couverture (quand elle tombe sur un verso). Elles tournent comme des plats et aucune couverture cartonnée n’est dessinée. Tirée du document : binding.cover: 'pages' sur un livre qui commence à sa première page et finit à sa dernière. |
spineImage | L’image imprimée sur le dos, sous forme d’URL, de canvas ou d’image. createFolioFromDocument ne cherche pas les ressources : passez l’image de la ressource que nomme folio.binding.spineImage. Ignorée pour une piqûre à cheval. |
textureBaseUrl | L’adresse où sont servies les textures photographiées du bureau. Tant qu’elles ne sont pas chargées, ou sans elle, le bureau est dessiné avec des textures procédurales. |
createFolio(container, { pages }) est la même visionneuse sur n’importe quelles pages : des URL d’images, des éléments <img> ou <canvas>, et "" pour une page blanche ; une page peut être { src, alt, paper }, paper étant un papier à la manière de :::paper pour ce feuillet. PageFlipper est le moteur three.js seul, pour un hôte qui construit son propre DOM de double page ; FlatPageFlipper est le tourne-page à plat antérieur au livre en 3D, conservé pour la table lumineuse du livre de recettes. La liste complète des options se trouve dans le README du paquet.
#Exemple en direct : un livre en 3D
Le pen importe postext et postext-folio depuis un CDN, met en page un court document et l’ouvre comme un livre. Saisissez la page de droite par son bord et faites-la passer de l’autre côté.
import { buildDocument } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';
const paragraph = `The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved. The light it gave was small, but it was enough to find the step.`;
// Thirty-six short sections: about ten pages to turn.
const markdown = ['# The Lantern']
.concat(Array.from({ length: 36 }, (_, i) => `## Evening ${i + 1}\n\n${paragraph} ${paragraph}\n\n${paragraph}`))
.join('\n\n');
const config = {
page: { sizePreset: '17x24', dpi: 150 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
const status = document.getElementById('status');
// The book: drag a page by its edge, click it, or use ← → and the buttons.
// Pages are painted at the size they are shown, around the open spread only.
createFolioFromDocument(document.getElementById('book'), doc, {
onChange: ({ pages }) => {
status.textContent = `${doc.pages.length} pages · open at ${pages.map((i) => i + 1).join('–')}`;
},
});
status.textContent = `${doc.pages.length} pages · drag a page by its edge to turn it`;index.html
<p id="status">Laying out…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}Charge un éditeur interactif depuis codepen.io. L’exemple importe la dernière version de postext depuis un CDN.
#Exemple en direct : des images de pages
createFolio avec des pages dessinées sur des canvas, une dernière page blanche et la couleur du papier.
import { createFolio } from 'https://esm.sh/postext-folio';
// Any pages will do: image URLs, <img> or <canvas> elements, and "" for a
// blank page. Here, eight pages drawn on canvases.
function drawPage(n) {
const canvas = document.createElement('canvas');
canvas.width = 600;
canvas.height = 840;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbf8f1';
ctx.fillRect(0, 0, 600, 840);
ctx.fillStyle = `hsl(${n * 45} 45% 45%)`;
ctx.fillRect(60, 80, 480, 320);
ctx.fillStyle = '#222';
ctx.font = 'bold 56px Georgia, serif';
ctx.fillText(`Plate ${n}`, 60, 480);
ctx.font = '22px Georgia, serif';
for (let line = 0; line < 8; line++) ctx.fillRect(60, 530 + line * 30, line === 7 ? 260 : 480, 3);
ctx.textAlign = 'center';
ctx.fillText(String(n), 300, 800);
return { src: canvas, alt: `Plate ${n}` };
}
const pages = Array.from({ length: 8 }, (_, i) => drawPage(i + 1));
// A blank page at the end, drawn as paper.
pages.push('');
const status = document.getElementById('status');
createFolio(document.getElementById('book'), {
pages,
firstPageRecto: true, // page 1 opens alone, on the right
binding: 'left', // 'right' lays a right-to-left book mirrored
paper: '#fbf8f1',
onChange: (state) => {
status.textContent = `Showing ${state.pages.map((i) => i + 1).join('–')} of ${pages.length}`;
},
});
status.textContent = 'Drag a page by its edge, click it, or use ← →';index.html
<p id="status">Drawing pages…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}Charge un éditeur interactif depuis codepen.io. L’exemple importe la dernière version de postext depuis un CDN.
#Livres EPUB (postext-epub)
postext-epub écrit un livre mis en page sous forme de fichier EPUB 3.3, dans le navigateur ou sous Node, sans serveur. Il lit les mêmes documents de chapitres que renderToPdf prend pour un livre, si bien que les numéros de page, les notes, les citations, les renvois, la table des matières et l’index arrivent résolus, et il renvoie le fichier sous forme d’octets. C’est lui qui écrit les fichiers de l’onglet EPUB 3 du Sandbox.
npm install postext postext-epubpostext est une dépendance pair (peer dependency), comme pour postext-pdf : mettez les deux à jour ensemble et, sur un CDN, épinglez-les à la même version.
#Mise en page fixe et recomposable
EPUB 3 définit deux rendus, fixés par la propriété rendition:layout du paquet ; layout choisit l’un des deux :
layout: 'fixed' | layout: 'reflowable' | |
|---|---|---|
| Nom EPUB | pre-paginated (mise en page fixe, FXL) | reflowable, la valeur par défaut d’EPUB |
| Documents de contenu | Un document XHTML par page imprimée, au format rogné de la page en px CSS | Un document XHTML par chapitre (une partie ouvre le sien) |
| Ce qui est conservé | La page : colonnes, flottants, titres courants, ouvertures, coupures et positions des lignes, dans les polices incorporées. Le texte reste du vrai texte : sélectionnable, consultable par recherche, lisible à voix haute | Le texte et sa structure : titres, paragraphes reconstruits à partir des lignes, listes, encadrés sous forme d’apartés, figures et tableaux après le texte qui les cite, notes, liens, repères de pages imprimées. Une feuille de style dérivée de la configuration |
| Ce qui est abandonné | Le choix du caractère, du corps et des marges par le lecteur ; sur un petit écran, la page est réduite | Les colonnes, les titres courants, la maquette des pages et les coupures de lignes exactes |
| Doubles pages et sens de lecture | page-spread-left / page-spread-right selon la parité et la reliure ; un livre relié à droite se lit de droite à gauche | Sens de lecture tiré de la reliure ; le chinois vertical garde vertical-rl, l’arabe est en dir="rtl" |
| Adapté à | Des pages composées avec soin : livres illustrés, manuels, catalogues, magazines ; grands écrans | Le texte suivi : romans, essais, rapports ; téléphones et liseuses à encre électronique |
Les deux rendus portent la même navigation : une table des matières tirée des titres et des pages de partie, une liste des pages avec les numéros de pages imprimées, des repères (couverture, table des matières imprimée, début du corps du texte) et un NCX pour les anciens systèmes de lecture.
#Écrire un livre
import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // one VDTDocument per chapter, in book order
const bytes = await renderToEpub(docs, {
layout: 'reflowable',
metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
fonts: bundle.fonts.map((f) => ({ family: f.family, weight: f.weight, style: f.style, bytes: new Uint8Array(f.bytes), format: f.format })),
resourceBytes: (fileId) => {
const data = bundle.files.get(fileId);
return data ? { bytes: data, mediaType: '' } : undefined;
},
onWarning: (w) => console.warn(w),
});Un document seul est un livre d’un chapitre : renderToEpub([doc], options).
renderToEpub(docs, options): Promise<Uint8Array>écrit le fichier.optionsvaut{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.metadata:titleetlanguage(une étiquette BCP 47) sont obligatoires ;subtitle,creators,identifier,date,publisher,rights,descriptionetmodifiedsont facultatifs. Un ISBN seul devienturn:isbn:…. Sansidentifier, le livre reçoit unurn:uuid:dérivé de son titre, de ses auteurs et de sa langue, si bien qu’une nouvelle version du même livre garde sa place dans la bibliothèque d’un lecteur. Passez aussimodifiedpour obtenir une sortie identique à l’octet près.fonts: les fontes à incorporer,{ family, weight, style, bytes, format, unicodeRange? },formatvalantwoff2,woff,ttfouotf. Chaque fonte devient un fichier et une règle@font-face; plusieurs fichiers avec leurunicodeRangeforment une seule fonte (les tranches de Google Fonts). Une famille, une graisse ou un style que les pages utilisent sans fonte incorporée est signalé parmissingFont, et les systèmes de lecture y substituent les leurs. N’incorporez que des polices dont la licence le permet : une fonte marquéeredistributable: falsen’est jamais écrite dans le fichier (le livre peut être composé avec elle, mais son fichier reste dehors, y compris des images SVG).resourceBytes(fileId): les images que placent les pages, de façon synchrone ou asynchrone, sous la forme{ bytes, mediaType }; unmediaTypevide est déduit des octets. Fournissez les bitmaps tels qu’ils sont stockés et les SVG sous forme de source, pas l’original d’impression PDF (svg.pdfFileId). Chaque image est stockée une seule fois. Un livre à encre unique (diagramStyle.singleInk) voit ses SVG recolorés dans le fichier. Un SVG reçoit, incorporées en lui, les fontes que nomme son texte, puisqu’un système de lecture l’affiche comme une image qui ne voit pas les polices du livre (voir Polices dans le texte SVG) : depuisfonts(les tranches qui contiennent ses caractères), puis depuissvgFonts.providerpour une famille que le texte du livre n’utilise pas. Les familles des fontes marquéesredistributable: false, et celles que nommesvgFonts.withhold(family), restent dehors et sont signalées une fois chacune parfontWithheld; une famille sans fonte est signalée parsvgFontUnavailable, des fontes au-delà desvgFonts.maxBytes(2 Mio) parsvgFontsTooLarge.svgFonts.inline: false,diagramStyle.inlineFonts: falseet lesvg.inlineFonts: falsed’une ressource gardent les octets tels quels. Une image sans octets est signalée parmissingImageet laissée comme un cadre vide.cover:{ bytes, mediaType, alt? }, une image JPEG, PNG, WebP ou SVG. Le livre s’ouvre alors sur un document de couverture qui la contient, et elle devient lecover-imagedu paquet (la vignette dans une bibliothèque). Sans elle, la mise en page fixe désigne sa première page comme couverture, et le livre recomposable n’a pas d’image de couverture.onProgress({ phase, done, total }):resources(polices et images),documents(pages pour une mise en page fixe, chapitres pour une recomposable), puispackage.signalinterrompt le travail entre deux étapes.readEpub(bytes)relit un fichier pour une visionneuse, sansDOMParser: mise en page, métadonnées, sens de lecture, chaque fichier par chemin, le manifeste, la séquence de lecture (spine), la table des matières, la liste des pages, la zone d’affichage de la mise en page fixe et la couverture. Le lecteur du Sandbox repose sur elle.
Les deux rendus portent les métadonnées EPUB Accessibility 1.1 (modes d’accès, fonctions comme la table des matières et les numéros de pages imprimées, risques et un résumé) et ne revendiquent par défaut aucune conformité WCAG. La liste complète des options et les limites se trouvent dans le README du paquet.
#Vérifier un fichier avec EPUBCheck
W3C EPUBCheck est le validateur de référence pour EPUB ; les librairies numériques vérifient avec lui les fichiers qu’elles reçoivent. Une fois installé (brew install epubcheck, ou la version Java), epubcheck book.epub liste les erreurs, les avertissements et les remarques d’usage ; les remarques d’usage que laisse la sortie de Postext (CSS-028, OBS-001, HTM_062) sont informatives. Dans le dépôt de Postext, pnpm --filter postext-epub epubcheck vérifie les livres d’exemple de la suite de tests, node packages/postext-epub/scripts/epubcheck.mjs book.postext --layout both met en page un fichier .postext ou un dossier de préréglage et vérifie les deux rendus, et pnpm --filter postext-epub validate exécute toute la matrice de livres (le guide, les préréglages vitrines, les livres chinois, arabes et ceux du livre de recettes), qui passent tous sans erreur ni avertissement.
#Paquets (fichiers .postext)
Un fichier .postext est un livre entier en un seul fichier : une archive zip qui contient un manifeste preset.json, un fichier Markdown par chapitre, les contenus des ressources (bitmaps, SVG, originaux d’impression PDF) et les fichiers des polices que nomme la configuration. Le Sandbox l’exporte et l’importe, et le skill d’agent le livre. Le paquet postext sait aussi le créer et l’ouvrir, si bien qu’un livre peut circuler entre ces outils et votre propre programme sans rien perdre.
my-book.postext
├── preset.json manifest: name, locale, chapters, config, resources, fonts
├── chapters/01-dusk.md
├── chapters/02-night.md
├── resources/lantern.svg
└── fonts/ebgaramond-400-normal.woff2
Le manifeste est décrit champ par champ dans l’annexe Format des paquets de préréglages du Sandbox. Un fichier peut aussi contenir layouts.json, les nombres de pages du Sandbox, afin que le livre s’y ouvre déjà paginé, ou un par édition d’un livre multilingue (layouts.zh-Hant.json, lu en premier). openBundle les ignore.
L’API est exportée par postext lui-même et par le sous-chemin postext/bundle, qui ajoute les fonctions de bas niveau. Importez depuis postext quand vous faites aussi le rendu. Les adaptateurs de paquets et les moteurs de rendu partagent ainsi une seule instance de module, ce qui compte sur un CDN comme esm.sh, où chaque point d’entrée est un build distinct.
#Ouvrir un paquet
openBundle prend les octets du fichier (un Uint8Array, un ArrayBuffer, ou un Blob / File issu d’un <input type="file">) et renvoie tout ce dont le moteur et ses sorties ont besoin :
import { openBundle } from 'postext';
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
bundle.chapters; // [{ title, file, markdown }, …] in book order
bundle.config; // PostextConfig, ready for buildDocument
bundle.resources; // Resource[]
bundle.files; // Map<path, Uint8Array>: every file of the bundle| Champ | Contenu |
|---|---|
manifest | Le preset.json validé. |
id, name, description | Tirés du manifeste. |
locale, locales | La langue dans laquelle le contenu a été lu, et toutes les langues que contient un paquet bilingue. options.locale en choisit une : d’abord l’étiquette exacte, puis la langue de base, puis la langue propre du paquet. |
chapters | { title, file, markdown } par chapitre. Un chapitre sans titre dans le manifeste prend le texte de son premier titre #. |
config | La palette de couleurs par défaut et les types de ressources dans la langue du paquet, puis la config du manifeste, puis les surcharges de la langue. La langue du paquet est le locale ci-dessus : un paquet dans une langue reçoit donc ses propres libellés quoi que demande options.locale. Un manifeste qui ne nomme aucune langue prend celle que fixe sa config (locale, puis la langue de césure), sinon options.locale. customFonts liste les familles de polices du paquet. C’est la même configuration que celle avec laquelle le Sandbox ouvre le paquet. |
resources | Les ressources, avec les légendes de la langue choisie. Une taille absente du manifeste est lue dans le fichier. |
fonts | Une entrée par fonte : { family, weight, style, format, file, bytes }. |
files | Chaque fichier de l’archive, indexé par son chemin. |
thumbnail, canvasScope | Le chemin de l’image de couverture, et la façon dont le paquet demande à être affiché : la view du manifeste, surchargée par le localized[…].view de la langue servie. |
start | Où commence le livre, pour un paquet qui contient une partie d’un ouvrage plus long : le start du manifeste, ou le localized[…].start de la langue servie. Absent pour un livre qui commence à son début. |
warnings | Problèmes non bloquants : un fichier de police non pris en charge, un original d’impression manquant. |
Le fileId d’un contenu est son chemin dans le paquet. resource.svg.fileId, resource.bitmap.fileId et le fileId de chaque variante de customFonts peuvent être cherchés directement dans bundle.files. openBundle lève une exception quand les octets ne sont pas un zip, quand il n’y a pas de preset.json valide (à la racine ou dans un unique dossier de premier niveau), ou quand un fichier que nomme le manifeste est absent.
Paquets écrits par postext 1.4 ou antérieur
Chaque manifeste qu’écrivent createBundle et le Sandbox porte configVersion: 14 : les règles de configuration pour lesquelles sa config a été écrite. Un manifeste qui ne le porte pas a été écrit par postext 1.4 ou antérieur, qui réglait dix-huit choses autrement :
- Les sauts aux titres (règles 3) : jusqu’à la 1.4, un objet
headingssans saut pour le H1 n’en avait aucun (voir Surcharges par niveau). - Le corps des formules (règles 4) : jusqu’à la 1.4, les formules sortaient 1,131 fois plus grandes que ne l’indique
fontSizeScale(voir Taille des formules). - L’espace sous une ressource en ligne (règles 5) : jusqu’à la 1.4, le texte qui suit une figure ou un tableau en
placement.position: 'here'reprenait à la ligne de grille suivante, sans espace de flottant en dessous (voirlayout.inlineResourceGapdans Mise en page). - L’espace autour d’une ressource en ligne dans un encadré (règles 6) : jusqu’à la 1.4, une telle ressource était collée au texte de l’encadré qui l’entoure (voir
layout.inlineResourceGapInBoxesdans Mise en page). - Les marques en ligne dans les titres (règles 6) : jusqu’à la 1.4, un titre imprimait les mots de ses marques
*italic*,**bold**et autres dans son propre style simple (voirheadings.inlineMarksdans Titres). - La taille d’une lettrine (règles 6) : jusqu’à la 1.4, la
dropCapd’un texte de maquette sansfontSizeétait aussi haute que toutes les boîtes de lignes qu’elle couvre, son sommet au-dessus de la première ligne (voirdropCapdans Éléments de texte). - La place sous une ligne terminée par un deux-points (règles 6) : jusqu’à la 1.4,
keepColonWithListjugeait suffisante pour la liste une ligne de place sous la ligne finissant par un deux-points, et un premier élément de deux lignes, que les règles des orphelines et des veuves gardent entier, passait à la colonne suivante sans elle (voirbodyText.colonListRoom). - Les lignes que laisse la coupure d’un encadré (règles 6) : jusqu’à la 1.4, un encadré coupé à l’intérieur d’un paragraphe ou d’un élément de liste pouvait en laisser une seule ligne d’un côté, pourvu que chaque côté de l’encadré contienne en tout ses
splitMinLineslignes (voirlayout.boxChildSplitMinLinesdans Mise en page). - Les coupures de ligne à un tiret (règles 7) : jusqu’à la 1.4, Knuth-Plass ne terminait jamais une ligne après un tiret cadratin ou demi-cadratin collé entre deux mots (
say—that’s), et l’algorithme ligne à ligne du texte mis en forme seulement entre deux lettres (voirbodyText.breakAfterDashesdans Texte courant). - Le texte en drapeau (règles 7) : jusqu’à la 1.4, le texte courant en drapeau était composé ligne à ligne, chaque ligne remplie avant la suivante, quoi qu’indique
optimalLineBreaking(voirbodyText.optimalRaggeddans Texte courant). - La coupure sous un titre (règles 8) : jusqu’à la 1.4, le paragraphe sous un titre en pied de colonne gardait autant de lignes qu’il en tenait là, si peu qu’il en passe à la colonne suivante (voir
headings.keepWithNextSplitdans Titres). - L’espace sous un conteneur
:::paragraphs(règles 8) : jusqu’à la 1.4, l’espace du style était placé sous le dernier paragraphe avant l’alignement sur la grille, l’espace au-dessus du bloc suivant (lemarginTopd’un titre) s’y ajoutait, et l’espacement entre paragraphes du texte était omis (voirbodyText.paragraphContainerSpacingdans Texte courant). - Les coupures de ligne au trait d’union d’un mot composé (règles 8) : jusqu’à la 1.4, Knuth-Plass ne terminait jamais une ligne justifiée après un trait d’union entre deux lettres (
well-known) dans un paragraphe sans mise en forme en ligne, alors qu’il le faisait dans un paragraphe mis en forme (voirbodyText.breakAfterHyphensdans Texte courant). - Les poèmes sans séparateur (règles 9) : jusqu’à la 1.22, un poème
:::versedont les vers ne portaient pas de||était composé en hémistiches isolés, chaque vers centré (voirbodyText.verse.layoutdans Vers). - Un retrait de première ligne à côté d’un retrait négatif (règles 9) : jusqu’à la 1.22, le
hangingIndentd’un style de paragraphe remplaçait sonfirstLineIndent, et la première ligne commençait àindent(voir Styles de paragraphe). - Une barre oblique inverse en fin de ligne (règles 9) : jusqu’à la 1.22, une barre oblique inverse qui terminait une ligne d’un paragraphe, d’une citation ou d’un élément de liste, ainsi que
\\devant une espace, s’imprimaient, et les lignes étaient jointes par une espace (voirbodyText.hardLineBreaksdans Texte courant). - Les blocs de code délimités (règles 9) : jusqu’à la 1.22, une clôture
```ou~~~et les lignes qu’elle contient étaient lues comme du Markdown : lignes jointes en paragraphes, une ligne#devenue titre, clôtures imprimées (voircodeStyle.blocksdans Listings de code). - Les vers reportés (règles 10) : en 1.23, un vers d’un poème composé ligne à ligne et plus large que la justification était reporté à son espacement naturel entre les mots, si peu qu’il déborde (voir
bodyText.verse.tightendans Vers).
openBundle et readBundle lisent la config d’un tel manifeste, et la configuration localized de chaque langue, au moyen de migrateConfig, qui écrit en clair les sauts que la 1.4 mettait en page et multiplie l’échelle des formules par 1,131 (ses marges d’affichage en em étant divisées par ce facteur). Un manifeste marqué de 3 à 7, écrit par une préversion de la 1.5, ne reçoit que les épinglages des règles postérieures à sa marque. Avec 3, ce sont le corps des formules, l’espace en ligne, les cinq épinglages des règles 6, les deux des règles 7 et les trois des règles 8 ; avec 4, l’espace en ligne et les épinglages des règles 6, 7 et 8 ; avec 5, les épinglages des règles 6, 7 et 8 ; avec 6, les épinglages des règles 7 et 8 ; avec 7, les seuls épinglages des règles 8. La coupure sous un titre (pinLegacyHeadingSplit) est écrite sous la forme headings.keepWithNextSplit: 'fill' sur les headings que les couches laissent en vigueur, quand un chapitre lu contient un titre et que la configuration ne nomme aucune valeur propre, garde headings.keepWithNext activé et ne désactive pas bodyText.avoidOrphans. Les coupures aux mots composés (pinLegacyHyphenBreaks) sont écrites sous la forme bodyText.breakAfterHyphens: false sur le bodyText en vigueur, quand un chapitre lu compose un trait d’union entre deux lettres et que la configuration ne le fixe pas déjà et ne désactive pas optimalLineBreaking. L’espace sous les conteneurs (pinLegacyParagraphContainerSpacing) est écrit sous la forme bodyText.paragraphContainerSpacing: 'add' sur le bodyText en vigueur, quand la configuration déclare un style de paragraphe (dans paragraphStyles ou dans les surcharges de la visionneuse HTML), qu’un chapitre lu ouvre un conteneur :::paragraphs sur une ligne à part et que la configuration ne le fixe pas déjà. Les coupures aux tirets (pinLegacyDashBreaks) sont écrites sous la forme bodyText.breakAfterDashes: false sur le bodyText en vigueur, quand un chapitre lu compose un tiret cadratin ou demi-cadratin collé entre deux mots (une lettre, un chiffre ou une ponctuation fermante avant lui, une lettre, un chiffre ou une parenthèse ou un guillemet ouvrant après lui ; un guillemet avant lui compte quand une lettre, un chiffre, une ponctuation fermante ou une espace insécable précède le guillemet, comme dans "no"—and, et non dans said "—Hola ; une marque en ligne qui touche le tiret ou le guillemet, comme les ** de **riddles.**—I, compte d’un côté comme de l’autre) et que la configuration ne le fixe pas déjà. La composition en drapeau (pinLegacyRaggedBreaking) est écrite sous la forme bodyText.optimalRagged: false sur le bodyText en vigueur, quand la configuration compose en drapeau une partie du texte courant (un textAlign autre que 'justify' sur le texte courant, un style de paragraphe, le corps d’un encadré, le corps d’une partie ou le corps d’un style de section (headingStyles[].bodyStyle), ou dans les surcharges de la visionneuse HTML), ne le fixe pas déjà et ne désactive pas optimalLineBreaking. L’espace dans les encadrés (pinLegacyBoxResourceGap) est écrit sous la forme layout.inlineResourceGapInBoxes: false sur le layout en vigueur, quand une ressource est incorporée sur une ligne à part à l’intérieur d’un :::callout des chapitres lus et que la configuration ne le fixe pas déjà. La coupure des encadrés (pinLegacyBoxChildCut) est écrite sous la forme layout.boxChildSplitMinLines: 1 sur le layout en vigueur, quand un chapitre lu ouvre un :::callout sur une ligne à part et que la configuration ne le fixe pas déjà. Les marques des titres (pinLegacyHeadingMarks) sont écrites sous la forme headings.inlineMarks: false sur les headings que les couches laissent en vigueur, quand un titre des chapitres lus porte une marque (*, _, ^, ~, :smallcaps[ ou un lien dans son intitulé) et que la configuration ne nomme aucune valeur propre. Les lettrines (pinLegacyDropCapSize) voient leur taille de la 1.4 écrite en clair sous la forme dropCap.fontSize, où qu’elles se trouvent : dans l’unité de l’interlignage de l’élément quand c’est une longueur, sinon dans l’unité de son corps. La place sous une ligne terminée par un deux-points (pinLegacyColonListRoom) est écrite sous la forme bodyText.colonListRoom: 'line' sur le bodyText en vigueur, quand une liste des chapitres lus suit une ligne qui finit par un deux-points (des lignes vides entre les deux étant admises) et que la configuration ne nomme pas de place et ne désactive pas keepColonWithList. L’espace en ligne (pinLegacyInlineGap) est écrit sous la forme layout.inlineResourceGap: 'above' sur le layout que les couches laissent en vigueur, quand une ligne des chapitres lus incorpore une ressource (::resource{id="…"} seul sur sa ligne, tel que le lit l’analyseur : une mention dans le texte courant ou dans un code en ligne ne compte pas) et que la configuration ne nomme aucun espace propre. Le corps des formules est épinglé sur le math que les couches laissent en vigueur (le math propre d’une langue remplace celui qui est partagé), et seulement quand les chapitres lus contiennent un $ : un paquet sans formules garde sa config telle qu’elle a été écrite. Quand ni le manifeste ni la langue ne nomment de math, celui qui est en vigueur est le baseConfig de readBundle (celui du lecteur), et il est épinglé lui aussi, puisque la 1.4 composait les formules du paquet à ce corps : un fontSizeScale: 1.5 de base se lit comme 1,5 × 1,1312. Les sauts aux titres de la base sont pris tels quels. Un ancien paquet garde donc ce que ces règles mettaient en page, et bundle.config montre les sauts, le corps des formules, les espaces, les marques des titres, les tailles des lettrines, la place sous une ligne terminée par un deux-points, la coupure des encadrés, les coupures aux tirets, les coupures aux mots composés, la composition en drapeau, la coupure sous un titre et l’espace sous les conteneurs avec lesquels il est mis en page. Les corrections de mise en page de la 1.5 n’ont pas d’épinglage et s’appliquent à lui comme à n’importe quel livre, si bien qu’une page qu’elles touchent peut encore bouger (voir Taille des formules pour la liste). Un preset.json écrit à la main pour les règles actuelles fixe "configVersion": 14 ; marquer ainsi le manifeste d’un ancien paquet est aussi la façon, en une ligne, de le lire avec les règles actuelles (un paquet sans version perd alors aussi son épinglage des sauts aux titres). Un manifeste marqué 8, écrit par postext 1.5 à 1.22, reçoit les seuls quatre épinglages des règles 9, comme tous les manifestes plus anciens : la disposition des vers (pinLegacyVerseLayout) est écrite sous la forme bodyText.verse.layout: 'bayt' sur le bodyText en vigueur, quand un chapitre lu compose un poème :::verse dont la clôture ne nomme aucune disposition et dont les vers ne portent aucun séparateur d’hémistiches, et que la configuration ne le fixe pas déjà ; les retraits appariés (pinLegacyPairedIndents) suppriment le firstLineIndent de chaque style de paragraphe (dans paragraphStyles ou dans les surcharges de la visionneuse HTML) qui fixe aussi un hangingIndent non nul ; les sauts de ligne forcés (pinLegacyHardBreaks) sont écrits sous la forme bodyText.hardLineBreaks: false sur le bodyText en vigueur, quand un chapitre lu termine par une barre oblique inverse une ligne d’un paragraphe, d’une citation ou d’un élément de liste dont le bloc continue en dessous, ou compose \\ devant une espace suivie d’autre texte (hors code et formules en ligne, formules centrées, titres et poèmes :::verse), et que la configuration ne le fixe pas déjà ; enfin, les blocs de code délimités (pinLegacyCodeBlocks) sont écrits sous la forme codeStyle.blocks: false, quand un chapitre lu ouvre une clôture ``` ou ~~~ (trois caractères ou plus, avec au plus trois espaces de retrait) et que la configuration ne le fixe pas déjà. Un manifeste marqué 9, écrit par postext 1.23, reçoit le seul épinglage des règles 10, comme tous les manifestes plus anciens : les vers reportés (pinLegacyVerseTightening) sont écrits sous la forme bodyText.verse.tighten: false sur le bodyText en vigueur, quand un chapitre lu compose un poème ligne à ligne (une clôture :::verse qui nomme layout=lines, ou qui ne nomme aucune disposition sur des vers sans séparateur d’hémistiches alors que la configuration ne compose pas ces poèmes en bayts) et que la configuration ne le fixe pas déjà. Un manifeste marqué 10, écrit par postext 1.24, reçoit le seul épinglage des règles 11, comme tous les manifestes plus anciens : l’équilibrage d’une grille de caractères (pinLegacyGridBalancing) est écrit sous la forme headings.balancing.enabled: true sur les headings en vigueur, quand la configuration fusionnée active cjk.grid.enabled en texte horizontal et ne fixe pas elle-même enabled, puisque la 1.24 équilibrait une telle page par défaut. Les règles 11 évitent aussi aux titres d’œuvres les coupures d’un seul caractère, composent les nombres cerclés comme des caractères chinois et composent le texte de maquette CJK selon les règles du texte courant (#637) : un manifeste marqué 10 ou antérieur reçoit cjk.titleMinChars: 1 (pinLegacyTitleBreaks) quand un chapitre lu compose un titre d’œuvre (《 〈 ou :book[), cjk.circledNumbers: 'western' (pinLegacyCircledNumbers) quand un chapitre compose un nombre cerclé (U+2460–U+24FF, U+2776–U+2793), et cjk.composeDesignText: false (pinLegacyDesignText) quand un chapitre lu ou la configuration elle-même contient du texte CJK, chacun sur le cjk en vigueur et seulement quand la configuration ne le fixe pas déjà. Elles coupent aussi les tableaux en ligne et composent :::columns dans le texte courant (#634) : un manifeste marqué 10 ou antérieur reçoit tableStyle.splitInline: false (pinLegacyInlineTableSplit) sur le tableStyle en vigueur quand un chapitre lu incorpore une ressource, et layout.flowColumns: false (pinLegacyFlowColumns) sur le layout en vigueur quand un chapitre lu ouvre une clôture :::columns sur une ligne à part, chacun seulement quand la configuration ne le fixe pas déjà. Elles offrent aussi à un flottant le haut de la colonne propre d’une ouverture sur toute la largeur de la page (#639) : un manifeste marqué 10 ou antérieur reçoit layout.floatsUnderOpener: false (pinLegacyOpenerHeadFloats) sur le layout en vigueur quand la configuration fusionnée fixe un niveau ou un style de titre avec span: 'page' et qu’un chapitre lu contient un titre, seulement quand la configuration ne le fixe pas déjà. Les règles 12 permettent à une page sur grille de caractères de tenir ses lignes (#717) : un manifeste marqué 11 ou antérieur dont la configuration fusionnée active cjk.grid.enabled reçoit bodyText.avoidWidows: true, et dans la région japan bodyText.avoidOrphans: true (pinLegacyGridBreakRules), sur le bodyText en vigueur, chacun seulement quand la configuration ne le fixe pas déjà. Elles gardent aussi deux caractères d’une note sur deux rangs de part et d’autre d’un saut de ligne (#720) : un tel manifeste reçoit cjk.warichu.minChars: 1 (pinLegacyNoteBreaks) sur le cjk en vigueur quand un chapitre lu compose une note :warichu[ ou que la configuration fusionnée fixe citations.notes: 'warichu', seulement quand la configuration ne le fixe pas déjà. Et dans la région Japon, un mot occidental prend l’espace han–latin à côté de ses parenthèses et de ses signes (#721) : un manifeste marqué 11 ou antérieur dont la configuration fusionnée se résout en région Japon reçoit cjk.latinSpacingEdge: 'letters' (pinLegacyLatinSpacingEdge) sur le cjk en vigueur quand un chapitre lu contient des kana ou des kanji, seulement quand la configuration ne le fixe pas déjà. Enfin, une ressource placée dans le texte prend placement.captionSide comme le fait un flottant de colonne (#729) : un manifeste marqué 11 ou antérieur dont la configuration fusionnée fixe layout.layoutType: 'oneAndHalf' reçoit layout.inlineCaptionSide: false (pinLegacyInlineCaptionSide) quand un chapitre lu incorpore une ressource, seulement quand la configuration ne le fixe pas déjà.
Les règles 12 changent aussi la façon d’équilibrer une colonne courte, et un manifeste marqué 11 ou antérieur garde ce avec quoi il a été mis en page. L’ordre des leviers (#707) : il reçoit headings.balancing.maxLinesBeforeLoose fixé à son propre maxLinesPerHeading, ou à 4 s’il n’en fixe aucun (pinLegacyLooseOrder), sur les headings en vigueur, quand un chapitre lu contient un titre et que la configuration fusionnée équilibre ses colonnes, permet à un titre d’occuper plus de deux lignes et ne fixe pas déjà la clé. La ligne sous une figure qui s’étend sur plusieurs colonnes (#708) : il reçoit headings.balancing.stretchAfterWideFloats: 'always' (pinLegacyWideFloatStretch) sur les headings en vigueur, quand un chapitre lu incorpore ou cite une ressource ou fait flotter un encadré, et que la configuration fusionnée équilibre ses colonnes, ne désactive pas stretchAfterFloats et ne fixe pas déjà la clé. Les coupures à l’intérieur des groupes d’un index (#712) : il reçoit index.groups.splitMinEntries: 1 (pinLegacyIndexGroupSplit) sur l’index en vigueur, quand un chapitre lu compose une clôture :::index sur une ligne à part et que la configuration ne fixe pas déjà la clé. L’unité de la coupe de niveau (#711) : il reçoit headings.balancing.trailingStep: 'grid' (pinLegacyTrailingStep) sur les headings en vigueur, quand un chapitre lu peut composer du texte hors de l’interlignage du texte courant (une clôture :::, un bloc de code délimité, une citation, une liste, une formule centrée ou une citation bibliographique), et que la configuration fusionnée équilibre ses colonnes, garde trailing ou beforeSpan activé et ne fixe pas déjà la clé.
Les règles 12 coupent le libellé d’un renvoi à ses espaces quand ce libellé est fait de mots (#702). Un manifeste marqué 11 ou antérieur reçoit crossRefs.breakLabels: false (pinLegacyRefLabelBreaks) sur les crossRefs en vigueur, quand un chapitre lu contient un renvoi (:ref{…}, \ref{…}, \eqref{…} ou un @id pandoc-crossref) et que la configuration ne nomme pas ce réglage : chaque libellé reste le mot unique sous lequel il a été mis en page.
Les règles 12 façonnent un mot sans lettre (46., 1367)) dans l’écriture du texte qui l’entoure (#725). Un manifeste marqué 11 ou antérieur reçoit bodyText.scriptContext: false (pinLegacyScriptContext) sur le bodyText en vigueur, quel que soit le contenu de ses chapitres, quand la configuration ne nomme pas ce réglage : un tel mot est façonné seul, comme il a été mis en page. La règle agit dans une fonte qui dessine sa ponctuation selon l’écriture, et on ne sait pas quelles fontes un livre charge au moment où son manifeste est lu.
Les règles 13 font de la langue dans laquelle le texte est façonné un réglage (#764), et les règles 14 lui donnent pour valeur par défaut la langue propre du document (#770). Un manifeste marqué 13 ou antérieur reçoit bodyText.shapingLanguage: 'host' (pinLegacyShapingLanguage) sur le bodyText en vigueur, quel que soit le contenu de ses chapitres, quand la configuration ne nomme pas ce réglage ; un manifeste qui nomme une valeur la garde. Son texte est mesuré dans la langue de la page qui héberge la mise en page, et sans langue dans un worker, comme il a été mis en page. Un manifeste marqué 13 a été écrit par postext 1.29 : les versions 1.29.1 et 1.29.2 mettaient en page dans la langue de l’hôte un livre qui ne nomme aucune valeur, comme le fait l’épinglage ; la 1.29.0, dont la valeur par défaut était 'document', le mettait en page dans celle du document avec un canvas et un PDF qui ne suivaient pas la mesure, et il se lit lui aussi avec 'host'. Un manifeste marqué 14 qui ne nomme aucune valeur est mis en page dans la langue du document.
Les règles 13 dessinent aussi la case d’un élément de tâche (- [ ], - [x]) : un carré que dessine le moteur, coché quand l’élément est fait, qui occupe une place à lui avant le texte de l’élément (#765). Un manifeste marqué 12 ou antérieur reçoit unorderedLists.taskBox: 'character' (pinLegacyTaskBox) sur les unorderedLists en vigueur, quand un chapitre contient un élément de tâche et que la configuration ne nomme pas ce réglage. Sa case reste le caractère ☐ ou ☑ composé dans la fonte de la liste, et le texte de l’élément commence après la largeur de ce caractère, comme il a été mis en page. Cette largeur venait de la police, quelle qu’elle soit, qui contenait le caractère, une police du système dans la plupart des livres, et on ne la connaît pas au moment où le manifeste est lu.
import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'A book with no maths.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
// layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
// headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Text.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nText.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'I say—that is all.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verse' }] }, 7, { content: ':::paragraphs{style="verse"}\nA line.\n:::' });
// => { paragraphStyles: [{ id: 'verse' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'A well-known tale.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // today's rules: `config` itselfcontent est le Markdown que la configuration met en page (une chaîne ou une liste de chapitres). Sans lui, le corps des formules est épinglé dès que les formules sont activées, l’espace sous les conteneurs dès que la configuration déclare un style de paragraphe, et les deux espaces, les marques des titres, la place sous une ligne terminée par un deux-points, la coupure des encadrés, les coupures aux tirets, la coupure sous un titre et les coupures aux mots composés toujours, puisque le moteur ne peut pas savoir si le livre contient une formule, un conteneur :::paragraphs, une figure en ligne, un titre comportant une marque, une liste introduite par un deux-points, un encadré, un tiret collé, un titre ou un mot composé. La composition en drapeau est épinglée par la seule configuration, avec ou sans contenu. Migrez une configuration stockée une seule fois et stockez-la de nouveau sous CONFIG_VERSION : l’épinglage des formules multiplie l’échelle, si bien qu’une configuration migrée deux fois grandirait deux fois. La disposition des vers et les blocs de code délimités sont épinglés sans lui eux aussi, puisque le livre peut contenir un poème :::verse sans séparateur ou une clôture, et les retraits appariés sont épinglés par la seule configuration. Il en va de même des vers reportés de la 1.23, puisque le livre peut composer un poème ligne à ligne.
#Mettre en page et rendre un paquet
Quatre fonctions relient un paquet ouvert au moteur et aux sorties :
loadBundleFonts(bundle)enregistre les fontes du paquet dansdocument.fonts, et leurs octets dans le registre de polices du moteur pour les images SVG (registerFontBytes). Attendez sa fin avant la mise en page, car celle-ci mesure le texte avec les polices dont dispose le navigateur. Les familles que le paquet nomme sans les contenir (Google Fonts) restent à charger par vos soins, comme pour tout autre document.registerBundleImages(bundle)décode les images pour la sortie canvas (renderPage,renderToCanvas), affiches des vidéos comprises.bundleImageUrl(bundle)est le résolveurresourceImageUrlderenderToHtml, etbundleVideoUrl(bundle)son résolveurresourceVideoUrlpour les fichiers vidéo que contient un paquet. Les deux recolorent les figures SVG quanddiagramStyle.singleInkest activé, une seule fois : ils recolorent le balisage et marquent les images pour qu’aucune sortie ne les teinte de nouveau (voir Encre unique sur le canvas et en HTML). Les deux incorporent aussi dans chaque SVG les fontes que nomme son texte, d’abord à partir des polices propres du paquet, puis des fontes enregistrées auprès du moteur (voir Polices dans le texte SVG) ;registerBundleImages(bundle, { onWarning })etbundleImageUrl(bundle, { onWarning })signalent une famille sans fonte.buildBundle(bundle)met les chapitres en page dans l’ordre et renvoie unVDTDocumentpar chapitre. Chaque chapitre continue le précédent : compteurs de titres et de ressources, partie ouverte, parité et numérotation des pages. Un chapitre qui imprime la table des matières (:::toc) ou l’index (:::index) reçoit le plan du livre entier. Il accepte les mêmes options quebuildDocument, plusconfigpour remplacer la configuration du paquet,cachepour partager un cache de mesures etmetadata(voir plus bas).bundleResourceBytes(bundle)etbundleFontProvider(bundle, { decodeWoff2, fallback })sont les optionsresourceBytesetfontProviderdurenderToPdfdepostext-pdf. Le fournisseur de polices choisit dans le paquet la graisse la plus proche du style demandé. Pour une fonte.woff2, il a besoin dedecompressWoff2, et pour une famille que le paquet ne contient pas, il appellefallbackavec les arguments du moteur de rendu,requestcompris, et transmet ce qu’il renvoie. Une fonction de secours qui ne télécharge que le fichierlatind’une famille imprime sous forme de boîtes vides une famille chinoise que le paquet n’incorpore pas ; une fonction de secours qui répond avec des tranches, commesliceFontProviderdans Polices chinoises, japonaises et coréennes, l’imprime en entier.
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
const docs = buildBundle(bundle); // one VDTDocument per chapter
const firstPage = renderPage(docs[0].pages[0], docs[0]); // a <canvas>
const pdf = await renderToPdf(docs, { // the whole book
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});Pour mettre en page un seul chapitre vous-même, passez bundle.chapters[i].markdown, bundle.resources et bundle.config à buildDocument, comme pour n’importe quel document.
Les métadonnées du livre. Comme dans le Sandbox, l’en-tête de métadonnées (front matter) du premier chapitre est celui du livre : buildBundle transmet son title, son author et le reste à chaque chapitre, si bien que les titres courants {title} et {author} tiennent sur toutes les pages et que le doc.metadata de chaque chapitre les porte. Un bloc de métadonnées en tête d’un chapitre ultérieur est ignoré : il est repéré par ses lignes --- et effacé sans être analysé, si bien qu’un YAML que l’analyseur rejetterait ne fait aucun mal. options.metadata fournit les valeurs que l’en-tête de métadonnées ne fixe pas (l’en-tête l’emporte). Le nombre de pages du livre parvient aussi à chaque chapitre : {bookTotalPages} l’imprime, alors que {totalPages} compte les pages du chapitre (voir Nombre de pages du livre).
const docs = buildBundle(bundle, { metadata: { author: 'A. Author' } });
docs[3].metadata.title; // the first chapter's `title:`Où commence le livre. Un paquet peut contenir une partie d’une publication plus longue : les pages 58 à 61 d’un numéro, le chapitre 4 d’un manuel. Son start dit ce qui le précède, dans les termes de la continuation de buildDocument : les pages qui précèdent la première (pageIndexOffset, qui décide du côté où tombe la page 1, et avec lui des marges en miroir et des titres courants pairs et impairs), la numérotation des pages en vigueur (pageNumbering), les compteurs de titres (headings), la partie ouverte (part) et les compteurs de ressources, d’énoncés, de notes de bas de page et de lignes. createBundle l’écrit comme start de preset.json, openBundle le renvoie sous forme de bundle.start, et buildBundle met en page le premier chapitre avec lui, comme le ferait buildDocument({ markdown, continuation: start }, config), puis enchaîne les chapitres suivants à partir de là ; {bookTotalPages} compte aussi les pages qui précèdent le livre. Un manifeste sans start se lit comme avant, et un lecteur qui ne connaît pas ce champ l’ignore. bookPageCount n’en fait pas partie : c’est le lecteur qui compte les pages. Dans un paquet en plusieurs langues, localized[…].start donne à une édition son propre début.
const { bytes } = await createBundle({
name: 'Field notes, chapter 4',
markdown,
config,
// Page 58, an even page, in chapter 4.
start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start; // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel; // '58'#Exemple en direct : ouvrir un paquet
Le pen charge depuis le dépôt un livre d’exemple de deux chapitres (lantern.postext, avec son propre caractère, une figure SVG et un tableau). Il enregistre les polices et les images du paquet, met le livre en page avec buildBundle et peint chaque page. Make the PDF rend les mêmes documents avec postext-pdf, en incorporant les polices du paquet. Choisissez un fichier .postext à vous, exporté du Sandbox par exemple, pour le voir de la même façon.
import {
openBundle,
loadBundleFonts,
registerBundleImages,
buildBundle,
bundleResourceBytes,
bundleFontProvider,
renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
async function show(data) {
// Chapters, config (fonts wired to the bundle's own files), resources and
// every file, keyed by its path inside the bundle.
const bundle = await openBundle(data);
// Layout measures text with the fonts the browser has: register the
// bundle's faces, and load the Google Fonts it names but does not carry
// (the default running heads use Open Sans; see the pen's CSS).
await loadBundleFonts(bundle);
await document.fonts.load('600 16px "Open Sans"');
await registerBundleImages(bundle);
// One VDTDocument per chapter, each continuing the one before it.
const docs = buildBundle(bundle);
const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
document.getElementById('pages').replaceChildren(...pages);
status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
+ (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
current = { bundle, docs };
pdfButton.disabled = false;
document.getElementById('links').hidden = true;
}
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
const id = family.toLowerCase().replace(/\s+/g, '-');
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
pdfButton.addEventListener('click', async () => {
pdfButton.disabled = true;
status.textContent = 'Rendering the PDF…';
const { bundle, docs } = current;
const bytes = await renderToPdf(docs, {
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
pdfButton.disabled = false;
});
document.getElementById('file').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
status.textContent = `Opening ${file.name}…`;
await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());index.html
<p>
<label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
<button id="pdf" disabled>Make the PDF</button>
<span id="links" hidden>
<a id="open" target="_blank" rel="noopener">open it</a> ·
<a id="download" download="book.pdf">download it</a>
</span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#pages {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
#pages canvas {
display: block;
width: 240px;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Charge un éditeur interactif depuis codepen.io. L’exemple importe la dernière version de postext depuis un CDN.
#Créer un paquet
createBundle écrit un fichier .postext à partir d’un document : ses chapitres, sa configuration, ses ressources et les contenus auxquels elles font référence.
import { createBundle } from 'postext';
const { bytes, manifest, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters: [
{ markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
{ title: 'Night', markdown: '# Night\n\n…' },
],
config,
resources: [{
id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0, updatedAt: 0,
}],
files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});| Entrée | Signification |
|---|---|
name, id, description, locale | Les métadonnées du manifeste. id vaut par défaut une version normalisée (slug) de name. |
chapters ou markdown | Le livre, un { title?, markdown } par chapitre, ou un document unique. |
config | La PostextConfig. Les valeurs égales aux valeurs par défaut sont omises du manifeste. |
resources | Les ressources. Une image désigne son contenu par bitmap.fileId / svg.fileId (et svg.pdfFileId pour un original d’impression). |
files | Les contenus par fileId (un objet ou une Map) : les images auxquelles font référence les ressources et les fichiers de polices auxquels font référence les variantes de config.customFonts. Les valeurs peuvent être un Uint8Array, un ArrayBuffer, un Blob ou une chaîne (balisage SVG). |
thumbnail | { data, mime } : une image de couverture (PNG, JPEG, WebP, GIF ou SVG). |
canvasScope | 'book' demande aux visionneuses de mettre en page le livre entier sur un seul canvas. |
start | Où commence le livre, pour un paquet qui contient une partie d’un ouvrage plus long : la continuation avec laquelle un document unique serait construit. Écrit comme start du manifeste. |
mtime | La date de modification écrite sur chaque fichier de l’archive (une Date, un horodatage ou une chaîne de date). Omise, c’est l’heure de l’appel, si bien que deux appels avec la même entrée donnent des octets différents. Passez une date fixe et la même entrée donne les mêmes octets, que vous pouvez hacher ou comparer. Un zip conserve une date et une heure sans fuseau horaire, par pas de deux secondes, de 1980 à 2099, et la date est écrite dans l’heure locale de la machine. Pour des octets identiques sur toutes les machines, construisez la date à partir de champs locaux, comme new Date(1980, 0, 1) : un horodatage ou une chaîne finissant par Z désigne un instant, qui tombe sur une heure locale différente dans chaque fuseau horaire ('1980-01-01T00:00:00Z' est encore en 1979 à l’ouest d’UTC). Une date hors de ces années, en heure locale, lève une exception. |
localized | D’autres langues du même livre, par étiquette de langue : { es: { chapters?, config?, resources? } }. Les entrées ci-dessus sont alors le contenu de locale, qui devient obligatoire. Voir Paquets bilingues. |
Il renvoie les bytes de l’archive, le manifest écrit sous forme de preset.json, chaque fichier dans files (chemin → octets) et une liste de warnings. Les fichiers sont nommés d’après l’identifiant de leur ressource (resources/lantern.svg) ou le nom de leur fichier de police (fonts/…), et les chapitres d’après leur ordre et leur titre (chapters/01-dusk.md). Les polices sont déclarées dans les fonts du manifeste, jamais dans config.customFonts. Certains éléments sont laissés de côté, chacun avec un avertissement :
- une ressource ou une fonte dont le contenu n’est pas dans
files - une fonte
.woff(la sortie PDF ne sait pas l’incorporer) - une famille marquée
redistributable: false
Dans le navigateur, passez bytes à un lien de téléchargement : URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })). Sous Node, écrivez-les avec fs.writeFile. createBundle et openBundle n’ont pas besoin de DOM. Les dist utilisent des chemins de modules sans extension : sous Node seul, sans bundler, il leur faut un hook de résolution. Le fichier docs/examples/open-bundle/build-sample.mjs du dépôt en montre un en quelques lignes.
#Paquets bilingues
Un fichier .postext peut contenir un livre en plusieurs langues, et openBundle(bytes, { locale }) le lit dans n’importe laquelle d’entre elles. createBundle en écrit un à partir de localized : une entrée par langue supplémentaire, chacune avec ce qui diffère du contenu principal (l’entrée locale) :
const { bytes, manifest } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
config,
resources: [lanternFigure, hoursTable],
files: { 'lantern.svg': svgEn, 'lantern-es.svg': svgEs },
localized: {
es: {
chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
config: { headings: { levels: [{ level: 1, numberingTemplate: 'Capítulo {1}' }] } },
resources: [
{ id: 'lantern', caption: 'El farol.', svg: { fileId: 'lantern-es.svg', width: 240, height: 150 } },
{ id: 'hours', caption: 'Horas de luz.' },
],
},
},
});
const es = await openBundle(bytes, { locale: 'es' }); // Spanish chapters, config and captionschapters: le livre dans cette langue. Les fichiers des chapitres vont dans un dossier par langue (chapters/en/01-dusk.md,chapters/es/01-anochecer.md) et leschaptersdu manifeste deviennent une table langue → chapitres. Une langue sanschapterslit les chapitres principaux ; quand aucune langue n’a ses propres chapitres, ceux-ci restent une liste unique.config: la configuration pour cette langue. Chaque clé de premier niveau remplace en bloc la clé partagée quand le paquet est lu dans cette langue :headings, ci-dessus, remplace tout l’objetheadings. Les clés omises, ou égales aux clés partagées, sont partagées et ne sont pas écrites : passer la configuration complète de la langue fonctionne donc aussi bien que passer les quelques clés qui changent. Une clé fixée à ses valeurs par défaut alors que la clé partagée ne l’est pas (layout: {}) est écrite telle quelle, et réinitialise donc la valeur partagée. Les polices sont partagées : les familles ducustomFontsd’une langue rejoignent lesfontsdu paquet.resources: le texte des ressources partagées, apparié parid:caption,note,altTextet letabled’un tableau. Une image qui contient du texte peut avoir sa propre illustration :bitmap.fileIdousvg.fileId(etsvg.pdfFileId) désignent un autre contenu dansfiles, écrit sousresources/es/lantern.svg. Les autres champs, comme le type ou le placement, sont partagés. Un identifiant qui ne figure pas dansresourcesest laissé de côté avec un avertissement, et une image de langue absente laisse la langue sur l’image partagée, avec un avertissement également.
Le manifeste liste toutes les langues dans locales (['en', 'es']), garde la langue principale comme locale et range les autres sous localized. openBundle sans langue lit la langue principale.
Quelle langue reçoit un lecteur. openBundle(bytes, { locale }) sert la langue exacte, sinon sa langue de base (es-MX lit es), sinon la langue principale, et bundle.locale indique celle qu’il a servie. Les chapitres et le texte viennent toujours de la même langue. La langue principale garde le texte partagé même quand localized contient une variante régionale de celle-ci : un paquet pt-PT avec une entrée pt-BR lit les légendes brésiliennes pour pt-BR seulement, et les légendes partagées pour pt-PT et pt.
#Exemple en direct : créer un paquet
Le pen construit un livre de deux chapitres avec une figure SVG et liste les fichiers que createBundle a écrits, ainsi que le manifeste. Il propose l’archive en téléchargement, puis la rouvre avec openBundle et peint sa première page : l’aller-retour complet en quelques lignes. Importez le fichier téléchargé dans le Sandbox pour continuer à y travailler.
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
<rect width="240" height="150" fill="#f3efe6"/>
<path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
<rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
<circle cx="120" cy="79" r="11" fill="#fff4c2"/>
<path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
const resources = [{
id: 'lantern',
typeId: 'figure',
kind: 'svg',
caption: 'The lantern by the door.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0,
updatedAt: 0,
}];
const text = 'The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.';
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
{ markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
{ markdown: `# Night\n\n${text}\n\n${text}` },
];
const config = {
layout: { layoutType: 'double' },
// Two short chapters that run on, with no blank verso between them (an
// H1 otherwise opens on a fresh recto), as in the open-bundle sample.
headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters,
config,
resources,
files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
const li = document.createElement('li');
li.textContent = `${path} (${data.length} B)`;
return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
// Round trip: open the file just written, the way any program would.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
`${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
<a id="download" download="lantern.postext">Download lantern.postext</a> ·
<a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
<section>
<h3>Files in the bundle</h3>
<ul id="files"></ul>
<h3>preset.json</h3>
<pre id="manifest"></pre>
</section>
<section>
<h3>Opened again: page 1</h3>
<div id="page"></div>
</section>
</div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#output {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
#output section {
flex: 1 1 280px;
min-width: 0;
}
h3 {
margin: 8px 0;
font-size: 14px;
}
ul {
margin: 0;
padding-left: 20px;
font-family: ui-monospace, monospace;
font-size: 13px;
}
pre {
max-height: 320px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 12px;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Charge un éditeur interactif depuis codepen.io. L’exemple importe la dernière version de postext depuis un CDN.
#Travailler avec des paquets
Comme le Sandbox, le skill d’agent et le paquet postext lisent et écrivent tous le même fichier, un fichier .postext est un moyen commode de faire passer un livre d’un outil à l’autre :
- Partir d’un paquet. Portez une publication existante avec le skill d’agent, ou concevez un livre dans le Sandbox et exportez-le (Télécharger (.postext) dans le menu ⋯ de sa ligne, dans le panneau Livres). Chargez le fichier depuis votre programme avec
openBundlepour le rendre en canvas, en HTML ou en PDF. Gardez le fichier comme source du livre : modifiez les chapitres, la configuration ou les ressources dans le code et réécrivez-le aveccreateBundle, ou rechargez-le simplement chaque fois qu’il change. - Déboguer et peaufiner dans le Sandbox. Quand quelque chose dans la sortie de votre programme demande du travail (une figure qui tombe sur la mauvaise page, un style de titre, l’équilibrage des colonnes), exportez avec
createBundlece que votre programme met en page. Importez ce fichier dans le Sandbox (Livres → Nouveau → Ouvrir un fichier .postext…), corrigez le texte, la maquette ou les figures avec l’aperçu en direct, le panneau Vérifications et la vue PDF, puis exportez-le à nouveau. Votre programme charge alors le fichier corrigé avecopenBundle. Vous pouvez aussi reporter dans votre code ce qui a changé : laconfigdu manifeste ne contient que les valeurs qui diffèrent des valeurs par défaut, et se lit donc comme un court diff.
#API de bas niveau
postext/bundle exporte aussi les briques qui se trouvent derrière openBundle et createBundle, pour les hôtes qui stockent ou servent les paquets à leur façon (un répertoire décompressé servi en HTTP, des enregistrements dans une base de données) :
openBundleZip(bytes)/zipBundle(files, { mtime }): la couche d’archive. L’ouverture tolère un dossier de premier niveau et ignore les entrées__MACOSXet les fichiers cachés. Les chemins qui sortent du paquet sont refusés.mtimedate les fichiers comme le fait l’entrée decreateBundle.readBundle(manifest, readFile, options)lit un manifeste, avec un rappelreadFile(path), et en tire chapitres, configuration, ressources, images et polices.optionsfixe la langue, la façon de nommer les identifiants de fichiers (ids), la configuration de base (baseConfig, sous celle du manifeste ; par défaut la palette et les types de ressources debundleBaseConfigdans la langue du paquet, que renvoieresolveBundleConfigLocale(manifest, locale), et un hôte qui passe son proprebaseConfigdevrait le localiser dans cette langue ; sous un manifeste antérieur àconfigVersion: 4, sonmathest épinglé avec celui du paquet, sous un manifeste antérieur à 5 l’espace en ligne de sonlayout, sous un manifeste antérieur à 6 l’espace dans les encadrés de sonlayout, la place sous une ligne terminée par un deux-points de sonbodyText, les marques en ligne de sesheadingset les tailles de ses lettrines, sous un manifeste antérieur à 7 les coupures aux tirets et la composition en drapeau de sonbodyText, et sous un manifeste antérieur à 8 la coupure sous un titre de sesheadingset les coupures aux mots composés et l’espace sous les conteneurs:::paragraphsde sonbodyText; voir Paquets écrits par postext 1.4 ou antérieur) et la façon de mesurer les tailles intrinsèques.readResolutionlit la résolution de chaque bitmap dans son fichier et la place dansbitmap.fileResolution; il vaut true par défaut quand le paquet fixelayout.bitmapResolution: 'file'.planBundle(meta, content)/resolveBundleFiles(plan, sources): le côté écriture, divisé en un plan pur (noms de fichiers et manifeste) et la résolution des octets au moyen des rappelsreadBlob/readFont.isBundleManifest(value), les sélecteurs de langue (pickChapterSpecs,pickLocaleOverrides,pickBundleView,resolveBundleLocale,resolveBundleConfigLocale),svgSize/bitmapSize/bitmapInfo(les pixels d’un bitmap et la résolution qu’indique son fichier), et les types du format (BundleManifest,BundleResourceSpec,BundleFontFamilySpec, …).CONFIG_VERSION,migrateConfig(config, configVersion, { content }),pinLegacyHeadingBreaks(config),pinLegacyMathSize(config),pinLegacyInlineGap(config),pinLegacyBoxResourceGap(config),pinLegacyHeadingMarks(config),pinLegacyDropCapSize(config),pinLegacyColonListRoom(config),pinLegacyBoxChildCut(config),pinLegacyDashBreaks(config),pinLegacyRaggedBreaking(config),pinLegacyHeadingSplit(config),pinLegacyParagraphContainerSpacing(config),pinLegacyHyphenBreaks(config)etLEGACY_MATH_SIZE(0,5 ÷ 0,442) : une configuration stockée exprimée dans les termes actuels (voir Paquets écrits par postext 1.4 ou antérieur).readBundlel’applique ; un hôte qui stocke les configurations à sa façon peut le faire aussi, une fois par copie stockée.
Le Sandbox repose sur ces fonctions. Il y ajoute ses propres identifiants de stockage et les enregistrements de pages layouts.json.