Saltar al contingut principal
Recepta número 13

Receptari · Capítol 1 · Pàgina i retícula

Una pàgina de llibre sobre una retícula de base

Una pàgina a dues columnes amb marges en mirall i una caixa de 44 interlineats justos, de manera que el text cau a la mateixa retícula als dos costats del llom.

En aquesta pàgina
Sortida
Canvas
Nivell
Intermedi
Postext
Provada amb Postext 1.4.1
Requereix ≥ 1.4.1
Llicència
Actualitzada el 26 de set. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en castellà: encara no hi ha edició en català
  • Format 210 × 280 mm
  • 2 columnes, espai entre columnes de 6 mm
  • Vollkorn 9,8/13,4
  • Playfair Display
  • Vollkorn SC
  • 3 pàgines
  • Nivell
  • Postext 1.4.1
  • Compost en 21 ms
  • 180 línies de codi

En poques paraules

Tres pàgines d'un assaig sobre com es componien els llibres antics. Ensenya a fer que cada línia de text quedi a la mateixa alçada a les dues columnes i a les dues pàgines encarades.

Què compondràs

Les pàgines 9 a 11 de La página pautada, un volum sobre l'ofici del llibre, recullen sencer «El canon secreto», un assaig sobre les proporcions que els copistes medievals donaven a les seves pàgines. Els marges segueixen aquest cànon, 2:3:4:6 i en mirall, en un format de 210 × 280 mm amb dues columnes de Vollkorn. El text de les dues columnes i de les dues pàgines encarades s'assenta en una retícula de base dibuixada en vermell pàl·lid. El títol, en Playfair Display, omple la primera columna de l'obertura, i el dibuix del cànon encapçala la segona. Els títols de secció queden mitja línia fora de la retícula i el text que els segueix hi torna. Una nota de taller en cos menor porta un interlineat propi de 10 pt. A la pàgina 11, l'assaig acaba sota una figura a tota l'amplada en dues columnes d'igual alçada, i el colofó tanca la segona.

Aquesta recepta respon a

  • Com preparo una pàgina de llibre: format, marges interior i exterior simètrics, dues columnes i espai entre columnes?
  • Com ajusto el text a una retícula de base perquè les línies s'alineïn entre columnes i pàgines encarades?
  • Com aconsegueixo columnes arran del peu i una última pàgina equilibrada (justificació vertical)?
  • Com evito vídues, òrfenes i últimes línies d'una sola paraula, i mantinc cada títol amb el seu text?

La resposta curta

script.js · línies 33–54al codi complet
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) };

Ingredients

Tipografia
Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1)
Recursos
  • The two drawings, made in code in the page's palette (Postext Cookbook, CC BY 4.0)

Elaboració

#1 · Que els títols surtin de la retícula, però mai les llistes

script.js · línies 104–117al codi complet
  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

L'espai que un títol porta a sobre es respecta tal com és, així que amb línia i mitja el títol queda entre dues ratlles de la retícula, i el text que el segueix s'ajusta a la ratlla següent (retícula de base). Abans del primer element d'una llista no hi ha aquest ajust, i per això la resposta curta posa a zero els marges de les llistes (valdria qualsevol nombre enter d'interlineats); amb l'1,5 em per defecte, els tres elements de la pàgina 10 quedarien 1,3 pt per sota de les línies de la columna del costat. Les dues línies del títol, de 46 pt, ocupen quatre interlineats cadascuna, i el seu marge inferior un més, així que l'assaig comença a la línia 10.

Pàgina 10: la llista de la segona columna segueix la retícula dels paràgrafs del costat; la nota de taller cau entre les ratlles, i el títol que la segueix queda mitja línia fora, amb el seu text de nou a la retícula.

#2 · Sortir de la retícula a posta, i tornar-hi

script.js · línies 120–125al codi complet
  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 és un contenidor :::paragraphs en 7,8 sobre 10 pt, fora de la retícula. Després de l'últim paràgraf, la posició s'arrodoneix fins a la ratlla següent, i el títol que ve després queda on quedaria després d'un paràgraf normal (el contenidor :::paragraphs). El colofó fa servir el mateix contenidor en 7,5 sobre 10 pt: les seves quatre línies sumen 40 pt, 0,2 pt menys que tres interlineats, així que l'última columna de l'assaig acaba a l'alçada de la del costat.

#3 · Col·locar les figures on la retícula les pugui absorbir

script.js · línies 285–298al codi complet
// 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 flotant mai no cau per sobre del paràgraf que el cita (col·locació). Tot i així, el cànon, citat a la primera columna, ocupa el capdamunt de la segona a la mateixa pàgina 9; la comparació a tota l'amplada, citada a la pàgina 10, espera a l'11. Cada banda s'arrodoneix a interlineats sencers, de manera que el text sota un dibuix comença en una ratlla de la retícula, a l'alçada de la columna del costat i de la pàgina del davant. Mesura el dibuix pensant en aquest arrodoniment: amb 61,8 mm, la banda del cànon, peu inclòs, ocupa 17 interlineats; amb 62,5 mm n'ocuparia 18 i deixaria gairebé una línia en blanc sota el peu.

Pàgina 11: el text sota la figura a tota l'amplada comença en una ratlla de la retícula, i l'assaig acaba en dues columnes anivellades; el colofó tanca la segona.

