Saltar al contenido principal
Receta número 20

Recetario · Capítulo 6 · Recuadros y notas

Notas finales a dos columnas en lugar de notas al pie

Un preprocesador breve pasa las notas al pie de Markdown a llamadas voladas y a una sección que un estilo de título compone en página propia, a dos columnas.

En esta página

pp. 18–19 · 4–5 de 5

  • Muestra en inglés: aún no hay edición en español
  • Formato 156 × 234 mm
  • 1 columna
  • Libre Bodoni 10/13,8
  • Archivo Narrow
  • Besley
  • 5 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 21 ms
  • 180 líneas de código

Lo que vas a componer

La primera conferencia de The Chemical History of a Candle, de Michael Faraday, en inglés, compuesta como una pequeña edición de lectura de 156 × 234 mm. Se abre sobre un campo de hollín con una vela encendida apoyada en su borde inferior y sigue en una columna justificada de Libre Bodoni. Donde un libro pondría notas al pie, unos números pequeños y volados, en naranja oscuro, remiten a las notas, reunidas en una página propia bajo una banda de hollín con la vela ya apagada. Van a dos columnas en bandera, en cuerpo de 8,2 pt y con el número en negrita colgado en la sangría. Tres de las seis son de William Crookes, editor del texto en 1861, y salen de la reimpresión de 1908; las otras tres, nuevas, van firmadas Ed. Las dos columnas acaban en la misma línea, y el colofón cierra la segunda.

Esta receta responde a

  • ¿Cómo hago notas al pie?
  • ¿Cómo compongo una bibliografía o un glosario (sangría francesa, cuerpo menor)?
  • ¿Cómo escribo superíndices, subíndices y fórmulas químicas sin escribir LaTeX?

La respuesta corta

script.js · líneas 29–56en el código completo
// Use: buildDocument({ markdown: endnotes(markdown) }, config()), with a 'note' paragraph style.
// [^label] in the text → **^n^**, numbered by first citation; bold, so it takes bodyText.boldColor.
// The [^label]: definitions (one line each) print where the first stood, a 'note' paragraph each:
// '**n** text', never '1. text', which would open a numbered list (gotcha: digit-period-list).
function endnotes(markdown, style = 'note') {
  const notes = new Map(); // label → text
  const HOLE = '\u0000'; // marks where a definition stood: the first becomes the notes
  const text = markdown.replace(/^\[\^([^\]\s]+)\]:[ \t]*(.+)\n?/gm,
    (_, label, note) => { notes.set(label, note.trim()); return HOLE; });
  const cited = []; // labels in order of first citation
  const number = (label) => {
    if (!notes.has(label)) throw new Error(`The note [^${label}] has no definition`);
    if (!cited.includes(label)) cited.push(label);
    return cited.indexOf(label) + 1;
  };
  // Markers side by side share one superscript, [^a][^b] → **^1,2^**, never 12 raised (note 12).
  // A word joiner (U+2060, no width) opens each one: after an italic, '*Royal George*' and the
  // bold's ** would make '***', which the parser reads as a bold run, and print the asterisks.
  const marked = text.replace(/(?:\[\^[^\]\s]+\])+/g, (run) => {
    const numbers = [...run.matchAll(/\[\^([^\]\s]+)\]/g)].map(([, label]) => number(label));
    return `⁠**^${numbers.join(',')}^**`;
  });
  const unused = [...notes.keys()].filter((label) => !cited.includes(label));
  if (unused.length) console.warn(`Notes never cited: ${unused.join(', ')}`);
  const entries = cited.map((label, i) => `**${i + 1}** ${notes.get(label)}`);
  const section = `:::paragraphs{style="${style}"}\n${entries.join('\n\n')}\n:::\n`;
  return marked.replace(HOLE, () => section).replaceAll(HOLE, ''); // () =>: '$&' stays text
}

Ingredientes

Tipografía
Libre Bodoni, Besley, Archivo Narrow (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Convierte las notas al pie en notas finales antes de componer

El preprocesador es la respuesta corta de arriba. Postext 1.4.1 no compone notas: la llamada [^1] figura entre lo que el formato no admite, y el motor ignora PostextContent.notes, aunque los tipos lo aceptan. Una llamada [^1] suelta se imprime tal cual; dos en un mismo párrafo emparejan sus circunflejos como un superíndice y elevan todo el texto que queda entre ellas. Por eso el pen reescribe el Markdown antes de pasárselo a buildDocument. Numera las llamadas por el orden en que el texto las cita por primera vez y coloca todas las definiciones, en ese orden, donde está la primera, bajo ## Notes. Cada nota empieza por **1**, porque 1. abriría una lista numerada. Un unidor de palabras (U+2060) abre cada llamada; sin él, el ** de la llamada que sigue a *Royal George* se juntaría con el asterisco que cierra la cursiva en un ***, y los asteriscos saldrían impresos.

#2 · Reserva la negrita para las llamadas

script.js · líneas 60–61en el código completo
const markers = { boldColor: col('ember'), // **^n^**: a superscript at 58 % of the text size
  referenceBold: false }; // [Fig. 1] follows the bold colour, set roman

El preprocesador escribe cada llamada como **^n^**. Los circunflejos ponen el número al 58 % del cuerpo del texto, elevado un tercio de ese cuerpo (formato en línea), y la negrita le da el color de bodyText.boldColor, que aquí está libre porque Faraday no usa negritas. Una cifra de 5,8 pt necesita un contraste de 4,5:1 sobre el papel, y el naranja de la llama solo llega a 2,6:1; por eso las llamadas van en el de la brasa, más oscuro. La remisión [Fig. 1] de la página 17 sale del mismo color y en redonda, porque referenceColor hereda el color de la negrita y referenceBold: false le quita la negrita.

#3 · Compón las notas en cuerpo menor, con el número colgado

script.js · líneas 65–79en el código completo
const NOTE = 8.2; // pt: the notes' size, about four fifths of the text
// The hang is a bold number and an en space, measured in the notes' face once the fonts are in:
// a bold 5 matches 2, 3 and 6 within 0.02 em (the 4 is 0.05 em wider, the 1 0.14 em narrower).
function hang(label = '5') { // from ten notes on, pass the widest label: hang('10')
  const ctx = new OffscreenCanvas(1, 1).getContext('2d');
  const width = (w, s) => { ctx.font = `${w} 100px "${TEXT}"`; return ctx.measureText(s).width; };
  return em((width(700, label) + width(400, ' ')) / 100);
}
// Ragged, as justifying would stretch the en space (gotcha: ragged-no-hyphenation).
const noteStyles = () => [{ id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.3),
  textAlign: 'left', hangingIndent: hang() },
  // 22 pt above the colophon is copy-fitted: its last line and the first column's share a line.
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.2), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(22) },
];

