Saltar al contenido principal
Receta número 26

Recetario · Capítulo 10 · Salida e integración

Muestrario con todas sus fuentes cargadas antes de componer

Un muestrario de cuatro páginas cuya primera composición nombra las fuentes que usa; el pen las carga, vacía la caché de anchos y vuelve a componer.

En esta página
  • Muestra en inglés: aún no hay edición en español
  • Formato 180 × 240 mm
  • 1 columna
  • Ysabeau Office 11/15,5
  • IBM Plex Mono
  • Noto Serif Display
  • 4 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 4 ms
  • 216 líneas de código

Lo que vas a componer

House Specimen Nº 3, de la imprenta imaginaria Pellow Lane Press, es un muestrario de cuatro páginas, en inglés, para tres fuentes. En la cubierta, un campo azul ultramar lleva una Ag blanca en Noto Serif Display cursiva de 240 pt y dos líneas en monoespaciada con los nombres de las tres fuentes; debajo va el título. La página 2 recorre Ysabeau Office en cascada, de 7 a 14 pt, y compone pangramas en español, polaco y checo cuyas ż, ř y ů necesitan un segundo archivo. La página 3 es la auditoría: una tabla en IBM Plex Mono con las diez fuentes que pidió la composición y sus cuerpos. La página 4 repite el primer párrafo de la página 1 según la primera composición y la última. La primera, hecha antes de que llegaran las fuentes, corta las líneas en otros sitios, y algunas se salen de la medida.

Esta receta responde a

  • ¿Por qué cambian mis cortes de línea o se solapan las palabras en el PDF, y cómo cargo bien las fuentes?
  • ¿Cómo uso mis fuentes corporativas o con licencia en la composición y las incrusto en el PDF?
  • ¿Cómo averiguo qué falla en mi documento (avisos, desbordamientos, composición que no converge)?

La respuesta corta

script.js · líneas 283–322en el código completo
// 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.`);
}

Ingredientes

Tipografía
Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Declara cada archivo sin descargarlo

script.js · líneas 260–279en el código 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' };
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 corresponde a un archivo, igual que una regla @font-face, así que una fuente con un archivo latin y otro latin-ext se declara con dos objetos FontFace que comparten familia, peso y estilo y solo se distinguen por el unicodeRange, copiado del CSS de la familia. Añadirlos a document.fonts no descarga nada: el navegador trae cada archivo cuando una llamada a load() o una línea de texto necesita sus caracteres. buildDocument no carga fuentes (solo lo hace loadBundleFonts, con los archivos que lleva dentro un paquete .postext), y customFonts en la configuración se limita a describir esos archivos, así que una página compuesta a partir de Markdown tiene que declarar los suyos.

#2 · Compón, carga, vacía la caché y vuelve a componer

script.js · líneas 348–355en el código completo
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ón mide con lo que tenga el navegador, que es una fuente de reserva, pero sus páginas ya nombran todas las fuentes que necesitan. buildWithLoadedFonts, la respuesta corta de arriba, recoge esos nombres en cada composición, carga las fuentes que aún faltan con el texto del cuadernillo como muestra y vuelve a componer, hasta que una composición ya no encuentra nada que cargar. Con esa muestra se descargan también los archivos latin-ext de la fuente de titulares y de la monoespaciada, que no componen ninguna de esas letras; si te importa lo que pesa la descarga, carga cada fuente solo con su propio texto. clearMeasurementCache() no lleva argumentos y vacía los anchos que guardó la primera composición; sin esa llamada, la segunda repite todos los cortes de línea de la primera (consulta «Variantes»).

#3 · Extrae la auditoría de la composición

script.js · líneas 326–343en el código completo
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 tabla sale del mismo recorrido, agrupado por fuente, con cada cuerpo pasado de los píxeles de la composición a puntos. Su nota lee otros dos campos de la composición que describe: converged, que vale true cuando las pasadas de la composición se estabilizaron, y warnings, que en la 1.4.1 recoge cada recuadro que no cabe en ninguna columna. Compruébalos antes de mostrar o exportar una página. La tabla tiene más fuentes de las que se ven en las páginas, porque cada bloque de texto, tabla y pie nombra una negrita, una cursiva y una negrita cursiva junto a su propia fuente, y renderToPdf las pide todas. La respuesta corta exige un FontFace propio para cada fuente con la que se compone algo, porque document.fonts.check() da por buena una familia sin declarar y una negrita que el navegador puede imitar. Las fuentes que solo se nombran se cargan si están declaradas; una familia sin archivo de cursiva pasa la comprobación, y la tabla marcaría sus cursivas con «no file».

#4 · Guarda la primera composición como prueba

script.js · líneas 124–168en el código 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 }; // 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 devuelve todas las composiciones, así que el pen puede volver a pintar la primera cuando ya han llegado las fuentes reales y compararla línea a línea con la última; de esa comparación sale la cifra del pie. Una línea sin estilos, medida con una fuente de reserva más estrecha, se sale de la medida hasta que el recorte de la columna, 2 pt más allá de su borde, le corta las últimas letras; por eso el pen pinta la página 1 con la columna ensanchada, para que se vea todo lo que desborda. El canvas y postext-pdf dibujan cada tramo de una línea justificada o con estilos mezclados donde lo midió la composición, así que una fuente real más ancha que la de reserva se monta sobre la palabra siguiente, y en un PDF compuesto antes de que lleguen las fuentes las palabras acaban solapadas.

