In breve
Tre pagine di un saggio su come si impaginavano i libri antichi. Mostra come far stare ogni riga di testo alla stessa altezza in entrambe le colonne e in entrambe le pagine affiancate.
Che cosa comporrai
Le pagine da 9 a 11 di La pagina rigata, una raccolta dedicata al libro, contengono «Il canone segreto», un saggio sulle proporzioni che gli amanuensi medievali davano alle loro pagine. I margini seguono il canone che il saggio descrive, 2:3:4:6 e speculari, su un formato di 210 × 280 mm intorno a due colonne di Vollkorn. Sulle pagine è disegnata in rosso pallido una griglia della linea di base, e il testo corrente di entrambe le colonne e di entrambe le pagine affiancate poggia su di essa. Il titolo in Playfair Display riempie la prima colonna dell’apertura e il disegno del canone sta in testa alla seconda. I titoli di sezione stanno mezza riga fuori griglia, e il testo che li segue ci torna sopra. Una nota di bottega in corpo minore corre su un’interlinea propria di 10 pt. A pagina 11 il saggio finisce sotto una figura a tutta larghezza in due colonne tagliate alla stessa lunghezza, e il colophon chiude la seconda.
Questa ricetta risponde a
- Come imposto una pagina di libro: formato, margini interni ed esterni speculari, due colonne e un intercolonna?
- Come aggancio il testo a una griglia della linea di base, così che le righe si allineino tra le colonne e tra le pagine affiancate?
- Come ottengo colonne pareggiate al piede e un’ultima pagina bilanciata (giustificazione verticale)?
- Come evito vedove, orfane e ultime righe di una sola parola (righe finali troppo corte), e tengo un titolo unito al suo testo?
La risposta breve
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) };
Ingredienti
- Funzionalità
- Margini speculariGriglia della linea di baseBilanciamento delle colonneUscire dalla griglia di propositoCollocazione delle figureVedove, orfane e righe corteStili di paragrafoFormato della paginaUna o due colonneFiletto tra le colonneUnità e dimensioniCarattere del testo correnteElenchi puntati e liste di controlloElenchi numeratiLivelli dei titoliTestatine e folioTestatine secondo il ruolo della paginaFigure e tabelle come risorsePagine su un canvas
- Tipo
- Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1)
- Risorse
- I due disegni, realizzati in codice con la tavolozza della pagina (Postext Cookbook, CC BY 4.0)
Preparazione
#1 · Fai uscire dalla griglia i titoli, mai gli elenchi
headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
levels: [
// parity 'odd': the essay opens on a recto.
// 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
Lo spazio sopra un titolo resta esattamente com’è impostato, quindi 1,5 interlinee mettono il titolo tra due linee della griglia, e il testo sotto viene agganciato alla linea successiva (griglia della linea di base). Un elenco non viene agganciato prima della sua prima voce, perciò la risposta breve azzera i margini degli elenchi (andrebbe bene anche qualunque numero intero di interlinee); con gli 1,5 em predefiniti, le tre voci di pagina 10 starebbero 1,3 pt sotto le linee della colonna accanto. Le due righe del titolo in corpo 46 prendono quattro interlinee ciascuna e il suo margine inferiore una in più, così il saggio comincia alla riga 10.

#2 · Lascia la griglia di proposito, e torna
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 è un contenitore :::paragraphs composto in corpo 7,8 su 10 pt, fuori griglia. Dopo il suo ultimo paragrafo la posizione viene arrotondata per eccesso alla linea di griglia successiva, così il titolo che segue sta dove starebbe dopo il testo corrente (il contenitore :::paragraphs). Il colophon usa lo stesso contenitore in corpo 7,5 su 10 pt: le sue quattro righe occupano 40 pt, 0,2 pt meno di tre interlinee, così l’ultima colonna del saggio finisce alla pari con quella accanto.
#3 · Colloca le figure dove la griglia può assorbirle
// 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 elemento flottante non va mai sopra il paragrafo che lo cita (collocazione). Il canone, citato nella prima colonna, può comunque prendere la testa della seconda a pagina 9; il confronto a tutta larghezza, citato a pagina 10, aspetta la pagina 11. Ogni fascia viene arrotondata a interlinee intere, così il testo sotto un disegno comincia su una linea di griglia, alla pari con la colonna accanto e con la pagina a fronte. Dimensiona il disegno su quell’arrotondamento: a 61,8 mm la fascia del canone, didascalia compresa, prende 17 interlinee; a 62,5 mm ne prenderebbe 18 e lascerebbe quasi una riga di bianco sotto la didascalia.

#4 · Lascia che il bilanciamento pareggi le colonne
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
const balancing = { enabled: true };
Ogni colonna piena delle pagine 9 e 10 finisce alla riga 44, perché la gabbia è alta esattamente 44 interlinee e il testo è stato adattato a essa. Le regole che tengono il titolo con il paragrafo seguente e quelle su vedove, orfane e righe finali troppo corte sono attive per impostazione predefinita. Quando una di esse fa finire una colonna una riga prima, il bilanciamento mette lo spazio mancante sopra un titolo precedente (bilanciamento delle colonne), dove si vede come un vuoto: adatta quindi il testo finché il bilanciamento non ha più nulla da riempire. Nell’ultima pagina divide le righe rimaste in modo che entrambe le colonne finiscano sulla stessa riga.
#5 · Specchia i margini e numera da pagina 9
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 risposta breve calcola il margine di piede: il formato di 280 mm meno il margine di testa di 24 mm e una gabbia di esattamente LINES interlinee (44 × 13,4 pt = 208 mm) lascia 48 mm, sei unità, come vuole la progressione 2:3:4:6. Con mirror: true i 16 mm impostati come left sono il margine interno, a sinistra su un recto e a destra su un verso (margini speculari), così le testatine ancorate a OUTER millimetri dal bordo della pagina finiscono dove finisce la gabbia, dal lato del taglio esterno; pages: 'body' le tiene fuori dall’apertura. startAt: 9 stampa i folii da 9 a 11, e pageIndexOffset: 8 conta le otto pagine che li precedono, così la specularità, la parità delle testatine e breakBefore: 'odd' seguono il libro rilegato. Con uno scarto di 7 la prima pagina conterebbe come verso, e l’interruzione su pagina dispari metterebbe una pagina bianca prima del saggio.
La ricetta 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.25.0 // An essay on the canon of page proportions, on a page that shows its geometry and grid. import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'it'; // @lang: the sample document's language ('en' | 'es' | 'pt' | 'fr' | 'de' | 'it') 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 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. const balancing = { enabled: true }; // #endregion const config = () => ({ locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr', de: 'de', it: 'it' }), // hyphenation, caption labels 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: [ // parity 'odd': the essay opens on a recto. // 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`---Esempio Markdown · 63 righe · content.it.md
title: "La pagina rigata" subtitle: "Saggi sulla fabbricazione dei libri" --- # Il canone segreto Prima che un copista medievale scrivesse una parola, la pagina era già stata misurata. I fogli di un fascicolo venivano forati lungo i bordi con una lesina e rigati da foro a foro con una punta secca o una punta di piombo, perché ogni riga di scrittura avesse una linea su cui posarsi e ogni carta del libro la stessa cornice. In molti manoscritti la rigatura si vede ancora, tenue come una filigrana. Conserva due decisioni che i copisti non hanno mai spiegato: quanto doveva essere grande lo specchio di scrittura, e in che punto della carta doveva stare. Il disegno della :ref{id="canon" style="full" case="lower"} dà la risposta che tre studiosi, lavorando ciascuno per conto proprio nel Novecento, hanno ricavato misurando i libri antichi stessi. Nel 1946 J. A. van de Graaf pubblicò in una rivista olandese una costruzione che trova la gabbia del testo di una pagina con la sola riga, senza misurare nulla. Un anno dopo il tipografo argentino Raúl Rosarivo sostenne in *Divina proporción tipográfica* che Gutenberg, Peter Schöffer e Nicolas Jenson avevano diviso le loro pagine in noni. E Jan Tschichold, che da giovane aveva difeso l’impaginazione asimmetrica, passò gli ultimi anni a mostrare perché le proporzioni antiche fossero così difficili da migliorare. I loro risultati concordano da vicino. Il metodo oggi si chiama canone segreto perché nessuno lo mise mai per iscritto, eppure lo si può misurare in un manoscritto dopo l’altro e nei primi libri a stampa. ## Tracciare il canone Alla costruzione bastano una doppia pagina aperta e una riga: 1. Traccia le due diagonali della doppia pagina. 2. Su ogni pagina, traccia la diagonale che va dalla testa del dorso al piede del taglio esterno. 3. Dove incrocia una diagonale della doppia pagina, alza una verticale fino alla testa della pagina. 4. Unisci la sua estremità superiore all’incrocio sulla pagina di fronte. 5. La nuova linea taglia la diagonale della pagina nell’angolo interno superiore della gabbia. L’angolo esterno superiore sta sulla diagonale della doppia pagina, quello esterno inferiore sulla diagonale della pagina, e il rettangolo si chiude da sé. Quel rettangolo è la gabbia dei copisti. Il margine interno è un nono della larghezza della pagina e quello esterno due noni; il margine di testa è un nono dell’altezza e quello di piede due noni. Su una pagina di due per tre i quattro margini stanno nel rapporto 2:3:4:6, nell’ordine interno, testa, esterno e piede. La gabbia ha le proporzioni della pagina ed è alta quanto la pagina è larga, come mostra il cerchio. ## La figura di Villard I disegnatori medievali sapevano anche dividere una linea senza un righello graduato. Il taccuino di Villard de Honnecourt, trentatré carte di disegni del Duecento oggi alla Bibliothèque nationale de France, contiene una figura che divide una linea in parti uguali con la sola riga: alcune diagonali tracciate su un rettangolo, e ogni incrocio segna un terzo, un quarto o un quinto del suo lato, fin dove il disegnatore vuole arrivare. Tschichold mostrò che la stessa figura trova i noni di una pagina. Le carte del taccuino, così come ci sono giunte, misurano circa 235 per 155 millimetri, vicino a due per tre. Rosarivo arrivò ai noni dall’altro capo. Lavorando con compasso e riga su libri stampati nel Quattrocento, concluse che le loro gabbie erano state trovate dividendo la pagina in nove parti in ciascun senso, di cui il testo ne occupa sei, e chiamò «numero segreto» la proporzione 2:3 di quelle pagine. Gli esperti del Museo Gutenberg di Magonza esaminarono le sue conclusioni, che il *Gutenberg-Jahrbuch* in seguito ripubblicò. :::paragraphs{style="note"} **Nota di bottega.** Su una pagina di 160 per 240 millimetri il canone dà margini di 17,8 millimetri al dorso, 26,7 in testa, 35,6 al taglio esterno e 53,3 al piede, e una gabbia di 106,7 per 160 millimetri, alta quanto la pagina è larga. Dividi la larghezza e l’altezza in nove e conta: una parte dentro, una sopra, due fuori e due sotto. ::: ## Righe su cui posarsi La rigatura fissava le righe oltre alla cornice. Fino ai primi del Duecento quasi tutti i copisti scrivevano la prima riga sopra la prima linea rigata; nel giro di una generazione cominciarono invece ad appenderla sotto quella linea, un cambiamento così regolare che i paleografi lo usano per datare i manoscritti. N. R. Ker, che lo descrisse nel 1960, lo collocò intorno al 1230. La rigatura disegnava anche le colonne: coppie di linee verticali delimitavano ogni colonna di un libro a due colonne, e il filetto tra le colonne di questa pagina ne è l’ultima traccia. I tipografi mantennero la disciplina. La Bibbia di Gutenberg è composta su due colonne di quaranta righe nelle prime pagine, quarantuno a pagina dieci e quarantadue in quasi tutte le pagine successive, ed è per questo che i bibliografi la chiamano Bibbia a 42 righe. Le righe furono avvicinate dentro la stessa gabbia, così la cornice contenne più testo e l’intera Bibbia richiese meno carta. ## Che cosa conserva una pagina di lavoro Il canone era pensato per grandi libri destinati a durare, con spazio nei margini per le note di un lettore. Oggi pochi libri possono permetterselo. Su questa pagina di 210 per 280 millimetri chiederebbe una gabbia di 140 per 187, e più di metà della carta resterebbe bianca. La pagina che stai leggendo conserva invece tre cose del canone: - la progressione dei margini, sempre 2:3:4:6, ora a passi di otto millimetri: 16 al dorso, 24 in testa, 32 al taglio esterno e 48 al piede; - la doppia pagina come unità del progetto: i due margini interni, sommati, equivalgono a un margine esterno, così il bianco attorno a ogni gabbia si legge uniforme sul libro aperto; - la rigatura stessa, oggi una griglia della linea di base di 13,4 punti su cui poggia il testo delle due colonne, su questa pagina e su quella di fronte. Rinuncia ai noni. La sua gabbia, 162 per 208 millimetri, è molto più grande di quella del canone, ed è divisa in due colonne di 78 millimetri con un intercolonna di sei. Composta su tutta la gabbia, una riga di questo carattere supererebbe i cento caratteri, troppo lunga perché l’occhio ritrovi l’inizio della successiva; una colonna ne tiene una cinquantina, ed esattamente 44 righe. Le due pagine sono disegnate affiancate nella :ref{id="pages" style="full" case="lower"}. Una pagina che conta le sue righe può anche allineare il piede delle colonne: ogni colonna piena di questo saggio finisce sulla quarantaquattresima riga. Quando le regole che tengono un titolo unito al suo testo, o che impediscono a un paragrafo di lasciare indietro una riga sola, farebbero finire una colonna una riga prima, la riga mancante diventa un po’ più di spazio sopra un titolo più in alto, e la colonna arriva comunque all’ultima riga. Nell’ultima pagina di un saggio le colonne sono invece tagliate alla stessa lunghezza, e il testo finisce in una fascia pari. Una griglia della linea di base fissa il posto di ogni riga prima che si componga una parola, come facevano la lesina e la punta secca sulla pergamena. Gli elenchi di questo saggio non aggiungono spazio proprio, così le loro voci cadono sulle stesse righe dei paragrafi della colonna accanto. Un titolo può stare tra due righe della griglia, con una riga e mezza sopra e mezza riga sotto, ma il testo che lo segue torna subito sulla griglia. La nota di bottega, in corpo più piccolo, segue un’interlinea più stretta tutta sua e ritorna sulla griglia dove finisce. Una figura è trattata allo stesso modo: la sua fascia, didascalia compresa, è arrotondata a righe intere (diciassette per la :ref{id="canon" style="full" case="lower"}), così il testo sotto un disegno in testa alla pagina si allinea con quello della pagina di fronte. Su queste pagine la griglia è tracciata in rosso pallido perché chi legge possa verificarvi il testo. Apri il libro su una doppia pagina qualsiasi e ogni riga della pagina di sinistra prosegue oltre il dorso in una riga di quella di destra, come la rigatura dei copisti correva da foro a foro sulle due metà del foglio aperto. :::paragraphs{style="colophon"} Composto in Vollkorn, corpo 9,8 su 13,4 punti, con Playfair Display e Vollkorn SC (SIL Open Font License 1.1) Testo e disegni: Postext Cookbook, CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // Captions and alt text in every sample language: [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.'], pt: ['O cânone sobre uma página dupla aberta de páginas 2:3. As diagonais fixam os cantos ' + 'das duas manchas; o círculo, da largura da página, tem exatamente a altura da mancha.', 'Duas páginas lado a lado cruzadas por linhas vermelhas de construção, uma mancha pautada ' + 'onde elas se cruzam em cada página e um círculo da largura da página esquerda.'], fr: ['Le canon sur une double page ouverte de pages 2:3. Les diagonales fixent les angles ' + 'des deux blocs de texte ; le cercle, aussi large que la page, est exactement aussi haut ' + 'que son bloc.', 'Deux pages en regard traversées de lignes de construction rouges, un bloc de texte réglé ' + 'là où elles se croisent sur chaque page, et un cercle aussi large que la page de gauche.'], de: ['Der Kanon auf einer aufgeschlagenen Doppelseite aus Seiten im Verhältnis 2:3. Die ' + 'Diagonalen legen die Ecken beider Satzspiegel fest; der Kreis, so breit wie die Seite, ' + 'ist genau so hoch wie ihr Satzspiegel.', 'Zwei gegenüberliegende Seiten mit roten Konstruktionslinien, auf jeder Seite ein linierter ' + 'Satzspiegel an ihren Schnittpunkten und ein Kreis so breit wie die linke Seite.'], it: ['Il canone su una doppia pagina aperta di pagine 2:3. Le diagonali fissano gli angoli ' + 'delle due gabbie; il cerchio, largo quanto la pagina, è alto esattamente quanto la sua ' + 'gabbia.', 'Due pagine affiancate attraversate da linee di costruzione rosse, una gabbia rigata dove ' + 'le linee si incrociano su ogni pagina e un cerchio largo quanto la pagina di sinistra.'], }, 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.'], pt: ['A página de 210 × 280 mm deste livro, duas vezes. À esquerda, dividida em nonos pelo ' + 'cânone, com uma mancha de 140 × 187 mm. À direita, como o livro a compõe: margens de ' + '16, 24, 32 e 48 mm e duas colunas de 44 linhas.', 'Duas páginas iguais: a da esquerda dividida numa grade de nove por nove em volta da ' + 'mancha, a da direita com duas colunas pautadas.'], fr: ['La page de 210 × 280 mm de ce livre, deux fois. À gauche, divisée en neuvièmes par le ' + 'canon, avec un bloc de texte de 140 × 187 mm. À droite, telle que le livre la compose : ' + 'marges de 16, 24, 32 et 48 mm et deux colonnes de 44 lignes.', 'Deux pages identiques : celle de gauche divisée en une grille de neuf sur neuf autour de ' + 'son bloc de texte, celle de droite avec deux colonnes réglées.'], de: ['Die Seite dieses Buches, 210 × 280 mm, zweimal. Links vom Kanon in Neuntel geteilt, mit ' + 'einem Satzspiegel von 140 × 187 mm. Rechts so, wie das Buch sie setzt: Stege von 16, 24, ' + '32 und 48 mm und zwei Spalten zu 44 Zeilen.', 'Zwei gleiche Seiten: die linke um ihren Satzspiegel in ein Raster von neun mal neun Feldern ' + 'geteilt, die rechte mit zwei linierten Spalten.'], it: ['La pagina di 210 × 280 mm di questo libro, due volte. A sinistra, divisa in noni dal ' + 'canone, con una gabbia di 140 × 187 mm. A destra, come la compone il libro: margini di ' + '16, 24, 32 e 48 mm e due colonne di 44 righe.', 'Due pagine uguali: quella di sinistra divisa in una griglia di nove per nove attorno alla ' + 'sua gabbia, quella di destra con due colonne rigate.'], }, }; // #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. 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 loadSvg('canon.svg', canonSvg(...CANON)); await loadSvg('pages.svg', pagesSvg(...PAGES)); const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'A book page on a baseline grid', es: 'Una página de libro sobre una rejilla base', pt: 'Uma página de livro sobre a grade de linhas de base', fr: 'Une page de livre sur une grille de ligne de base', de: 'Eine Buchseite auf dem Grundlinienraster', it: 'Una pagina di libro sulla griglia della linea di base' }) });Kit · core, fonts, viewer, images: uguale in ogni ricetta · 270 righe
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Nascondi la griglia per la tipografia
enabled controlla solo le linee disegnate sulle pagine finite; l’impaginazione resta comunque sulla griglia di 13,4 pt, quindi nessuna riga si sposta.
- baselineGrid: { enabled: true, color: col('grid') },
+ baselineGrid: { enabled: false },#Guarda che cosa fa il bilanciamento
Senza bilanciamento, la pagina 11 fa scendere le ultime otto righe e il colophon lungo la prima colonna e lascia vuota la seconda.
-const balancing = { enabled: true };
+const balancing = { enabled: false };Errori frequenti
Errore frequente
Un elemento flottante 'top' va alla pagina dopo la sua citazione, a meno che non possa aprire la pagina che lo cita
Per impostazione predefinita un elemento flottante non va mai sopra il proprio riferimento, quindi un flottante 'top' a tutta larghezza citato a pagina N apre la pagina N+1. Imposta placement.citingPage: true sulla risorsa o sul defaultPlacement del suo tipo (o layout.floatsAtCitingPage per tutto il libro, da postext 1.25) e aprirà invece la pagina N, con il testo sopra il riferimento che scende sotto di esso, quando entra entro layout.maxTopFraction e la riga che lo cita resta su quella pagina. Non apre mai la prima pagina di un capitolo né una pagina dopo un’interruzione forzata: lì apre ancora la pagina successiva, e 'auto' o 'bottom' possono prendere il piede della pagina che lo cita. Collocazione delle figure →
- La regola per cui un elemento flottante
topnon arriva mai sulla pagina che lo cita vale per gli elementi flottanti a tutta larghezza. Un elemento flottantetopdi colonna può arrivare sulla pagina che lo cita, in testa a una colonna successiva, come fa la figura 1.1 a pagina 9. - Formati di pagina, margini e intercolonna accettano unità assolute (mm, pt, cm, in). Un
emlì non ha un corpo rispetto a cui misurarsi, e la costruzione si ferma con «dimensionToPx: baseFontSizePx is required for unit "em"». - Lo spazio tra parole di Vollkorn è stretto, 0,2 em, quindi in una colonna di circa 50 caratteri alcune righe giustificate risultano troppo spaziate o troppo strette. L’esempio è stato adattato riga per riga sul rapporto delle righe troppo spaziate della cattura; ricontrollalo dopo ogni modifica al testo o alla giustezza.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Il saggio «The Secret Canon» e le sue versioni spagnola, portoghese, francese e italiana, «El canon secreto», «O cânone secreto», «Le canon secret» e «Il canone segreto», scritti per questa ricetta · Postext Cookbook · CC BY 4.0
- Immagini
- I due disegni, realizzati in codice con la tavolozza della pagina · Postext Cookbook · CC BY 4.0
- Font
- Vollkorn (SIL OFL 1.1) · Playfair Display (SIL OFL 1.1) · Vollkorn SC (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


