Aller au contenu principal
Recette numéro 26

Recettes · Chapitre 10 · Sortie et intégration

Un spécimen aux polices chargées avant la mise en page

Spécimen de quatre pages construit une fois trop tôt, pour preuve, puis avec chaque police voulue par la configuration et le texte chargée avant toute mesure.

  • Exemple en anglais : pas encore d’édition française
  • Format 180 × 240 mm
  • 1 colonne
  • Ysabeau Office 11/15,5
  • IBM Plex Mono
  • Noto Serif Display
  • 4 pages
  • Niveau
  • Postext 1.29.1
  • Mis en page en 6 ms
  • 188 lignes de code

En bref

Un livret de quatre pages qui présente trois caractères. Il montre pourquoi toutes les polices doivent finir de charger avant la mise en page des pages, sinon les lignes se coupent aux mauvais endroits.

Ce que vous allez composer

House Specimen Nº 3, de l’imaginaire Pellow Lane Press, est un spécimen typographique de quatre pages pour trois caractères. Sur la couverture, un aplat outremer porte un Ag blanc en Noto Serif Display italique de 240 pt et deux lignes en chasse fixe qui nomment les trois caractères ; le titre se trouve sous l’aplat. La page 2 déroule Ysabeau Office en cascade de 7 à 14 pt, puis compose des pangrammes espagnol, polonais et tchèque dont les ż, ř et ů demandent un second fichier de police. La page 3 est l’audit : un tableau en IBM Plex Mono, rempli par le pen, des dix polices que déclare le livret, des six dans lesquelles ses pages sont composées et des fichiers que le navigateur a récupérés pour chacune. La page 4 compose deux fois le premier paragraphe de la page 1, d’après une construction précoce et d’après la dernière. La construction précoce a tourné avant que le navigateur ait le moindre fichier de police : ses lignes se terminent à d’autres endroits et certaines dépassent la justification.

Cette recette répond à

  • Pourquoi mes coupures de ligne changent-elles, ou des mots se chevauchent-ils dans le PDF, et comment charger correctement les polices ?
  • Comment utiliser mes propres polices (de marque ou sous licence) dans la mise en page et les incorporer au PDF ?
  • Comment savoir ce qui ne va pas dans mon document (avertissements, débordements, mise en page qui ne converge pas) ?

La réponse courte

script.js · lignes 314–331dans le code complet
// buildDocumentWithFonts starts with prepareFonts: it reads the config and the text for every
// face they ask for (text, headings, chips, table, captions, opener, running heads), in each
// weight and slant, and loads them for the characters the document sets. The page declares its
// files (declareFaces), so they load through document.fonts; a face nothing declares would be
// asked of a resolve(family, weight, style) option. Then it builds, reads the faces the pages
// are set in, loads any that the config did not name and builds again.
let report; // { loaded, missing, synthesized }: lists of { family, weight, style }
const build = () => buildDocumentWithFonts(content(), config(),
  { onFonts: (found) => { report = found; } }); // what the last look at the pages found

kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
const early = buildDocument(content(), config()); // on purpose: the page has no font yet (page 4)
declareFaces(FONTS);
const fitted = await build(); // every face loaded before a line is measured
audit = auditOf(fitted, report); // page 3's table
drawProof(early, fitted); // page 4's picture
const doc = await build(); // nothing left to load: the same pages, table and picture filled in
showPages(doc, { title: 'Load every font before layout' });

Ingrédients

Typographie
Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Déclarer chaque fichier sans le récupérer

script.js · lignes 268–287dans le code complet
const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS
  latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
    + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
  'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
    + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,'
    + 'U+A720-A7FF' };
