Aller au contenu principal

Chapitre 3.17 · Partie II · Le métier

Utilisation programmatique : fichiers PDF

postext-pdf depuis le code : installation, API, fournisseurs de polices dans le navigateur et dans Node, polices CJK, octets des ressources, texte vertical, workers et fichiers prêts pour l’impression

Mis à jour 2026-10-1622 minDans le chapitre 3 Configurationenescadefritptzhjaar

En bref

Cette page s’adresse aux personnes qui écrivent du code et veulent un fichier PDF. Elle montre comment installer le paquet PDF et transformer en quelques lignes un livre mis en page en fichier. Le paquet a besoin des fichiers de police : la page explique comment les lui fournir, dans un navigateur ou sur un serveur. Elle traite des polices pour le chinois, le japonais et le coréen, des images, du texte écrit de haut en bas et des liens. Elle se termine par un exemple complet et par des fichiers prêts pour un imprimeur.

#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. renderToPdf est 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 que renderToPdf.

#Installation

npm install postext postext-pdf

postext 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 un VDTDocument (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[]> que renderToPdf utilise pour demander les octets d’une police quand il doit incorporer une nouvelle combinaison famille/graisse/style. request.codePoints contient 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-range de sa règle @font-face et sa place dans l’ordre des déclarations. Un PdfFontFile est un Uint8Array doté 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, accessible et colorSpace reprennent le pdfGeneration du document (voir Génération PDF (configuration)). resourceBytes est décrit dans Octets des ressources et originaux d’impression ; onWarning dans Quelles fontes sont demandées au fournisseur et dans Avertissements dans le document. characterGrid: true imprime la grille que cjk.grid.show dessine à l’écran et que le PDF omet sinon (voir Grille de caractères). harfbuzzWasm indique où charger le harfbuzz.wasm de 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. print reçoit les réglages de production pour l’impression (norme PDF/X, profil de sortie, noir, contrôle en amont) et reprend sinon le print du document ; avec une norme PDF/X, ou colorSpace: 'cmyk', chaque couleur est séparée au moyen du profil ICC de sortie, dont outputProfile fournit les octets (sinon il est téléchargé depuis profileBaseUrl, par défaut la copie du dossier icc/ de postext sur le CDN de npm). date (une Date, un temps en millisecondes ou une chaîne de date, comme le mtime de createBundle) 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ême date donne 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é par onWarning ; distinguez-le par kind : '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é (reason liste 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 à un onWarning que 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 que pdf-lib sait incorporer directement.
  • createPdfWorker(options?), depuis postext-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 liste font-family du 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 :

  1. 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 ;
  2. 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 de weight: 600 sur 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 bytesCache au 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, et renderToPdf le signale par variableFontDefaultInstance. 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 avec pyftsubset.
  • Utilisez les versions TrueType. Les fichiers .otf de 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 par cffEmbeddedWhole. 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 .otf JP de 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 OpenType JAN , si bien que la fonctionnalité locl de 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 un Span avec son /Lang. Avec bodyText.shapingLanguage: 'document', la valeur par défaut depuis postext 1.30, chaque document et chaque isolat est façonné dans sa propre langue (ZHS pour 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 formes JAN .

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 text composé 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 depuis fontProvider (sauf si diagramStyle.inlineFonts ou le svg.inlineFonts de la ressource vaut false) ;
  • 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 OpenType vert et fwid, 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 /ActualText contenant son texte, si bien que la copie et l’extraction de texte la lisent telle qu’elle est écrite. pdftotext et 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 page WritingMode /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.

Postext · générer un PDF dans le navigateur
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 de renderToPdf, avec deux différences. resourceBytes est une Map<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’Image et 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 un Worker que vous créez vous-même, pour les outils de build qui contrôlent les URL des workers. Ce worker doit exécuter postext-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' (ou pdfGeneration: { 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 } dans RenderToPdfOptions : 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).