#5 · Una cascada al ritmo del texto

script.js · líneas 31–45en el código completo
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 compone el texto en cuerpo 11 sobre 15,5 pt, con una línea en blanco entre párrafos en lugar de sangría, y en bandera, para que cada espacio entre palabras conserve su ancho natural. La negrita, la cursiva y las referencias van en el color del texto; por omisión tomarían el color principal. Cada cuerpo de la cascada y cada pangrama es un estilo de párrafo de dos interlíneas (31 pt), así que la página conserva el ritmo de 15,5 pt del texto. Los cuerpos se rotulan con chips en monoespaciada, y los de una sola cifra llevan media letra de relleno a cada lado para que todas las muestras empiecen en el mismo borde.

#6 · Tres fuentes en una cubierta

script.js · líneas 49–76en el código 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 }); // 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 cubierta es el diseño del nivel H1: las letras del atributo glyphs del título, en la fuente de titulares a 240 pt, y debajo dos líneas en monoespaciada que nombran esa fuente y las de texto y etiquetas, como en el muestrario de una fundición tipográfica. span: 'page' deja que el campo, anclado al sangrado, salga por el borde superior; dentro de la columna, el diseño se recorta en el margen superior y pierde la parte alta del campo y el antetítulo. minHeight se cuenta desde el margen superior y reserva el espacio hasta Y.end, 42 mm por debajo del campo; sin él, el texto empieza una línea más arriba, pegado a la entradilla.

La receta completa

// ═══ 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`---
Muestra en Markdown · 77 líneas · 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 en todas las recetas · 235 líneas// ─── 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 ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗

Variantes

#Deja la caché en paz

El pen sigue cargando todas las fuentes, pero la segunda composición conserva todos los cortes de línea de la primera: las dos tiras de la página 4 rebasan el filete, el pie cuenta 0 líneas cortadas en otro sitio y en los pies en monoespaciada las palabras se montan unas sobre otras, porque cada tramo se dibuja donde lo midió la fuente de reserva.

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

#Olvida una fuente

Quita la negrita de IBM Plex Mono, que compone el antetítulo y la fila de cabecera de la tabla, y el pen se detiene con No FontFace for IBM Plex Mono 700 normal antes de mostrar ninguna página; si solo se hubiera usado document.fonts.check(), el navegador habría engrosado la redonda.

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

#Incrusta las mismas fuentes en un PDF

renderToPdf incrusta los bytes que su fontProvider devuelve para cada fuente, y el de un PDF de verdad con las mismas fuentes incrustadas sirve los archivos latin de Fontsource, que no pasan del Latin-1: para las ż, ł, ř y ů de este cuadernillo, haz que devuelva el archivo completo de cada fuente desde tu propia carpeta.

Errores frecuentes

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

Error frecuente

fontFamily es un nombre de familia, nunca una pila CSS

Una pila como 'Lora, serif' se lee como una única familia que no existe, así que el texto se mide sin aviso con una fuente de reserva y el canvas, el HTML y el PDF no coinciden. Escribe una sola familia. Fuentes antes de componer →

Error frecuente

Los archivos latin de Fontsource solo traen glifos del rango latino

El proveedor del PDF incrusta los archivos latin de Fontsource, que cubren el español y las lenguas de Europa occidental pero no →, ≈, ✓, ★, el griego ni las letras de Europa central; esos glifos faltan en el PDF. Mantén el texto del PDF dentro del rango latin. Fuentes incrustadas en el PDF →

Error frecuente

El PDF pide todos los pesos y estilos de cada familia

renderToPdf pide al proveedor de fuentes la negrita, la cursiva y la negrita cursiva de cada familia que un bloque podría usar, aunque nunca se imprima, y un solo rechazo detiene la exportación. El proveedor debe ajustarse al peso más cercano que tenga la familia y volver a la redonda cuando no haya cursiva. Fuentes incrustadas en el PDF →

Error frecuente

Una configuración se cachea por identidad: crea un objeto nuevo

El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones

postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →

Error frecuente

El lineHeight de un texto de diseño es un múltiplo, nunca una medida

En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →

Error frecuente

El desbordamiento del texto de diseño es 'ellipsis-end' por defecto

Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →

Comprobación del Sandbox · missingFont

Fuente no cargada

Por qué. Una familia que nombra la configuración no llegó a cargarse en el navegador, así que el texto se midió y se dibujó con una fuente de reserva del sistema.

Solución. Corrige el nombre de la familia (una sola familia, sin pila CSS) y carga todas las fuentes antes de la primera composición; en un pen, añádela a FONTS. Documentación →

Comprobación del Sandbox · missingFontVariant

Variante de fuente ausente

Por qué. A una familia propia le falta el archivo de un peso y estilo que usa el documento, como su cursiva o su negrita.

Solución. Sube o declara la variante que falta, o deja de usar ese peso o estilo. Documentación →

  • document.fonts.load(font) sin texto de muestra solo carga el archivo que cubre el espacio, el latin; pásale el texto, o la ż y la ř saldrán sin aviso en una fuente del sistema.
  • renderPageToCanvas y postext-pdf recortan cada columna 2 pt más allá de su borde, así que una línea medida con una fuente de reserva más estrecha pierde sus últimas letras sin ningún aviso.

Créditos

Texto
Texto original, CC BY 4.0
Fuentes
Ysabeau Office (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)