const FILES = new Map(); // FontFace → the subset its file covers, for the audit
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
  for (const { family, weight, style } of facesOf(fonts)) {
    const id = family.toLowerCase().replaceAll(' ', '-');
    for (const [subset, unicodeRange] of Object.entries(SUBSETS)) {
      const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
      // Adding a face fetches nothing: the file downloads when a load or a line needs it.
      const face = new FontFace(family, `url(https://cdn.jsdelivr.net/npm/@fontsource/${file})`,
        { weight: `${weight}`, style, unicodeRange });
      FILES.set(face, subset);
      document.fonts.add(face);
    }
  }
}

Un FontFace est un fichier, comme une règle @font-face : une police qui a un fichier latin et un fichier latin-ext se déclare avec deux objets FontFace qui partagent famille, graisse et style et diffèrent par unicodeRange, recopié du CSS de la famille. Les ajouter à document.fonts ne télécharge rien : le navigateur récupère un fichier quand un appel à load() ou une ligne de texte a besoin de ses caractères. Postext charge les polices qu’une page déclare par document.fonts ; une police que rien ne déclare est demandée à l’option resolve, et c’est ainsi que les autres recettes obtiennent leurs fichiers Fontsource auprès du kit. customFonts dans une configuration décrit seulement les fichiers d’un paquet .postext : une page construite à partir de Markdown déclare les siennes ou fournit un resolve.

#2 · Charger les polices, puis construire

Le code est la réponse courte plus haut. buildDocumentWithFonts commence par prepareFonts, qui lit dans la configuration et dans le texte chaque police qu’ils demandent, dans chaque graisse et chaque inclinaison, et les charge avec les caractères du document comme échantillon : les fichiers latin-ext arrivent ainsi avec les pangrammes polonais et tchèque (Charger les polices avant la mise en page). Il construit ensuite, lit les polices dans lesquelles les pages sont composées, charge celles que la configuration ne nommait pas et reconstruit. onFonts reçoit le rapport de ce dernier examen : les polices trouvées loaded, celles qui sont missing, et celles que le navigateur aurait synthesized à partir d’une autre graisse ou inclinaison. Le pen construit trois fois parce que le livret imprime sa propre preuve : une fois trop tôt, une fois avec les polices, et une dernière fois avec le tableau et l’image remplis.

#3 · Construire l’audit à partir du rapport

script.js · lignes 291–309dans le code complet
const SET = { loaded: 'yes', synthesized: 'faked', missing: 'in a fallback' }; // the report
const nameOf = (f) => `${f.family.replaceAll('"', '')} ${f.weight} ${f.style}`; // or a FontFace
function auditOf(doc, report) {
  const found = Object.keys(SET).flatMap((kind) => report[kind].map((f) => [nameOf(f), kind]));
  const inPages = new Map(found);
  const loaded = [...FILES].filter(([face]) => face.status === 'loaded');
  const faces = new Map([...facesOf(FONTS), ...Object.keys(SET).flatMap((kind) => report[kind])]
    .map((f) => [nameOf(f), f])); // the declared faces, then any the pages set without a file
  const rows = [...faces].map(([name, f], i, all) => [
    i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
    `${f.weight}${f.style === 'italic' ? ' italic' : ''}`, SET[inPages.get(name)] ?? 'no',
    loaded.filter(([face]) => nameOf(face) === name).map(([, subset]) => subset).join(' · ')
      || 'none'].map((cell) => ({ content: cell })));
  // What else to read in a finished layout: its content warnings, its passes, its warnings.
  return { rows, note: `${report.loaded.length} of ${rows.length} faces set in the pages · `
    + `${loaded.length} of ${FILES.size} files fetched · fontFallback warnings: `
    + `${fallbacksIn(doc) || 'none'} · ${doc.converged ? '' : 'not '}converged · layout warnings: `
    + `${doc.warnings?.length || 'none'}` };
}

