Saltar al contenido principal
Receta número 13

Recetario · Capítulo 1 · Página y retícula

Una página de libro sobre una rejilla base

Una página a dos columnas con márgenes en espejo y una caja de 44 interlineados justos, de modo que el texto cae en la misma rejilla a ambos lados del lomo.

p. 9 · 1 de 3

  • Formato 210 × 280 mm
  • 2 columnas, medianil de 6 mm
  • Vollkorn 9,8/13,4
  • Playfair Display
  • Vollkorn SC
  • 3 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 21 ms
  • 180 líneas de código

Lo que vas a componer

Las páginas 9 a 11 de La página pautada, un volumen sobre el oficio del libro, recogen entero «El canon secreto», un ensayo sobre las proporciones que los copistas medievales daban a sus páginas. Los márgenes siguen ese canon, 2:3:4:6 y en espejo, en un formato de 210 × 280 mm con dos columnas de Vollkorn. El texto de ambas columnas y de las dos páginas enfrentadas se asienta en una rejilla base dibujada en rojo pálido. El título, en Playfair Display, llena la primera columna de la apertura, y el dibujo del canon encabeza la segunda. Los títulos de sección quedan media línea fuera de la rejilla y el texto que los sigue vuelve a ella. Una nota de taller en cuerpo menor lleva un interlineado propio de 10 pt. En la página 11, el ensayo termina bajo una figura a todo el ancho en dos columnas de igual altura, y el colofón cierra la segunda.

Esta receta responde a

  • ¿Cómo preparo una página de libro: formato, márgenes interior y exterior simétricos, dos columnas y medianil?
  • ¿Cómo ajusto el texto a una rejilla base para que las líneas se alineen entre columnas y páginas enfrentadas?
  • ¿Cómo consigo columnas a ras del pie y una última página equilibrada (justificación vertical)?
  • ¿Cómo evito viudas, huérfanas y últimas líneas de una sola palabra, y mantengo cada título con su texto?

La respuesta corta

script.js · líneas 33–54en el código completo
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };

Ingredientes

Tipografía
Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1)
Recursos
  • Los dos dibujos, hechos en código con la paleta de la página (Postext Cookbook, CC BY 4.0)

Elaboración

#1 · Que los títulos salgan de la rejilla, pero nunca las listas

script.js · líneas 104–117en el código completo
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink

El espacio que un título lleva encima se respeta tal cual, así que con línea y media el título queda entre dos rayas de la rejilla, y el texto que lo sigue se ajusta a la raya siguiente (rejilla base). Antes del primer elemento de una lista no hay ese ajuste, y por eso la respuesta corta pone a cero los márgenes de las listas (valdría cualquier número entero de interlineados); con el 1,5 em por defecto, los tres elementos de la página 10 quedarían 1,3 pt por debajo de las líneas de la columna de al lado. Las dos líneas del título, de 46 pt, ocupan cuatro interlineados cada una, y su margen inferior uno más, así que el ensayo empieza en la línea 10.

Página 10: la lista de la segunda columna sigue la rejilla de los párrafos de al lado; la nota de taller cae entre las rayas, y el título que la sigue queda media línea fuera, con su texto de nuevo en la rejilla.

#2 · Salir de la rejilla a propósito, y volver

script.js · líneas 120–125en el código completo
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],

La nota es un contenedor :::paragraphs en 7,8 sobre 10 pt, fuera de la rejilla. Tras su último párrafo, la posición se redondea hasta la raya siguiente, y el título que viene después queda donde quedaría tras un párrafo normal (el contenedor :::paragraphs). El colofón usa el mismo contenedor en 7,5 sobre 10 pt: sus cuatro líneas suman 40 pt, 0,2 pt menos que tres interlineados, así que la última columna del ensayo termina a la altura de la de al lado.

#3 · Colocar las figuras donde la rejilla pueda absorberlas

script.js · líneas 285–298en el código completo
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];

Un flotante nunca cae por encima del párrafo que lo cita (colocación). Aun así, el canon, citado en la primera columna, ocupa la cabeza de la segunda en la misma página 9; la comparación a todo el ancho, citada en la página 10, espera a la 11. Cada banda se redondea a interlineados enteros, de modo que el texto bajo un dibujo empieza en una raya de la rejilla, a la altura de la columna de al lado y de la página de enfrente. Mide el dibujo pensando en ese redondeo: con 61,8 mm, la banda del canon, pie incluido, ocupa 17 interlineados; con 62,5 mm ocuparía 18 y dejaría casi una línea en blanco bajo el pie.

