In breve
Una piccola rivista di tipografia, composta in colonne strette con entrambi i bordi del testo allineati. Mostra come tenere uniformi gli spazi tra le parole, accanto a una versione con le righe irregolari marcate in giallo.
Che cosa comporrai
Il numero 12 di Galley, la rivista di un laboratorio di tipografia, composta in A5 su due colonne di circa quaranta caratteri, dove una riga giustificata ha solo cinque o sei spazi tra parole per assorbire l’avanzo. L’apertura disegna il modello Knuth–Plass su una fascia grafite: una riga di riquadri di parole le cui molle gialle di colla si tendono fino a riempire la giustezza. Il saggio spiega perché le colonne strette si allargano e stampa in monospaziato, tra i paragrafi, le impostazioni dietro ogni regola. Al piede di pagina 3 una prova al banco compone un paragrafo in bandiera, giustificato senza trattini e giustificato con i trattini. Il pen compone anche il numero con la divisione in righe first fit e con le protezioni contro vedove e orfane disattivate, e mostra le due pagine 2 affiancate sopra le pagine, come nella scheda di questa ricetta, con le righe lasche velate di giallo evidenziatore e le righe isolate e quelle finali troppo corte segnalate nel margine.
Questa ricetta risponde a
- Come ottengo una buona giustificazione e sillabazione per testi in spagnolo, francese o tedesco?
- Come evito vedove, orfane e ultime righe di una sola parola (righe finali troppo corte), e tengo un titolo unito al suo testo?
- Perché una riga esce in bandiera o troppo spaziata (URL, composti lunghi, parole lunghe nelle celle)?
- Come scopro che cosa non va nel mio documento (avvisi, sforamenti, impaginazione che non converge)?
La risposta breve
// Knuth–Plass breaking, hyphenation and the widow, orphan and runt penalties are all on by
// default. A narrow column also needs a tighter fence round the glue, in multiples of a
// normal word space (defaults 0.6 and 2): lines past the upper fence cost more than any
// hyphen, so the breaker hyphenates or re-breaks the paragraph before it stretches that far.
const FENCES = { minWordSpacing: 0.8, maxWordSpacing: 1.6 };
const bodyText = { // config().bodyText
fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
...FENCES,
};
// Hyphenation follows the document's locale:
const locale = t({ en: 'en-us', es: 'es' }); // config().locale: each edition's patterns
// The control: first-fit breaking, which sets each line once and moves on, with the widow and
// orphan guards off (runts are priced inside Knuth–Plass only). Same text, fonts and measure.
const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false };
const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } });
Ingredienti
- Funzionalità
- Divisione in righe ottimale (Knuth–Plass)Vedove, orfane e righe corteAvvisi e diagnosticaSillabazione e lingua del documentoRientri, allineamento e spaziatura dei paragrafiColonne in un riquadro o nel testoRiquadri annidatiRiquadriAperture progettateImmagini nei progetti di paginaAttributi dei titoliStili di paragrafoPagine su un canvasFigure e tabelle come risorseTavolozza di colori semanticaTestatine e folioTestatine secondo il ruolo della pagina
- Usa anche
- Fascia di capitolo a tutta larghezza
- Tipo
- Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Stringi i limiti, tieni le protezioni
Il codice di questo passo è nella risposta breve qui sopra. Knuth–Plass, la sillabazione, le penalità per vedove, orfane e righe finali corte e i titoli tenuti con il paragrafo seguente sono attivi per impostazione predefinita, quindi l’edizione pubblicata restringe soltanto i limiti della spaziatura tra parole a 0,8 e 1,6 volte uno spazio normale (i valori predefiniti sono 0,6 e 2). Una riga oltre il limite superiore costa più di qualsiasi trattino o riga corta, quindi l’algoritmo prova prima altre divisioni; togli i due limiti e questo saggio compone due righe oltre 1,6. La sillabazione segue locale: 'es', 'fr' e 'de' hanno i propri pattern TeX, un tag regionale come 'es-ES' prende quelli della sua lingua, e una lingua senza pattern viene sillabata come inglese americano, con un avviso nella console.
#2 · Marca le righe lasche dall’albero dell’impaginazione
// findLooseLines gives the justified lines whose word spaces stretch past a threshold, here the
// upper fence. The rest is read from the VDT: a line past 3× is set ragged (line.ragged), and a
// paragraph cut by a column is two blocks.
function marks(doc, page) {
const body = doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)).filter((b) =>
b.type === 'paragraph' && b.containerId === undefined && b.textAlign === 'justify');
const loose = findLooseLines(doc, { threshold: FENCES.maxWordSpacing, pageIndex: page.index });
const out = loose.map(({ x, y, width: w, height: h }) => ({ x, y, w, h, kind: 'loose' }));
for (const column of page.columns) {
for (const b of column.blocks.filter((x) => body.includes(x))) {
const parts = body.filter((o) => o.contentIndex === b.contentIndex);
b.lines.forEach((line) => {
const at = { x: b.bbox.x, y: line.bbox.y, w: b.bbox.width, h: line.bbox.height, column };
if (line.ragged) out.push({ ...at, kind: 'loose' }); // past 3×: no ratio, set ragged
if (b.lines.length === 1 && parts.length > 1) { // Postext's names (see the essay):
out.push({ ...at, kind: b === parts[0] ? 'widow' : 'orphan' }); // foot : head
} else if (line.isLastLine && !/\s/.test(line.text.trim())) {
out.push({ ...at, kind: 'runt' }); // one word alone on a paragraph's last line
}
});
}
}
return out;
}
const TAGS = t({ en: { widow: 'widow', orphan: 'orphan', runt: 'runt' },
es: { widow: 'viuda', orphan: 'huérfana', runt: 'corta' } });
function paintMarks(canvas, list, scale) {
const ctx = canvas.getContext('2d');
ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
for (const m of list) { // loose lines: a wash; lone lines and runts: a tag in the margin
const loose = m.kind === 'loose';
ctx.globalCompositeOperation = loose ? 'multiply' : 'source-over'; // the ink shows through
ctx.fillStyle = loose ? palette.marker : palette.graphite;
if (loose) { ctx.fillRect(m.x - 2, m.y + 1, m.w + 4, m.h - 1); continue; }
ctx.font = `600 ${m.h * 0.48}px "${MONO}"`;
const w = ctx.measureText(TAGS[m.kind]).width + m.h * 0.5;
const x = m.column.index === 0 ? m.x - w - m.h * 0.35 : m.x + m.w + m.h * 0.35;
ctx.fillRect(x, m.y + m.h * 0.12, w, m.h * 0.8);
ctx.fillStyle = palette.marker;
ctx.fillText(TAGS[m.kind], x + m.h * 0.25, m.y + m.h * 0.7);
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
findLooseLines restituisce le righe giustificate i cui spazi tra parole si allargano oltre una soglia, qui il limite superiore, ciascuna con la fascia che un’evidenziazione copre (Righe lasche nel tuo canvas). marks() aggiunge ciò che quell’elenco tralascia, letto dal VDT: una riga oltre 3× non ha rapporto, perché il motore la compone in bandiera e la segnala invece con line.ragged, e un paragrafo diviso tra due colonne torna come un blocco per frammento, ed è così che si trovano le righe isolate. A quaranta caratteri i limiti da soli non tengono ogni riga sotto 1,6; mentre il saggio veniva calibrato, le marche mostravano quali frasi riformulare.
#3 · Metti il controllo accanto alla pagina pubblicata
function compare(pairs) {
document.head.insertAdjacentHTML('beforeend', `<style>
#compare { background: ${palette.graphite}; color: ${palette.haze}; padding: 36px 24px 44px;
font: 500 12px/1.4 "${MONO}", monospace; } #compare > * { max-width: 860px; margin: 0 auto; }
#compare h2 { font: 800 clamp(30px, 6vw, 72px)/0.95 "${DISPLAY}", sans-serif; color: #fff;
margin: 6px auto 26px; letter-spacing: -0.01em; } #compare figure { margin: 0; }
#compare .kicker { color: ${palette.marker}; letter-spacing: .16em; text-transform: uppercase; }
#compare .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
#compare canvas { width: 100%; display: block; }
#compare figcaption { margin-bottom: 12px; text-transform: uppercase; letter-spacing: .12em; }
#compare figcaption b { display: block; margin-bottom: 6px; color: #fff; letter-spacing: 0;
font: 800 clamp(18px, 2.4vw, 26px)/1 "${DISPLAY}"; text-transform: none; }
@media (max-width: 640px) { #compare .pair { grid-template-columns: 1fr; } }</style>`);
const section = Object.assign(document.createElement('section'), { id: 'compare' });
section.innerHTML = `<p class="kicker">${t({ en: 'Same text · same design · page 2',
es: 'El mismo texto · el mismo diseño · página 2' })}</p><h2>${t({
en: 'Two line breakers', es: 'Dos formas de cortar' })}</h2><div class="pair"></div>`;
for (const [name, doc] of pairs) {
const page = doc.pages[1];
const list = marks(doc, page); // one walk per edition: the counts and the paint share it
const n = (...kinds) => list.filter((m) => kinds.includes(m.kind)).length;
const counts = `${t({ en: 'loose', es: 'flojas' })} ${n('loose')} · `
+ `${t({ en: 'lone', es: 'solas' })} ${n('widow', 'orphan')} · `
+ `${t({ en: 'runts', es: 'cortas' })} ${n('runt')}`;
const figure = document.createElement('figure');
figure.innerHTML = `<figcaption><b>${name}</b><span>${counts}</span></figcaption>`;
const canvas = figure.appendChild(document.createElement('canvas'));
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', `${name}, ${t({ en: 'page', es: 'página' })} 2: ${counts}`);
renderPageToCanvas(page, doc, canvas, { scale: 1000 / page.width });
paintMarks(canvas, list, 1000 / page.width);
section.querySelector('.pair').append(figure);
}
document.getElementById('pages').before(section); // #pages: the desk showPages() builds
}
Le due edizioni condividono un’unica grafica, quindi ogni differenza tra le due pagine 2 viene dall’algoritmo di divisione in righe e dalle sue protezioni. In inglese il controllo greedy lascia 47 delle sue 110 righe giustificate oltre 1,6 e 30 oltre 2, tre delle quali oltre 3× e composte in bandiera dal motore, mentre l’edizione Knuth–Plass le tiene tutte e 105 tra 0,80 e 1,60. A pagina 2 il controllo porta anche l’ultima riga di un paragrafo da sola in testa a una colonna, cosa che avoidOrphans: false consente, e chiude un altro paragrafo con una riga finale troppo corta.
#4 · Dai a ogni impostazione un riquadro proprio
// A box sets all its :::columns in one body style, so each setting is a nested box; breaks="3"
// counts a nested box as one block. The bench floats to a page foot.
const [SLIP_GAP, FRAME] = [3, 4]; // mm: between slips; the bench's frame round them
const slip = (id, body) => ({ id, background: col('paper'), marginBottom: mm(SLIP_GAP),
padding: { top: mm(2.2), right: mm(2.6), bottom: mm(2.4), left: mm(2.6) },
titleStyle: { fontFamily: MONO, fontSize: pt(6.6), fontWeight: 600, color: col('muted'),
gap: mm(1.6) }, // code keeps its case: textAlign, not TEXTALIGN
body: { fontSize: pt(8.6), lineHeight: pt(11.6), firstLineIndent: pt(0), ...body } });
const calloutStyles = [
{ id: 'bench', span: 'page', placement: 'bottom', background: col('graphite'),
columnGap: mm(FRAME), // the foot needs a FRAME too: the last slip's marginBottom is dropped
padding: { top: mm(3.4), right: mm(FRAME), bottom: mm(FRAME), left: mm(FRAME) },
titleStyle: { ...caps(7.5, 600), color: col('marker'), gap: mm(2.4) },
body: { fontSize: pt(8.6), lineHeight: pt(11.6), color: col('paper'), textAlign: 'left',
firstLineIndent: pt(0) } },
slip('ragged', { textAlign: 'left' }), // ragged text keeps its words whole by default
slip('unhyphenated', { hyphenation: false }), // justified, like the body text
slip('justified', {}), // justified and hyphenated: the body text's own settings
{ id: 'settings', backgroundEnabled: false, marginTop: pt(3), marginBottom: pt(3), // config
stripe: { enabled: true, side: 'left', width: pt(2), color: col('graphite') },
padding: { top: pt(1), right: pt(0), bottom: pt(1), left: mm(3) },
body: { fontFamily: MONO, fontSize: pt(7), lineHeight: pt(9.5), color: col('graphite'),
firstLineIndent: pt(0) } },
];
Un riquadro compone tutte le colonne di un gruppo :::columns in un solo stile di testo, quindi ogni striscia è un riquadro annidato con un proprio body, e breaks="3" impila la striscia in bandiera e quella senza trattini nella prima colonna. La striscia in bandiera non ha bisogno di hyphenation: false: il testo in bandiera tiene intere le parole a meno che non si attivi bodyText.hyphenation.ragged. La prova al banco flotta al piede di pagina 3 con placement: 'bottom', e il suo padding inferiore ripete la cornice di 4 mm perché il marginBottom dell’ultima striscia si perde alla fine del riquadro.
#5 · Disegna il modello nell’apertura
const BAND = 104; // mm from the trim's top edge to the band's foot
const DIAGRAM = { y: 64, w: MEASURE + 8, h: 34 }; // mm: its top on the page, width, height
const LABEL = 7; // pt: the diagram's labels, tracked less than caps() so the legend fits
const text = (id, content, family, size, color, x, y, extra) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) },
size: { width: mm(MEASURE) } }, ...extra });
const opener = () => ({ // a function: the diagram's constants are defined further down
enabled: true,
slot: { elements: [
// The band reaches below the diagram, so it sets the height the opener reserves; the H1's
// default marginBottom adds a line of white, and the body starts on the second grid line
// under the band.
{ kind: 'box', id: 'band', style: { backgroundColor: col('graphite') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
text('kicker', '{attr.kicker}', MONO, 7.5, 'marker', INNER, TOP, caps(7.5, 600)),
text('title', '{titleText}', DISPLAY, 29, 'paper', INNER, TOP + 5, // one line in both
{ fontWeight: 800, lineHeight: 1.02 }),
text('standfirst', '{attr.standfirst}', TEXT, 10.5, 'haze', INNER, TOP + 19,
{ italic: true, lineHeight: 1.3 }),
{ kind: 'image', id: 'diagram', resourceId: 'diagram', placement: { anchor: { to: 'page',
edge: 'top-left' }, offset: { x: mm(INNER), y: mm(DIAGRAM.y) },
size: { width: mm(DIAGRAM.w), height: mm(DIAGRAM.h) } } },
// The diagram's labels are design text: set and tracked like the opener's other labels.
...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze',
INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })),
] },
});
Il diagramma è un elemento immagine della grafica dell’H1. Come figura sarebbe un elemento flottante top a tutta pagina, e un elemento flottante top citato a pagina 1 apre pagina 2. La fascia scende sotto il diagramma, e un’apertura riserva l’altezza fino al suo elemento più basso, quindi il testo comincia sotto la fascia senza un minHeight, una riga più in basso a causa del marginBottom predefinito dell’H1. Le etichette sono testo della grafica posato sulle coordinate stesse del disegno, composto e spaziato come le altre etichette dell’apertura.
La ricetta completa
// ═══ Postext Cookbook · Nº 014 · Justification lab ════════════════════════════════ // https://postext.dev/en/cookbook/justification-lab // Code: MIT · Text: original (CC BY 4.0) · Diagram: generated in code (CC BY 4.0) // Fonts: Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1) · Needs postext ≥ 1.25.0 // A type journal's essay set twice from one design, Knuth–Plass and greedy: the two page 2s // side by side with their loose lines marked from the layout tree, then the published pages. import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, parseMarkdownWithIssues, findLooseLines, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'justification-lab'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Graphite, paper and one highlighter yellow; col() links each colour to its palette entry. const palette = { ink: '#1f2124', // text: a graphite near-black graphite: '#2e3136', // the accent: the opener band, the bench box, folios marker: '#ffe14d', // highlighter yellow: glue and loose lines, never type on white haze: '#c3c7cc', // type on graphite: the standfirst, the diagram's labels muted: '#66686c', // running heads, settings lines, the colophon paper: '#ffffff', }; 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' } })), { id: 'main-color', name: 'defaults', value: { hex: palette.graphite, model: 'hex' } }, // no blue ]; const [TEXT, DISPLAY, MONO] = ['Petrona', 'Bricolage Grotesque', 'Source Code Pro']; const [BODY, LEAD] = [9.5, 13]; // pt: body size and leading, the grid both columns share // mm: an A5 trim, mirrored margins and a narrow gutter: two columns of about 40 characters const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [148, 210, 20, 20, 16, 13, 5]; const MEASURE = TRIM_W - INNER - OUTER; // mm: 119, the text width the opener aligns to const caps = (size, weight = 500) => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: weight, letterSpacing: pt(size * 0.16), textTransform: 'uppercase' }); // tracked mono labels // #region answer: one design, two line breakers: Knuth–Plass inside fences, greedy without // Knuth–Plass breaking, hyphenation and the widow, orphan and runt penalties are all on by // default. A narrow column also needs a tighter fence round the glue, in multiples of a // normal word space (defaults 0.6 and 2): lines past the upper fence cost more than any // hyphen, so the breaker hyphenates or re-breaks the paragraph before it stretches that far. const FENCES = { minWordSpacing: 0.8, maxWordSpacing: 1.6 }; const bodyText = { // config().bodyText fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default ...FENCES, }; // Hyphenation follows the document's locale: const locale = t({ en: 'en-us', es: 'es' }); // config().locale: each edition's patterns // The control: first-fit breaking, which sets each line once and moves on, with the widow and // orphan guards off (runts are priced inside Knuth–Plass only). Same text, fonts and measure. const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false }; const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } }); // #endregion // #region opener: the title on a graphite band, with the diagram drawn in as an image element const BAND = 104; // mm from the trim's top edge to the band's foot const DIAGRAM = { y: 64, w: MEASURE + 8, h: 34 }; // mm: its top on the page, width, height const LABEL = 7; // pt: the diagram's labels, tracked less than caps() so the legend fits const text = (id, content, family, size, color, x, y, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, size: { width: mm(MEASURE) } }, ...extra }); const opener = () => ({ // a function: the diagram's constants are defined further down enabled: true, slot: { elements: [ // The band reaches below the diagram, so it sets the height the opener reserves; the H1's // default marginBottom adds a line of white, and the body starts on the second grid line // under the band. { kind: 'box', id: 'band', style: { backgroundColor: col('graphite') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, text('kicker', '{attr.kicker}', MONO, 7.5, 'marker', INNER, TOP, caps(7.5, 600)), text('title', '{titleText}', DISPLAY, 29, 'paper', INNER, TOP + 5, // one line in both { fontWeight: 800, lineHeight: 1.02 }), text('standfirst', '{attr.standfirst}', TEXT, 10.5, 'haze', INNER, TOP + 19, { italic: true, lineHeight: 1.3 }), { kind: 'image', id: 'diagram', resourceId: 'diagram', placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(INNER), y: mm(DIAGRAM.y) }, size: { width: mm(DIAGRAM.w), height: mm(DIAGRAM.h) } } }, // The diagram's labels are design text: set and tracked like the opener's other labels. ...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze', INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })), ] }, }); // #endregion const HEAD_Y = 11; // mm from the top (bottom) edge: running heads in the margin, folios outside const head = (id, content, parity, edge, x, extra) => ({ ...text(id, content, MONO, 7.5, 'muted', 0, 0, caps(7.5)), parity, pages: 'body', align: edge.split('-')[1], // left | right placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEAD_Y : -HEAD_Y) } }, ...extra }); const folio = { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(8), letterSpacing: pt(0), color: col('graphite') }; const header = { elements: [ head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('v-title', '{title} · {subtitle}', 'even', 'top-left', OUTER + 8), head('r-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)), head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -OUTER, { ...folio, pages: 'opener' })] }; // the opener's folio drops to its foot // #region bench: three slips in a 2 × 2 grid, each a nested box with a body style of its own // A box sets all its :::columns in one body style, so each setting is a nested box; breaks="3" // counts a nested box as one block. The bench floats to a page foot. const [SLIP_GAP, FRAME] = [3, 4]; // mm: between slips; the bench's frame round them const slip = (id, body) => ({ id, background: col('paper'), marginBottom: mm(SLIP_GAP), padding: { top: mm(2.2), right: mm(2.6), bottom: mm(2.4), left: mm(2.6) }, titleStyle: { fontFamily: MONO, fontSize: pt(6.6), fontWeight: 600, color: col('muted'), gap: mm(1.6) }, // code keeps its case: textAlign, not TEXTALIGN body: { fontSize: pt(8.6), lineHeight: pt(11.6), firstLineIndent: pt(0), ...body } }); const calloutStyles = [ { id: 'bench', span: 'page', placement: 'bottom', background: col('graphite'), columnGap: mm(FRAME), // the foot needs a FRAME too: the last slip's marginBottom is dropped padding: { top: mm(3.4), right: mm(FRAME), bottom: mm(FRAME), left: mm(FRAME) }, titleStyle: { ...caps(7.5, 600), color: col('marker'), gap: mm(2.4) }, body: { fontSize: pt(8.6), lineHeight: pt(11.6), color: col('paper'), textAlign: 'left', firstLineIndent: pt(0) } }, slip('ragged', { textAlign: 'left' }), // ragged text keeps its words whole by default slip('unhyphenated', { hyphenation: false }), // justified, like the body text slip('justified', {}), // justified and hyphenated: the body text's own settings { id: 'settings', backgroundEnabled: false, marginTop: pt(3), marginBottom: pt(3), // config stripe: { enabled: true, side: 'left', width: pt(2), color: col('graphite') }, padding: { top: pt(1), right: pt(0), bottom: pt(1), left: mm(3) }, body: { fontFamily: MONO, fontSize: pt(7), lineHeight: pt(9.5), color: col('graphite'), firstLineIndent: pt(0) } }, ]; // #endregion const config = () => ({ locale, colorPalette, page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText, headings: { fontFamily: DISPLAY, fontWeight: 800, color: col('ink'), levels: [ // the essay opens on a recto { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener() }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, paragraphStyles: [ { id: 'colophon', fontFamily: MONO, fontSize: pt(6.6), lineHeight: pt(9), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], calloutStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Esempio Markdown · 86 righe · content.en.md
title: "Galley" subtitle: "Notes from the type bench · No. 12" author: "Galley" --- # The river problem {kicker="Galley · No. 12 · Justification" standfirst="Justify a narrow column and its word spaces are the first thing to open up. How a line breaker that weighs the whole paragraph keeps the grey even, and the eight settings that govern it."} Hold a newspaper page at arm’s length and half close your eyes. In a good column the text turns into an even grey. In a bad one, pale channels wander down through it, from a gap in one line to the gap below it. Printers call them rivers. They form when the spaces open up on several lines at once and happen to fall in line, and nothing opens spaces faster than a narrow measure. A column of forty characters has five or six word spaces to a line. Carry one long word on to the next line and those few spaces must share its whole width between them: each may have to grow to twice its width, or more. In a column twice as wide, twice as many spaces take up the same width, and each grows only half as much as before. ## Boxes, glue and penalties In 1981, Donald E. Knuth and Michael F. Plass described a paragraph the way TeX still sees it. Words are boxes, fixed in width. The spaces between them are glue, with a natural width and a limit to how far it may stretch or shrink. Penalties mark the places where a line may end, each with a price: ending on a hyphen costs something, ending between two words costs nothing. The drawing at the head of this article shows one line in those terms, as measured and as set, with its glue stretched until the line fills the measure and the word that ran past it broken at a hyphen. The breaker then prices every line by how far its glue had to move, cubing the figure so one very loose line costs more than a string of slightly loose ones, and then adds the penalties. Of all the ways to break the paragraph, it keeps the one whose total is lowest, looking back as far as the first line to find it. ## Greedy and total fit The older method, first fit, survives on the web. It fills each line with as many words as will fit before moving on, and a line once set stays set, so one more short word taken now can leave the next line with a gap that no later break can close. The whole-paragraph method sets one line a little looser when that spares the next one a gap. Across a whole column the trade leaves fewer loose lines than first fit, and so fewer gaps that can line up into rivers. ## Fences for the glue Two numbers fence the glue in: a space may shrink to 80 per cent of its natural width and grow to 160 per cent. Much tighter, and the breaker runs out of ways to fill a line; any looser, and the eye sees the gaps. Past the upper fence, every extra stretch costs more than any hyphen or short last line, so the breaker looks for another way to fill the line first. :::callout{type="settings"} minWordSpacing: 0.8 maxWordSpacing: 1.6 ::: :::callout{type="bench" title="Bench test · one paragraph, three settings"} :::columns{count=2 breaks="3"} :::callout{type="ragged" title="ragged · textAlign: 'left'"} Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice. ::: :::callout{type="unhyphenated" title="justified · hyphenation: false"} Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice. ::: :::callout{type="justified" title="justified · hyphenation: true"} Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice. ::: The same words set three ways, in a measure a little narrower than these columns. Ragged text breaks only between words unless it is told to hyphenate. Justified without hyphens, a few spaces take up all the slack. With hyphens, the spaces stay even at the cost of a few broken words. ::: ::: ## Hyphens Hyphens hand the breaker more places to end a line, and in a narrow measure they are not optional. Postext hyphenates with the TeX patterns of the document’s language, which it takes from an exact code: *en-us* for this issue and *es* for its Spanish edition. A code it lacks, such as *eu-ES*, falls back to American English with a warning. The patterns serve justified text only; ragged lines break only between words, so a narrow ragged column gets a deep rag. ## Widows, orphans and runts A paragraph that breaks across columns leaves a line behind or carries one over. A lone first line at the foot of a column and a lone last line at the head of the next are the pair the style manuals forbid, though printers have never agreed which of the two is the widow. Postext settles it by position: avoidWidows applies at the foot of the column, avoidOrphans at its head. :::callout{type="settings"} widowPenalty: 1000 orphanPenalty: 1000 slackWeight: 10 ::: Each rule is a price in the breaker’s sums, weighed against the white space that obeying it would leave: a lone line costs its penalty, and the empty lines a split would leave at the foot of a column cost ten times the square of their number. The breaker takes the cheaper way. A runt is a last line too short to stand alone: one word, or the tail of one, under a full paragraph. Postext prices it inside the line breaker, so a set of breaks that brings a second word down wins whenever the fences allow. When no other set of breaks stays inside the fences, it sets the paragraph one line shorter, tightening the spaces first and then, if it has to, the letters, by no more than ten thousandths of an em. :::callout{type="settings"} runtMinCharacters: 20 runtPenalty: 1000 maxRuntTracking: 10 ::: ## What the eye forgives A long word can still leave one line of a narrow column a shade looser than its neighbours, and a paragraph that must end somewhere will now and then end on a short line. The breaker can move the extra space from one line to another but cannot remove it. Near a long word it adds a trace to each of five lines rather than leave the whole amount in a single gap, where it would show as a hole in the grey of the column. A line that still gapes is left to the editor, who can usually close it by changing one word in the sentence. :::paragraphs{style="colophon"} Galley is set in Petrona, Bricolage Grotesque and Source Code Pro (SIL Open Font License). Text and diagram: original, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: boxes, glue and penalties: one line as measured and as set, and its resource const resources = [{ id: 'diagram', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'diagram.svg', width: DIAGRAM.w * 10, height: DIAGRAM.h * 10 }, // 10 px a mm altText: t({ en: 'A line of word boxes whose last word overruns the measure; then the same ' + 'line hyphenated, its springs stretched until it fills the measure exactly.', es: 'Una línea de cajas cuya última palabra rebasa la medida; después, la misma línea ' + 'partida, con los muelles estirados hasta llenar la medida justa.' }) }]; // Word boxes in mm; the long last word may break at a hyphenation penalty after its first part. const WORDS = [[14], [8], [17], [6.5], [13.5], [10], [16.7, 16]]; const [ROW_H, GLUE, HYPHEN, ROWS] = [4.4, 3.6, 2, [7, 20.5]]; // mm; ROWS: the rows' tops const SET = WORDS.reduce((sum, w) => sum + w[0], HYPHEN); // the set line: boxes to the hyphen const STRETCH = (MEASURE - SET) / (WORDS.length - 1) / GLUE; // what fills the measure: ×1.45 const R = (v) => Math.round(v * 100) / 100; function spring(x, y, w) { // a zigzag of eight turns: glue, stretched or at rest const pts = Array.from({ length: 9 }, (_, i) => `${R(x + (i * w) / 8)} ${R(y + (i % 2 ? -1 : 1) * 0.9)}`); return `<path d="M${R(x)} ${R(y)}L${pts.join('L')}L${R(x + w)} ${R(y)}" fill="none" ` + `stroke="${palette.marker}" stroke-width="0.45" stroke-linejoin="round"/>`; } function row(y, glue, broken) { // one line of boxes; `broken`: set up to the penalty let [x, out] = [0, '']; const box = (w, h = ROW_H) => { out += `<rect x="${R(x)}" y="${R(y + (ROW_H - h) / 2)}" ` + `width="${R(w)}" height="${h}" rx="0.5" fill="${palette.paper}"/>`; x += w; }; WORDS.forEach((word, i) => { if (i) { out += spring(x, y + ROW_H / 2, glue); x += glue; } box(word[0]); if (word.length === 1) return; // The penalty: a flagged break inside the word, marked by a yellow wedge. Taken, it sets // a hyphen (a short bar); passed over, the rest of the word runs on past the measure. out += `<path d="M${R(x - 1.1)} ${y - 2.6}h2.2l-1.1 1.9z" fill="${palette.marker}"/>`; if (broken) box(HYPHEN, 1); else box(word[1]); }); return out; } function diagram() { const measure = `<path d="M${MEASURE - 0.2} 3V${ROWS[1] + ROW_H + 2}" ` // stops over the legend + `stroke="${palette.haze}" stroke-width="0.4" stroke-dasharray="0.8 0.8"/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${DIAGRAM.w * 10}" ` + `height="${DIAGRAM.h * 10}" viewBox="0 0 ${DIAGRAM.w} ${DIAGRAM.h}">` + `${row(ROWS[0], GLUE, false)}${row(ROWS[1], GLUE * STRETCH, true)}${measure}</svg>`; } function diagramLabels() { // [id, text, x, y] in mm from the diagram's top-left corner const k = STRETCH.toFixed(2).replace('.', t({ en: '.', es: ',' })); return [ ['l-natural', t({ en: 'As measured: the word overruns', es: 'Medida natural: la palabra no cabe' }), 0, ROWS[0] - 5.5], ['l-set', t({ en: `As set: hyphenated, each space ×${k}`, es: `Compuesta: partida, cada espacio ×${k}` }), 0, ROWS[1] - 5.5], ['l-legend', t({ en: 'Box: a word · glue: a space · penalty: a break · dashes: the measure', es: 'Caja: palabra · cola: espacio · penalización: corte · trazos: la medida' }), 0, ROWS[1] + ROW_H + 3.5], ]; } // #endregion // #region marks: the highlighter: loose lines, lone lines and runts read from the layout tree // findLooseLines gives the justified lines whose word spaces stretch past a threshold, here the // upper fence. The rest is read from the VDT: a line past 3× is set ragged (line.ragged), and a // paragraph cut by a column is two blocks. function marks(doc, page) { const body = doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)).filter((b) => b.type === 'paragraph' && b.containerId === undefined && b.textAlign === 'justify'); const loose = findLooseLines(doc, { threshold: FENCES.maxWordSpacing, pageIndex: page.index }); const out = loose.map(({ x, y, width: w, height: h }) => ({ x, y, w, h, kind: 'loose' })); for (const column of page.columns) { for (const b of column.blocks.filter((x) => body.includes(x))) { const parts = body.filter((o) => o.contentIndex === b.contentIndex); b.lines.forEach((line) => { const at = { x: b.bbox.x, y: line.bbox.y, w: b.bbox.width, h: line.bbox.height, column }; if (line.ragged) out.push({ ...at, kind: 'loose' }); // past 3×: no ratio, set ragged if (b.lines.length === 1 && parts.length > 1) { // Postext's names (see the essay): out.push({ ...at, kind: b === parts[0] ? 'widow' : 'orphan' }); // foot : head } else if (line.isLastLine && !/\s/.test(line.text.trim())) { out.push({ ...at, kind: 'runt' }); // one word alone on a paragraph's last line } }); } } return out; } const TAGS = t({ en: { widow: 'widow', orphan: 'orphan', runt: 'runt' }, es: { widow: 'viuda', orphan: 'huérfana', runt: 'corta' } }); function paintMarks(canvas, list, scale) { const ctx = canvas.getContext('2d'); ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on for (const m of list) { // loose lines: a wash; lone lines and runts: a tag in the margin const loose = m.kind === 'loose'; ctx.globalCompositeOperation = loose ? 'multiply' : 'source-over'; // the ink shows through ctx.fillStyle = loose ? palette.marker : palette.graphite; if (loose) { ctx.fillRect(m.x - 2, m.y + 1, m.w + 4, m.h - 1); continue; } ctx.font = `600 ${m.h * 0.48}px "${MONO}"`; const w = ctx.measureText(TAGS[m.kind]).width + m.h * 0.5; const x = m.column.index === 0 ? m.x - w - m.h * 0.35 : m.x + m.w + m.h * 0.35; ctx.fillRect(x, m.y + m.h * 0.12, w, m.h * 0.8); ctx.fillStyle = palette.marker; ctx.fillText(TAGS[m.kind], x + m.h * 0.25, m.y + m.h * 0.7); } ctx.setTransform(1, 0, 0, 1, 0, 0); } // #endregion // #region compare: the control beside the published page, each with its count of marks function compare(pairs) { document.head.insertAdjacentHTML('beforeend', `<style> #compare { background: ${palette.graphite}; color: ${palette.haze}; padding: 36px 24px 44px; font: 500 12px/1.4 "${MONO}", monospace; } #compare > * { max-width: 860px; margin: 0 auto; } #compare h2 { font: 800 clamp(30px, 6vw, 72px)/0.95 "${DISPLAY}", sans-serif; color: #fff; margin: 6px auto 26px; letter-spacing: -0.01em; } #compare figure { margin: 0; } #compare .kicker { color: ${palette.marker}; letter-spacing: .16em; text-transform: uppercase; } #compare .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; } #compare canvas { width: 100%; display: block; } #compare figcaption { margin-bottom: 12px; text-transform: uppercase; letter-spacing: .12em; } #compare figcaption b { display: block; margin-bottom: 6px; color: #fff; letter-spacing: 0; font: 800 clamp(18px, 2.4vw, 26px)/1 "${DISPLAY}"; text-transform: none; } @media (max-width: 640px) { #compare .pair { grid-template-columns: 1fr; } }</style>`); const section = Object.assign(document.createElement('section'), { id: 'compare' }); section.innerHTML = `<p class="kicker">${t({ en: 'Same text · same design · page 2', es: 'El mismo texto · el mismo diseño · página 2' })}</p><h2>${t({ en: 'Two line breakers', es: 'Dos formas de cortar' })}</h2><div class="pair"></div>`; for (const [name, doc] of pairs) { const page = doc.pages[1]; const list = marks(doc, page); // one walk per edition: the counts and the paint share it const n = (...kinds) => list.filter((m) => kinds.includes(m.kind)).length; const counts = `${t({ en: 'loose', es: 'flojas' })} ${n('loose')} · ` + `${t({ en: 'lone', es: 'solas' })} ${n('widow', 'orphan')} · ` + `${t({ en: 'runts', es: 'cortas' })} ${n('runt')}`; const figure = document.createElement('figure'); figure.innerHTML = `<figcaption><b>${name}</b><span>${counts}</span></figcaption>`; const canvas = figure.appendChild(document.createElement('canvas')); canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `${name}, ${t({ en: 'page', es: 'página' })} 2: ${counts}`); renderPageToCanvas(page, doc, canvas, { scale: 1000 / page.width }); paintMarks(canvas, list, 1000 / page.width); section.querySelector('.pair').append(figure); } document.getElementById('pages').before(section); // #pages: the desk showPages() builds } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages and the comparison paint, loaded before the build. const FONTS = { Petrona: ['400', '400i', '700', '700i'], 'Bricolage Grotesque': ['800'], 'Source Code Pro': ['400', '500', '600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('diagram.svg', diagram()); const build = (cfg) => buildDocumentWithFonts({ markdown, resources }, cfg(), kitFonts(FONTS)); const greedy = await build(control); // first: the control, for the comparison only const doc = await build(config); // last: the published pages showPages(doc, { title: t({ en: 'Justification lab', es: 'Laboratorio de justificación' }) }); compare([[t({ en: 'Greedy, no guards', es: 'Voraz, sin protecciones' }), greedy], ['Knuth–Plass', doc]]); // The build's own report: parse issues (a ::: left open) and layout warnings. const { issues } = parseMarkdownWithIssues(markdown); kitStatus(t({ en: `${doc.pages.length} pages · parse issues ${issues.length} · layout warnings `, es: `${doc.pages.length} páginas · problemas de análisis ${issues.length} · avisos ` }) + (doc.warnings?.length ?? 0));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
#Sillaba un testo tedesco
La lingua sceglie i pattern, quindi un testo tedesco prende 'de', o un tag regionale come 'de-AT'.
-const locale = t({ en: 'en-us', es: 'es' }); // config().locale: each edition's patterns
+const locale = 'de'; // German patterns; 'de-AT' picks them too#Marca solo le righe peggiori
Alza la soglia da 1,6 a 2 e l’evidenziatore marca solo le righe i cui spazi sono più che raddoppiati.
- const loose = findLooseLines(doc, { threshold: FENCES.maxWordSpacing, pageIndex: page.index });
+ const loose = findLooseLines(doc, { threshold: 2, pageIndex: page.index });Errori frequenti
Errore frequente
avoidWidows protegge il piede di una colonna, avoidOrphans la sua testa
Postext chiama a modo suo le due righe isolate: avoidWidows (widowMinLines, widowPenalty) impedisce che la prima riga di un paragrafo resti sola al piede di una colonna, e avoidOrphans (orphanMinLines, orphanPenalty) impedisce che la sua ultima riga resti sola in testa alla successiva. Molti manuali di stile danno i due nomi al contrario, quindi scegli l’impostazione in base a dove agisce. Entrambe sono attive per impostazione predefinita, tranne su una griglia dei caratteri (cjk.grid): lì avoidWidows è disattivata, e nella regione Giappone anche avoidOrphans, perché una pagina contenga le righe che la sua griglia conta (da postext 1.28). Funzionano come penalità: l’impaginazione pesa ciascuna contro le righe vuote che rispettarla lascerebbe. Vedove, orfane e righe corte →
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 →
Verifica della Sandbox · looseLine
Riga lasca
Perché. Una riga giustificata allarga i suoi spazi oltre la soglia, di solito a causa di una parola lunga, di un URL o di una giustezza stretta.
Soluzione. Attiva la sillabazione nella lingua giusta, allarga la giustezza, riformula, oppure componi il passaggio in bandiera. Documentazione →
- Un riquadro rimasto alla fine di una colonna scende al piede della colonna quando le colonne sono bilanciate, lontano dal paragrafo a cui appartiene. Calibra il testo perché la colonna sia piena, come è servito per il riquadro delle impostazioni sotto «Fences for the glue», oppure imposta
headings.balancing.closingBoxsu'off', che non sposta mai il riquadro. - La striscia senza trattini non mostra gli spazi più larghi di cui avrebbe bisogno. Una riga giustificata i cui spazi dovrebbero allargarsi oltre tre volte uno spazio normale si compone in bandiera alla sua spaziatura naturale, in un riquadro come nel testo, quindi le prime due righe della striscia finiscono prima della giustezza. Le righe sotto quel limite restano giustificate, e sono loro gli spazi larghi che la striscia mostra.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- The essay “The river problem” and its Spanish version “El problema de los ríos”, the bench test and the diagram, written and drawn for this recipe · Postext Cookbook · CC BY 4.0
- Font
- Petrona (SIL OFL 1.1) · Bricolage Grotesque (SIL OFL 1.1) · Source Code Pro (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)


