Chapitre 3.18 · Partie II · Le métier
Utilisation programmatique : HTML, Folio et EPUB
Le livre mis en page dans une page web, en livre 3D avec postext-folio, et en EPUB 3 à mise en page fixe ou recomposable avec postext-epub
En bref
Cette page s’adresse aux personnes qui écrivent du code et veulent montrer un livre à l’écran. La première section place les pages dans une page web et explique en quoi cette vue diffère du canvas et du PDF. La deuxième montre le livre en 3D, ouvert sur un bureau, avec des pages que le lecteur tourne. La dernière section produit un livre numérique EPUB, à pages fixes ou avec un texte qui s’adapte à l’écran, et vérifie le fichier.
#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.) | 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.
#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.