Página 11: el texto bajo la figura a todo el ancho empieza en una raya de la rejilla, y el ensayo termina en dos columnas niveladas; el colofón cierra la segunda.

#4 · Dejar que el equilibrado cuadre las columnas

script.js · líneas 88–91en el código completo
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };

Toda columna llena de las páginas 9 y 10 termina en la línea 44, porque la caja mide exactamente 44 interlineados y el texto se ajustó a ella. Las reglas que mantienen cada título con su texto y evitan viudas, huérfanas y líneas cortas vienen activadas por defecto. Si una de ellas deja una columna con una línea de menos, el equilibrado pone el espacio que falta sobre un título anterior (equilibrado de columnas), donde se ve como un hueco; retoca el texto hasta que el equilibrado no tenga nada que rellenar. En la última página reparte las líneas que quedan para que las dos columnas terminen en la misma raya.

#5 · Márgenes en espejo, y folios desde la página 9

script.js · líneas 58–84en el código completo
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };

La respuesta corta calcula el margen de pie: a los 280 mm del formato les resta los 24 mm del margen de cabeza y una caja de exactamente LINES interlineados (44 × 13,4 pt = 208 mm), y quedan 48 mm, las seis unidades que pide la progresión 2:3:4:6. Con mirror: true, los 16 mm de left son el margen interior, que cae a la izquierda en las páginas impares y a la derecha en las pares (márgenes simétricos), así que las cabeceras, ancladas a OUTER milímetros del borde de la página, terminan donde lo hace la caja, del lado del corte; pages: 'body' las quita de la apertura. startAt: 9 imprime los folios 9 a 11, y pageIndexOffset: 8 cuenta las ocho páginas anteriores, de modo que el espejo, la paridad de las cabeceras y breakBefore: 'odd' siguen al libro encuadernado. Con un desplazamiento de 7, la primera página contaría como par y el salto metería una página en blanco antes del ensayo.

La receta completa

// ═══ Postext Cookbook · Nº 013 · A book page on a baseline grid ═════════════════════
// https://postext.dev/en/cookbook/baseline-grid-book-page
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0)
// Fonts: Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// An essay on the canon of page proportions, on a page that shows its geometry and grid.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'baseline-grid-book-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// A scribe's colours: ink, one rubric red and the pale red of a ruled page.
const palette = {
  ink: '#211d1a', // text: a warm near-black
  rubric: '#b3261e', // the one accent: title, numbers, folios, the drawings' lines
  grid: '#efc6bd', // the baseline grid, as pale as a manuscript's ruling
  rule: '#d5cbbb', // the column rule and the drawings' hairlines
  muted: '#716860', // running heads, the workshop note, the colophon (5.1:1 on paper)
  tint: '#ede4d3', // the ground of the drawings
  paper: '#fbf8f1',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // Defaults this config does not restate link to main-color: point it at the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];

// #region answer: one grid for the whole spread: trim, mirrored margins, two columns, leading
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
// #endregion

// #region heads: the book's title on versos, the essay's on rectos, folios from page 9
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
// #endregion

// #region balance: full columns end flush, and the last page ends level
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: defaultResourceTypes(LANG), // Spanish captions (gotcha: resource-types-locale)
  colorPalette,
  page,
  layout,
  bodyText: { ...body, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('rubric'), referenceBold: false,
    firstLineIndent: mm(4), indentAfterHeading: false }, // hyphenation and widow rules are on
  // #region flow: heads that leave the grid and come back to it; lists that never leave it
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
  // #endregion
  // #region note: small type leaves the grid on purpose, and the flow snaps back after it
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  // #endregion
  captionStyle: { fontSize: pt(8), labelColor: col('rubric'), descriptionItalic: true },
  header,
  footer,
});

// #region art: the two drawings, in the page's palette (lines only: no text, no filters)
const BLOCK_W = TRIM.width - 6 * UNIT; // mm: the text block, 162 mm
const COLUMN_W = (BLOCK_W - GUTTER) / 2; // mm: one column, 78 mm
const svgFile = (w, h, markup) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"`
  + ` width="${w * 10}" height="${h * 10}">`
  + `<rect width="${w}" height="${h}" fill="${palette.tint}"/>${markup}</svg>`;
const ln = (x1, y1, x2, y2, stroke, w = 0.4) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"`
  + ` stroke="${stroke}" stroke-width="${w}" stroke-linecap="round"/>`;
