En poques paraules
Una petita revista sobre tipografia, en columnes estretes amb el text alineat a tots dos costats. Ensenya a mantenir iguals els espais entre paraules, al costat d'una versió amb les línies desiguals marcades en groc.
Què compondràs
El número 12 de Galerada, revista d'un taller tipogràfic, en A5 i a dues columnes d'uns quaranta caràcters, on cada línia justificada reparteix la folgança entre cinc o sis espais. L'obertura dibuixa sobre una banda grafit el model de Knuth i Plass: caixes de paraules unides per molles grogues de cola que s'estiren fins a omplir la mesura. L'assaig explica per què s'afluixen les columnes estretes i intercala en monoespaiada la configuració de cada regla. Al peu de la pàgina 3, un banc de proves compon un paràgraf en bandera, justificat sense partir paraules i justificat partint-les. El pen compon a més una segona edició amb el tall voraç, sense proteccions de vídues ni òrfenes, i, abans del número, mostra juntes les dues pàgines 2, com a la targeta: les línies fluixes, en groc fosforescent; les soles i les curtes, assenyalades al marge.
Aquesta recepta respon a
- Com aconsegueixo una bona justificació i partició de mots en textos en castellà, francès o alemany?
- Com evito vídues, òrfenes i últimes línies d'una sola paraula, i mantinc cada títol amb el seu text?
- Per què una línia surt en bandera o massa estirada (URL, compostos llargs, paraules llargues en cel·les)?
- Com esbrino què falla en el meu document (avisos, desbordaments, composició que no convergeix)?
La resposta curta
// 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, by exact code (gotcha: hyphenation-locales):
const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
// 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;
// a fresh object on every call, like config() itself, because the engine caches resolved
// configs by identity (gotcha: config-cache-identity).
const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false };
const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } });
Ingredients
- Funcions
- Tall de línies òptim (Knuth–Plass)Vídues, òrfenes i línies curtesAvisos i diagnòsticPartició de mots i llengua del documentSagnats, alineació i separació de paràgrafsColumnes dins d'un requadreRequadres niatsRequadresObertures dissenyadesImatges als dissenys de pàginaAtributs de títolEstils de paràgrafPàgines en un canvasFigures i taules com a recursosPaleta de color semànticaCapçaleres i folisCapçaleres segons el tipus de pàgina
- També fa servir
- Banda de capítol d'amplada completa
- Tipografia
- Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Estreny els límits i conserva les proteccions
El codi d'aquest pas és la resposta curta de més amunt. Knuth–Plass, la partició de mots, les penalitzacions de vídues, òrfenes i línies curtes i els títols units al seu text vénen activats per defecte, així que l'edició publicada només estreny els límits de l'espaiat a 0,8 i 1,6 vegades un espai normal (per defecte són 0,6 i 2). Una línia que passa del límit superior costa més que qualsevol guionet o línia curta, així que l'algorisme prova abans altres talls; sense aquests dos límits, aquest assaig compon vuit línies per sobre d'1,6. La partició depèn de locale i del seu codi exacte: 'es', 'fr' i 'de' tenen els seus propis patrons de TeX, mentre que 'es-ES' es parteix com l'anglès dels Estats Units sense cap avís.
#2 · Marca les línies fluixes des de l'arbre de composició
// debug.looseLineHighlight is Sandbox-only (gotcha: sandbox-only-warnings), so the pen reads the
// VDT: justified lines carry justifiedSpaceRatio; 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 out = [];
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.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
out.push({ ...at, kind: 'loose' }); // ragged: past 3×, so the engine set it 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);
}
Només el Sandbox aplica debug.looseLineHighlight, i doc.warnings mai no esmenta una línia fluixa, així que marks() llegeix directament el VDT. Cada línia justificada porta el seu justifiedSpaceRatio, un paràgraf partit entre dues columnes torna com un bloc per fragment, i una línia de més de 3× no té proporció, perquè el motor la compon en bandera i la marca amb line.ragged. Amb quaranta caràcters per línia, els límits no basten perquè totes quedin per sota d'1,6; mentre s'ajustava el text de l'assaig, les marques van assenyalar quines frases calia reescriure.
#3 · Posa el control al costat de la pàgina publicada
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
}
Les dues edicions surten del mateix disseny, així que qualsevol diferència entre les dues pàgines 2 es deu a l'algorisme de tall i a les seves proteccions. Al text castellà, el control voraç deixa 26 de les seves 108 línies justificades per sobre d'1,6, i sis d'elles passen de 2; l'edició de Knuth–Plass manté les 105 entre 0,80 i 1,59. A la pàgina 2 del control, a més, un paràgraf acaba en una línia curta que només porta el final d'una paraula partida.
#4 · Dona a cada ajust el seu propi requadre
// 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 (gotcha: callout-columns). 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' }), // never hyphenated (gotcha: ragged-no-hyphenation)
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 requadre compon totes les columnes d'un grup :::columns amb un únic estil de text, així que cada fitxa és un requadre niat amb el seu propi body, i breaks="3" apila a la primera columna la fitxa en bandera i la que no parteix paraules. La fitxa en bandera no necessita hyphenation: false, perquè el text en bandera mai no es parteix. El banc flota al peu de la pàgina 3 amb placement: 'bottom', i el seu farciment inferior repeteix el marc de 4 mm perquè el marginBottom de l'última fitxa es perd al final del requadre.
#5 · Dibuixa el model a l'obertura
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 box reserves the opener's height, and the H1's default marginBottom adds a line
// of white: the body starts on the second grid line under the band. The diagram could not
// reserve it, as images never count (gotcha: opener-image-no-reserve).
{ 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 }), // a multiple (gotcha: design-lineheight-multiple)
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) } } },
// An SVG image cannot use web fonts (gotcha: svg-no-webfonts): its labels are design text.
...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze',
INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })),
] },
});
El diagrama és un element d'imatge del disseny de l'H1. Com a figura seria un flotant top a tota l'amplada, i un flotant top citat a la pàgina 1 obre la pàgina 2. La banda és una caixa, i les caixes compten per a l'alçada que reserva una obertura, però les imatges no, així que el text comença sota la banda sense minHeight, una línia més avall pel marginBottom que l'H1 porta per defecte. Els rètols són text de disseny col·locat sobre les coordenades del mateix dibuix, perquè un SVG pintat com a imatge no pot fer servir les fonts web de la pàgina.
La recepta 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.4.1 // 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 { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseMarkdownWithIssues, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'justification-lab'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Graphite, paper, one highlighter yellow; col() writes hex too (gotcha: palette-skips-designs). 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, by exact code (gotcha: hyphenation-locales): const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English // 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; // a fresh object on every call, like config() itself, because the engine caches resolved // configs by identity (gotcha: config-cache-identity). 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 box reserves the opener's height, and the H1's default marginBottom adds a line // of white: the body starts on the second grid line under the band. The diagram could not // reserve it, as images never count (gotcha: opener-image-no-reserve). { 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 }), // a multiple (gotcha: design-lineheight-multiple) 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) } } }, // An SVG image cannot use web fonts (gotcha: svg-no-webfonts): its labels are design text. ...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 (gotcha: callout-columns). 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' }), // never hyphenated (gotcha: ragged-no-hyphenation) 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 = () => ({ // a factory: the engine caches resolved configs per object 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: [ // restated: a headings object drops the H1 break (gotcha: headings-drop-h1-break) { 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`---Mostra en Markdown · 86 línies · content.es.md
title: "Galerada" subtitle: "Cuadernos de composición · n.º 12" author: "Galerada" --- # El problema de los ríos {kicker="Galerada · n.º 12 · Justificación" standfirst="En una columna estrecha y justificada, los espacios entre palabras son lo primero que se abre. Cómo un algoritmo que sopesa el párrafo entero mantiene uniforme el gris, y los ocho ajustes que lo gobiernan."} Sostén una página de periódico con el brazo extendido y entorna los ojos. En una buena columna, el texto se vuelve un gris uniforme. En una mala, unos canales pálidos la recorren de arriba abajo, de un hueco al de la línea siguiente. Los tipógrafos los llaman ríos, o calles. Se forman cuando los espacios se abren en varias líneas a la vez y coinciden en vertical, y nada los abre tanto como una medida estrecha. En una columna de cuarenta caracteres, cada línea tiene cinco o seis espacios entre palabras. Si una palabra larga pasa a la línea siguiente, esos pocos espacios se reparten todo su ancho, y cada uno puede llegar al doble. Si la columna mide el doble, lo reparten el doble de espacios y cada uno crece la mitad. ## Cajas y cola En 1981, Donald E. Knuth y Michael F. Plass describieron un párrafo tal como todavía lo ve TeX. Las palabras son cajas de ancho fijo. Los espacios son cola: tienen un ancho natural y un límite a lo que pueden estirarse o encogerse. Las penalizaciones marcan los puntos donde una línea puede terminar, y su precio: acabar en guion cuesta algo; entre dos palabras, nada. El dibujo que abre el artículo muestra una línea en esos términos, medida y compuesta, con su cola estirada hasta llenar la medida. Después, el algoritmo pone precio a cada línea según lo que se ha movido su cola, lo eleva al cubo, para que una línea muy floja cueste más que varias un poco flojas, y le suma las penalizaciones. De todos los cortes posibles del párrafo, se queda con el de menor coste total, sumadas todas sus líneas. ## Línea a línea o en bloque El método antiguo, el del primer ajuste, sobrevive en la web. Llena cada línea con las palabras que caben y pasa a la siguiente sin volver atrás, así que meter ahora una palabra corta más puede dejar la línea siguiente con un hueco imposible de cerrar. El del párrafo entero afloja un poco una línea para ahorrarle un hueco a la siguiente. Repetido a lo largo de una columna, ese intercambio deja menos líneas flojas que el primer ajuste y, con ellas, menos huecos que se alineen en ríos. ## Límites para la cola La cola tiene dos límites: cada espacio puede encogerse hasta el 80 % de su ancho natural y crecer hasta el 160 %. Si se estrechan, el algoritmo se queda sin maneras de llenar la línea; si se aflojan, el ojo ve los huecos. Pasado el límite superior, cada estirón cuesta más que un guion o una línea final corta, así que el algoritmo prueba antes otra salida: partir una palabra o llevarla a otra línea. :::callout{type="settings"} minWordSpacing: 0.8 maxWordSpacing: 1.6 ::: :::callout{type="bench" title="Banco de pruebas · un párrafo, tres ajustes"} :::columns{count=2 breaks="3"} :::callout{type="ragged" title="bandera · textAlign: 'left'"} Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan. ::: :::callout{type="unhyphenated" title="justificado · hyphenation: false"} Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan. ::: :::callout{type="justified" title="justificado · hyphenation: true"} Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan. ::: Las mismas palabras, compuestas de tres maneras en una medida algo más estrecha que estas columnas. En bandera solo se cortan entre palabras, haya partición o no. Justificadas y sin partir, unos pocos espacios cargan con toda la holgura. Con partición, los espacios se igualan a cambio de unos cuantos guiones. ::: ::: ## Partir palabras La separación silábica le da al algoritmo más puntos donde terminar una línea, y en una medida estrecha es imprescindible. Postext usa los patrones de TeX del idioma del documento, indicado con un código exacto: *es* en esta edición y *en-us* en la inglesa. Con un código sin patrones, como *es-ES*, recurre al inglés estadounidense sin avisar. Los patrones solo actúan en texto justificado: en bandera, las líneas se cortan entre palabras y una columna estrecha queda muy desigual. ## Viudas y huérfanas Una primera línea sola al pie de una columna y una última línea sola en la cabeza de la siguiente son la pareja que los manuales prohíben, aunque nadie se ha puesto de acuerdo en cuál es la viuda. Postext lo resuelve por posición: avoidWidows actúa al pie de cada columna, y avoidOrphans, en su cabeza. :::callout{type="settings"} widowPenalty: 1000 orphanPenalty: 1000 slackWeight: 10 ::: Cada regla es un precio que el algoritmo compara con el blanco que dejaría cumplirla. Una línea sola cuesta su penalización, y las líneas vacías que un corte dejaría al pie de la columna cuestan diez veces el cuadrado de su número. Gana lo más barato. Una línea corta es una última línea que no llega a sostenerse sola: una palabra, o el final de una, bajo un párrafo lleno. Postext le pone precio dentro del algoritmo de corte, así que gana el juego de cortes que baje una segunda palabra, si los límites lo permiten. Si no lo permite ninguno, compone el párrafo con una línea menos: aprieta los espacios y, si hace falta, las letras, sin pasar de diez milésimas de cuadratín. :::callout{type="settings"} runtMinCharacters: 20 runtPenalty: 1000 maxRuntTracking: 10 ::: ## Lo que el ojo perdona Una palabra larga aún puede hacer que una línea de una columna estrecha quede algo más floja que sus vecinas, y un párrafo que ha de acabar en algún sitio a veces acaba en una línea corta. El algoritmo puede pasar de una línea a otra el espacio que sobra, pero no quitarlo. Junto a una palabra larga, lo reparte entre cinco líneas en vez de dejarlo entero en un solo hueco, que se vería incluso con el brazo extendido. Una línea que aún se abre más de la cuenta queda para el corrector, que casi siempre la cierra con cambiar una palabra de la frase por otra más corta o más larga. :::paragraphs{style="colophon"} Galerada se compone en Petrona, Bricolage Grotesque y Source Code Pro (SIL Open Font License). Texto y diagrama: originales, 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 // debug.looseLineHighlight is Sandbox-only (gotcha: sandbox-only-warnings), so the pen reads the // VDT: justified lines carry justifiedSpaceRatio; 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 out = []; 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.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) { out.push({ ...at, kind: 'loose' }); // ragged: past 3×, so the engine set it 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 (gotcha: fonts-first). const FONTS = { Petrona: ['400', '400i', '700', '700i'], 'Bricolage Grotesque': ['800'], 'Source Code Pro': ['400', '500', '600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('diagram.svg', diagram()); const build = (cfg) => buildWithFonts(() => buildDocument({ markdown, resources }, cfg()), markdown); 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 engine's own report: parse issues (a ::: left open) and layout warnings, never loose lines. 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: igual a totes les receptes · 270 línies
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Parteix un text en alemany
Els patrons de partició es trien pel codi exacte, així que un text alemany necessita 'de'.
-const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
+const locale = 'de'; // German patterns; 'de-DE' would hyphenate as English#Marca només les pitjors línies
Puja el llindar d'1,6 a 2 i el ressaltador marca només les línies els espais de les quals passen del doble.
- if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
+ if (line.justifiedSpaceRatio > 2 || line.ragged) {Errors freqüents
Error freqüent
avoidWidows vigila el peu de la columna, i avoidOrphans, el capdamunt
Postext dona nom propi a les dues línies soles: avoidWidows (widowMinLines, widowPenalty) evita que la primera línia d'un paràgraf quedi sola al peu d'una columna, i avoidOrphans (orphanMinLines, orphanPenalty), que l'última quedi sola al capdamunt de la següent. Molts manuals d'estil fan servir els dos noms al revés, així que tria l'opció pel lloc on actua. Totes dues vénen activades i funcionen com a penalitzacions: la composició compara cadascuna amb les línies buides que deixaria respectar-la. Vídues, òrfenes i línies curtes →
Error freqüent
En el text en bandera no hi ha partició de mots
La partició de mots només s'aplica al text justificat; el text en bandera talla entre paraules, així que una columna estreta en bandera queda molt desigual. Justifica el passatge o eixampla la mesura. Partició de mots i llengua del document →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
Error freqüent
La majoria dels avisos només existeixen al Sandbox
Els ids, estils i directives desconeguts, les fonts que falten i les línies fluixes els comprova el Sandbox, no el motor: un pen només rep doc.warnings i parseMarkdownWithIssues. Un estil desconegut se substitueix per un altre sense avís i una directiva desconeguda s'imprimeix com a text, així que revisa els teus ids. Avisos i diagnòstic →
Error freqüent
Una configuració es desa a la memòria cau per identitat: crea un objecte nou
El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →
Error freqüent
:::columns només funciona dins d'un requadre i no es parteix
:::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un. Columnes dins d'un requadre →
Error freqüent
Les imatges d'una obertura no compten per a l'alçada que reserva
A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →
Error freqüent
Un flotant 'top' no cau mai a la pàgina que el cita
Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita. Col·locació de figures →
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
Comprovació del Sandbox · looseLine
Línia fluixa
Per què. Una línia justificada estira els espais per sobre del llindar, gairebé sempre per una paraula llarga, un URL o una mesura estreta.
Solució. Activa la partició de mots en la llengua correcta, eixampla la mesura, reformula o compon aquest passatge en bandera. Documentació →
- Un requadre que queda al final d'una columna baixa fins al peu quan s'equilibren les columnes, lluny del paràgraf al qual pertany. Ajusta el text perquè la columna s'ompli, com va caldre amb el requadre de configuració sota «Límites para la cola».
- A postext 1.4.1 una línia dins d'un requadre mai no es compon en bandera, per molt que s'estiri: la fitxa sense partició conserva una línia amb espais de gairebé quatre vegades el normal, que al text corrent es compondria en bandera.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- 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
- Fonts
- Petrona (SIL OFL 1.1) · Bricolage Grotesque (SIL OFL 1.1) · Source Code Pro (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