Le tableau part des polices que déclare FONTS et ajoute ce que le rapport dit de chacune : employée dans les pages, imitée par le navigateur ou composée en police de secours. La dernière colonne liste les fichiers dont le FontFace a atteint loaded. Seize des vingt ont été récupérés : Postext charge les quatre polices de la famille du texte, puisque ** et * peuvent les demander dans n’importe quel paragraphe, et les deux italiques en chasse fixe, qu’aucun réglage ne nomme, n’ont jamais été téléchargées. La note lit trois autres choses dans le document fini : contentWarnings, où une police mesurée en police de secours apparaît comme fontFallback ; converged, vrai quand les passes de la mise en page se sont stabilisées ; et warnings. Vérifiez les trois avant d’afficher ou d’exporter une page.

#4 · Garder une construction précoce comme preuve

script.js · lignes 126–174dans le code complet
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
  width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
  height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0, faces: 0 }; // lines the early build broke elsewhere, of all
const fallbacksIn = (doc) => // a build lists each face its font set could not give
  (doc.contentWarnings ?? []).filter((w) => w.kind === 'fontFallback').length;
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, placement: here,
  bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
  caption: 'The first paragraph of page 1 as the early build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The early build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere, and its own warnings name `
    + `${proof.faces} faces set in a fallback. The rule marks the measure.`,
  altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
    + 'break in other places and some run past a vertical rule; in the lower one every line '
    + 'stops short of it.' });
function drawProof(first, last) {
  const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
    .map((b) => b.lines.map((l) => l.text));
  const [before, after] = [first, last].map(linesOf);
  proof.total = before.flat().length;
  proof.faces = fallbacksIn(first);
  proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
  const canvas = Object.assign(document.createElement('canvas'), PROOF);
  const ctx = canvas.getContext('2d');
  const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
  const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
  // The renderer clips each column 2 pt past its edge, which would cut the early build's lines
  // at the measure: paint a copy of page 1 whose column reaches across the whole strip.
  const wide = (column) => ({ ...column,
    bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
  [first, last].forEach((doc, i) => {
    const page = document.createElement('canvas');
    renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
      { scale: 2 }); // 300 dpi
    const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
    ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
      0, i * (PROOF.height - strip), PROOF.width, strip);
  });
  ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
  ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
  ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
  ctx.fillRect(edge, 0, 2, PROOF.height);
  ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
  ['early', 'last'].forEach((name, i) => // on the last line of each strip
    ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
  registerResourceImage('proof.png', canvas);
}

Le pen appelle buildDocument une fois avant de déclarer la moindre police : cette construction mesure donc chaque mot dans la police de secours du navigateur. Une fois les vraies polices en place, le pen la peint de nouveau et la compare ligne par ligne avec la dernière construction ; le nombre de lignes de la légende vient de cette comparaison, et le nombre de polices des propres avertissements fontFallback de la construction précoce. Une ligne simple mesurée dans une police de secours plus étroite dépasse la justification jusqu’au masque de la colonne, à 2 pt de son bord, qui coupe ses dernières lettres : le pen peint donc la page 1 avec une colonne élargie pour montrer tout le dépassement. Le canvas et postext-pdf dessinent chaque segment d’une ligne justifiée ou à styles mêlés là où la mise en page l’a mesuré : une vraie police plus large que la police de secours s’imprime par-dessus le mot suivant, et les mots d’un PDF construit avant l’arrivée de ses polices se chevauchent. Rien de la construction précoce ne passe à la suivante : prepareFonts trouve les nouvelles polices dans document.fonts et abandonne les largeurs mesurées dans leurs familles, si bien qu’aucun appel à clearMeasurementCache() n’est nécessaire.

#5 · Une cascade au rythme du texte

script.js · lignes 30–44dans le code complet
const bodyText = () => ({
  fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
  ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
  { id: 'pangram', ...line(13) },
  { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];

Ysabeau Office compose le texte en 11 sur 15,5 pt, avec une ligne blanche entre les paragraphes au lieu d’un alinéa. Le texte est en drapeau : chaque espace entre les mots garde sa largeur naturelle. Le gras, l’italique et les renvois sont composés à l’encre ; laissés tels quels, ils prendraient la couleur principale. Chaque corps de la cascade et chaque pangramme est un style de paragraphe haut de deux interlignes (31 pt) : la page garde le rythme de 15,5 pt du texte. Les étiquettes de corps sont des pastilles en chasse fixe, et les corps à un chiffre reçoivent une demi-lettre de marge intérieure de chaque côté, pour que chaque échantillon commence au même bord.

#6 · Trois caractères sur une couverture

script.js · lignes 48–74dans le code complet
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
  end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // a running head wraps too, never '…'
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // the cover is a recto
  span: 'page', // the heading is set across the page; its field hangs from the bleed
  advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
    slot: { elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
        anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
      text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
        at(MARGIN.inner, Y.kicker)),
      text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
        color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
      text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
      text('faces', '{attr.faces}', { ...label, color: col('mist') },
        { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
      text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
        color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
      text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
        lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
        offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
    ] } } });

