Saltar al contingut principal
Recepta número 26

Receptari · Capítol 10 · Sortida i integració

Mostrari amb totes les fonts carregades abans de compondre

Un mostrari de quatre pàgines on la primera composició anomena les fonts que usa; el pen les carrega, buida la memòria cau d'amplades i recompon.

En aquesta pàgina
Gènere
Catàlegs
Sortida
Canvas
Nivell
Intermedi
Postext
Provada amb Postext 1.4.1
Requereix ≥ 1.4.1
Llicència
Actualitzada el 26 de set. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en anglès: encara no hi ha edició en català
  • Format 180 × 240 mm
  • 1 columna
  • Ysabeau Office 11/15,5
  • IBM Plex Mono
  • Noto Serif Display
  • 4 pàgines
  • Nivell
  • Postext 1.4.1
  • Compost en 4 ms
  • 216 línies de codi

En poques paraules

Un fullet de quatre pàgines que mostra tres tipus de lletra. Ensenya per què totes les fonts han d'acabar de carregar-se abans de compondre les pàgines; si no, les línies es tallen on no toca.

Què compondràs

House Specimen Nº 3, de la impremta imaginària Pellow Lane Press, és un mostrari de quatre pàgines, en anglès, per a tres fonts. A la coberta, un camp blau ultramar porta una Ag blanca en Noto Serif Display cursiva de 240 pt i dues línies en monoespaiada amb els noms de les tres fonts; a sota va el títol. La pàgina 2 recorre Ysabeau Office en cascada, de 7 a 14 pt, i compon pangrames en espanyol, polonès i txec les ż, ř i ů dels quals necessiten un segon fitxer. La pàgina 3 és l'auditoria: una taula en IBM Plex Mono amb les deu fonts que va demanar la composició i els seus cossos. La pàgina 4 repeteix el primer paràgraf de la pàgina 1 segons la primera composició i l'última. La primera, feta abans que arribessin les fonts, talla les línies en altres llocs, i algunes surten de la mesura.

Aquesta recepta respon a

  • Per què canvien els meus talls de línia o se solapen les paraules al PDF, i com carrego bé les fonts?
  • Com faig servir les meves fonts corporatives o amb llicència en la composició i les incrusto al PDF?
  • Com esbrino què falla en el meu document (avisos, desbordaments, composició que no convergeix)?

La resposta curta

script.js · línies 283–322al codi complet
// Every block, table, caption, chip, opener and running head keeps the font string it is set in
// (fontString, headerFontString…) and those of the bold and italics it may use (boldFontString…).
function fontStringsIn(doc) {
  const found = new Map(); // font string → true when something is set in it
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    for (const [key, value] of Object.entries(node)) {
      if (typeof value !== 'string' || !/fontString$/i.test(key)) walk(value);
      else found.set(value, found.get(value) || !/(bold|italic)FontString$/i.test(key));
    }
  };
  walk(doc.pages); walk(doc.blocks); // not doc.config: it is large and holds no font strings
  return found;
}
function faceOf(font) { // 'italic 700 22.9px "Source Serif 4"' → { family, weight, style, px }
  const [, italic, weight = '400', px, family] = /^(italic )?(\d+ )?([\d.]+)px (.+)$/.exec(font);
  return { family: family.replaceAll('"', ''), weight: weight.trim(), px: Number(px),
    style: italic ? 'italic' : 'normal' };
}
const nameOf = (face) => `${face.family} ${face.weight} ${face.style}`; // a FontFace works too
async function buildWithLoadedFonts(build, sample) { // → every build, first to last
  const builds = [];
  while (builds.length < 4) {
    builds.push(build()); // the first one measures with whatever faces the browser has
    // fonts.check() says yes to an undeclared family and to a face it can fake, so each face that
    // something is set in needs a FontFace of its own; a bold or italic that is only named loads
    // if declared (a family with no italic has none). load() fetches the files the sample needs.
    const declared = new Set([...document.fonts].map(nameOf)), missing = new Set(), pending = [];
    for (const [font, set] of fontStringsIn(builds.at(-1))) {
      const name = nameOf(faceOf(font));
      if (!declared.has(name)) { if (set) missing.add(name); }
      else if (!document.fonts.check(font, sample)) pending.push(font);
    }
    if (missing.size) throw new Error(`No FontFace for ${[...missing].join(', ')}`);
    if (!pending.length) return builds;
    await Promise.all(pending.map((font) => document.fonts.load(font, sample)));
    clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
  }
  throw new Error(`The fonts had not settled after ${builds.length} builds.`);
}

