Kurz gesagt
Eine kleine Zeitschrift über Schrift, in schmalen Spalten mit beiden Textkanten auf einer Linie. Sie zeigt, wie die Lücken zwischen den Wörtern gleichmäßig bleiben, neben einer Fassung, deren ungleichmäßige Zeilen gelb markiert sind.
Was Sie setzen
Heft 12 von Galley, der Zeitschrift eines Schriftlabors, auf A5 in zwei Spalten von etwa vierzig Zeichen gesetzt, in denen eine Blocksatzzeile nur fünf oder sechs Wortabstände hat, um den Überschuss aufzunehmen. Der Auftakt zeichnet das Knuth-Plass-Modell auf ein graphitgraues Band: eine Zeile aus Wortkästen, deren gelbe Leimfedern sich dehnen, um die Satzbreite zu füllen. Der Essay erklärt, warum schmale Spalten locker werden, und druckt die Einstellungen hinter jeder Regel in Monospace zwischen seine Absätze. Am Fuß von Seite 3 setzt ein Prüfstand einen Absatz im Flattersatz, im Blocksatz ohne Trennung und im Blocksatz mit Trennung. Der Pen setzt das Heft außerdem mit First-Fit-Umbruch und abgeschaltetem Schutz vor Witwen und Waisen und zeigt beide Seiten 2 nebeneinander über den Seiten, wie auf der Karte dieses Rezepts, mit lockeren Zeilen in Textmarkergelb unterlegt und allein stehenden Zeilen und zu kurzen Ausgangszeilen am Rand markiert.
Dieses Rezept beantwortet
- Wie erreiche ich guten Blocksatz und gute Silbentrennung für spanischen, französischen oder deutschen Text?
- Wie vermeide ich Witwen, Waisen und Ausgangszeilen mit nur einem Wort, und wie halte ich eine Überschrift bei ihrem Text?
- Warum wird eine Zeile flatternd oder zu stark gesperrt gesetzt (URLs, lange Komposita, lange Wörter in Zellen)?
- Wie finde ich heraus, was mit meinem Dokument nicht stimmt (Warnungen, Überlauf, nicht konvergierendes Layout)?
Die kurze Antwort
// 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 } });
Zutaten
- Funktionen
- Optimaler Zeilenumbruch (Knuth-Plass)Witwen, Waisen und kurze AusgangszeilenWarnungen und DiagnoseSilbentrennung und DokumentspracheEinzüge, Ausrichtung und AbsatzabständeSpalten in einem Kasten oder im TextVerschachtelte KästenKästenGestaltete AuftakteBilder in SeitengestaltungenÜberschriftenattributeAbsatzformateSeiten auf einem CanvasAbbildungen und Tabellen als RessourcenSemantische FarbpaletteKolumnentitel und SeitenzahlenKopfzeilen nach Seitenrolle
- Nutzt außerdem
- Kapitelband über die ganze Breite
- Schrift
- Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Die Grenzen enger ziehen, den Schutz behalten
Der Code für diesen Schritt steht oben in der kurzen Antwort. Knuth-Plass, Silbentrennung, die Strafpunkte für Witwen, Waisen und zu kurze Ausgangszeilen und Überschriften, die beim Folgetext bleiben, sind standardmäßig an, deshalb verengt die veröffentlichte Ausgabe nur die Grenzen des Wortabstands auf das 0,8- und 1,6-Fache eines normalen Leerzeichens (die Standardwerte sind 0,6 und 2). Eine Zeile über der oberen Grenze kostet mehr als jeder Trennstrich und jede zu kurze Ausgangszeile, deshalb probiert der Umbruch zuerst andere Trennstellen; löschen Sie die beiden Grenzen, und dieser Essay setzt zwei Zeilen über 1,6. Die Silbentrennung folgt locale: 'es', 'fr' und 'de' bekommen ihre eigenen TeX-Muster, ein regionales Tag wie 'es-ES' nimmt die seiner Sprache, und eine Sprache ohne Muster wird wie amerikanisches Englisch getrennt, mit einer Warnung in der Konsole.
#2 · Lockere Zeilen aus dem Layoutbaum markieren
// 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 liefert die Blocksatzzeilen, deren Wortabstände über einen Schwellenwert hinaus gedehnt sind, hier die obere Grenze, jede mit dem Band, das eine Hervorhebung abdeckt (Lockere Zeilen in Ihrem eigenen Canvas). marks() ergänzt, was diese Liste auslässt, gelesen aus dem VDT: Eine Zeile über dem 3-Fachen hat kein Verhältnis, weil die Engine sie in Flattersatz setzt und stattdessen mit line.ragged kennzeichnet, und ein auf zwei Spalten verteilter Absatz kommt als ein Block pro Bruchstück zurück, und so werden die allein stehenden Zeilen gefunden. Bei vierzig Zeichen halten die Grenzen allein nicht jede Zeile unter 1,6; während der Essay eingepasst wurde, zeigten die Markierungen, welche Sätze umzuformulieren waren.
#3 · Die Kontrolle neben die veröffentlichte Seite legen
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
}
Beide Ausgaben teilen sich eine Gestaltung, deshalb kommt jeder Unterschied zwischen den beiden Seiten 2 vom Zeilenumbruch und seinem Schutz. Im Englischen lässt die gierige Kontrolle 47 ihrer 110 Blocksatzzeilen über 1,6 und 30 über 2, drei davon über dem 3-Fachen und von der Engine in Flattersatz gesetzt, während die Knuth-Plass-Ausgabe alle 105 zwischen 0,80 und 1,60 hält. Auf Seite 2 trägt die Kontrolle außerdem die letzte Zeile eines Absatzes allein an den Kopf einer Spalte, was avoidOrphans: false zulässt, und beendet einen anderen Absatz mit einer zu kurzen Ausgangszeile.
#4 · Jeder Einstellung einen eigenen Kasten geben
// 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) } },
];
Ein Kasten setzt alle Spalten einer Gruppe :::columns in einem Textformat, deshalb ist jeder Streifen ein verschachtelter Kasten mit eigenem body, und breaks="3" stapelt den Streifen im Flattersatz und den ohne Trennung in der ersten Spalte. Der Streifen im Flattersatz braucht kein hyphenation: false: Flattersatz hält seine Wörter ganz, es sei denn, bodyText.hyphenation.ragged ist eingeschaltet. Der Prüfstand gleitet mit placement: 'bottom' an den Fuß von Seite 3, und sein unterer Innenabstand wiederholt den Rahmen von 4 mm, weil das marginBottom des letzten Streifens am Ende des Kastens wegfällt.
#5 · Das Modell in den Auftakt zeichnen
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) })),
] },
});
Das Diagramm ist ein Bildelement der Gestaltung der H1. Als Abbildung wäre es ein Gleitobjekt top über die ganze Seitenbreite, und ein auf Seite 1 zitiertes Gleitobjekt top eröffnet Seite 2. Das Band reicht unter das Diagramm, und ein Auftakt reserviert die Höhe bis zu seinem tiefsten Element, sodass der Text ohne minHeight unter dem Band beginnt, wegen des standardmäßigen marginBottom der H1 eine Zeile tiefer. Die Beschriftungen sind Gestaltungstext auf den eigenen Koordinaten der Zeichnung, gesetzt und gesperrt wie die übrigen Beschriftungen des Auftakts.
Das ganze Rezept
// ═══ 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`---Markdown-Beispiel · 86 Zeilen · 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: in jedem Rezept gleich · 270 Zeilen
// ─── 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 ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Einen deutschen Text trennen
Die Locale wählt die Muster, ein deutscher Text nimmt also 'de' oder ein regionales Tag wie '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#Nur die schlimmsten Zeilen markieren
Heben Sie den Schwellenwert von 1,6 auf 2, und der Textmarker hebt nur die Zeilen hervor, deren Wortabstände sich mehr als verdoppelt haben.
- const loose = findLooseLines(doc, { threshold: FENCES.maxWordSpacing, pageIndex: page.index });
+ const loose = findLooseLines(doc, { threshold: 2, pageIndex: page.index });Häufige Fehler
Häufiger Fehler
avoidWidows schützt den Fuß einer Spalte, avoidOrphans ihren Kopf
Postext benennt die beiden einzelnen Zeilen auf eigene Weise: avoidWidows (widowMinLines, widowPenalty) verhindert, dass die erste Zeile eines Absatzes allein am Fuß einer Spalte steht (im Deutschen eine Waise, der Schusterjunge), und avoidOrphans (orphanMinLines, orphanPenalty), dass seine letzte Zeile allein am Kopf der nächsten steht (eine Witwe, das Hurenkind). Viele Satzhandbücher vergeben die beiden Namen umgekehrt, wählen Sie die Einstellung also danach, wo sie wirkt. Beide sind standardmäßig aktiv, außer auf einem Zeichenraster (cjk.grid): dort ist avoidWidows aus, in der Region Japan auch avoidOrphans, damit eine Seite die Zeilen aufnimmt, die ihr Raster zählt (seit postext 1.28). Sie wirken als Strafwerte: das Layout wägt jeden gegen die Leerzeilen ab, die seine Befolgung hinterließe. Witwen, Waisen und kurze Ausgangszeilen →
Häufiger Fehler
Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen
Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen. Platzierung von Abbildungen →
Sandbox-Prüfung · looseLine
Lockere Zeile
Ursache. Eine Blocksatzzeile dehnt ihre Wortabstände über den Schwellenwert hinaus, meist wegen eines langen Wortes, einer URL oder einer schmalen Satzbreite.
Lösung. Schalten Sie die Silbentrennung in der richtigen Sprache ein, verbreitern Sie die Satzbreite, formulieren Sie um oder setzen Sie die Passage im Flattersatz. Doku →
- Ein Kasten am Ende einer Spalte rutscht beim Spaltenausgleich an den Fuß der Spalte, weg von dem Absatz, zu dem er gehört. Passen Sie den Text so ein, dass die Spalte voll ist, wie es der Kasten mit den Einstellungen unter „Fences for the glue“ brauchte, oder setzen Sie
headings.balancing.closingBoxauf'off', was den Kasten nie verschiebt. - Der Streifen ohne Trennung zeigt nicht die größten Lücken, die er bräuchte. Eine Blocksatzzeile, deren Wortabstände sich über das Dreifache eines normalen Leerzeichens dehnen müssten, wird mit natürlichem Abstand in Flattersatz gesetzt, in einem Kasten wie im Grundtext, deshalb enden die ersten beiden Zeilen des Streifens vor der Satzbreite. Die Zeilen unter dieser Grenze bleiben im Blocksatz, und ihre sind die weiten Lücken, die der Streifen zeigt.
Nachweise
- Rezept
- 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
- Schriften
- Petrona (SIL OFL 1.1) · Bricolage Grotesque (SIL OFL 1.1) · Source Code Pro (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