La couverture est la maquette du niveau H1 : les lettres de l’attribut glyphs du titre dans le caractère de titrage à 240 pt, et dessous deux lignes en chasse fixe qui nomment ce caractère ainsi que ceux du texte et des étiquettes, comme sur la feuille de spécimen d’une fonderie. span: 'page' étend le titre sur toute la page, et l’aplat pend de l’angle supérieur gauche du fond perdu : il sort par le bord du haut. minHeight se compte depuis la marge du haut et réserve la place jusqu’à Y.end, 42 mm sous l’aplat ; sans lui, le texte commence une ligne plus haut, serré sous le chapeau.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 026 · Type specimen with every font loaded before layout ═════════
// https://postext.dev/en/cookbook/fonts-before-layout
// Code: MIT · Text: original (CC BY 4.0) · Picture: cut from the pen's own early and last builds
// Fonts: Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import { buildDocument, buildDocumentWithFonts, renderPageToCanvas, defaultResourceTypes,
  registerResourceImage } from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'fonts-before-layout';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const PAGE = { width: 180, height: 240 }; // mm
const MARGIN = { top: 22, bottom: 24, inner: 20, outer: 48 }; // mm: inner is the spine side
const MEASURE = PAGE.width - MARGIN.inner - MARGIN.outer; // 112 mm: about 70 letters at 11 pt
const FIELD = 122; // mm from the top edge: the ultramarine field of the opener
const LEAD = 15.5; // pt: the body leading and the step of every vertical space
const DPI = 150; // the page's resolution: the proof on page 4 is cut at twice this
const [TEXT, DISPLAY, MONO] = ['Ysabeau Office', 'Noto Serif Display', 'IBM Plex Mono'];
const palette = { ink: '#16161a', ultramarine: '#3246d3', mist: '#c9d0f6', // mist: 4.6:1 on
  rule: '#cfc9bd', muted: '#6b6a70', paper: '#ffffff' }; // ultramarine, for labels on the field
// Every colour is linked to its palette entry; main-color catches any default left unstated.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => Object.entries({ ...palette, 'main-color': palette.ultramarine })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const label = { fontFamily: MONO, fontSize: pt(7.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase' };
const display = { fontFamily: DISPLAY, fontWeight: 900, italic: true };

// #region type: the text face at 11 on 15.5 pt, and a waterfall of it on two leads a line
const bodyText = () => ({
  fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
  ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
  { id: 'pangram', ...line(13) },
  { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];
// #endregion

// #region opener: the H1 as a bleed field, the display face at 240 pt, labels naming the faces
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
  end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // a running head wraps too, never '…'
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // the cover is a recto
  span: 'page', // the heading is set across the page; its field hangs from the bleed
  advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
    slot: { elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
        anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
      text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
        at(MARGIN.inner, Y.kicker)),
      text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
        color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
      text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
      text('faces', '{attr.faces}', { ...label, color: col('mist') },
        { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
      text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
        color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
      text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
        lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
        offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
    ] } } });
