Vai al contenuto principale
Ricetta numero 26

Ricettario · Capitolo 10 · Output e integrazione

Un campionario con ogni font caricato prima di impaginare

Un campionario di quattro pagine costruito una volta troppo presto, come prova, e poi con ogni carattere chiesto da config e testo caricato prima di misurare.

In questa pagina
  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 180 × 240 mm
  • 1 colonna
  • Ysabeau Office 11/15,5
  • IBM Plex Mono
  • Noto Serif Display
  • 4 pagine
  • Livello
  • Postext 1.29.1
  • Impaginato in 6 ms
  • 188 righe di codice

In breve

Un libretto di quattro pagine che mette in mostra tre caratteri. Mostra perché tutti i font devono finire di caricarsi prima di impaginare le pagine, altrimenti le righe vanno a capo nei punti sbagliati.

Che cosa comporrai

House Specimen Nº 3, dell’immaginaria Pellow Lane Press, è un campionario di quattro pagine per tre caratteri. In copertina un campo blu oltremare contiene una Ag bianca in Noto Serif Display corsivo a 240 pt e due righe in monospaziato che nominano i tre caratteri; il titolo sta sotto il campo. La pagina 2 fa scorrere Ysabeau Office in una cascata da 7 a 14 pt, poi compone pangrammi spagnoli, polacchi e cechi le cui ż, ř e ů richiedono un secondo file di font. La pagina 3 è la verifica: una tabella in IBM Plex Mono, compilata dal pen, dei dieci caratteri dichiarati dal libretto, dei sei in cui sono composte le sue pagine e dei file che il browser ha scaricato per ciascuno. La pagina 4 compone due volte il primo paragrafo di pagina 1, da una costruzione anticipata e dall’ultima. La costruzione anticipata è partita prima che il browser avesse un solo file di font, quindi le sue righe finiscono in altri punti e alcune superano la giustezza.

Questa ricetta risponde a

  • Perché cambiano i miei a capo, o le parole si sovrappongono nel PDF, e come carico correttamente i font?
  • Come uso i font del mio marchio o con licenza nell’impaginazione e li incorporo nel PDF?
  • Come scopro che cosa non va nel mio documento (avvisi, sforamenti, impaginazione che non converge)?

La risposta breve

script.js · righe 314–331nel codice completo
// 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' });

Ingredienti

Tipo
Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1)
Risorse
Nessuna: ogni immagine è disegnata nel codice

Preparazione

#1 · Dichiara ogni file senza scaricarlo

script.js · righe 268–287nel codice completo
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 è un solo file, come una regola @font-face, quindi un carattere con un file latin e uno latin-ext si dichiara con due oggetti FontFace che condividono famiglia, peso e stile e differiscono per unicodeRange, copiato dal CSS della famiglia. Aggiungerli a document.fonts non scarica niente: il browser scarica un file quando una chiamata a load() o una riga di testo ha bisogno dei suoi caratteri. Postext carica i caratteri che una pagina dichiara tramite document.fonts; un carattere che nessuno dichiara viene chiesto all’opzione resolve, ed è così che le altre ricette prendono dal kit i loro file Fontsource. customFonts in una configurazione descrive solo i file di un pacchetto .postext, quindi una pagina costruita dal Markdown dichiara i propri font o passa un resolve.

#2 · Carica i caratteri, poi costruisci

Il codice è nella risposta breve qui sopra. buildDocumentWithFonts comincia con prepareFonts, che legge nella configurazione e nel testo ogni carattere richiesto, in ogni peso e inclinazione, e li carica usando come campione i caratteri del documento stesso, così i file latin-ext arrivano con i pangrammi polacchi e cechi (Caricare i font prima dell’impaginazione). Poi costruisce, legge i caratteri in cui sono composte le pagine, carica quelli che la configurazione non nominava e costruisce di nuovo. onFonts riceve il resoconto di quest’ultimo controllo: i caratteri trovati loaded, quelli missing e quelli che il browser avrebbe synthesized da un altro peso o un’altra inclinazione. Il pen costruisce tre volte perché il libretto stampa la propria prova: una volta troppo presto, una volta con i caratteri e un’ultima volta con la tabella e l’immagine compilate.