Ingredients

Tipografia
Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · Declara cada fitxer sense baixar-lo

script.js · línies 260–279al codi 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' };
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
  for (const [family, specs] of Object.entries(fonts)) {
    const id = family.toLowerCase().replaceAll(' ', '-');
    for (const spec of specs) {
      const [weight, style] = [spec.slice(0, 3), spec.endsWith('i') ? 'italic' : 'normal'];
      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 url = `https://cdn.jsdelivr.net/npm/@fontsource/${file}`;
        document.fonts.add(new FontFace(family, `url(${url})`, { weight, style, unicodeRange }));
      }
    }
  }
}

Un FontFace correspon a un fitxer, igual que una regla @font-face, així que una font amb un fitxer latin i un altre de latin-ext es declara amb dos objectes FontFace que comparteixen família, pes i estil i només es distingeixen pel unicodeRange, copiat del CSS de la família. Afegir-los a document.fonts no baixa res: el navegador baixa cada fitxer quan una crida a load() o una línia de text necessita els seus caràcters. buildDocument no carrega fonts (només ho fa loadBundleFonts, amb els fitxers que porta a dins un paquet .postext), i customFonts a la configuració es limita a descriure aquests fitxers, així que una pàgina composta a partir de Markdown ha de declarar els seus.

#2 · Compon, carrega, buida la memòria cau i torna a compondre

script.js · línies 348–355al codi complet
kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
declareFaces(FONTS);
const build = () => buildDocument({ markdown, resources: resources() }, config());
const builds = await buildWithLoadedFonts(build, markdown);
audit = auditOf(builds); // page 3's table
drawProof(builds[0], builds.at(-1)); // page 4's picture
const doc = (await buildWithLoadedFonts(build, markdown)).at(-1); // nothing is left to load
showPages(doc, { title: 'Load every font before layout' });

La primera composició mesura amb el que tingui el navegador, que és una font de reserva, però les seves pàgines ja anomenen totes les fonts que necessiten. buildWithLoadedFonts, la resposta curta de més amunt, recull aquests noms a cada composició, carrega les fonts que encara falten amb el text del quadernet com a mostra i torna a compondre, fins que una composició ja no troba res per carregar. Amb aquesta mostra es baixen també els fitxers latin-ext de la font de titulars i de la monoespaiada, que no componen cap d'aquestes lletres; si et preocupa el pes de la baixada, carrega cada font només amb el seu propi text. clearMeasurementCache() no porta arguments i buida les amplades que va desar la primera composició; sense aquesta crida, la segona repeteix tots els talls de línia de la primera (consulta «Variants»).

#3 · Extreu l'auditoria de la composició