// #endregion

// Running heads at the outer edge of the text; a drop folio there too on the opener (a recto).
const HEADS = { top: 13, bottom: 12 }; // mm from the top and the bottom edge of the page
const head = (id, content, parity, edge, x, pages = 'body') => text(id, content, { parity,
  pages, fontFamily: MONO, fontSize: pt(7.5), color: col('muted') }, { anchor: { to: 'page',
  edge }, offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEADS.top : -HEADS.bottom) } });
const header = () => ({ elements: [
  head('verso', '{pageNumber} · {title}', 'even', 'top-left', MARGIN.outer),
  head('recto', '{chapterTitle} · {pageNumber}', 'odd', 'top-right', -MARGIN.outer)] });
const footer = () => ({ elements: [
  head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -MARGIN.outer, 'opener')] });

const config = () => ({ locale: 'en-us', // the sample is in English
  // "Table 1", not "Table 1.1": the booklet has one chapter. Table captions sit above.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
  colorPalette: colorPalette(), layout: { layoutType: 'single' },
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: DPI,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: { ...bodyText(), paragraphContainerSpacing: 'add' }, // samples stay two leads apart
  paragraphStyles: paragraphStyles(), chipStyles: chipStyles(),
  headings: { fontFamily: DISPLAY, fontWeight: 900, color: col('ink'), levels: [cover(),
    { level: 2, fontSize: pt(16), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }] },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ultramarine'), headerColor: col('paper'), headerFontFamily: MONO,
    headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(7.5), cellPadding: mm(1) },
  captionStyle: { fontFamily: MONO, fontSize: pt(7.5), labelColor: col('ultramarine'),
    note: { color: col('muted') } },
  header: header(), footer: footer(),
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The table and the picture come from the builds themselves (section 4).
let audit = { rows: [], note: '' };
const content = () => ({ markdown, resources: resources() }); // read again for each build
const here = { position: 'here' }; // both sit where ::resource puts them
const resources = () => [
  { id: 'faces', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: here,
    caption: 'Every face this booklet declares: whether Postext found it set in the pages, and '
      + 'the files the browser fetched for it.', note: audit.note,
    table: { model: { headerRowCount: 1, columnWidths: [31, 19, 28, 34], rows: [
      ['Family', 'Face', 'In the pages', 'Files fetched']
        .map((cell) => ({ content: cell, isHeader: true })),
      ...audit.rows] } } },
  proofFigure(), // drawn from the builds just below
];

// #region art-proof: page 1's first paragraph from the early build, over the same from the last
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
  width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
  height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0, faces: 0 }; // lines the early build broke elsewhere, of all
const fallbacksIn = (doc) => // a build lists each face its font set could not give
  (doc.contentWarnings ?? []).filter((w) => w.kind === 'fontFallback').length;
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, placement: here,
  bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
  caption: 'The first paragraph of page 1 as the early build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The early build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere, and its own warnings name `
    + `${proof.faces} faces set in a fallback. The rule marks the measure.`,
  altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
    + 'break in other places and some run past a vertical rule; in the lower one every line '
    + 'stops short of it.' });