Las notas van a 8,2 pt sobre 10,7 pt, unos cuatro quintos del cuerpo del texto. El hangingIndent de su estilo de párrafo se mide en la fuente, una vez cargada: un 5 en negrita y un espacio de medio cuadratín suman 1,12 em en Libre Bodoni, así que la primera palabra de cada nota queda alineada con sus líneas siguientes (estilos de párrafo). Con el número fuera, en la sangría francesa, las notas no necesitan espacio entre ellas. Van en bandera porque la justificación estiraría ese espacio, y una columna justificada de 55 mm a 8,2 pt quedaría con demasiado blanco entre palabras. Las virgulillas marcan los subíndices igual que los circunflejos marcan los superíndices, como en C~25~H~52~ en la nota 4 y H~2~O en la nota 5.

#4 · Da a las notas una página a dos columnas

script.js · líneas 116–129en el código completo
const BAND = 82; // mm from the trim's top: level with the foot of Figure 1 across the spread
// The band sets the reserve too, but the first grid line clear of it lies 3 mm under the soot:
const BAND_GAP = 5; // mm more of minHeight moves the notes down a line
const notesSection = { id: 'notes', // an opener page: the drop folio, no running heads
  breakBefore: { enabled: true, parity: 'any' }, span: 'page', // the next page, either side
  layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns of about 40 characters
  advancedDesign: { enabled: true, minHeight: mm(BAND - TOP + BAND_GAP), slot: { elements: [
    soot(BAND), art('snuffed', 32, 64, BAND, 12), series,
    text('kicker', '{attr.kicker}', LABEL, 9.5, 'flame', at('container', 'top-left', 0, 16),
      caps(9.5)),
    text('title', '{titleText}', DISPLAY, 52, 'wax', below('kicker', 0.5, 80), display),
    text('intro', '{attr.intro}', LABEL, 8.4, 'rule', below('title', 2.5, 92),
      { lineHeight: 1.3 }),
  ] } } };

El título ## Notes {style="notes" …} abre una sección cuyas páginas toman la disposición y el diseño del estilo. parity: 'any' la empieza en la página siguiente, sea par o impar, y span: 'page' extiende la banda sobre las dos columnas, de modo que llega al corte y la segunda columna empieza debajo (estilos de encabezado). La banda fija la reserva, pero la primera línea de la rejilla base libre de ella queda solo 3 mm por debajo del hollín; con los 5 mm que BAND_GAP suma a minHeight, las notas empiezan una línea más abajo. El filete de columna se declara en el layout del documento porque 1.4.1 no dibuja el columnRule de un estilo de título. El equilibrado de columnas, activo por defecto, corta la última página para que las dos columnas acaben en la misma línea (equilibrado de columnas).

#5 · Abre la conferencia sobre un campo de hollín

script.js · líneas 101–112en el código completo
const FIELD = 112; // mm from the trim's top
// The field reaches below the heading, so it sets the reserve (gotcha: opener-reserves-anchored);
// the H1's default bottom margin (0.5 em of 18 pt) rides on it: the text starts a grid line lower.
const opener = { enabled: true,
  slot: { elements: [soot(FIELD), art('candle', 40, 100, FIELD, 10), series,
    text('kicker', '{attr.kicker}', LABEL, 9.5, 'flame', below('series', 17, 60), caps(9.5)),
    text('title', '{titleText}', DISPLAY, 48, 'wax', below('kicker', 1.5, 96), display),
    // 86 mm breaks the subtitle after a dash: no-break spaces do not hold (gotcha: nbsp-breaks)
    text('subtitle', '{attr.subtitle}', DISPLAY, 11.5, 'rule', below('title', 3.5, 86),
      { fontWeight: 500, italic: true, lineHeight: 1.3 }),
    text('byline', '{attr.byline}', LABEL, 7.6, 'rule', below('subtitle', 7, 84), caps(7.6)),
  ] } };

El campo es una caja anclada a la página, y la vela, un elemento de imagen, se apoya en su borde inferior. El primer nivel de título lleva span: 'page' aunque la conferencia va a una sola columna: sin él, 1.4.1 recorta el diseño en la cabeza de la columna en vez de pintarlo desde el corte. El antetítulo, el subtítulo y la firma salen de la línea del título, # A Candle {kicker="Lecture I" subtitle="…" byline="…"}, así que la conferencia II no pediría código nuevo. Como el campo baja más que el título, es él quien fija la reserva. A ella se suma el margen inferior por defecto del título, de 0,5 em, y el texto empieza una línea de la rejilla base por debajo de la primera libre de hollín. El subtítulo mide 86 mm de ancho para que se parta después de una semirraya, porque en el texto de un diseño un espacio de no separación no impide el corte.