#4 · Deixar que l'equilibrat quadri les columnes

script.js · línies 88–91al codi complet
// 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 };

Tota columna plena de les pàgines 9 i 10 acaba a la línia 44, perquè la caixa fa exactament 44 interlineats i el text s'hi va ajustar. Les regles que mantenen cada títol amb el seu text i eviten vídues, òrfenes i línies curtes vénen activades per defecte. Si una d'elles deixa una columna amb una línia de menys, l'equilibrat posa l'espai que falta sobre un títol anterior (equilibrat de columnes), on es veu com un forat; retoca el text fins que l'equilibrat no tingui res a omplir. A l'última pàgina reparteix les línies que queden perquè les dues columnes acabin a la mateixa ratlla.

#5 · Marges en mirall, i folis des de la pàgina 9

script.js · línies 58–84al codi complet
// 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 resposta curta calcula el marge de peu: als 280 mm del format els resta els 24 mm del marge de capçalera i una caixa d'exactament LINES interlineats (44 × 13,4 pt = 208 mm), i queden 48 mm, les sis unitats que demana la progressió 2:3:4:6. Amb mirror: true, els 16 mm de left són el marge interior, que cau a l'esquerra a les pàgines senars i a la dreta a les parelles (marges simètrics), així que les capçaleres, ancorades a OUTER mil·límetres de la vora de la pàgina, acaben on ho fa la caixa, del costat del tall; pages: 'body' les treu de l'obertura. startAt: 9 imprimeix els folis 9 a 11, i pageIndexOffset: 8 compta les vuit pàgines anteriors, de manera que el mirall, la paritat de les capçaleres i breakBefore: 'odd' segueixen el llibre enquadernat. Amb un desplaçament de 7, la primera pàgina comptaria com a parella i el salt hi ficaria una pàgina en blanc abans de l'assaig.

La recepta completa

Sandbox
// ═══ 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`---
Mostra en Markdown · 63 línies · 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 a totes les receptes · 270 línies// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · 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 ───────────────────────────────────────────────────────────────────────

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

Variants

#Amagar la retícula per a impremta

enabled només decideix si es dibuixen les ratlles sobre les pàgines ja compostes; la composició segueix la retícula de 13,4 pt en tots dos casos, així que no es mou cap línia.

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

#Veure què fa l'equilibrat

Sense equilibrat, les deu últimes línies de la pàgina 11 i el colofó van totes a la primera columna, i la segona queda buida.

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

Errors freqüents

Error freqüent

Un flotant 'top' no cau mai a la pàgina que el cita

Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita. Col·locació de figures →

Error freqüent

Un flotant superior pot fer baixar l'última columna en una pàgina de tancament

A postext 1.4.1, quan un capítol o un reportatge acaba en una pàgina que s'obre amb un flotant superior a tota l'amplada i les seves línies es reparteixen de manera desigual entre les columnes, l'ajust stretchAfterFloats afegeix una línia en blanc sota el flotant a la columna més curta en lloc de deixar que acabi abans, i les dues columnes ja no comencen a la mateixa línia. Posa headings.balancing.stretchAfterFloats a false o ajusta el text a un nombre parell de línies. Equilibri de columnes →

Error freqüent

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

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

Error freqüent

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

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

Error freqüent

Només 8 llengües tenen partició de mots, amb el codi exacte

La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →

Error freqüent

Tradueix Figura i Taula amb defaultResourceTypes(locale)

El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes. Figura i Taula en la teva llengua →

Error freqüent

Posa entre cometes cada valor del frontmatter

YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984". Metadades del document →

Error freqüent

Carrega totes les fonts abans de compondre

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

  • Un nivell de títol amb span: 'page' i sense advancedDesign hauria d'estendre el títol sobre les dues columnes, però postext 1.4.1 el mesura amb l'amplada d'una sola: «El canon secreto» s'imprimeix en una línia a tota l'amplada i reserva l'alçada de dues; el filet de columna travessa aquesta banda. Aquí el títol es queda a la primera columna.
  • La regla segons la qual un flotant top mai no cau a la pàgina que el cita val per als flotants a tota l'amplada. Un flotant top de columna sí que pot caure a la pàgina que el cita, al capdamunt d'una columna posterior, com la figura 1.1 a la pàgina 9.
  • El format, els marges i l'espai entre columnes van en unitats absolutes (mm, pt, cm, in). Un em no hi té cap cos amb què mesurar-se, i la composició s'atura amb «dimensionToPx: baseFontSizePx is required for unit "em"».
  • L'espai entre paraules de Vollkorn és estret, 0,2 em, així que en columnes d'uns 50 caràcters algunes línies justificades queden fluixes o atapeïdes. El text es va ajustar línia a línia amb l'informe de línies fluixes de la captura; torna a comprovar-ho si canvies el text o l'amplada.

Crèdits

Text
  • The essay “The Secret Canon” and its Spanish version “El canon secreto”, written for this recipe · Postext Cookbook · CC BY 4.0
Imatges
  • The two drawings, made in code in the page's palette · Postext Cookbook · CC BY 4.0
Fonts
Vollkorn (SIL OFL 1.1) · Playfair Display (SIL OFL 1.1) · Vollkorn SC (SIL OFL 1.1)
Sandbox