Saltar al contenido principal
Receta número 25

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

Un PDF de verdad con las mismas fuentes incrustadas

Un programa de mano exportado a PDF. Cada fuente se descarga una sola vez, para FontFace y para el PDF, y cada título se convierte en un marcador.

En esta página
Salida
Canvas · PDF
Postext
Probada con Postext 1.4.1
Requiere ≥ 1.4.1 · postext-pdf ≥ 1.4.1
Licencia
Actualizada el 25 sept 2026
Código MIT · Texto CC BY 4.0
  • Muestra en inglés: aún no hay edición en español
  • Formato 148 × 210 mm
  • 1 columna
  • Crimson Text 10/13,3
  • Fraunces
  • Tenor Sans
  • 4 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 39 ms
  • 180 líneas de código

Lo que vas a componer

El programa de mano, en inglés, de un recital al atardecer, Home from Sea: cuatro páginas A5 para soprano, violonchelo y arpa. La cubierta es azul petróleo, con el título en Fraunces cursiva dorada y filas de escamas de olas doradas que suben desde el pie. Dentro, el orden del programa es una tabla con el encabezado azul en versales de Tenor Sans y el total sobre fondo claro. Las notas van en Crimson Text justificado, y los poemas de las tres canciones, de Longfellow, Stevenson y Tennyson, conservan las sangrías de sus ediciones impresas. El botón Build the PDF genera el archivo que mandarías al público o colgarías en la web de la sala. Sus páginas coinciden línea a línea con la pantalla. Incrusta solo las siete fuentes que cargó el navegador, y cada título es un marcador. Para imprenta, mira el PDF con sangrado y CMYK.

Esta receta responde a

  • ¿Cómo exporto un PDF de verdad en el navegador, con las fuentes incrustadas?
  • ¿Por qué cambian mis cortes de línea o se solapan las palabras en el PDF, y cómo cargo bien las fuentes?

La respuesta corta

script.js · líneas 15–51en el código completo
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}

Ingredientes

Tipografía
Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1)
Recursos
  • Las olas seigaiha de la cubierta, dibujadas en código con la paleta de la página (Ignacio Ferro, CC BY 4.0)

Elaboración

#1 · Una descarga por fuente (peso y estilo), para la página y para el PDF

El código es la respuesta corta de arriba. Postext mide cada palabra con las fuentes que el navegador tiene cargadas al componer, y el PDF dibuja cada línea donde la dejó esa composición. Si el PDF incrusta otro archivo, como la instancia por defecto de una fuente variable o una fuente de reserva del sistema, cada palabra sigue en su sitio, pero sus letras tienen otro ancho, así que las palabras se solapan o dejan huecos. Por eso la receta descarga cada fuente una sola vez. Con sus bytes se crea un FontFace antes de la primera composición, y el proveedor de fuentes pasa esos mismos bytes por decompressWoff2 para el PDF, de modo que la exportación no descarga ninguna fuente.

#2 · Primero las fuentes, después la composición

script.js · líneas 363–367en el código completo
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Home from Sea · a recital programme' });

registerFaces() no se resuelve hasta que se hayan cargado todas las fuentes de FONTS, así que la primera composición ya mide con las fuentes que va a incrustar el PDF. No hace falta una segunda, porque FONTS enumera cada negrita y cada cursiva que puede pedir un bloque de Crimson Text o de Fraunces, y buildWithFonts, del kit, no encuentra nada que añadir (Caché de medidas). Esa comprobación solo sirve para la pantalla. Si a FONTS le falta una fuente, buildWithFonts la carga y vuelve a componer, con un aviso en la consola si un bloque se compone con ella y sin aviso si es una negrita o una cursiva, pero el PDF recibe igualmente la fuente más próxima de las que tiene el proveedor.

#3 · La exportación y la lista de lo que incrustó

script.js · líneas 371–386en el código completo
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
  document.getElementById('pt-actions').prepend(bar);
  return renderToPdf(doc, {
    fontProvider, // the answer: the page's own font files
    resourceBytes: imageBytes, // the cover drawing, as vector paths
    outlines: true, // the default, spelled out: each heading becomes a bookmark
    onProgress: ({ phase, pages, totalPages }) => {
      bar.value = pages / totalPages;
      const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
        save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
      kitStatus(`PDF · ${says[phase]}`);
    },
  });
}, `${RECIPE}.pdf`);