#6 · Usa la llama sobre el hollín y la brasa sobre el papel

script.js · líneas 13–23en el código completo
const palette = {
  ink: '#1f1c19', paper: '#fffdf8', // text and the soot of the fields; a warm white page
  flame: '#e08a1e', ember: '#a9560c', // kickers on soot (6:1); markers and numbers (5.1:1)
  wax: '#f6efe1', rule: '#d4c6ad', // type on soot; hairlines and small type on soot (10:1)
  muted: '#6d6356', blue: '#4f7cae', // running heads, colophon (5.8:1); a flame's blue foot
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the ember, so nothing prints blue.
// col() writes the hex too: design slots do not read the palette (gotcha: palette-skips-designs).
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ember })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

El naranja de la llama da un contraste de 6:1 sobre el hollín, pero solo de 2,6:1 sobre el papel. Por eso se reserva para los antetítulos de las bandas oscuras y para los dibujos, y todo lo pequeño que va en naranja sobre el papel (llamadas, números de nota, la etiqueta del pie de figura) usa la brasa, más oscura, que da 5,1:1. main-color apunta a la brasa, así que cualquier valor por defecto que la configuración no repita sigue el diseño en lugar de salir en azul.

La receta completa

// ═══ Postext Cookbook · Nº 020 · Endnotes in two columns instead of footnotes ═════════
// https://postext.dev/en/cookbook/endnotes-instead-of-footnotes
// Code: MIT · Text: Faraday, ed. Crookes (PD, Gutenberg #14474) · Notes, drawings: CC BY 4.0
// Fonts: Libre Bodoni, Besley, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'endnotes-instead-of-footnotes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: soot, wax and a flame; the ember is the flame dark enough for small type
const palette = {
  ink: '#1f1c19', paper: '#fffdf8', // text and the soot of the fields; a warm white page
  flame: '#e08a1e', ember: '#a9560c', // kickers on soot (6:1); markers and numbers (5.1:1)
  wax: '#f6efe1', rule: '#d4c6ad', // type on soot; hairlines and small type on soot (10:1)
  muted: '#6d6356', blue: '#4f7cae', // running heads, colophon (5.8:1); a flame's blue foot
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the ember, so nothing prints blue.
// col() writes the hex too: design slots do not read the palette (gotcha: palette-skips-designs).
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ember })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Libre Bodoni', DISPLAY = 'Besley', LABEL = 'Archivo Narrow'; // text; titles; labels
const TOP = 22, INNER = 18, OUTER = 22; // mm: margins, and a 116 mm measure of 74 characters

// #region answer: Markdown footnotes become raised markers and a Notes section
// Use: buildDocument({ markdown: endnotes(markdown) }, config()), with a 'note' paragraph style.
// [^label] in the text → **^n^**, numbered by first citation; bold, so it takes bodyText.boldColor.
// The [^label]: definitions (one line each) print where the first stood, a 'note' paragraph each:
// '**n** text', never '1. text', which would open a numbered list (gotcha: digit-period-list).
function endnotes(markdown, style = 'note') {
  const notes = new Map(); // label → text
  const HOLE = '\u0000'; // marks where a definition stood: the first becomes the notes
  const text = markdown.replace(/^\[\^([^\]\s]+)\]:[ \t]*(.+)\n?/gm,
    (_, label, note) => { notes.set(label, note.trim()); return HOLE; });
  const cited = []; // labels in order of first citation
  const number = (label) => {
    if (!notes.has(label)) throw new Error(`The note [^${label}] has no definition`);
    if (!cited.includes(label)) cited.push(label);
    return cited.indexOf(label) + 1;
  };
  // Markers side by side share one superscript, [^a][^b] → **^1,2^**, never 12 raised (note 12).
  // A word joiner (U+2060, no width) opens each one: after an italic, '*Royal George*' and the
  // bold's ** would make '***', which the parser reads as a bold run, and print the asterisks.
  const marked = text.replace(/(?:\[\^[^\]\s]+\])+/g, (run) => {
    const numbers = [...run.matchAll(/\[\^([^\]\s]+)\]/g)].map(([, label]) => number(label));
    return `⁠**^${numbers.join(',')}^**`;
  });
  const unused = [...notes.keys()].filter((label) => !cited.includes(label));
  if (unused.length) console.warn(`Notes never cited: ${unused.join(', ')}`);
  const entries = cited.map((label, i) => `**${i + 1}** ${notes.get(label)}`);
  const section = `:::paragraphs{style="${style}"}\n${entries.join('\n\n')}\n:::\n`;
  return marked.replace(HOLE, () => section).replaceAll(HOLE, ''); // () =>: '$&' stays text
}
// #endregion

// #region markers: the lecture has no bold of its own, so the bold colour is free for the markers
const markers = { boldColor: col('ember'), // **^n^**: a superscript at 58 % of the text size
  referenceBold: false }; // [Fig. 1] follows the bold colour, set roman
// #endregion