script.js · línies 326–343al codi complet
function auditOf(builds) {
  const doc = builds.at(-1), faces = new Map(), declared = new Set([...document.fonts].map(nameOf));
  for (const face of [...fontStringsIn(doc).keys()].map(faceOf)) {
    const name = `${face.weight}${face.style === 'italic' ? ' italic' : ''}`; // '400 italic'
    const key = `${Object.keys(FONTS).indexOf(face.family)} ${name}`; // FONTS order, upright first
    // A face with no file is only named, never set: the browser fakes it if a line asks for it.
    if (!faces.has(key)) faces.set(key, { family: face.family, sizes: new Set(),
      face: declared.has(nameOf(face)) ? name : `${name} · no file` });
    faces.get(key).sizes.add(Math.round((face.px * 72 * 10) / DPI) / 10); // px back to pt
  }
  const rows = [...faces].sort(([a], [b]) => a.localeCompare(b)).map(([, f], i, all) => [
    i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
    f.face, [...f.sizes].sort((a, b) => a - b).join(' · ')].map((content) => ({ content })));
  const files = [...document.fonts].filter((face) => face.status === 'loaded').length;
  const warnings = doc.warnings?.length || 'no'; // what else to read in a finished layout
  return { rows, note: `Build ${builds.length}: ${rows.length} faces · ${files} files loaded · `
    + `${doc.converged ? 'converged' : 'not converged'} · ${warnings} layout warnings` };
}

La taula surt del mateix recorregut, agrupat per font, amb cada cos passat dels píxels de la composició a punts. La seva nota llegeix dos camps més de la composició que descriu: converged, que val true quan les passades de la composició s'han estabilitzat, i warnings, que a la 1.4.1 recull cada requadre que no cap a cap columna. Comprova'ls abans de mostrar o exportar una pàgina. La taula té més fonts de les que es veuen a les pàgines, perquè cada bloc de text, taula i peu anomena una negreta, una cursiva i una negreta cursiva al costat de la seva pròpia font, i renderToPdf les demana totes. La resposta curta exigeix un FontFace propi per a cada font amb què es compon alguna cosa, perquè document.fonts.check() dona per bona una família sense declarar i una negreta que el navegador pot imitar. Les fonts que només s'anomenen es carreguen si estan declarades; una família sense fitxer de cursiva passa la comprovació, i la taula marcaria les seves cursives amb «no file».

#4 · Desa la primera composició com a prova

script.js · línies 124–168al codi 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 }; // lines of text the first build broke elsewhere, of all
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 first build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The first build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere. 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.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 first 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
  ['first', '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);
}

buildWithLoadedFonts retorna totes les composicions, així que el pen pot tornar a pintar la primera quan ja han arribat les fonts reals i comparar-la línia a línia amb l'última; d'aquesta comparació surt la xifra del peu. Una línia sense estils, mesurada amb una font de reserva més estreta, surt de la mesura fins que el retall de la columna, 2 pt més enllà de la seva vora, li talla les últimes lletres; per això el pen pinta la pàgina 1 amb la columna eixamplada, perquè es vegi tot el que desborda. El canvas i postext-pdf dibuixen cada tram d'una línia justificada o amb estils barrejats allà on el va mesurar la composició, així que una font real més ampla que la de reserva es munta sobre la paraula següent, i en un PDF compost abans que arribin les fonts les paraules acaben solapades.

#5 · Una cascada al ritme del text

script.js · línies 31–45al codi complet
const bodyText = () => ({ // one family name, never a CSS stack (gotcha: font-family-one-name)
  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 compon el text en cos 11 sobre 15,5 pt, amb una línia en blanc entre paràgrafs en lloc de sagnat, i en bandera, perquè cada espai entre paraules conservi la seva amplada natural. La negreta, la cursiva i les referències van en el color del text; per defecte prendrien el color principal. Cada cos de la cascada i cada pangrama és un estil de paràgraf de dues interlínies (31 pt), així que la pàgina conserva el ritme de 15,5 pt del text. Els cossos es retolen amb xips en monoespaiada, i els d'una sola xifra porten mitja lletra d'emplenament a cada costat perquè totes les mostres comencin a la mateixa vora.

#6 · Tres fonts en una coberta

script.js · línies 49–76al codi 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 }); // design text wraps instead of ending in an ellipsis
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // restated (gotcha: headings-drop-h1-break)
  span: 'page', // lets the field reach the top edge: in the column it stops at the top margin
  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)),
      // lineHeight multiplies the size (gotcha: design-lineheight-multiple)
      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 coberta és el disseny del nivell H1: les lletres de l'atribut glyphs del títol, en la font de titulars a 240 pt, i a sota dues línies en monoespaiada que anomenen aquesta font i les de text i d'etiquetes, com en el mostrari d'una foneria tipogràfica. span: 'page' deixa que el camp, ancorat a la sang, surti per la vora superior; dins de la columna, el disseny es retalla al marge superior i perd la part alta del camp i l'avanttítol. minHeight es compta des del marge superior i reserva l'espai fins a Y.end, 42 mm per sota del camp; sense aquest valor, el text comença una línia més amunt, enganxat a l'entradeta.