renderToPdf llama a onProgress en tres fases: prepare cuando ya ha incrustado las fuentes y la cubierta, pages tras cada página y save antes de escribir el archivo. Aquí pide al proveedor diez fuentes. La línea de estado enumera los siete archivos que sirvió el proveedor, que son exactamente los de FONTS, y tres sustitutas, todas de Tenor Sans, una familia con solo la redonda de peso 400 que aquí nunca se usa en negrita ni en cursiva. Cualquier otra sustituta delata una fuente que falta en FONTS. Si quitas Crimson Text 600, la línea muestra Crimson Text 600 → 400 y la negrita de The Harbour Consort, en la página 4, sale con el peso normal. La vista previa de CodePen no puede mostrar un PDF, así que offerPdf, del kit, cambia el botón por dos enlaces: uno abre el PDF en otra pestaña y el otro lo descarga (Generación de PDF).

#4 · El árbol de títulos es el árbol de marcadores

script.js · líneas 108–116en el código completo
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };

En el panel de marcadores del PDF aparecen el título de la cubierta, Programme con About the music debajo, The texts con sus tres canciones y The performers. Los marcadores reproducen los niveles de título, así que el índice del PDF se decide al elegir esos niveles: aquí, un H1 por sección y un H2 por canción. The performers tiene un estilo de título propio, un H1 sin salto de página ni apertura, de modo que comparte la página 4 con el poema de Tennyson y aun así tiene un marcador de primer nivel. El \\ que parte en dos el título de la cubierta queda como un espacio en el marcador.

#5 · Título y autor desde el frontmatter

script.js · líneas 80–93en el código completo
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};

Todos los valores del frontmatter van entre comillas. La cubierta imprime dos de ellos con {author} y {subtitle}, y el PDF toma de ahí el título, Home from Sea, y el autor, The Harbour Consort, para sus propiedades de documento. La fecha y el lugar son atributos del título de la cubierta. El dibujo de detrás es un solo SVG del ancho de la página, y el PDF lo conserva como trazados vectoriales.

La receta completa

// ═══ Postext Cookbook · Nº 025 · A real PDF with the same fonts embedded ═══════════
// https://postext.dev/en/cookbook/pdf-with-embedded-fonts
// Code: MIT · Text: notes original (CC BY 4.0), poems in the public domain · Cover: drawn in code
// Fonts: Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: one download per face: the layout measures it, the PDF embeds it
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
// #endregion

const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#1a2326', band: '#0f2a33', // text, a sea-green near-black; night teal: cover and titles
  gilt: '#c9a227', bronze: '#806414', // the accent; deepened to 5.4:1 for small type on paper
  foam: '#e3ebe8', rule: '#b9c6c2', // cover small type and the table's total; hairlines
  muted: '#5c6b70', paper: '#fbfaf6' }; // feet and colophon; the page
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.band]] // the defaults'
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // id: teal, never blue

const PAGE = { width: 148, height: 210 }; // mm: an A5 programme
const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 28 }; // mm, mirrored: a 102 mm measure
const LEAD = 13.3; // pt: the leading of text and verse, 1.33 × the 10 pt body
const LABEL = 7.5, TRACK = 0.2; // pt: kickers, feet, table head, date; em: capitals' tracking
const H2 = { italic: true, fontSize: pt(13.5), lineHeight: pt(2 * LEAD) }; // two lines of text

const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size),
  letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) });
const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true,
  fontSize: pt(size), lineHeight: 1 }); // a multiple (gotcha: design-lineheight-multiple)
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default)
const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });

// #region cover: the drawing fills the page; the frontmatter and the heading set the type
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};
// #endregion

const opener = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // kicker, title, rule
  text('kicker', '{attr.kicker}', at('container', 'top-left', 0), label(LABEL, 'bronze')),
  text('title', '{titleText}', at('#kicker', 'below', 1.5), { ...title(26), color: col('band') }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('gilt'),
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(14) } } },
] } };