// #region notes: 8.2 pt, set ragged, each number hanging in the indent
const NOTE = 8.2; // pt: the notes' size, about four fifths of the text
// The hang is a bold number and an en space, measured in the notes' face once the fonts are in:
// a bold 5 matches 2, 3 and 6 within 0.02 em (the 4 is 0.05 em wider, the 1 0.14 em narrower).
function hang(label = '5') { // from ten notes on, pass the widest label: hang('10')
  const ctx = new OffscreenCanvas(1, 1).getContext('2d');
  const width = (w, s) => { ctx.font = `${w} 100px "${TEXT}"`; return ctx.measureText(s).width; };
  return em((width(700, label) + width(400, ' ')) / 100);
}
// Ragged, as justifying would stretch the en space (gotcha: ragged-no-hyphenation).
const noteStyles = () => [{ id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.3),
  textAlign: 'left', hangingIndent: hang() },
  // 22 pt above the colophon is copy-fitted: its last line and the first column's share a line.
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.2), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(22) },
];
// #endregion

// Design text wraps (it ends in an ellipsis by default: gotcha overflow-ellipsis-default).
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  overflow: 'wrap', align: 'left', ...extra });
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s * 0.18) });
const display = { fontWeight: 800, lineHeight: 1 }; // the titles, set solid
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });
const below = (id, y, width) => at(`#${id}`, 'below', 0, y, width);
// A field of soot from the trim's top, edge to edge, and a candle standing on its lower edge.
const soot = (height) => ({ kind: 'box', id: 'soot', style: { backgroundColor: col('ink') },
  placement: { ...at('page', 'top-left', 0, 0), size: { width: 'fill', height: mm(height) } } });
const art = (id, width, height, foot, x) => ({ kind: 'image', id, resourceId: id,
  placement: at('page', 'top-right', -x, foot - height, width) });

const series = text('series', '{title}', LABEL, 7.6, 'rule', at('container', 'top-left', 0, 4),
  caps(7.6)); // the book's title, from the frontmatter, heads both bands

// #region opener: the lecture opens on a field of soot with a lit candle
const FIELD = 112; // mm from the trim's top
// The field reaches below the heading, so it sets the reserve (gotcha: opener-reserves-anchored);
// the H1's default bottom margin (0.5 em of 18 pt) rides on it: the text starts a grid line lower.
const opener = { enabled: true,
  slot: { elements: [soot(FIELD), art('candle', 40, 100, FIELD, 10), series,
    text('kicker', '{attr.kicker}', LABEL, 9.5, 'flame', below('series', 17, 60), caps(9.5)),
    text('title', '{titleText}', DISPLAY, 48, 'wax', below('kicker', 1.5, 96), display),
    // 86 mm breaks the subtitle after a dash: no-break spaces do not hold (gotcha: nbsp-breaks)
    text('subtitle', '{attr.subtitle}', DISPLAY, 11.5, 'rule', below('title', 3.5, 86),
      { fontWeight: 500, italic: true, lineHeight: 1.3 }),
    text('byline', '{attr.byline}', LABEL, 7.6, 'rule', below('subtitle', 7, 84), caps(7.6)),
  ] } };
// #endregion

// #region section: the notes open a page of their own, in two columns under a band of soot
const BAND = 82; // mm from the trim's top: level with the foot of Figure 1 across the spread
// The band sets the reserve too, but the first grid line clear of it lies 3 mm under the soot:
const BAND_GAP = 5; // mm more of minHeight moves the notes down a line
const notesSection = { id: 'notes', // an opener page: the drop folio, no running heads
  breakBefore: { enabled: true, parity: 'any' }, span: 'page', // the next page, either side
  layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns of about 40 characters
  advancedDesign: { enabled: true, minHeight: mm(BAND - TOP + BAND_GAP), slot: { elements: [
    soot(BAND), art('snuffed', 32, 64, BAND, 12), series,
    text('kicker', '{attr.kicker}', LABEL, 9.5, 'flame', at('container', 'top-left', 0, 16),
      caps(9.5)),
    text('title', '{titleText}', DISPLAY, 52, 'wax', below('kicker', 0.5, 80), display),
    text('intro', '{attr.intro}', LABEL, 8.4, 'rule', below('title', 2.5, 92),
      { lineHeight: 1.3 }),
  ] } } };
// #endregion

const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(7.6), color: col('muted'), ...caps(7.6),
  placement: at('page', edge, x, 13), ...extra });
const folio = { fontFamily: DISPLAY, fontSize: pt(8.6), fontWeight: 700, color: col('ink'),
  letterSpacing: pt(0) }; // untracked figures