const leaf = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"`
  + ` fill="${palette.paper}" stroke="${palette.rule}" stroke-width="0.3"/>`;
const ruling = (x, y, w, h, n) => Array.from({ length: n }, (_, i) => // a ruled text block
  ln(x, y + (h * (i + 0.8)) / n, x + w, y + (h * (i + 0.8)) / n, palette.rule, 0.25)).join('');
const dot = (x, y) => `<circle cx="${x}" cy="${y}" r="0.8" fill="${palette.rubric}"/>`;

// Figure 1: the canon constructed on an open spread of 2:3 pages (sizes in mm).
function canonSvg(w, h) {
  const W = (w - 8) / 2, H = 1.5 * W, X = 4, Y = 3.5; // two 2:3 pages, the ninths under them
  const at = (fx, fy) => [X + fx * W, Y + fy * H]; // a point in page widths and heights
  const line = (a, b) => ln(...at(...a), ...at(...b), palette.rubric);
  let s = leaf(X, Y, W, H) + leaf(X + W, Y, W, H);
  s += ruling(...at(2 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // verso text block
  s += ruling(...at(10 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // recto text block
  s += `<circle cx="${X + W / 2}" cy="${Y + (4 * H) / 9}" r="${W / 2}" fill="none"`
    + ` stroke="${palette.muted}" stroke-width="0.35"/>`; // a page wide, a block tall
  s += ln(X + W, Y, X + W, Y + H, palette.muted, 0.5); // the spine
  s += line([0, 1], [2, 0]) + line([0, 0], [2, 1]); // 1 · the spread's diagonals
  s += line([1, 0], [0, 1]) + line([1, 0], [2, 1]); // 2 · each page's diagonal
  s += line([2 / 3, 1 / 3], [2 / 3, 0]) + line([4 / 3, 1 / 3], [4 / 3, 0]); // 3 · the verticals
  s += line([2 / 3, 0], [4 / 3, 1 / 3]) + line([4 / 3, 0], [2 / 3, 1 / 3]); // 4 · across the spine
  for (let i = 0; i <= 18; i++) { // the ninths, ticked under the spread
    const [x, y] = at(i / 9, 1);
    s += ln(x, y + 1.2, x, y + (i % 9 ? 2.8 : 4), palette.muted, 0.3);
  }
  for (const c of [[2 / 9, 1 / 9], [8 / 9, 1 / 9], [2 / 9, 7 / 9], [10 / 9, 1 / 9], [16 / 9, 1 / 9],
    [16 / 9, 7 / 9]]) s += dot(...at(...c)); // 5 · the corners the lines fix
  return svgFile(w, h, s);
}

// Figure 2: this book's page twice, at a quarter of its size.
function pagesSvg(w, h) {
  const k = (h - 8) / TRIM.height, W = TRIM.width * k, H = TRIM.height * k, Y = 4, GAP = 20;
  const X1 = (w - 2 * W - GAP) / 2, X2 = X1 + W + GAP;
  let s = leaf(X1, Y, W, H) + leaf(X2, Y, W, H);
  for (let i = 1; i < 9; i++) { // the canon's ninths
    s += ln(X1 + (i * W) / 9, Y, X1 + (i * W) / 9, Y + H, palette.grid, 0.3);
    s += ln(X1, Y + (i * H) / 9, X1 + W, Y + (i * H) / 9, palette.grid, 0.3);
  }
  s += ruling(X1 + W / 9, Y + H / 9, (6 * W) / 9, (6 * H) / 9, 32);
  s += `<rect x="${X1 + W / 9}" y="${Y + H / 9}" width="${(6 * W) / 9}" height="${(6 * H) / 9}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  const colW = COLUMN_W * k, top = Y + 3 * UNIT * k, tall = LINES * LEAD * MM_PER_PT * k;
  const left = X2 + 2 * UNIT * k;
  for (const x of [left, left + colW + GUTTER * k]) s += ruling(x, top, colW, tall, LINES);
  s += `<rect x="${left}" y="${top}" width="${BLOCK_W * k}" height="${tall}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  s += ln(X1, Y, X1, Y + H, palette.muted, 0.6) + ln(X2, Y, X2, Y + H, palette.muted, 0.6);
  return svgFile(w, h, s);
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 63 líneas · content.es.mdtitle: "La página pautada" subtitle: "Ensayos sobre la hechura de los libros" --- # El canon secreto Antes de que un copista medieval escribiera una palabra, la página ya estaba medida. Los pliegos de cada cuaderno se picaban por los bordes con un punzón y se pautaban de agujero a agujero con una punta seca o de plomo, de modo que cada renglón tuviera una línea en la que asentarse y cada hoja del libro el mismo marco. En muchos manuscritos el pautado aún se ve, tenue como una filigrana. Guarda dos decisiones que los copistas nunca explicaron: qué tamaño debía tener la caja de escritura y en qué lugar de la hoja debía ir. El dibujo de la :ref{id="canon" style="full" case="lower"} da la respuesta que tres estudiosos, cada uno por su lado, midieron en el siglo XX en los propios libros antiguos. En 1946, J. A. van de Graaf publicó en una revista neerlandesa una construcción que encuentra la caja de una página solo con una regla, sin medir nada. Un año más tarde, el tipógrafo argentino Raúl Rosarivo defendió en *Divina proporción tipográfica* que Gutenberg, Peter Schöffer y Nicolas Jenson habían dividido sus páginas en novenos. Y Jan Tschichold, que de joven había abogado por la composición asimétrica, dedicó sus últimos años a mostrar por qué aquellas proporciones antiguas eran tan difíciles de mejorar. Sus resultados apenas difieren. Al método se le llama el canon secreto porque nadie lo puso por escrito, aunque puede medirse en un manuscrito tras otro y en los primeros libros impresos. ## Cómo se traza el canon Para la construcción basta un pliego abierto de dos páginas y una regla sin marcas: 1. Traza las dos diagonales del pliego abierto. 2. En cada página, traza la diagonal que va de la cabeza del lomo al pie del corte delantero. 3. Donde esa diagonal cruza una diagonal del pliego, levanta una vertical hasta la cabeza de la página. 4. Une su extremo con el cruce de la otra página. 5. La nueva línea corta la diagonal de la página en la esquina superior interior de la caja. La esquina superior exterior queda sobre la diagonal del pliego, la inferior exterior sobre la de la página, y el rectángulo se cierra solo. Ese rectángulo es la caja de los copistas. El margen interior mide un noveno del ancho, y el exterior, dos; el de cabeza, un noveno del alto, y el de pie, dos. En una página de dos por tres, los márgenes guardan la proporción 2:3:4:6: interior, cabeza, exterior y pie. La caja tiene las proporciones de la página y es tan alta como esta es ancha, como muestra el círculo. ## La figura de Villard En la Edad Media también se sabía dividir una línea sin medirla. El álbum de Villard de Honnecourt, treinta y tres hojas de dibujos del siglo XIII que hoy guarda la Biblioteca Nacional de Francia en París, contiene una figura que divide una línea en partes iguales solo con una regla: unas pocas diagonales sobre un rectángulo, y cada cruce marca un tercio, un cuarto o un quinto de su lado, hasta donde quiera llegar el dibujante. Tschichold mostró que la misma figura encuentra los novenos de una página. Las hojas del álbum, tal como se conservan, miden unos 235 por 155 milímetros, cerca de dos por tres. Rosarivo llegó a los novenos por la vía contraria. Con compás y regla estudió libros impresos en el siglo XV y concluyó que sus cajas salían de dividir la página en nueve partes en cada sentido, seis de ellas para el texto, y llamó «número secreto» a la proporción 2:3 de aquellas páginas. Especialistas del Museo Gutenberg de Maguncia examinaron sus hallazgos, y el *Gutenberg-Jahrbuch* los publicó de nuevo. :::paragraphs{style="note"} **Nota de taller.** En una página de 160 por 240 milímetros, el canon da márgenes de 17,8 milímetros en el lomo, 26,7 en la cabeza, 35,6 en el corte y 53,3 en el pie, y una caja de 106,7 por 160 milímetros, tan alta como ancha es la página. Divide el ancho y el alto en nueve partes y cuenta: una dentro, una arriba, dos fuera y dos abajo. ::: ## Líneas en las que apoyarse El pautado fijaba los renglones además del marco. Hasta comienzos del siglo XIII, casi todos los copistas escribían la primera línea sobre la primera raya; en una generación empezaron a colgarla debajo, y el cambio es tan regular que los paleógrafos fechan manuscritos con él. N. R. Ker, que lo describió en 1960, lo situó hacia 1230. El pautado trazaba también las columnas: pares de rayas verticales limitaban cada columna de un libro a dos columnas, y el filete entre las columnas de esta página es su último rastro. Los impresores mantuvieron la disciplina. La Biblia de Gutenberg está compuesta a dos columnas de cuarenta líneas en sus primeras páginas, cuarenta y una en la página diez y cuarenta y dos en casi todas las demás; de ahí que los bibliógrafos la llamen Biblia de 42 líneas. Las líneas se apretaron dentro de la misma caja, de modo que el marco admitiera más texto y la Biblia entera necesitara menos papel. ## Lo que conserva una página de trabajo El canon se pensó para libros grandes, hechos para durar, con sitio en los márgenes para las notas del lector. Pocos libros pueden permitírselo hoy. En esta página de 210 por 280 milímetros pediría una caja de 140 por 187, y más de la mitad del papel quedaría en blanco. La página que el lector tiene delante conserva, en cambio, tres cosas del canon: - la progresión de sus márgenes, que sigue siendo 2:3:4:6, ahora en pasos de ocho milímetros: 16 en el lomo, 24 en la cabeza, 32 en el corte y 48 en el pie; - el pliego como unidad de diseño: los dos márgenes interiores juntos miden lo mismo que uno exterior, de modo que el blanco que rodea cada caja se reparte por igual en el libro abierto; - el propio pautado, convertido en una rejilla base de 13,4 puntos en la que se asienta el texto de las dos columnas, en esta página y en la de enfrente. Renuncia a los novenos. Su caja, de 162 por 208 milímetros, es bastante mayor que la del canon y se divide en dos columnas de 78 milímetros con un medianil de seis. A todo el ancho de la caja, una línea de este cuerpo pasaría de los cien caracteres, demasiado larga para que el ojo encuentre el comienzo de la siguiente; una columna lleva unos cincuenta, y exactamente 44 líneas. La :ref{id="pages" style="full" case="lower"} muestra las dos páginas una junto a otra. Una página que cuenta sus líneas puede también igualar el pie de sus columnas: toda columna llena de este ensayo termina en la línea cuarenta y cuatro. Si las reglas que mantienen un título junto a su texto, o que impiden que un párrafo quede con una línea suelta, dejan una columna con una línea de menos, la que falta entra como un poco más de espacio sobre un título anterior, y la columna llega igualmente a la última línea. En la última página de un ensayo, en cambio, las columnas se cortan a la misma altura y el texto termina en una banda nivelada. Una rejilla base fija dónde irá cada línea antes de componer una sola palabra, como hacían el punzón y la punta seca en el pergamino. Las listas de este ensayo no añaden espacio propio, así que sus elementos caen en las mismas líneas que los párrafos de la columna de al lado. Un título puede quedar entre dos líneas de la rejilla, con línea y media encima y media línea debajo, pero el texto que lo sigue vuelve enseguida a la rejilla. La nota de taller, de cuerpo menor, se compone con un interlineado más apretado y vuelve a la rejilla donde termina. Con las figuras se hace lo mismo: su banda se redondea a líneas enteras, de modo que el texto que hay bajo un dibujo en la cabeza de una página se alinea con el de la página de enfrente. En estas páginas la rejilla se dibuja en rojo pálido para poder comprobarla. Si se abre el libro por cualquier parte, cada línea de la página izquierda pasa, a través del lomo, a una línea de la derecha, como el pautado de un manuscrito cruzaba de agujero a agujero las dos mitades del pliego abierto. :::paragraphs{style="colophon"} Compuesto en Vollkorn de 9,8 sobre 13,4 puntos, con Playfair Display y Vollkorn SC (SIL Open Font License 1.1) Texto y dibujos: Postext Cookbook, CC BY 4.0 :::
`; // content.<lang>.md, inlined by the Cookbook // Captions and alt text in both sample languages: [caption, altText]. const CAPTIONS = { canon: { en: ['The canon on an open spread of 2:3 pages. The diagonals fix the corners of both text ' + 'blocks; the circle, as wide as a page, is exactly as tall as its block.', 'Two facing pages crossed by red construction lines, a ruled text block where they cross ' + 'on each page, and a circle as wide as the left page.'], es: ['El canon sobre un pliego abierto de páginas 2:3. Las diagonales fijan las esquinas de ' + 'las dos cajas; el círculo, tan ancho como la página, es tan alto como su caja.', 'Dos páginas enfrentadas cruzadas por líneas rojas de construcción, una caja pautada donde ' + 'se cortan en cada página y un círculo tan ancho como la página izquierda.'], }, pages: { en: ['This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a ' + 'text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 ' + 'mm and two columns of 44 lines.', 'Two identical pages: the left one divided into a nine-by-nine grid around its text block, ' + 'the right one holding two ruled columns.'], es: ['La página de 210 × 280 mm de este libro, dos veces. A la izquierda, dividida en ' + 'novenos por el canon, con una caja de 140 × 187 mm. A la derecha, tal como la compone ' + 'el libro: márgenes de 16, 24, 32 y 48 mm y dos columnas de 44 líneas.', 'Dos páginas iguales: la izquierda, dividida en una cuadrícula de nueve por nueve alrededor ' + 'de su caja; la derecha, con dos columnas pautadas.'], }, }; // #region figures: one figure heads the next column, the other the next page // mm: a column wide and just short enough for a 17-lead band with its caption; a block wide const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64]; const figure = (id, [width, height], placement, [caption, altText]) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, placement, caption, altText, createdAt: 0, updatedAt: 0, }); const resources = [ // A float never lands above the paragraph that cites it. A column 'top' float cited in // the first column can still take the head of the second, on the same page. figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)), // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page). // Each band is rounded up to whole leads, so the text under it stays on the grid. figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { // text, display and label faces (Vollkorn SC ships no italic) Vollkorn: ['400', '400i', '700'], 'Playfair Display': ['700', '700i'], 'Vollkorn SC': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('canon.svg', canonSvg(...CANON)); await loadSvg('pages.svg', pagesSvg(...PAGES)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A book page on a baseline grid', es: 'Una página de libro sobre una rejilla base' }) });
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

#Ocultar la rejilla para imprenta

enabled solo decide si se dibujan las rayas sobre las páginas ya compuestas; la composición sigue la rejilla de 13,4 pt en los dos casos, así que no se mueve ninguna línea.

-  baselineGrid: { enabled: true, color: col('grid') },
+  baselineGrid: { enabled: false },

#Ver lo que hace el equilibrado

Sin equilibrado, las diez últimas líneas de la página 11 y el colofón van todos a la primera columna, y la segunda queda vacía.

-const balancing = { stretchAfterFloats: false };
+const balancing = { enabled: false };

Errores frecuentes

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

Un flotante superior puede bajar la última columna en una página de cierre

En postext 1.4.1, cuando un capítulo o un reportaje termina en una página que se abre con un flotante superior a todo el ancho y sus líneas se reparten de forma desigual entre las columnas, el ajuste stretchAfterFloats añade una línea en blanco bajo el flotante en la columna más corta en vez de dejar que termine antes, y las dos columnas ya no empiezan en la misma línea. Pon headings.balancing.stretchAfterFloats a false o ajusta el texto a un número par de líneas. Equilibrado de columnas →

Error frecuente

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

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

Error frecuente

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

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

Error frecuente

Solo 8 idiomas tienen separación silábica, con el código exacto

La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →

Error frecuente

Traduce Figura y Tabla con defaultResourceTypes(locale)

El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →

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

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 →

  • Un nivel de título con span: 'page' y sin advancedDesign debería extender el título sobre las dos columnas, pero postext 1.4.1 lo mide con el ancho de una sola: «El canon secreto» se imprime en una línea a todo el ancho y reserva la altura de dos; el filete de columna atraviesa esa banda. Aquí el título se queda en la primera columna.
  • La regla de que un flotante top nunca cae en la página que lo cita vale para los flotantes a todo el ancho. Un flotante top de columna sí puede caer en la página que lo cita, en la cabeza de una columna posterior, como la figura 1.1 en la página 9.
  • El formato, los márgenes y el medianil van en unidades absolutas (mm, pt, cm, in). Un em no tiene ahí cuerpo con el que medirse, y la composición se detiene con «dimensionToPx: baseFontSizePx is required for unit "em"».
  • El espacio entre palabras de Vollkorn es estrecho, 0,2 em, así que en columnas de unos 50 caracteres algunas líneas justificadas quedan flojas o apretadas. El texto se ajustó línea a línea con el informe de líneas flojas de la captura; vuelve a comprobarlo si cambias el texto o la medida.

Créditos

Texto
  • El ensayo «El canon secreto» y su versión inglesa, «The Secret Canon», escritos para esta receta · Postext Cookbook · CC BY 4.0
Imágenes
  • Los dos dibujos, hechos en código con la paleta de la página · Postext Cookbook · CC BY 4.0
Fuentes
Vollkorn (SIL OFL 1.1) · Playfair Display (SIL OFL 1.1) · Vollkorn SC (SIL OFL 1.1)