La recepta 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 first and last builds
// Fonts: Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, 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; // font strings carry px at this resolution; the audit turns them back into pt
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 keeps its palette id beside its hex, because 1.4.1 paints design slots from the
// hex (gotcha: palette-skips-designs); 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 = () => ({ // one family name, never a CSS stack (gotcha: font-family-one-name)
  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 }); // design text wraps instead of ending in an ellipsis
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // restated (gotcha: headings-drop-h1-break)
  span: 'page', // lets the field reach the top edge: in the column it stops at the top margin
  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)),
      // lineHeight multiplies the size (gotcha: design-lineheight-multiple)
      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 = () => ({ // a new object per build (gotcha: config-cache-identity)
  // "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(), 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 here = { position: 'here' }; // both sit where ::resource puts them
const resources = () => [
  { id: 'faces', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: here,
    caption: 'Faces this document asked for, read from its own layout.', note: audit.note,
    table: { model: { headerRowCount: 1, columnWidths: [3, 2, 5], rows: [
      ['Family', 'Face', 'Sizes (pt)'].map((content) => ({ content, isHeader: true })),
      ...audit.rows] } } },
  proofFigure(), // drawn from the builds just below
];

// #region art-proof: page 1's first paragraph from the first 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 }; // lines of text the first build broke elsewhere, of all
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 first build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The first build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere. 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.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 first 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
  ['first', '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`---
Mostra en Markdown · 77 línies · 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 and keeps the widths, so a face that arrives a second late leaves the page broken for a fallback, with no warning. These pages were built three times: once to learn which faces the layout asks for, again once all of them had loaded, and a last time to print their list on page 3 and, on page 4, what the first 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 script compiled the table below from the layout. After the first build, it walked the finished pages for every font they had asked for, in the text, headings, chips, tables, captions, opener and running heads. It loaded each face that had not arrived, emptied the measurement cache and built the pages again, then wrote down what it had found. ::resource{id="faces"} Some faces in the table set nothing in this booklet. Beside the face of every block of text, table and caption, Postext names a bold, an italic and a bold italic, whether the text uses them or not, and a PDF export asks for all of them. The script loads each one it has a file for. Before loading anything, the script also checked that every face some text is set in had a file of its own. It could not rely on the browser’s check, which answers yes for a family nobody declared and for any bold it can fake by thickening the regular. :::pagebreak ## What the first build got wrong The first build ran before any of these files had arrived, so the browser measured its words in a fallback face. Drawn in the real faces, its lines no longer fit the measure. ::resource{id="proof"} The fallback widths stay in the measurement cache, and a second build made without emptying it breaks every line where the first one did. :::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: every frontmatter value is quoted // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the layout asks 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 }; // #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' }; function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder for (const [family, specs] of Object.entries(fonts)) { const id = family.toLowerCase().replaceAll(' ', '-'); for (const spec of specs) { const [weight, style] = [spec.slice(0, 3), spec.endsWith('i') ? 'italic' : 'normal']; 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 url = `https://cdn.jsdelivr.net/npm/@fontsource/${file}`; document.fonts.add(new FontFace(family, `url(${url})`, { weight, style, unicodeRange })); } } } } // #endregion // #region answer: build, collect every font the layout asked for, load it, clear, build again // Every block, table, caption, chip, opener and running head keeps the font string it is set in // (fontString, headerFontString…) and those of the bold and italics it may use (boldFontString…). function fontStringsIn(doc) { const found = new Map(); // font string → true when something is set in it const walk = (node) => { if (!node || typeof node !== 'object') return; for (const [key, value] of Object.entries(node)) { if (typeof value !== 'string' || !/fontString$/i.test(key)) walk(value); else found.set(value, found.get(value) || !/(bold|italic)FontString$/i.test(key)); } }; walk(doc.pages); walk(doc.blocks); // not doc.config: it is large and holds no font strings return found; } function faceOf(font) { // 'italic 700 22.9px "Source Serif 4"' → { family, weight, style, px } const [, italic, weight = '400', px, family] = /^(italic )?(\d+ )?([\d.]+)px (.+)$/.exec(font); return { family: family.replaceAll('"', ''), weight: weight.trim(), px: Number(px), style: italic ? 'italic' : 'normal' }; } const nameOf = (face) => `${face.family} ${face.weight} ${face.style}`; // a FontFace works too async function buildWithLoadedFonts(build, sample) { // → every build, first to last const builds = []; while (builds.length < 4) { builds.push(build()); // the first one measures with whatever faces the browser has // fonts.check() says yes to an undeclared family and to a face it can fake, so each face that // something is set in needs a FontFace of its own; a bold or italic that is only named loads // if declared (a family with no italic has none). load() fetches the files the sample needs. const declared = new Set([...document.fonts].map(nameOf)), missing = new Set(), pending = []; for (const [font, set] of fontStringsIn(builds.at(-1))) { const name = nameOf(faceOf(font)); if (!declared.has(name)) { if (set) missing.add(name); } else if (!document.fonts.check(font, sample)) pending.push(font); } if (missing.size) throw new Error(`No FontFace for ${[...missing].join(', ')}`); if (!pending.length) return builds; await Promise.all(pending.map((font) => document.fonts.load(font, sample))); clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared } throw new Error(`The fonts had not settled after ${builds.length} builds.`); } // #endregion // #region audit: page 3's table, one row per face the walk found, with every size it set function auditOf(builds) { const doc = builds.at(-1), faces = new Map(), declared = new Set([...document.fonts].map(nameOf)); for (const face of [...fontStringsIn(doc).keys()].map(faceOf)) { const name = `${face.weight}${face.style === 'italic' ? ' italic' : ''}`; // '400 italic' const key = `${Object.keys(FONTS).indexOf(face.family)} ${name}`; // FONTS order, upright first // A face with no file is only named, never set: the browser fakes it if a line asks for it. if (!faces.has(key)) faces.set(key, { family: face.family, sizes: new Set(), face: declared.has(nameOf(face)) ? name : `${name} · no file` }); faces.get(key).sizes.add(Math.round((face.px * 72 * 10) / DPI) / 10); // px back to pt } const rows = [...faces].sort(([a], [b]) => a.localeCompare(b)).map(([, f], i, all) => [ i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once f.face, [...f.sizes].sort((a, b) => a - b).join(' · ')].map((content) => ({ content }))); const files = [...document.fonts].filter((face) => face.status === 'loaded').length; const warnings = doc.warnings?.length || 'no'; // what else to read in a finished layout return { rows, note: `Build ${builds.length}: ${rows.length} faces · ${files} files loaded · ` + `${doc.converged ? 'converged' : 'not converged'} · ${warnings} layout warnings` }; } // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: declare the files, build until the fonts settle, audit, build the last time kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below declareFaces(FONTS); const build = () => buildDocument({ markdown, resources: resources() }, config()); const builds = await buildWithLoadedFonts(build, markdown); audit = auditOf(builds); // page 3's table drawProof(builds[0], builds.at(-1)); // page 4's picture const doc = (await buildWithLoadedFonts(build, markdown)).at(-1); // nothing is left to load showPages(doc, { title: 'Load every font before layout' }); // #endregion
Kit · core, fonts, viewer: igual a totes les receptes · 235 línies// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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 subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Deixa la memòria cau tranquil·la

El pen continua carregant totes les fonts, però la segona composició conserva tots els talls de línia de la primera: les dues tires de la pàgina 4 sobrepassen el filet, el peu compta 0 línies tallades en un altre lloc i als peus en monoespaiada les paraules es munten les unes sobre les altres, perquè cada tram es dibuixa on el va mesurar la font de reserva.

-    clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
+    // clearMeasurementCache();

#Oblida una font

Treu la negreta d'IBM Plex Mono, que compon l'avanttítol i la fila de capçalera de la taula, i el pen s'atura amb No FontFace for IBM Plex Mono 700 normal abans de mostrar cap pàgina; si només s'hagués fet servir document.fonts.check(), el navegador hauria engruixit la rodona.

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

#Incrusta les mateixes fonts en un PDF

renderToPdf incrusta els bytes que el seu fontProvider retorna per a cada font, i el d'un PDF de debò amb les mateixes fonts incrustades serveix els fitxers latin de Fontsource, que no passen del Latin-1: per a les ż, ł, ř i ů d'aquest quadernet, fes que retorni el fitxer complet de cada font des de la teva pròpia carpeta.

Errors freqüents

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

Error freqüent

fontFamily és un nom de família, mai una pila CSS

Una pila com 'Lora, serif' es llegeix com una única família que no existeix, així que el text es mesura sense avís amb una font de reserva i el canvas, l'HTML i el PDF no coincideixen. Escriu una sola família. Fonts abans de compondre →

Error freqüent

Els fitxers latin de Fontsource només porten glifs de l'interval llatí

El proveïdor del PDF incrusta els fitxers latin de Fontsource, que cobreixen el castellà i les llengües de l'Europa occidental però no →, ≈, ✓, ★, el grec ni les lletres de l'Europa central; aquests glifs falten al PDF. Mantén el text del PDF dins de l'interval latin. Fonts incrustades al PDF →

Error freqüent

El PDF demana tots els gruixos i estils de cada família

renderToPdf demana al proveïdor de fonts la negreta, la cursiva i la negreta cursiva de cada família que un bloc podria fer servir, encara que no s'imprimeixi mai, i un sol rebuig atura l'exportació. El proveïdor s'ha d'ajustar al gruix més proper que tingui la família i tornar a la rodona quan no hi hagi cursiva. Fonts incrustades al PDF →

Error freqüent

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

Una paleta canviada no arriba als elements de disseny ni al color de les remissions

postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →

Error freqüent

El lineHeight d'un text de disseny és un múltiple, mai una mesura

En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →

Error freqüent

El desbordament del text de disseny és 'ellipsis-end' per defecte

Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →

Comprovació del Sandbox · missingFont

Font no carregada

Per què. Una família que anomena la configuració no s'ha arribat a carregar al navegador, de manera que el text s'ha mesurat i dibuixat amb una font de reserva del sistema.

Solució. Corregeix el nom de la família (una sola família, sense pila CSS) i carrega totes les fonts abans de la primera composició; en un pen, afegeix-la a FONTS. Documentació →

Comprovació del Sandbox · missingFontVariant

Variant de font absent

Per què. A una família pròpia li falta el fitxer d'un pes i un estil que fa servir el document, com ara la cursiva o la negreta.

Solució. Puja o declara la variant que falta, o deixa de fer servir aquest pes o estil. Documentació →

  • document.fonts.load(font) sense text de mostra només carrega el fitxer que cobreix l'espai, el latin; passa-li el text, o la ż i la ř sortiran sense avís en una font del sistema.
  • renderPageToCanvas i postext-pdf retallen cada columna 2 pt més enllà de la seva vora, així que una línia mesurada amb una font de reserva més estreta perd les últimes lletres sense cap avís.

Crèdits

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