const header = { elements: [ // the book on the verso, the lecture on the recto, folios outside
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
  head('recto-title', '{attr.kicker} · {chapterTitle}', 'odd', 'top-right', -(OUTER + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const dropFolio = text('drop', '{pageNumber}', DISPLAY, 8.6, 'ink', at('page', 'bottom', 0, -12),
  { fontWeight: 700, align: 'center', pages: 'opener' }); // the lecture's and the notes' openers

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, header, footer: { elements: [dropFolio] },
  page: { width: mm(156), height: mm(234), dpi: 150, // a trade octavo
    backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(23), left: mm(INNER),
      right: mm(OUTER), mirror: true } }, // left is the inner margin
  // Drawn only where a page has two columns: the notes' (gotcha: section-column-rule).
  layout: { layoutType: 'single', columnRule: { enabled: true, color: col('rule') } },
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(13.8), color: col('ink'),
    italicColor: col('ink'), ...markers, firstLineIndent: mm(4.5), indentAfterHeading: false,
    minWordSpacing: 0.85, maxWordSpacing: 1.8 }, // the loosest lines reach 1.78 under any cap
  headings: { fontFamily: DISPLAY, color: col('ink'), levels: [
    // Break restated (gotcha: headings-drop-h1-break); the span lets the design reach the trim.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      advancedDesign: opener },
  ] },
  headingStyles: [notesSection], paragraphStyles: noteStyles(),
  // One figure, numbered through the book: "Figure 1", not the chapter-scoped "Figure 1.1".
  resourceTypes: [{ id: 'figure', name: 'Figure', shortLabel: 'Fig.', captionPrefix: 'Figure',
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8.2), labelColor: col('ember') },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 36 líneas · content.en.mdtitle: "The Chemical History of a Candle" author: "Michael Faraday" --- # A Candle {kicker="Lecture I" subtitle="The Flame – Its Sources – Structure – Mobility – Brightness" byline="Michael Faraday · Christmas 1860"} I purpose, in return for the honour you do us by coming to see what are our proceedings here, to bring before you, in the course of these lectures, the Chemical History of a Candle. I have taken this subject on a former occasion;[^christmas] and were it left to my own will, I should prefer to repeat it almost every year—so abundant is the interest that attaches itself to the subject, so wonderful are the varieties of outlet which it offers into the various departments of philosophy. There is not a law under which any part of this universe is governed which does not come into play, and is touched upon in these phenomena. There is no better, there is no more open door by which you can enter into the study of natural philosophy, than by considering the physical phenomena of a candle. I trust, therefore, I shall not disappoint you in choosing this for my subject rather than any newer topic, which could not be better, were it even so good. But we must speak of candles as they are in commerce. Here are a couple of candles commonly called dips. They are made of lengths of cotton cut off, hung up by a loop, dipped into melted tallow, taken out again and cooled, then re-dipped until there is an accumulation of tallow round the cotton. In order that you may have an idea of the various characters of these candles, you see these which I hold in my hand—they are very small, and very curious. They are, or were, the candles used by the miners in coal mines. In olden times the miner had to find his own candles; and it was supposed that a small candle would not so soon set fire to the fire-damp in the coal mines as a large one; and for that reason, as well as for economy’s sake, he had candles made of this sort—20, 30, 40, or 60 to the pound. They have been replaced since then by the steel-mill, and then by the Davy-lamp, and other safety-lamps of various kinds. I have here a candle that was taken out of the *Royal George*[^george], it is said, by Colonel Pasley. It has been sunk in the sea for many years, subject to the action of salt water. It shews you how well candles may be preserved; for though it is cracked about and broken a good deal, yet, when lighted, it goes on burning regularly, and the tallow resumes its natural condition as soon as it is fused. Mr. Field, of Lambeth, has supplied me abundantly with beautiful illustrations of the candle and its materials. I shall therefore now refer to them. And, first, there is the suet—the fat of the ox—Russian tallow, I believe, employed in the manufacture of these dips, which Gay Lussac, or some one who entrusted him with his knowledge, converted into that beautiful substance, stearin, which you see lying beside it. A candle, you know, is not now a greasy thing like an ordinary tallow candle, but a clean thing, and you may almost scrape off and pulverise the drops which fall from it without soiling anything. This is the process he adopted:[^stearin]—The fat or tallow is first boiled with quick-lime, and made into a soap, and then the soap is decomposed by sulphuric acid, which takes away the lime, and leaves the fat re-arranged as stearic acid, whilst a quantity of glycerin is produced at the same time. Glycerin—absolutely a sugar, or a substance similar to sugar—comes out of the tallow in this chemical change. The oil is then pressed out of it; and you see here this series of pressed cakes, shewing how beautifully the impurities are carried out by the oily part as the pressure goes on increasing, and at last you have left that substance which is melted, and cast into candles as here represented. The candle I have in my hand is a stearin candle, made of stearin from tallow in the way I have told you. Then here is a sperm candle, which comes from the purified oil of the spermaceti whale. Here also are yellow bees-wax and refined bees-wax, from which candles are made. Here, too, is that curious substance called paraffin, and some paraffin candles made of paraffin obtained from the bogs of Ireland.[^paraffin] I have here also a substance brought from Japan, since we have forced an entrance into that out-of-the-way place—a sort of wax which a kind friend has sent me, and which forms a new material for the manufacture of candles. There is another condition which you must learn as regards the candle, without which you would not be able fully to understand the philosophy of it, and that is the vaporous condition of the fuel. In order that you may understand that, let me shew you a very pretty, but very common-place experiment. If you blow a candle out cleverly, you will see the vapour rise from it. You have, I know, often smelt the vapour of a blown-out candle—and a very bad smell it is; but if you blow it out cleverly, you will be able to see the vapour into which this solid matter is transformed. I will blow out one of these candles in such a way as not to disturb the air around it, by the continuing action of my breath; and now, if I hold a lighted taper two or three inches from the wick, you will observe a train of fire going through the air till it reaches the candle. I am obliged to be quick and ready, because, if I allow the vapour time to cool, it becomes condensed into a liquid or solid, or the stream of combustible matter gets disturbed. Now, as to the shape or form of the flame. It concerns us much to know about the condition which the matter of the candle finally assumes at the top of the wick—where you have such beauty and brightness as nothing but combustion or flame can produce.[^combustion] You have the glittering beauty of gold and silver, and the still higher lustre of jewels, like the ruby and diamond; but none of these rival the brilliancy and beauty of flame. What diamond can shine like flame? It owes its lustre at night-time to the very flame shining upon it. The flame shines in darkness, but the light which the diamond has is as nothing until the flame shine upon it, when it is brilliant again. The candle alone shines by itself, and for itself, or for those who have arranged the materials. Now, let us look a little at the form of the flame as you see it under the glass shade. It is steady and equal; and its general form is that which is represented in the diagram [:ref{id="flame"}], varying with atmospheric disturbances, and also varying according to the size of the candle. It is a bright oblong—brighter at the top than towards the bottom—with the wick in the middle, and besides the wick in the middle, certain darker parts towards the bottom, where the ignition is not so perfect as in the part above. I can give you here a little further illustration, for the purpose of shewing you how flame goes up or down; according to the current. I have here a flame—it is not a candle-flame—but you can, no doubt, by this time, generalise enough to be able to compare one thing with another. What I am about to do is to change the ascending current that takes the flame upwards into a descending current. This I can easily do by the little apparatus you see before me. The flame, as I have said, is not a candle flame, but it is produced by alcohol, so that it shall not smoke too much. I will also colour the flame with another substance,[^copper] so that you may trace its course; for with the spirit alone you could hardly see well enough to have the opportunity of tracing its direction. By lighting this spirit-of-wine, we have then a flame produced; and you observe that when held in the air, it naturally goes upwards. You understand now easily enough why flames go up under ordinary circumstances—it is because of the draught of air by which the combustion is formed. But now, by blowing the flame down, you see I am enabled to make it go downwards into this little chimney—the direction of the current being changed. Before we have concluded this course of lectures, we shall shew you a lamp in which the flame goes up and the smoke goes down, or the flame goes down and the smoke goes up. You see, then, that we have the power in this way of varying the flame in different directions. It is too bad that we have not got further; but we must not, under any circumstances, keep you beyond your time. It will be a lesson to me in future to hold you more strictly to the philosophy of the thing, than to take up your time so much with these illustrations. ## Notes {style="notes" kicker="Lecture I" intro="The raised numbers in the lecture point here. Notes signed by the editor are new to this edition; the rest are William Crookes’s, from the impression of 1908."} [^christmas]: Faraday first gave this course of six lectures at Christmas 1848. The text printed here is that of his second course, given at the Royal Institution in 1860–61 and published in 1861, edited by William Crookes. *Ed.* [^george]: The *Royal George* sunk at Spithead on the 29th of August, 1782. Colonel Pasley commenced operations for the removal of the wreck by the explosion of gunpowder, in August, 1839. The candle which Professor Faraday exhibited must therefore have been exposed to the action of salt water for upwards of fifty-seven years. [^stearin]: The fat or tallow consists of a chemical combination of fatty acids with glycerine. The lime unites with the palmitic, oleic, and stearic acids, and separates the glycerine. After washing, the insoluble lime soap is decomposed with hot dilute sulphuric acid. The melted fatty acids thus rise as an oil to the surface, when they are decanted. They are again washed and cast into thin plates, which, when cold, are placed between layers of cocoa-nut matting, and submitted to intense hydraulic pressure. In this way the soft oleic acid is squeezed out, whilst the hard palmitic and stearic acids remain. These are further purified by pressure at a higher temperature, and washing in warm dilute sulphuric acid, when they are ready to be made into candles. These acids are harder and whiter than the fats from which they were obtained, whilst at the same time they are cleaner and more combustible. [^paraffin]: Paraffin wax is a mixture of hydrocarbons with 20 to 40 carbon atoms, such as C~25~H~52~. Karl von Reichenbach first isolated it from wood tar in 1830; by 1860 it was distilled for candles from shale, peat and coal. *Ed.* [^combustion]: As it burns, the vapour of the wax combines with oxygen from the air and leaves the flame as water, H~2~O, and carbon dioxide, CO~2~, which Faraday calls “carbonic acid”. He finds the water in Lecture II and the carbonic acid in Lecture V. *Ed.* [^copper]: The alcohol had chloride of copper dissolved in it: this produces a beautiful green flame. :::paragraphs{style="colophon"} Set in Libre Bodoni, Besley and Archivo Narrow, all three under the SIL Open Font License. The text is Michael Faraday’s, as edited by William Crookes in 1861, from the impression of 1908 (Project Gutenberg eBook 14474), abridged; the drawings and the editor’s notes are CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra }); const resources = [ svgResource('candle', 1600, 4000), svgResource('snuffed', 1100, 2200), // uncited: design only // Cited on page 17, the 'top' figure heads page 18 (gotcha: top-float-next-page). svgResource('flame', 2320, 1200, { placement: { position: 'top' }, caption: 'A candle flame as ' + 'it looks under a glass shade (left) and in section (right): the dark core of wax vapour ' + 'round the wick, the blue foot where the air first meets it, the bright zone where soot ' + 'glows, and the faint mantle, the hottest part. Heated air rises round it and draws it up.', altText: 'A candle flame, whole and in section, with arrows of rising air' }), ]; // #region art: a lit candle, a snuffed one and the flame in section, in the palette (seeded) // No words in the drawings: an SVG drawn as an image cannot use web fonts // (gotcha: svg-no-webfonts). Arrowheads are paths (gotcha: svg-no-marker-filters). function rng(seed) { // Mulberry32: the same smoke on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const n = (v) => +v.toFixed(2); const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const op = (v) => ` fill-opacity="${v}"`; // Light round a flame: a radial gradient from the flame's colour to nothing. const halo = (x, y, r, strength) => `<radialGradient id="h${x}" cx="0.5" cy="0.5" r="0.5">` + `<stop offset="0" stop-color="${palette.flame}" stop-opacity="${strength}"/>` + `<stop offset="0.45" stop-color="${palette.flame}" stop-opacity="${strength * 0.35}"/>` + `<stop offset="1" stop-color="${palette.flame}" stop-opacity="0"/></radialGradient>` + dot(x, y, r, `url(#h${x})`); // A flame from its foot (x, base) up to its tip: round below, drawn out above. function tongue(x, base, top, half) { const h = base - top; return `M${n(x)} ${n(top)}C${n(x + half * 0.3)} ${n(top + h * 0.28)} ${n(x + half)} ` + `${n(top + h * 0.5)} ${n(x + half)} ${n(top + h * 0.74)}` + `C${n(x + half)} ${n(base - h * 0.04)} ` + `${n(x + half * 0.5)} ${n(base)} ${n(x)} ${n(base)}C${n(x - half * 0.5)} ${n(base)} ` + `${n(x - half)} ${n(base - h * 0.04)} ${n(x - half)} ${n(top + h * 0.74)}C${n(x - half)} ` + `${n(top + h * 0.5)} ${n(x - half * 0.3)} ${n(top + h * 0.28)} ${n(x)} ${n(top)}Z`; } // The flame's zones: mantle, bright body, dark core round the wick, blue foot. function flameAt(x, base, top, half, { lit = true, section = false } = {}) { const h = base - top; const P = palette; let out = shape(tongue(x, base + 2, top - h * 0.08, half * 1.22), P.flame, section ? `${op(0.16)} stroke="${P.flame}" stroke-width="0.8"` : op(0.22)); out += shape(tongue(x, base, top, half), P.flame); if (!section) { // as seen: brighter above, darker below out += shape(tongue(x, base - h * 0.22, top + h * 0.1, half * 0.72), P.wax, op(0.55)) + shape(tongue(x, base - h * 0.38, top + h * 0.2, half * 0.42), P.wax, op(0.75)); } out += shape(tongue(x, base, base - h * (section ? 0.5 : 0.36), half * (section ? 0.5 : 0.36)), section ? P.ink : P.ember, op(section ? 0.72 : 0.7)); out += shape(`M${n(x - half * 0.95)} ${n(base - h * 0.1)}Q${n(x)} ${n(base + h * 0.06)} ` + `${n(x + half * 0.95)} ${n(base - h * 0.1)}Q${n(x + half * 0.7)} ${n(base + h * 0.03)} ` + `${n(x)} ${n(base + h * 0.03)}Q${n(x - half * 0.7)} ${n(base + h * 0.03)} ` + `${n(x - half * 0.95)} ${n(base - h * 0.1)}Z`, P.blue, op(lit ? 0.9 : 0)); return out; } // A pillar of wax from y down past the art's foot, with the cup of melted wax on top. function pillar(x, y, half, foot, drip = 0) { const P = palette; const shade = `<linearGradient id="w${x}" x1="0" x2="1" y1="0" y2="0">` // rounded by light + `<stop offset="0" stop-color="${P.rule}"/><stop offset="0.3" stop-color="${P.wax}"/>` + `<stop offset="0.62" stop-color="${P.wax}"/><stop offset="1" stop-color="${P.rule}"/>` + '</linearGradient>'; return shade + `<rect x="${n(x - half)}" y="${n(y)}" width="${n(half * 2)}" ` + `height="${n(foot - y)}" fill="url(#w${x})"/>` + (drip ? shape(`M${n(x - half * 0.78)} ${n(y)}h${n(half * 0.34)}v${n(drip)}a${n(half * 0.17)} ` + `${n(half * 0.17)} 0 0 1-${n(half * 0.34)} 0Z`, P.wax) : '') + `<ellipse cx="${n(x)}" cy="${n(y)}" rx="${n(half)}" ry="${n(half * 0.2)}" ` + `fill="${P.wax}"/>` + `<ellipse cx="${n(x)}" cy="${n(y + half * 0.02)}" rx="${n(half * 0.76)}" ` + `ry="${n(half * 0.13)}" fill="${P.rule}"${op(0.8)}/>`; // the cup of melted wax } const wick = (x, y, len, lean = 2) => line(`M${n(x)} ${n(y)}q${n(lean * 0.3)} ${n(-len * 0.5)} ` + `${n(lean)} ${n(-len)}`, palette.ink, 1.6); // Rising air: a curve from beside the foot to above the tip, and a path arrowhead. function draught(x0, y0, x1, y1, color, width, opacity) { const head = shape(`M${n(x1 - 2.2)} ${n(y1 + 3.2)}L${n(x1)} ${n(y1 - 0.6)}L${n(x1 + 2.2)} ` + `${n(y1 + 3.2)}Z`, color, op(opacity)); return line(`M${n(x0)} ${n(y0)}C${n(x0)} ${n((y0 + y1) / 2)} ${n(x1)} ${n(y0 - (y0 - y1) * 0.6)} ` + `${n(x1)} ${n(y1 + 2)}`, color, width, ` stroke-opacity="${opacity}"`) + head; } function candle() { // the opener's: 40 × 100 mm, lit, with a halo on the soot return svg(160, 400, halo(80, 128, 80, 0.34) + flameAt(80, 206, 66, 19) + pillar(80, 222, 30, 400, 46) + wick(79, 223, 22)); } function snuffed() { // the notes band's: 32 × 64 mm, blown out, three strands of smoke const r = rng(7); const P = palette; let out = pillar(55, 140, 24, 220, 26) + wick(54, 141, 14, 3); for (let k = 0; k < 3; k++) { // three strands of vapour, thinning as they rise let d = `M${n(57 + k)} 126`; for (let y = 126, a = r() * 6; y > 8; y -= 14, a += 1.3) { d += `S${n(57 + Math.sin(a) * (4 + (126 - y) * 0.12))} ${n(y - 7)} ` + `${n(57 + Math.sin(a + 0.8) * (3 + (126 - y) * 0.1))} ${n(y - 14)}`; } out += line(d, P.rule, 1.2 - k * 0.3, ` stroke-opacity="${0.55 - k * 0.15}"`); } return svg(110, 220, out + dot(57, 127, 1.6, P.ember)); } function flame() { // Figure 1: 116 × 60 mm, as seen and in section, on soot const P = palette; let out = `<rect width="232" height="120" rx="2" fill="${P.ink}"/>`; out += halo(70, 60, 46, 0.3) + flameAt(70, 92, 24, 11) + pillar(70, 100, 17, 120, 12) + wick(69.5, 101, 12) // then the glass shade, open below, drawn over the candle it stands round + `<path d="M47 112V17a5 5 0 0 1 5-5h36a5 5 0 0 1 5 5v95" fill="${P.wax}"${op(0.05)} ` + `stroke="${P.rule}" stroke-opacity="0.4" stroke-width="0.8"/>` + line('M52 18v88', P.wax, 1.4, ' stroke-opacity="0.18"'); for (const side of [-1, 1]) { // the air the flame heats, rising round it out += draught(160 + side * 32, 108, 160 + side * 10, 14, P.rule, 1, 0.75) + draught(160 + side * 44, 104, 160 + side * 22, 30, P.rule, 1, 0.5); } return svg(232, 120, out + flameAt(160, 92, 24, 11, { section: true }) + pillar(160, 100, 17, 120) + wick(159.5, 101, 12)); } const drawings = { candle, snuffed, flame }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Libre Bodoni': ['400', '400i', '700'], // text and notes (700: the numbers) Besley: ['500i', '700', '800'], 'Archivo Narrow': ['400', '400i', '600', '700'] }; // labels // ─── 4 · Build & show ─────────────────────────────────────────────────────── const source = endnotes(markdown); // the answer, run before the engine sees the text await Promise.all([loadFonts(FONTS, source), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); // The lecture starts on folio 15, a recto, 14 pages into the book (gotcha: parity-page1-recto). const continuation = { pageIndexOffset: 14, pageNumbering: { startAt: 15 } }; const doc = await buildWithFonts( () => buildDocument({ markdown: source, resources, continuation }, config()), source); showPages(doc, { title: 'The Chemical History of a Candle, Lecture I' });
Kit · core, fonts, viewer, images: igual en todas las recetas · 270 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 · 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

#Imprime las llamadas en tinta

Si el texto usa la negrita para su propio énfasis, pon las llamadas en tinta: boldColor da el mismo color a las llamadas y a cada palabra en negrita.

-const markers = { boldColor: col('ember'), // **^n^**: a superscript at 58 % of the text size
+const markers = { boldColor: col('ink'), // **^n^**: a superscript at 58 % of the text size

#Deja que las columnas de notas acaben desiguales

Sin el equilibrado de la última página, la primera columna se llena hasta el pie antes de que empiece la segunda.

-  headings: { fontFamily: DISPLAY, color: col('ink'), levels: [
+  headings: { fontFamily: DISPLAY, color: col('ink'), balancing: { trailing: false }, levels: [

Errores frecuentes

Error frecuente

«1998. » o «- » al principio de un párrafo abren una lista

Un párrafo que empieza por un número, un punto y un espacio, o por un guion y un espacio, se convierte en un elemento de lista. Pon un unidor de palabras (U+2060) antes del número y escribe los diálogos con raya. Escapes y caracteres literales →

Error frecuente

En el texto en bandera no hay separación silábica

La separación silábica solo se aplica al texto justificado; el texto en bandera corta entre palabras, así que una columna estrecha en bandera queda muy desigual. Justifica el pasaje o ensancha la medida. Separación silábica e idioma del documento →

Error frecuente

El filete de columna de un estilo de título no se dibuja

En postext 1.4.1, el layout de un estilo de título pasa su sección a dos columnas, pero los renderizadores solo leen el layout.columnRule del documento, así que un columnRule dentro de headingStyles[].layout se ignora. Declara el filete en el layout del documento: solo se dibuja donde una página tiene más de una columna, de modo que un libro a una columna lo muestra únicamente en su sección a dos columnas. Filete de columna →

Error frecuente

Un espacio de no separación sigue partiendo la línea

En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase. Escapes y caracteres literales →

Error frecuente

Un flotante 'top' nunca cae en la página que lo cita

Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita. Colocación de figuras →

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

Una apertura reserva altura hasta su elemento anclado más bajo

Una apertura de diseño avanzado reserva la altura de su elemento más bajo, y cuentan también los anclados a la página o a la sangre que quedan por debajo del título, así que un adorno al pie de la página empuja el texto a la siguiente. Deja esos adornos por encima del título, pásalos a una ranura de cabecera o de pie, o fija la reserva con minHeight. Aperturas diseñadas →

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

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

El texto dentro de un SVG <img> no puede usar fuentes web

Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →

Error frecuente

Sin <marker> ni filtros en los SVG, o pasan a mapa de bits

Una figura SVG solo sigue siendo vectorial en el PDF sin <marker>, filtros ni máscaras; si no, pasa a mapa de bits, y los filtros muy anidados pueden dejarla en blanco en Chrome. Dibuja las puntas de flecha como trazados. Figuras y tablas como recursos →

Error frecuente

La página 1 es impar: planifica con números físicos

La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue. Saltos de página y de columna →

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

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

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 →

Créditos

Texto
Fuentes
Libre Bodoni (SIL OFL 1.1) · Besley (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)