function drawProof(first, last) {
  const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
    .map((b) => b.lines.map((l) => l.text));
  const [before, after] = [first, last].map(linesOf);
  proof.total = before.flat().length;
  proof.faces = fallbacksIn(first);
  proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
  const canvas = Object.assign(document.createElement('canvas'), PROOF);
  const ctx = canvas.getContext('2d');
  const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
  const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
  // The renderer clips each column 2 pt past its edge, which would cut the early build's lines
  // at the measure: paint a copy of page 1 whose column reaches across the whole strip.
  const wide = (column) => ({ ...column,
    bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
  [first, last].forEach((doc, i) => {
    const page = document.createElement('canvas');
    renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
      { scale: 2 }); // 300 dpi
    const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
    ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
      0, i * (PROOF.height - strip), PROOF.width, strip);
  });
  ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
  ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
  ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
  ctx.fillRect(edge, 0, 2, PROOF.height);
  ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
  ['early', 'last'].forEach((name, i) => // on the last line of each strip
    ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
  registerResourceImage('proof.png', canvas);
}
// #endregion

const markdown = String.raw`---
Exemple Markdown · 77 lignes · content.en.mdtitle: "House Specimen" author: "Pellow Lane Press" --- # Three faces, proofed {kicker="Pellow Lane Press · House specimen Nº 3" lead="Our text, display and label faces at work, and proof that each of them had arrived before these lines were set." glyphs="Ag" label="Noto Serif Display 900 italic · 240 pt" faces="Text: Ysabeau Office · Labels: IBM Plex Mono"} A compositor in a metal shop could only set a line in a face that was in the case. Postext measures every word with the fonts the browser holds at that moment, so a page built a second too early is broken for a fallback face. These pages were built three times: once too early, on purpose, with no font file in the browser; again once Postext had loaded every face the design asks for; and a last time to print their list on page 3 and, on page 4, what the early build got wrong. ## Seven sizes of the text face Ysabeau, drawn by Christian Thalmann, carries the letterforms of the Garamond tradition into a low-contrast sans serif. Its Office cut sets tabular lining figures and a level hyphen by default. :::paragraphs{style="s7"} :chip[7 pt]{style="size-1"} Credits and map legends, where small print needs open counters. ::: :::paragraphs{style="s8"} :chip[8 pt]{style="size-1"} Captions and table notes, where the tabular figures keep 1,048 and 2,096 in step. ::: :::paragraphs{style="s9"} :chip[9 pt]{style="size-1"} A reference column set close; the long ascenders keep the lines apart. ::: :::paragraphs{style="s10"} :chip[10 pt]{style="size"} Notes and asides, a size below the text they sit beside. ::: :::paragraphs{style="s11"} :chip[11 pt]{style="size"} The text of this booklet, eleven on fifteen and a half. ::: :::paragraphs{style="s12"} :chip[12 pt]{style="size"} A standfirst, or a first reader for children. ::: :::paragraphs{style="s14"} :chip[14 pt]{style="size"} A heading, or a line on a poster. ::: ## Beyond Latin-1 Spanish needs nothing beyond the latin file of each face. Polish and Czech need more: ż, ł, ř and ů live in a second file, latin-ext, which the browser fetches only when a load or a line asks for those letters. :::paragraphs{style="pangram"} :chip[es]{style="size"} El veloz murciélago hindú comía feliz cardillo y kiwi. :chip[pl]{style="size"} Zażółć gęślą jaźń. :chip[cs]{style="size"} Příliš žluťoučký kůň úpěl ďábelské ódy. ::: :::pagebreak ## The proof The table below is Postext’s own report. Before it measured a line of the second build, it read the design and the text for every face they ask for, in the text, headings, chips, table, captions, opener and running heads, and loaded each one for the letters these pages set. Once the pages were laid out it looked at the faces their lines were set in, and found none missing. ::resource{id="faces"} Some faces in the table set nothing in this booklet. Postext loads the regular, the italic, the bold and the bold italic of the text face, because any paragraph may ask for them with a pair of asterisks. The two italics of the mono face were declared and never asked for, so the browser fetched no file for them. A face with no file would not pass unnoticed. The browser sets a family nobody declared in a system face and fakes a missing bold by thickening the regular; Postext names both in its report and among the warnings of the finished document. :::pagebreak ## What the early build got wrong The early build ran before the browser had any of these files, so its words were measured in a fallback face. Drawn in the real faces, its lines no longer fit the measure. ::resource{id="proof"} When the faces arrived, Postext dropped every width it had measured in their families, and the next build measured each word again. :::paragraphs{style="colophon"} Set in Ysabeau Office, Noto Serif Display and IBM Plex Mono (SIL OFL 1.1) · Text: original, CC BY 4.0 · Pellow Lane Press is imaginary. :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design may ask for (page 3 lists them), each declared from two files. const FONTS = { 'Ysabeau Office': ['400', '400i', '700', '700i'], // text, waterfall, pangrams, lead 'Noto Serif Display': ['900', '900i'], // the glyphs, the title, the subheads 'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions }; const facesOf = (fonts) => Object.entries(fonts).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); // #region declare: one FontFace per file, as a stylesheet has one @font-face rule per file const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,' + 'U+A720-A7FF' }; const FILES = new Map(); // FontFace → the subset its file covers, for the audit function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder for (const { family, weight, style } of facesOf(fonts)) { const id = family.toLowerCase().replaceAll(' ', '-'); for (const [subset, unicodeRange] of Object.entries(SUBSETS)) { const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; // Adding a face fetches nothing: the file downloads when a load or a line needs it. const face = new FontFace(family, `url(https://cdn.jsdelivr.net/npm/@fontsource/${file})`, { weight: `${weight}`, style, unicodeRange }); FILES.set(face, subset); document.fonts.add(face); } } } // #endregion // #region audit: page 3's table: every face declared or set, the report's word, its files const SET = { loaded: 'yes', synthesized: 'faked', missing: 'in a fallback' }; // the report const nameOf = (f) => `${f.family.replaceAll('"', '')} ${f.weight} ${f.style}`; // or a FontFace function auditOf(doc, report) { const found = Object.keys(SET).flatMap((kind) => report[kind].map((f) => [nameOf(f), kind])); const inPages = new Map(found); const loaded = [...FILES].filter(([face]) => face.status === 'loaded'); const faces = new Map([...facesOf(FONTS), ...Object.keys(SET).flatMap((kind) => report[kind])] .map((f) => [nameOf(f), f])); // the declared faces, then any the pages set without a file const rows = [...faces].map(([name, f], i, all) => [ i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once `${f.weight}${f.style === 'italic' ? ' italic' : ''}`, SET[inPages.get(name)] ?? 'no', loaded.filter(([face]) => nameOf(face) === name).map(([, subset]) => subset).join(' · ') || 'none'].map((cell) => ({ content: cell }))); // What else to read in a finished layout: its content warnings, its passes, its warnings. return { rows, note: `${report.loaded.length} of ${rows.length} faces set in the pages · ` + `${loaded.length} of ${FILES.size} files fetched · fontFallback warnings: ` + `${fallbacksIn(doc) || 'none'} · ${doc.converged ? '' : 'not '}converged · layout warnings: ` + `${doc.warnings?.length || 'none'}` }; } // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region answer: one call loads every face the config and the text ask for, then builds // buildDocumentWithFonts starts with prepareFonts: it reads the config and the text for every // face they ask for (text, headings, chips, table, captions, opener, running heads), in each // weight and slant, and loads them for the characters the document sets. The page declares its // files (declareFaces), so they load through document.fonts; a face nothing declares would be // asked of a resolve(family, weight, style) option. Then it builds, reads the faces the pages // are set in, loads any that the config did not name and builds again. let report; // { loaded, missing, synthesized }: lists of { family, weight, style } const build = () => buildDocumentWithFonts(content(), config(), { onFonts: (found) => { report = found; } }); // what the last look at the pages found kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below const early = buildDocument(content(), config()); // on purpose: the page has no font yet (page 4) declareFaces(FONTS); const fitted = await build(); // every face loaded before a line is measured audit = auditOf(fitted, report); // page 3's table drawProof(early, fitted); // page 4's picture const doc = await build(); // nothing left to load: the same pages, table and picture filled in showPages(doc, { title: 'Load every font before layout' }); // #endregion
Kit · core, fonts, viewer : identique dans toutes les recettes · 224 lignes// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Fournir les fichiers avec resolve

kitFonts(FONTS), dans d’une chaîne Markdown à une page mise en forme, ne déclare rien : Postext demande à son resolve les fichiers de chaque police et les ajoute lui-même à document.fonts.

#Oublier une police

Omettez le gras d’IBM Plex Mono, qui compose le surtitre, l’en-tête du tableau et les étiquettes des légendes : le navigateur épaissit le romain, le tableau marque la police faked, et la note compte un avertissement fontFallback, qui nomme IBM Plex Mono 700 comme synthesized.

-  'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions
+  'IBM Plex Mono': ['400', '400i', '700i'], // labels, chips, the table, captions

#Intégrer les mêmes polices dans un PDF

renderToPdf intègre les octets que son fontProvider renvoie pour chaque police, et le fournisseur de un vrai PDF avec les mêmes polices intégrées sert les fichiers latin de Fontsource, qui s’arrêtent au Latin-1 : pour les ż, ł, ř et ů de ce livret, faites-lui renvoyer le fichier complet de chaque police depuis votre propre dossier.

Erreurs fréquentes

Erreur fréquente

Les fichiers Fontsource couvrent les lettres, pas les symboles

Le kit intègre le fichier latin Fontsource d’un caractère, plus son fichier latin-ext pour č, ł, † et son fichier greek pour α, χ (dans une famille qui en fournit un : ce n’est pas le cas de Lora et de Gelasio), quand le texte les compose, à l’écran et dans le PDF. Les symboles comme →, ≈, ✓ et ★ ne figurent dans aucun de ces fichiers et manquent dans le PDF : dessinez-les, composez-les en maths, ou choisissez un caractère dont les fichiers les contiennent. Polices intégrées au PDF →

Avertissement de mise en page · fontFallback

Police de substitution dans la mise en page

Pourquoi. Un style dans lequel le texte a été composé ne figurait pas dans le jeu de polices au moment de la mise en page (`reason: 'missing'`), ou la famille n’a pas de fichier pour cette graisse ou cette inclinaison et le navigateur la dessine à partir d’un autre style (`'synthesized'`) : les coupures de ligne ont donc été mesurées avec un style qui n’est pas celui qu’incorpore le PDF.

Correction. Chargez les polices avant la première composition : `prepareFonts(content, config, { resolve })`, ou composez avec `buildDocumentWithFonts`, qui charge aussi tout style utilisé par les pages et recompose. Pour `synthesized`, chargez cette graisse ou cette inclinaison, ou choisissez une graisse que la famille fournit. Docs →

Vérification du Sandbox · missingFont

Police manquante

Pourquoi. Une famille nommée dans la configuration ne s’est jamais chargée dans le navigateur : le texte a donc été mesuré et dessiné avec une police de substitution du système.

Correction. Corrigez le nom de la famille (une seule famille, pas de liste CSS) et chargez chaque style avant la première composition ; dans un pen, ajoutez-la à FONTS. Docs →

Vérification du Sandbox · missingFontVariant

Variante de police manquante

Pourquoi. Une famille personnalisée n’a pas de fichier pour une graisse et un style qu’utilise le document, comme son italique ou son gras.

Correction. Téléversez ou déclarez la variante manquante, ou cessez d’utiliser cette graisse ou ce style. Docs →

  • Si vous chargez une police vous-même, document.fonts.load(font) sans texte d’échantillon ne charge que le fichier qui couvre l’espace, le fichier latin, et ż et ř retombent sur une police système. prepareFonts passe les caractères du document lui-même.
  • renderPageToCanvas et postext-pdf masquent chaque colonne à 2 pt de son bord : une ligne mesurée avec une police de secours plus étroite perd ses dernières lettres. La construction qui l’a mesurée nomme la police dans doc.contentWarnings comme fontFallback : lisez cette liste avant d’afficher ou d’exporter une page.

Crédits

Texte
Texte original, CC BY 4.0
Polices
Ysabeau Office (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
Sandbox