#3 · Costruisci la verifica dal resoconto

script.js · righe 291–309nel codice completo
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'}` };
}

La tabella parte dai caratteri che FONTS dichiara e aggiunge ciò che il resoconto dice di ciascuno: composto nelle pagine, simulato dal browser o composto con un ripiego. L’ultima colonna elenca i file il cui FontFace è arrivato a loaded. Ne sono stati scaricati sedici su venti: Postext carica tutti e quattro gli stili della famiglia del testo, perché ** e * possono richiederli in qualunque paragrafo, e i due corsivi monospaziati, che nessuna impostazione nomina, non sono mai stati scaricati. La nota legge altre tre cose dal documento finito: contentWarnings, dove un carattere misurato con un ripiego compare come fontFallback; converged, vero quando i passaggi dell’impaginazione si sono stabilizzati; e warnings. Controlla tutti e tre prima di mostrare o esportare una pagina.

#4 · Conserva una costruzione anticipata come prova

script.js · righe 126–174nel codice completo
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);
}

Il pen chiama buildDocument una volta prima di dichiarare un solo carattere, quindi quella costruzione misura ogni parola nel font di ripiego del browser. Quando ci sono i caratteri veri, il pen la ridipinge e la confronta riga per riga con l’ultima costruzione; il numero di righe nella didascalia viene da quel confronto, e il numero di caratteri dagli avvisi fontFallback della costruzione anticipata stessa. Una riga semplice misurata con un ripiego più stretto supera la giustezza finché il ritaglio della colonna, 2 pt oltre il suo bordo, non ne taglia le ultime lettere, quindi il pen dipinge la pagina 1 con una colonna allargata per mostrare tutto lo sconfinamento. Il canvas e postext-pdf disegnano ogni tratto di una riga giustificata o con stili misti dove l’impaginazione l’ha misurato, quindi un carattere vero più largo del ripiego si stampa sopra la parola successiva, e le parole di un PDF costruito prima che arrivassero i suoi font si sovrappongono. Nulla della costruzione anticipata passa alla successiva: prepareFonts trova i nuovi caratteri in document.fonts e scarta le larghezze misurate nelle loro famiglie, quindi non serve chiamare clearMeasurementCache().

#5 · Una cascata sul ritmo del testo

script.js · righe 30–44nel codice completo
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 compone il testo in 11 su 15,5 pt, con una riga vuota tra i paragrafi invece di un rientro. Il testo è in bandiera, quindi ogni spazio tra le parole mantiene la sua larghezza naturale. Grassetto, corsivo e rimandi sono nel colore dell’inchiostro; lasciati a sé, prenderebbero il colore principale. Ogni corpo della cascata e ogni pangramma è uno stile di paragrafo alto due interlinee (31 pt), così la pagina mantiene il ritmo di 15,5 pt del testo. Le etichette dei corpi sono chip monospaziati, e i corpi di una cifra ricevono mezza lettera di padding per lato, così ogni campione comincia sullo stesso bordo.

#6 · Tre caratteri in una copertina

script.js · righe 48–74nel codice completo
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 copertina è il design del livello H1: le lettere dell’attributo glyphs del titolo nel carattere da titolazione a 240 pt e, sotto, due righe in monospaziato che nominano quel carattere e quelli del testo e delle etichette, come nel foglio campione di una fonderia di caratteri. span: 'page' stende il titolo su tutta la pagina, e il campo pende dall’angolo superiore sinistro dell’abbondanza, quindi esce dal bordo superiore. minHeight conta dal margine superiore e riserva lo spazio fino a Y.end, 42 mm sotto il campo; senza di esso il testo comincia una riga più in alto, stretto sotto l’attacco.