const foot = (parity, edge, x, content) => ({ kind: 'text', id: parity, content, parity,
  ...label(LABEL, 'muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x),
    y: mm(-MARGIN.bottom / 2) } } });

// #region headings: the heading tree is the bookmark tree
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
// #endregion

const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  colorPalette, resourceTypes: [plain],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: { fontFamily: 'Crimson Text', fontSize: pt(10), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), boldFontWeight: 600,
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 },
  headings, headingStyles: [cover, aside], layout: { layoutType: 'single' },
  paragraphStyles: [ // verse: a paragraph per line, never stretched if a line ever turns over
    { id: 'verse', textAlign: 'left', firstLineIndent: pt(0) },
    { id: 'verse-in', textAlign: 'left' }, // a line the poet indented: the body's 4.5 mm
    { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(6.5), lineHeight: pt(9.3),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('band'), headerColor: col('paper'), headerFontFamily: 'Tenor Sans',
    headerFontSize: pt(LABEL), headerBold: false, bodyFontSize: pt(9), cellPadding: mm(1.2) },
  header: { elements: [] }, footer: { elements: [ // no running heads: folios in the feet, 10 mm up
    foot('even', 'bottom-left', MARGIN.outer, '{pageNumber} · {title}'), // verso: the programme
    foot('odd', 'bottom-right', -MARGIN.outer, '{chapterTitle} · {pageNumber}')] }, // recto
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 162 líneas · content.en.mdtitle: "Home from Sea" subtitle: "Three new songs and older water music for soprano, cello and harp" author: "The Harbour Consort" --- # Home \\ from Sea {style="cover" when="Saturday 17 October 2026 · 6 pm" where="The Sail Loft, Kellan Harbour"} # Programme {kicker="Twilight recital · 17 October 2026"} ::resource{id="order"} ## About the music Hester Vane wrote *Home from Sea* for the Harbour Consort last winter, and tonight is its first performance. Its three songs set poems written within ten years of one another, and between them the consort plays older water music in its own arrangements, so that each new song follows a piece the room may already know. The poems follow on the next pages, in the order in which they are sung. Mendelssohn’s boat song rocks in six-eight; then, in Longfellow’s *The Tide Rises, the Tide Falls*, the harp keeps the tide turning and the cello takes the curlew’s call. Fauré wrote his *Élégie* in 1880 as the slow movement of a cello sonata he never finished; its lament leads into Stevenson’s *Requiem*, set almost as a folk song. Debussy’s *La cathédrale engloutie*, a piano prelude that Lior Bensaid has arranged for harp, follows the Breton legend of a church that rises from the sea on clear mornings and sinks again. Last comes Tennyson’s *Crossing the Bar*, which the poet asked to have placed at the end of every collection of his poems. Vane gives it the same place in her cycle. # The texts {kicker="Home from Sea · three songs"} ## The Tide Rises, the Tide Falls :::paragraphs{style="verse"} The tide rises, the tide falls, The twilight darkens, the curlew calls; Along the sea-sands damp and brown The traveller hastens toward the town, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space Darkness settles on roofs and walls, But the sea in the darkness calls and calls; The little waves, with their soft, white hands, Efface the footprints in the sands, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space The morning breaks; the steeds in their stalls Stamp and neigh, as the hostler calls; The day returns, but nevermore Returns the traveller to the shore, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: ::: :::space ## Requiem :::paragraphs{style="verse"} Under the wide and starry sky, Dig the grave and let me lie. Glad did I live and gladly die, :::paragraphs{style="verse-in"} And I laid me down with a will. ::: :::space This be the verse you grave for me: *Here he lies where he longed to be*; *Home is the sailor*, *home from sea*, :::paragraphs{style="verse-in"} *And the hunter home from the hill*. ::: ::: :::pagebreak ## Crossing the Bar :::paragraphs{style="verse"} Sunset and evening star, :::paragraphs{style="verse-in"} And one clear call for me! ::: And may there be no moaning of the bar, :::paragraphs{style="verse-in"} When I put out to sea, ::: :::space But such a tide as moving seems asleep, :::paragraphs{style="verse-in"} Too full for sound and foam, ::: When that which drew from out the boundless deep :::paragraphs{style="verse-in"} Turns again home. ::: :::space Twilight and evening bell, :::paragraphs{style="verse-in"} And after that the dark! ::: And may there be no sadness of farewell, :::paragraphs{style="verse-in"} When I embark; ::: :::space For tho’ from out our bourne of Time and Place :::paragraphs{style="verse-in"} The flood may bear me far, ::: I hope to see my Pilot face to face :::paragraphs{style="verse-in"} When I have crost the bar. ::: ::: # The performers {style="aside"} **The Harbour Consort** was formed in 2019 by three musicians who had played together at the town’s lifeboat-day concerts for years: Morwenna Hale, soprano, Ada Pryor, cello, and Lior Bensaid, harp. Its twilight recitals in the Sail Loft run from October to March. Hester Vane, the consort’s composer this season, writes mostly for voices and small ensembles, and *Home from Sea* is her second song cycle. :::paragraphs{style="colophon"} Set in Crimson Text, Fraunces and Tenor Sans (SIL Open Font License), from the same font files on screen and in this PDF. Poems by Longfellow (1880), Stevenson (1887) and Tennyson (1889), public domain; notes CC BY 4.0. Town, consort and composer are imagined. :::
`; // content.<lang>.md, inlined by the Cookbook const plain = { id: 'plain', name: 'Programme', shortLabel: '', captionPrefix: '', // no "Table 1" numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const row = (who, what, time, more) => [who, what, time].map((content, i) => ({ content, align: i === 2 ? 'right' : 'left', ...more })); // durations flush right const resources = [ { id: 'order', typeId: 'plain', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, // where ::resource sets it, not floated to the foot table: { model: { headerRowCount: 1, columnWidths: [28, 55, 17], rows: [ row('COMPOSER', 'WORK', 'DURATION', { isHeader: true }), // capitals: the head is a label row('Felix Mendelssohn', '*Venetian Boat Song*, op. 30 no. 6', '3′05″'), row('Hester Vane', '*The Tide Rises, the Tide Falls* · Longfellow', '4′20″'), row('Gabriel Fauré', '*Élégie*, op. 24', '6′50″'), row('Hester Vane', '*Requiem* · Stevenson', '3′10″'), row('Claude Debussy', '*La cathédrale engloutie*', '6′15″'), row('Hester Vane', '*Crossing the Bar* · Tennyson', '5′40″'), row('', 'About half an hour, without an interval', '29′20″', { background: col('foam') }), ] } } }, { id: 'cover', typeId: 'plain', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'cover.svg', width: PAGE.width * 10, height: PAGE.height * 10 }, altText: 'Night-teal cover whose lower half is rows of gilt wave scales, fading upward.' }, ]; // #region art: seigaiha, the blue-sea-wave pattern, as gilt rings on night teal const WAVES = 115; // mm from the top edge: where the waves begin, under the venue function coverArt(w, h, top) { // mm: the page, and where the waves begin const R = 12.5; // mm: the radius of one scale const f = (n) => +n.toFixed(2); const rows = Math.ceil((h - top) / (R / 2)) + 1; let out = `<rect width="${w}" height="${h}" fill="${palette.band}"/>`; for (let i = 0; i <= rows; i++) { // top row first: each row hides the lower half of the last const y = top + (i * R) / 2; const glow = f(0.5 + 0.5 * (i / rows) ** 1.3); // half-lit at the top, full gilt at the foot for (let x = (i % 2) * R; x <= w + R; x += 2 * R) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${R}" fill="${palette.band}"/>`; for (const k of [0.9, 0.64, 0.38]) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(k * R)}" fill="none" ` + `stroke="${palette.gilt}" stroke-width="${f(0.09 * R)}" stroke-opacity="${glow}"/>`; } out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(0.12 * R)}" fill="${palette.gilt}" ` + `fill-opacity="${glow}"/>`; } } return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}"><clipPath id="page"><rect width="${w}" height="${h}"/></clipPath>` + `<g clip-path="url(#page)">${out}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Each bold and italic a block may ask for. Tenor Sans has 400 only (gotcha: faked-font-styles). const FONTS = { 'Crimson Text': ['400', '400i', '600', '600i'], Fraunces: ['300', '300i'], 'Tenor Sans': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the faces first, then the layout, then a check that nothing was missed await registerFaces(); // the answer: every face in FONTS, from its own bytes await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES)); // buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds. const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Home from Sea · a recital programme' }); // #endregion // #region pdf: the export: bookmarks from the headings, a progress bar, the faces it embedded const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 }); const list = (faces) => [...faces].join(', ') || 'none'; offerPdf(() => { document.getElementById('pt-actions').prepend(bar); return renderToPdf(doc, { fontProvider, // the answer: the page's own font files resourceBytes: imageBytes, // the cover drawing, as vector paths outlines: true, // the default, spelled out: each heading becomes a bookmark onProgress: ({ phase, pages, totalPages }) => { bar.value = pages / totalPages; const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`, save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` }; kitStatus(`PDF · ${says[phase]}`); }, }); }, `${RECIPE}.pdf`); // #endregion
Kit · core, fonts, viewer, pdf, images: igual en todas las recetas · 310 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /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

#Prescinde de los marcadores

Una hoja suelta o un cartel no necesitan marcadores, y sin ellos el PDF se abre con la barra lateral cerrada.

-    outlines: true, // the default, spelled out: each heading becomes a bookmark
+    outlines: false,

#Sirve tú las fuentes

Cambia la URL de Fontsource por la de tus copias de los mismos archivos WOFF2 estáticos, uno por peso y estilo, con los nombres que usa Fontsource. La página y el PDF siguen compartiendo cada descarga.

-    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
+    files.set(file, fetch(`/fonts/${file}.woff2`)
       .then((res) => {
-        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
+        if (!res.ok) throw new Error(`No font file ${file}.woff2`);

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

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

La negrita o la cursiva que la familia no trae se simula en pantalla, no en el PDF

Cuando un texto pide un peso o un estilo que su familia no trae (una cabecera de tabla en negrita con una fuente de rótulos de un solo estilo, cursiva en una sans sin cursivas), el navegador lo sintetiza en el canvas y en HTML: engruesa o inclina la redonda con los mismos anchos. Un PDF solo incrusta fuentes reales, así que allí se imprime la fuente más cercana que dé el proveedor, sin engrosar ni inclinar. Pon en tu lista de fuentes solo las que la familia trae y ajusta cada estilo a ellas, por ejemplo con tableStyle.headerBold: false. Fuentes incrustadas en el PDF →

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

Entrecomilla cada valor del frontmatter

YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →

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

Un estilo de título hereda el salto de página de su nivel

Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }. Estilos de título →

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 →

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 →

Enumera en FONTS cada negrita y cada cursiva que pueda usar tu texto. Si olvidas una, en pantalla no lo notarás, porque el kit la carga sin avisar; el PDF, en cambio, imprime la fuente más próxima que tenga el proveedor, y solo lo delatan las sustitutas de la línea de estado.

Usa archivos estáticos, uno por peso y estilo. De un WOFF2 variable solo se incrusta la instancia por defecto, así que una negrita saldría con el peso normal (¿Por qué un proveedor de fuentes?).

Créditos

Texto
  • «The Tide Rises, the Tide Falls», con sus sangrías, tal como se imprimió en The Complete Poetical Works of Henry Wadsworth Longfellow · Henry Wadsworth Longfellow · dominio público
  • «Requiem», con las sangrías y las cursivas de la primera edición de Underwoods (1887) · Robert Louis Stevenson · dominio público
  • «Crossing the Bar», con sus versos cortos sangrados, de la primera edición de Demeter and Other Poems (1889), en la transcripción revisada de Wikisource · Alfred Tennyson · dominio público
  • El programa, las notas sobre la música, los intérpretes y el colofón · Ignacio Ferro · CC BY 4.0
Imágenes
  • Las olas seigaiha de la cubierta, dibujadas en código con la paleta de la página · Ignacio Ferro · CC BY 4.0
Fuentes
Crimson Text (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
PDF