La ricetta completa

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`---
Esempio Markdown · 77 righe · 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: uguale in ogni ricetta · 224 righe// ─── 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 ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Varianti

#Passa i file con resolve

kitFonts(FONTS), in da una stringa Markdown a una pagina progettata, non dichiara nulla: Postext chiede al suo resolve i file di ogni carattere e li aggiunge da sé a document.fonts.

#Dimentica un carattere

Togli il grassetto di IBM Plex Mono, che compone l’occhiello, l’intestazione della tabella e le etichette delle didascalie: il browser ispessisce il regolare, la tabella segna il carattere come faked e la nota conta un avviso fontFallback, che nomina IBM Plex Mono 700 come synthesized.

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

#Incorpora gli stessi caratteri in un PDF

renderToPdf incorpora i byte che il suo fontProvider restituisce per ogni carattere, e il provider di un vero PDF con gli stessi font incorporati serve i file latin di Fontsource, che si fermano al Latin-1: per le ż, ł, ř e ů di questo libretto, fagli restituire il file di font completo di ogni carattere da una tua cartella.

Errori frequenti

Errore frequente

I file di Fontsource coprono le lettere, non i simboli

Il kit incorpora il file latin di Fontsource di un carattere, più il suo file latin-ext per č, ł, † e il suo file greek per α, χ (nelle famiglie che ne hanno uno: Lora e Gelasio no), quando il testo li compone, a schermo e nel PDF. Simboli come →, ≈, ✓ e ★ non sono in nessuno di quei file e spariscono nel PDF: disegnali, componili come matematica o scegli un carattere i cui file li contengano. Font incorporati nel PDF →

Avviso di impaginazione · fontFallback

Font di ripiego nell’impaginazione

Perché. Una variante con cui era composto il testo non era nel set di font quando il documento è stato impaginato (`reason: 'missing'`), oppure la famiglia non ha un file per quel peso o quell’inclinazione e il browser la disegna partendo da un’altra variante (`'synthesized'`), quindi gli a capo sono stati misurati con una variante diversa da quella che il PDF incorpora.

Soluzione. Carica le varianti prima della prima build: `prepareFonts(content, config, { resolve })`, oppure componi con `buildDocumentWithFonts`, che carica anche ogni variante usata dalle pagine e ricompone. Per `synthesized`, carica quel peso o quell’inclinazione, oppure imposta un peso che la famiglia include. Documentazione →

Verifica della Sandbox · missingFont

Font mancante

Perché. Una famiglia indicata nella configurazione non si è mai caricata nel browser, quindi il testo è stato misurato e disegnato con un font di sistema di ripiego.

Soluzione. Correggi il nome della famiglia (una sola famiglia, non un elenco CSS) e carica ogni variante prima della prima build; in un pen, elencala in FONTS. Documentazione →

Verifica della Sandbox · missingFontVariant

Variante di font mancante

Perché. Una famiglia personalizzata non ha un file per un peso e uno stile usati dal documento, come il suo corsivo o il suo grassetto.

Soluzione. Carica o dichiara la variante mancante, oppure smetti di usare quel peso o quello stile. Documentazione →

  • Se carichi un carattere da solo, document.fonts.load(font) senza testo di esempio carica solo il file che copre lo spazio, quello latin, e ż e ř ricadono su un carattere di sistema. prepareFonts passa i caratteri del documento stesso.
  • renderPageToCanvas e postext-pdf ritagliano ogni colonna 2 pt oltre il suo bordo, quindi una riga misurata con un ripiego più stretto perde le ultime lettere. La costruzione che l’ha misurata nomina il carattere in doc.contentWarnings come fontFallback: leggi quell’elenco prima di mostrare o esportare una pagina.

Crediti

Testo
Testo originale, CC BY 4.0
Font
Ysabeau Office (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
Sandbox