What you'll build
Issue 41 of The Tideline, a county weekly, leads with the return of the night ferry across the Sawyer, and the pen proofs the story twice. The first pass is the copy as filed, the corrected story with six slips put back, among them a stamp typed as a directive that does not exist and a fact box that never closes. Those six slips leave ten marks. As on the card, each fault is underlined in red on the line where it was set (a loose line is washed instead), and its number in the margin matches its entry in the list beside the pages. Clicking a line or an entry selects its Markdown. The pages below are the second pass. The checks find nothing in it, and a PROOF · 2ND PASS stamp is pinned in the top right corner.
This recipe answers
- How do I find out what is wrong with my document (warnings, overflow, non-converging layout)?
- How do I map a click on the rendered page back to the Markdown source, to build an editor?
- Why is one line set ragged or over-stretched (URLs, long compounds, long words in cells)?
The short answer
const MM = DPI / 25.4; // page px per mm
function proof(md, doc) {
const faults = [];
const add = (kind, from, to, detail, at) => faults.push({ kind, from, to, detail, at });
const { blocks, issues } = parseMarkdownWithIssues(md); // a $, $$ or ::: left open
for (const i of issues) add(i.kind, i.sourceStart, i.sourceEnd);
for (const w of doc.warnings ?? []) { // calloutOverflow: a box no cut could split
add(w.kind, w.sourceStart, w.sourceEnd, Math.round(w.overflowPx / MM), w);
}
if (!doc.converged) add('unsettled', 0, 1, doc.iterationCount); // the layout never settled
// 1.4.1 reports none of these (gotcha: sandbox-only-warnings).
const ids = new Set(resources.map((r) => r.id)); // an unknown id prints '?'
for (const m of md.matchAll(/:ref\{id="([^"]*)"|^::resource\{id="([^"]*)"/gm)) {
if (!ids.has(m[1] ?? m[2])) add('unknownResourceId', m.index, m.index + m[0].length);
}
for (const m of md.matchAll(/^:::?([a-z][\w-]*)/gim)) { // an unknown fence prints as text
const known = KNOWN_CONTAINERS.has(m[1]) || KNOWN_DIRECTIVES.has(m[1]) || m[1] === 'resource';
if (!known) add('unknownDirective', m.index, m.index + m[0].length);
}
for (const b of blocks) { // formulas count as faults only because this story has none
if (b.startNumber > 999) add('yearList', b.sourceStart, b.sourceEnd); // 1971. opens a list
for (const s of b.spans ?? []) if (s.math) add('formula', s.math.sourceStart, s.math.sourceEnd);
}
// The lines as set: spaces past maxWordSpacing, a line set ragged, a hyphen in an address.
const max = doc.config.bodyText.maxWordSpacing;
for (const l of doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks))
.flatMap((b) => b.lines ?? [])) {
const ratio = Math.round(l.justifiedSpaceRatio * 100) / 100; // as the report prints it
if (ratio > max) add('looseLine', l.sourceStart, l.sourceEnd, ratio);
if (l.ragged) add('raggedLine', l.sourceStart, l.sourceEnd); // past 3×: no ratio left
if (l.hyphenated && /[./]\S+-$/.test(l.text)) add('addressHyphen', l.sourceStart, l.sourceEnd);
}
return faults.sort((a, b) => a.from - b.from);
}
The checks: what the parser and the layout report, and what they leave to you
Ingredients
- Features
- Warnings and diagnosticsEscapes and literal charactersBoxes that split or stay wholeOptimal line breaking (Knuth–Plass)Pinned boxes and badgesCitations that place figuresMargin column for floatsMargin notesColumns inside a boxCallout boxesDesigned openersFull-width chapter bandHeading attributesRunning heads and foliosHeads by page rolePages on a canvasFigures and tables as resourcesCustom resource typesSemantic colour palettePaper colour
- Also uses
- Column and a half
- Type
- Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Read what the engine reports, then check the rest
The code is the short answer above. parseMarkdownWithIssues() returns an issue for each $, $$ or ::: left open, and doc.warnings holds a calloutOverflow for each box that no cut could split; in 1.4.1 the engine reports nothing else. doc.converged and doc.iterationCount say whether the layout settled, and this story settles after one iteration with or without its faults. The Sandbox's Checks panel, described under Sidebar panels, also lists unknown ids, unknown directives and loose lines, but the Sandbox computes those itself, so proof() repeats the work. It compares :ref and ::resource ids with the resources of the build, and fence names with KNOWN_CONTAINERS and KNOWN_DIRECTIVES. For this story it also flags a list block whose startNumber is a year and any formula, since the story has none, and it reads the set lines for spaces stretched past maxWordSpacing, lines set ragged and hyphens added inside an address.
#2 · Put the faults back into the corrected copy
const FIRST_PASS = [ // [corrected, faulty], replaced wherever it occurs
[':::callout{type="stamp"}\n:::', t({ en: ':::stamp', es: ':::sello' })], // no such directive
[':ref{id="route"}', ':ref{id="route-map"}'], // an id no resource has
['\\$', '$'], // bare dollars (gotcha: dollar-math)
[' https://', ' '], // a web address without its scheme
[':::\n:::\n', ''], // the fact box's two closing fences
['\u20601971.', '1971.'], // the word joiner before 1971 (gotcha: digit-period-list)
];
const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md);
The pen keeps one Markdown file, the corrected story, and makes the first pass from it by undoing six fixes, so the two versions cannot drift apart and each mark has a known cause. The corrected copy is built last, and the pages below show that build. \$ escapes the dollar sign of a price, so no formula opens. A word joiner (U+2060) before each year in the timeline on page 2 stops an entry such as 1971. The Tern… from opening a list numbered from its year; the first pass takes out only the one before 1971. With the scheme https://, 1.4.1 sets a web address as an address, never hyphenated and broken only at its joints, as on page 1 before .example. Without it the address is an ordinary word, and the first pass hyphenates it as night-ferry.
#3 · Mark each fault where it was set
const linesOf = (page) => [...page.columns.flatMap((c) => c.blocks), ...(page.floats ?? [])]
.flatMap((b) => (b.lines ?? []).map((l) => ({ ...l.bbox, from: l.sourceStart, to: l.sourceEnd,
width: l.justifiedSpaceRatio // a bbox is the natural width; a justified line fills the block
? b.bbox.x + b.bbox.width - l.bbox.x : l.bbox.width })));
const REACH = 80; // characters after a fence where its first line may start
function spotOf(page, f) { // where a fault shows on this page: its first line, in page px
if (f.at) { // a box that ran off its column: a bar under the column's foot
const c = page.columns[f.at.columnIndex]?.bbox;
return f.at.pageIndex === page.index ? { ...c, y: c.y + c.height, height: 1.6 * MM } : null;
}
const lines = linesOf(page); // a fence sets no line of its own: then the first line after it
return lines.find((r) => r.from < f.to && r.to > f.from)
?? lines.find((r) => r.from >= f.from && r.from - f.to < REACH);
}
function paintMarks(canvas, page, faults, scale) {
const ctx = canvas.getContext('2d');
ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
Object.assign(ctx, { font: `${3 * MM}px "${MONO}"`, textAlign: 'center' });
const taken = []; // the numbers set so far: two on one line sit side by side
faults.forEach((f, n) => {
const r = spotOf(page, f);
if (!r) return;
const wash = f.kind === 'looseLine'; // a loose line is washed, any other fault underlined
Object.assign(ctx, { fillStyle: palette.proof, globalAlpha: wash ? 0.2 : 1 });
ctx.fillRect(r.x, wash ? r.y : r.y + r.height, r.width, wash ? r.height : 0.45 * MM);
const [left, y] = [r.x + r.width / 2 < page.width / 2, r.y + r.height / 2]; // nearest margin
const shift = taken.filter((ty) => Math.abs(ty - y) < 4.5 * MM).length * 5.2 * MM;
const [x, edge] = [left ? 6.5 * MM + shift : page.width - 6.5 * MM - shift,
left ? r.x : r.x + r.width]; // the number, and a leader from it to the text
taken.push(y);
ctx.globalAlpha = 1;
ctx.fillRect(Math.min(x, edge), y - 0.12 * MM, Math.abs(x - edge), 0.24 * MM);
ctx.beginPath();
ctx.arc(x, y, 2.4 * MM, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = palette.paper;
ctx.fillText(String(n + 1), x, y + 1.05 * MM);
});
}
Every line of the VDT carries sourceStart and sourceEnd, so a fault's source range leads to the line that printed it. A container fence prints no line of its own, and its mark goes on the first line that starts within REACH (80) characters after it. A calloutOverflow gets a bar under the foot of the column it names. One slip can leave several marks. The bare dollars turn $2.50 on foot, $ into a formula, which prints nothing because this pen never loads the maths engine, and the third dollar opens a formula that never closes. Without that phrase, one line of the fares paragraph comes out with spaces 1.9 times their normal width. The two missing fences leave the box and its :::columns group open to the end of the story, and a columns group never splits, so the box runs 256 mm past its column. proof() calls a line loose when its justifiedSpaceRatio is above maxWordSpacing (1.6 here). A line whose spaces would pass 3× has no ratio to compare, because 1.4.1 sets it ragged instead, so proof() checks ragged as well.
#4 · Select the Markdown behind a line
function selectSource(from, to) {
const all = source.value; // measure the wrapped height of the text before the selection
source.value = all.slice(0, from);
const top = source.scrollHeight;
source.value = all;
source.focus({ preventScroll: true });
source.setSelectionRange(from, to); // the offsets the parser and the layout give
source.scrollTop = top > source.clientHeight ? top - source.clientHeight / 3 : 0;
}
function onPageClick(canvas, page) {
canvas.onclick = ({ clientX, clientY }) => {
const box = canvas.getBoundingClientRect(); // CSS px to page px
const [x, y] = [(clientX - box.left) * (page.width / box.width),
(clientY - box.top) * (page.height / box.height)];
const hit = linesOf(page).find((r) => x >= r.x && x <= r.x + r.width && y >= r.y
&& y <= r.y + r.height);
if (hit?.from !== undefined) selectSource(hit.from, hit.to);
};
}
A click arrives in CSS pixels. onPageClick() scales it to page pixels, finds the line whose bbox contains it and selects that line's source range in the textarea; an entry in the list selects its fault's range the same way. selectSource() focuses the textarea without scrolling the page and sets its scrollTop from the height of the text before the selection. In 1.4.1 a justified line's bbox keeps the line's natural width, not the width it is painted at, so linesOf() extends it to the right edge of its block. Otherwise the wash would stop short of the margin, and a click near the right edge would select nothing.
#5 · Let the fact box split, pin the stamp
const boxes = [
// keepTogether: false: cut between two blocks at the page foot; a line of white closes it.
{ id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') },
padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') },
body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) },
lists: { bulletChar: '■', color: col('proof'), bulletFontSize: pt(5) } },
// 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title.
{ id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false,
title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }),
fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } },
border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1),
padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) },
titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } },
];
keepTogether: false lets the fact box start at the foot of page 1 and go on overleaf under its red rule, without its title. The box has no background and no rule at its foot, so on page 2 only white space separates the timetable from the story, and marginBottom is a full line of leading, 14 pt; with 4 pt the timetable would end 1.5 mm above the next paragraph. The stamp is placement: 'fixed', out of the flow and pinned 8 mm below the top edge, and width: 'auto' shrinks it to its title, 42 mm wide instead of the main column's 110 mm.
The whole recipe
// ═══ Postext Cookbook · Nº 055 · A galley proof with every fault marked in red ══════ // https://postext.dev/en/cookbook/proof-sheet-diagnostics // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseMarkdownWithIssues, KNOWN_DIRECTIVES, KNOWN_CONTAINERS, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'proof-sheet-diagnostics'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // col() writes the hex beside each paletteId (gotcha: palette-skips-designs). const palette = { // proof: the one accent and the marks; rule: the map's banks; muted: furniture ink: '#1d1d1b', proof: '#d7263d', paper: '#f6f3ea', rule: '#bdb8aa', muted: '#76726a' }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [...Object.entries(palette), ['main-color', palette.proof]] // every default .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // on main-color: red const [TEXT, DISPLAY, MONO] = ['Charis SIL', 'Chivo', 'Fragment Mono']; const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [190, 253, 22, 20, 17, 14]; // mm, mirrored const MEASURE = TRIM_W - INNER - OUTER; // mm: 159, the text block the opener spans const [LEAD, ART_H, DPI] = [14, 50, 150]; // pt: the text's leading; mm: the drawing; page px/inch const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; const caps = (size) => ({ fontFamily: MONO, fontSize: pt(size), letterSpacing: pt(size * 0.12), textTransform: 'uppercase', fontWeight: 400 }); const below = (id, gap, width = MEASURE) => ({ anchor: { to: id, edge: id === 'container' ? 'top-left' : 'below' }, offset: { x: mm(0), y: mm(gap) }, size: { width: mm(width) } }); const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement, ...extra }); const opener = { enabled: true, slot: { elements: [ // the drawing, then the words under it { kind: 'image', id: 'art', resourceId: 'crossing', placement: { ...below('container', 0), size: { width: mm(MEASURE), height: mm(ART_H) } } }, // The words reserve the drawing's height; an image never does (gotcha: opener-image-no-reserve). text('kicker', '{attr.kicker}', MONO, 8, 'proof', below('container', ART_H + 6), caps(8)), text('title', '{titleText}', DISPLAY, 50, 'ink', below('#kicker', 1.4), { fontWeight: 900, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple) text('standfirst', '{attr.standfirst}', TEXT, 11.5, 'ink', below('#title', 4, 136), { italic: true, lineHeight: 1.3 }), text('byline', '{attr.byline}', MONO, 7.5, 'muted', below('#standfirst', 3), caps(7.5))] } }; const head = (id, content, parity, edge, x, y = 12) => text(id, content, MONO, 7.5, 'muted', { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, { ...caps(7.5), parity, pages: 'body', align: edge.split('-')[1] ?? 'center' }); // y: mm from the trim const header = { elements: [ head('verso', t({ en: '{pageNumber} The Tideline · issue 41', es: '{pageNumber} La Marea · número 41' }), 'even', 'top-left', OUTER), head('recto', t({ en: 'News · the night ferry {pageNumber}', es: 'Noticias · la barcaza nocturna {pageNumber}' }), 'odd', 'top-right', -OUTER)] }; const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, -11), pages: 'opener' }] }; // the opener's folio drops to its foot // #region boxes: the fact box splits where the page ends; the stamp is pinned to a corner const boxes = [ // keepTogether: false: cut between two blocks at the page foot; a line of white closes it. { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD), stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') }, padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') }, body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) }, lists: { bulletChar: '■', color: col('proof'), bulletFontSize: pt(5) } }, // 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title. { id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false, title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }), fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } }, border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1), padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) }, titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } }, ]; // #endregion const side = (id, body, fontFamily = DISPLAY) => ({ id, span: 'side', backgroundEnabled: false, padding: NONE, titleStyle: { ...caps(7), color: col('proof'), gap: mm(2) }, // outer column body: { fontFamily, textAlign: 'left', firstLineIndent: pt(0), ...body } }); const calloutStyles = [...boxes, side('quote', { fontSize: pt(12.5), lineHeight: pt(15) }), side('dates', { fontSize: pt(9), lineHeight: pt(12), paragraphSpacing: true }), side('colophon', { fontSize: pt(6.5), lineHeight: pt(9.5), color: col('muted') }, MONO)]; const config = () => ({ // a fresh object per build (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), colorPalette, // exact codes (gotcha: hyphenation-locales) resourceTypes: [{ id: 'figure', name: t({ en: 'Map', es: 'Mapa' }), captionPrefix: t({ en: 'Map', es: 'Mapa' }), shortLabel: t({ en: 'map', es: 'mapa' }), numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: DPI, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'oneAndHalf', sideColumnPercent: 27, sideColumnRole: 'floats', sideColumnSide: 'outer', gutterWidth: mm(6) }, bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // a :ref (map 1) takes boldColor firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6, maxRuntTracking: 0 }, // gotcha: runt-tracking-unpainted headings: { fontFamily: DISPLAY, color: col('ink'), levels: [ // the H1 break restated, as a { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, // headings object advancedDesign: opener }, // drops it (gotcha: headings-drop-h1-break) ] }, calloutStyles, header, footer, captionStyle: { fontFamily: DISPLAY, fontSize: pt(8), lineHeight: pt(11), color: col('ink'), labelBold: true, labelColor: col('proof') }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`# The Night Ferry Returns {kicker="Estuary · Transport" standfirst="After eleven winters without a late crossing, the Port Alder ferry sails after dark again from Friday, on a battery boat and a timetable built round the cannery’s night shift." byline="Nora Ekdahl · Galley for issue 41, October 1, 2026"}Markdown sample · 52 lines · content.en.md
:::callout{type="stamp"} ::: For the first time since 2015, the last boat across the Sawyer will leave after the last bus. From Friday, October 2, the county ferry *Marram* adds eight evening crossings to her day timetable, the last from the Water Street slip in Port Alder at 11:40 p.m. and the last back from Crane Landing, on the route in :ref{id="route"}, at midnight. The night boats run every day of the year except December 25, the first at 8:20 p.m. Fares do not change: \$2.50 on foot, \$9 with a car and driver, and \$40 for a monthly pass; riders over 65 or under 18 travel at half price. The winter timetable is posted at both slips and at https://aldercounty.example/nightferry. :::callout{type="facts" title="The crossing in figures"} - **Vessel.** *Marram*, battery-electric, 82 feet, built 2026. - **Capacity.** 149 passengers, 18 cars, 12 bicycles. - **Crossing.** 1.4 miles; 12 minutes on the flood, 15 on the ebb. - **Crew.** A master, a deckhand and an engineer on every night sailing. :::columns{count=2 breaks="2"} **From Water Street** 8:20, 9:40, 10:40 and 11:40 p.m. **From Crane Landing** 8:40, 10:00, 11:00 p.m. and midnight. ::: ::: :::callout{type="quote" title="Dana Whitcomb"} “On the bus I got home at ten past one. On the midnight boat I’ll be in by half past twelve.” ::: The old *Tern*, a diesel boat built in 1971, broke her port shaft twice in six weeks in the winter of 2015, and the county stopped sailing her after dark. Since then the Crane Landing cannery has run a bus for its night line round by the Highway 9 bridge, 19 miles each way. The *Marram* arrived from the builder’s yard in June and has run the day timetable since August 3. She charges from shore power at both slips while she loads, and that is what pays for the late sailings: the county puts her energy bill for a night of eight crossings at less than the *Tern* burned in diesel on two. Her master, Ilse Brandauer, learned to handle passenger boats on the Danube at Passau, where she spent nine years before she married a Port Alder boatbuilder. Asked what she misses, she named a word, *Donaudampfschifffahrtsgesellschaftskapitän*, the “Danube steamship company captain” that German children use as a tongue-twister. :::callout{type="dates" title="Timeline"} 1971. The *Tern*, a diesel boat, enters service on the Sawyer. 2015. She breaks her port shaft twice in six weeks; the night sailings end. 2026. The *Marram* takes the day timetable on August 3 and the night sailings on October 2. ::: The route bows upstream of the Coffin Rock shoal on the ebb, when the current would set the boat onto it, and runs straight across on the flood, which is why the timetable gives two crossing times. Sailings held for fog are announced by text alert half an hour before departure. Dana Whitcomb packs salmon on the night line and has ridden the cannery bus since the *Tern* stopped. “On the bus I got home at ten past one. On the midnight boat I’ll be in by half past twelve,” she said. The cannery has moved the end of its night shift from 11:45 to 11:30 so the line can make the last boat. Six households on the slip side of Water Street have written to the county commission about cars queuing at their doors after dark. The ferry office says queuing cars will wait in the lot behind the old cannery office, not on the street. The commission agreed the night service for three years at its September meeting, by four votes to one. The dissenting commissioner, Ray Tolliver, asked for a count of night riders by the end of January before any money is committed for a fourth year. The ferry office will count every crossing from the first night. The *Tern* has been laid up at the county yard since August and is for sale. The ferry office has had two offers, both from oyster growers up the coast who want her as a work boat, and expects to decide in November. :::callout{type="colophon"} The Tideline, issue 41. Set in Charis SIL, Chivo and Fragment Mono (SIL OFL). Text and drawings CC BY 4.0. Port Alder and its people are fictional. :::`; // content.<lang>.md, inlined by the Cookbook // #region first-pass: the galley as filed, the corrected text with its faults put back const FIRST_PASS = [ // [corrected, faulty], replaced wherever it occurs [':::callout{type="stamp"}\n:::', t({ en: ':::stamp', es: ':::sello' })], // no such directive [':ref{id="route"}', ':ref{id="route-map"}'], // an id no resource has ['\\$', '$'], // bare dollars (gotcha: dollar-math) [' https://', ' '], // a web address without its scheme [':::\n:::\n', ''], // the fact box's two closing fences ['\u20601971.', '1971.'], // the word joiner before 1971 (gotcha: digit-period-list) ]; const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md); // #endregion // #region answer: the checks: what the parser and the layout report, and what they leave to you const MM = DPI / 25.4; // page px per mm function proof(md, doc) { const faults = []; const add = (kind, from, to, detail, at) => faults.push({ kind, from, to, detail, at }); const { blocks, issues } = parseMarkdownWithIssues(md); // a $, $$ or ::: left open for (const i of issues) add(i.kind, i.sourceStart, i.sourceEnd); for (const w of doc.warnings ?? []) { // calloutOverflow: a box no cut could split add(w.kind, w.sourceStart, w.sourceEnd, Math.round(w.overflowPx / MM), w); } if (!doc.converged) add('unsettled', 0, 1, doc.iterationCount); // the layout never settled // 1.4.1 reports none of these (gotcha: sandbox-only-warnings). const ids = new Set(resources.map((r) => r.id)); // an unknown id prints '?' for (const m of md.matchAll(/:ref\{id="([^"]*)"|^::resource\{id="([^"]*)"/gm)) { if (!ids.has(m[1] ?? m[2])) add('unknownResourceId', m.index, m.index + m[0].length); } for (const m of md.matchAll(/^:::?([a-z][\w-]*)/gim)) { // an unknown fence prints as text const known = KNOWN_CONTAINERS.has(m[1]) || KNOWN_DIRECTIVES.has(m[1]) || m[1] === 'resource'; if (!known) add('unknownDirective', m.index, m.index + m[0].length); } for (const b of blocks) { // formulas count as faults only because this story has none if (b.startNumber > 999) add('yearList', b.sourceStart, b.sourceEnd); // 1971. opens a list for (const s of b.spans ?? []) if (s.math) add('formula', s.math.sourceStart, s.math.sourceEnd); } // The lines as set: spaces past maxWordSpacing, a line set ragged, a hyphen in an address. const max = doc.config.bodyText.maxWordSpacing; for (const l of doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)) .flatMap((b) => b.lines ?? [])) { const ratio = Math.round(l.justifiedSpaceRatio * 100) / 100; // as the report prints it if (ratio > max) add('looseLine', l.sourceStart, l.sourceEnd, ratio); if (l.ragged) add('raggedLine', l.sourceStart, l.sourceEnd); // past 3×: no ratio left if (l.hyphenated && /[./]\S+-$/.test(l.text)) add('addressHyphen', l.sourceStart, l.sourceEnd); } return faults.sort((a, b) => a.from - b.from); } // #endregion // #region marks: each fault underlined in red where it was set, numbered in the nearest margin const linesOf = (page) => [...page.columns.flatMap((c) => c.blocks), ...(page.floats ?? [])] .flatMap((b) => (b.lines ?? []).map((l) => ({ ...l.bbox, from: l.sourceStart, to: l.sourceEnd, width: l.justifiedSpaceRatio // a bbox is the natural width; a justified line fills the block ? b.bbox.x + b.bbox.width - l.bbox.x : l.bbox.width }))); const REACH = 80; // characters after a fence where its first line may start function spotOf(page, f) { // where a fault shows on this page: its first line, in page px if (f.at) { // a box that ran off its column: a bar under the column's foot const c = page.columns[f.at.columnIndex]?.bbox; return f.at.pageIndex === page.index ? { ...c, y: c.y + c.height, height: 1.6 * MM } : null; } const lines = linesOf(page); // a fence sets no line of its own: then the first line after it return lines.find((r) => r.from < f.to && r.to > f.from) ?? lines.find((r) => r.from >= f.from && r.from - f.to < REACH); } function paintMarks(canvas, page, faults, scale) { const ctx = canvas.getContext('2d'); ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on Object.assign(ctx, { font: `${3 * MM}px "${MONO}"`, textAlign: 'center' }); const taken = []; // the numbers set so far: two on one line sit side by side faults.forEach((f, n) => { const r = spotOf(page, f); if (!r) return; const wash = f.kind === 'looseLine'; // a loose line is washed, any other fault underlined Object.assign(ctx, { fillStyle: palette.proof, globalAlpha: wash ? 0.2 : 1 }); ctx.fillRect(r.x, wash ? r.y : r.y + r.height, r.width, wash ? r.height : 0.45 * MM); const [left, y] = [r.x + r.width / 2 < page.width / 2, r.y + r.height / 2]; // nearest margin const shift = taken.filter((ty) => Math.abs(ty - y) < 4.5 * MM).length * 5.2 * MM; const [x, edge] = [left ? 6.5 * MM + shift : page.width - 6.5 * MM - shift, left ? r.x : r.x + r.width]; // the number, and a leader from it to the text taken.push(y); ctx.globalAlpha = 1; ctx.fillRect(Math.min(x, edge), y - 0.12 * MM, Math.abs(x - edge), 0.24 * MM); ctx.beginPath(); ctx.arc(x, y, 2.4 * MM, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = palette.paper; ctx.fillText(String(n + 1), x, y + 1.05 * MM); }); } // #endregion // #region source: a click on a proof page selects the Markdown that set the line function selectSource(from, to) { const all = source.value; // measure the wrapped height of the text before the selection source.value = all.slice(0, from); const top = source.scrollHeight; source.value = all; source.focus({ preventScroll: true }); source.setSelectionRange(from, to); // the offsets the parser and the layout give source.scrollTop = top > source.clientHeight ? top - source.clientHeight / 3 : 0; } function onPageClick(canvas, page) { canvas.onclick = ({ clientX, clientY }) => { const box = canvas.getBoundingClientRect(); // CSS px to page px const [x, y] = [(clientX - box.left) * (page.width / box.width), (clientY - box.top) * (page.height / box.height)]; const hit = linesOf(page).find((r) => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height); if (hit?.from !== undefined) selectSource(hit.from, hit.to); }; } // #endregion const DESK_W = 420; // CSS px: a page's width on the proof desk (style.css); words: index.html const say = (key, value = '') => $('words').content.querySelector(`[data-key="${key}"]`) .dataset[LANG].replace('{}', value.toLocaleString(LANG)); const passOf = (md) => md === markdown ? 'second' : md === firstPass(markdown) ? 'first' : 'edited'; function proofDesk(md, draft) { const faults = proof(md, draft); $('galley').replaceChildren(...draft.pages.map((page) => { const canvas = Object.assign(document.createElement('canvas'), { role: 'img', ariaLabel: say('page', page.index + 1) }); const scale = (Math.min(devicePixelRatio, 2) * DESK_W) / page.width; renderPageToCanvas(page, draft, canvas, { scale }); paintMarks(canvas, page, faults, scale); onPageClick(canvas, page); return canvas; })); const [n, copy] = [faults.length, say(passOf(md))]; // copy: first pass, second pass, an edit $('verdict').textContent = `${say(n > 1 ? 'faults' : n ? 'fault' : 'clean', n)} ${copy}`; $('passes').textContent = `iterationCount ${draft.iterationCount} · converged ${draft.converged}`; $('report').replaceChildren(...faults.map((f, n) => { const li = document.createElement('li'); li.innerHTML = '<button type="button"><b></b><span></span><code></code></button>'; const [b, span, code] = li.firstChild.children; [b.textContent, span.textContent] = [n + 1, say(f.kind, f.detail)]; code.textContent = `${f.kind} · ${md.slice(f.from, f.to).split('\n')[0]}`; li.firstChild.onclick = () => selectSource(f.from, f.to); return li; })); return faults; } // #region art: the night crossing and the route map, drawn in the page's palette function rng(seed) { // Mulberry32: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const R = (v) => Math.round(v * 100) / 100; const rect = (x, y, w, h, fill, opacity = 1) => `<rect x="${R(x)}" y="${R(y)}" width="${R(w)}" ` + `height="${R(h)}" fill="${fill}" opacity="${R(opacity)}"/>`; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` + `viewBox="0 0 ${w} ${h}">${body}</svg>`; function crossing() { // 159 × 50 mm, 10 units a mm const [W, H, SEA] = [MEASURE * 10, ART_H * 10, 290]; const rand = rng(41); const { ink, paper, proof, rule } = palette; const land = '#34332f'; // the far bank, one step up from the ink sky let s = rect(0, 0, W, H, ink) + `<circle cx="${W * 0.8}" cy="92" r="40" fill="${paper}"/>`; let shore = `M0 ${SEA}`; // the far bank: Crane Landing, the cannery and its stack for (let x = 0; x <= W; x += 40) shore += `L${x} ${R(SEA - 18 - rand() * 22)}`; s += `<path d="${shore}L${W} ${SEA}Z" fill="${land}"/>`; s += `<path d="M1040 ${SEA}V226h120v-34h64v34h56V${SEA}Z M1172 192V120h13v72Z" ` + `fill="${land}"/>`; for (let i = 0; i < 6; i++) s += rect(1056 + i * 34, 240, 12, 8, paper, 0.8); for (let y = SEA + 12; y < H; y += 13 + (y - SEA) * 0.06) { // the water: broken lines for (let x = rand() * 60; x < W; x += 60 + rand() * 90) { s += rect(x, y, 20 + rand() * 60, 2.4, rule, 0.16 + rand() * 0.24); } } for (let y = SEA + 8; y < H - 6; y += 11) { // the moon's path on the water const w = 26 + rand() * 54; s += rect(W * 0.8 - w / 2 + (rand() - 0.5) * 28, y, w, 3, paper, 0.7); } const [fx, fy] = [380, SEA + 76]; // the Marram, a double-ended ferry, from abeam s += `<path d="M${fx} ${fy}h400l-28 30h-344Z M${fx + 56} ${fy}v-40h288v40Z ` + `M${fx + 146} ${fy - 40}v-26h108v26Z" fill="${paper}"/>`; for (let i = 0; i < 9; i++) s += rect(fx + 72 + i * 30, fy - 29, 15, 13, ink); s += rect(fx + 197, fy - 90, 6, 24, paper) // the mast and its light, then the port light + `<circle cx="${fx + 200}" cy="${fy - 96}" r="7" fill="${paper}"/>` + `<circle cx="${fx + 30}" cy="${fy - 6}" r="8" fill="${proof}"/>`; for (let i = 0; i < 6; i++) { // the ferry's lights on the water s += rect(fx + 40 + rand() * 320, fy + 40 + i * 12, 20 + rand() * 50, 3, paper, 0.5); } return svg(W, H, s + rect(fx + 24, fy + 40, 12, 34, proof, 0.7)); } function routeMap() { // 42 × 44 mm, 10 units a mm: south bank at the foot, the sea to the right const [W, H] = [420, 440]; const { ink, paper, proof, rule } = palette; const line = (d, color, extra = '') => `<path d="${d}" fill="none" stroke="${color}" stroke-width="5" ${extra}/>`; return svg(W, H, rect(0, 0, W, H, paper) + `<path d="M0 0H${W}V58C340 76 250 50 170 68S60 56 0 80Z" fill="${rule}"/>` // the banks + `<path d="M0 ${H}H${W}V372C330 356 250 388 170 370S60 384 0 360Z" fill="${rule}"/>` + `<ellipse cx="262" cy="214" rx="58" ry="26" fill="none" stroke="${ink}" stroke-width="3"` + ' stroke-dasharray="6 7"/>' // the shoal + line('M164 370V70', ink) // the flood: straight across + line('M164 370C150 300 84 270 86 214S150 110 164 70', proof, 'stroke-dasharray="14 9"') + rect(150, 362, 28, 20, ink) + rect(150, 56, 28, 20, ink) // the two slips + line('M300 318h72', ink) + `<path d="M394 318l-26-11v22Z" fill="${ink}"/>` // the ebb + line('M380 150v48', ink) + `<path d="M380 128l-11 26h22Z" fill="${ink}"/>` // north, + line('M371 118V92L389 118V92', ink, 'stroke-linejoin="miter"')); // under its N } const resources = [{ id: 'route', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'route.svg', width: 420, height: 440 }, placement: { span: 'side' }, caption: t({ en: 'Night route, north up. Solid: straight across on the flood, 12 minutes. ' + 'Dashed red: on the ebb, when the current runs out to sea (arrow), bowed upstream of the ' + 'Coffin Rock shoal (dotted), 15 minutes.', es: 'Ruta nocturna, norte arriba. Continua: en línea recta con la llenante, 12 minutos. ' + 'Roja discontinua: con la vaciante (flecha), aguas arriba del bajo Piedra Negra ' + '(punteado), 15 minutos.' }), altText: t({ en: 'A plan of the crossing: two banks, a dotted shoal, and between two slips a ' + 'straight black track and a dashed red track bowed away from the shoal; an arrow marked N ' + 'points north, another points out to sea.', es: 'Plano del cruce: dos orillas, un bajo punteado y, entre dos rampas, una ruta negra recta ' + 'y una ruta roja discontinua que se abre lejos del bajo; una flecha con una N señala el ' + 'norte y otra, el mar.' }) }, { id: 'crossing', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'crossing.svg', width: MEASURE * 10, height: ART_H * 10 }, altText: t({ en: 'A ferry with lit windows and a red port light crossing a dark estuary under ' + 'a full moon, a cannery on the far bank.', es: 'Una barcaza con las ventanas encendidas y la luz roja de babor cruza de noche un estuario ' + 'bajo la luna llena, con una planta en la otra orilla.' }) }]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Charis SIL': ['400', '400i', '700', '700i'], Chivo: ['400', '700', '900'], 'Fragment Mono': ['400'] }; // every face, loaded before the first build (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), loadSvg('crossing.svg', crossing()), loadSvg('route.svg', routeMap())]); const $ = (id) => document.getElementById(id); // the proof desk of index.html const source = $('source'); for (const el of document.querySelectorAll('#proof [data-en]')) el.textContent = el.dataset[LANG]; const build = (m) => buildWithFonts(() => buildDocument({ markdown: m, resources }, config()), m); const proofAgain = async (md) => proofDesk(source.value = md, await build(md)); const first = await proofAgain(firstPass(markdown)); // first: the galley as it came in const doc = await build(markdown); // last: the corrected galley, the pages below showPages(doc, { title: say('title') }); $('again').onclick = () => proofAgain(source.value); $('fixed').onclick = () => proofAgain(markdown); selectSource(first[0].from, first[0].to); // the first fault, selected in the MarkdownKit · core, fonts, viewer, images: the same in every recipe · 270 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
<section id="proof">
<header>
<p id="slug" data-en="The Tideline 41 · news galley · proof desk" data-es="La Marea 41 · galerada · mesa de pruebas"></p>
<h2 id="verdict"></h2>
<p id="passes"></p>
</header>
<div class="desk">
<div id="galley"></div>
<ol id="report"></ol>
</div>
<div class="source">
<label for="source" data-en="The galley’s Markdown: click a line on a page to select it here" data-es="El Markdown de la galerada: pulsa una línea de una página para seleccionarla aquí"></label>
<textarea id="source" spellcheck="false"></textarea>
<p>
<button id="again" type="button" data-en="Proof again" data-es="Revisar de nuevo"></button>
<button id="fixed" type="button" data-en="Load the second pass" data-es="Cargar las segundas pruebas"></button>
</p>
</div>
<template id="words"><!-- the report's words, in the language of the sample -->
<i data-key="unclosedMath" data-en="Unclosed maths" data-es="Fórmula sin cerrar"></i>
<i data-key="unclosedMathBlock" data-en="Unclosed display maths" data-es="Fórmula en bloque sin cerrar"></i>
<i data-key="formula" data-en="Stray formula" data-es="Fórmula perdida"></i>
<i data-key="unclosedContainer" data-en="Unclosed fence" data-es="Valla sin cerrar"></i>
<i data-key="calloutOverflow" data-en="Box runs {} mm past its column" data-es="El recuadro desborda su columna en {} mm"></i>
<i data-key="unknownResourceId" data-en="Unknown id: prints “?”" data-es="Id desconocido: imprime «?»"></i>
<i data-key="unknownDirective" data-en="Unknown directive: prints as text" data-es="Directiva desconocida: sale como texto"></i>
<i data-key="yearList" data-en="A year opened a numbered list" data-es="Un año abrió una lista numerada"></i>
<i data-key="looseLine" data-en="Loose line, {}×" data-es="Línea floja, {}×"></i>
<i data-key="raggedLine" data-en="Line set ragged: its spaces would pass 3×" data-es="Línea en bandera: sus espacios pasarían de 3×"></i>
<i data-key="addressHyphen" data-en="Hyphen added inside an address" data-es="Guion añadido dentro de una dirección"></i>
<i data-key="unsettled" data-en="Layout did not settle in {} passes" data-es="La composición no se asentó en {} pasadas"></i>
<i data-key="faults" data-en="{} faults" data-es="{} fallos"></i>
<i data-key="fault" data-en="{} fault" data-es="{} fallo"></i>
<i data-key="clean" data-en="Nothing to mark" data-es="Nada que marcar"></i>
<i data-key="first" data-en="on the first pass" data-es="en las primeras pruebas"></i>
<i data-key="second" data-en="on the second pass" data-es="en las segundas pruebas"></i>
<i data-key="edited" data-en="in the edited copy" data-es="en la copia editada"></i>
<i data-key="page" data-en="Proof, page {}" data-es="Prueba, página {}"></i>
<i data-key="title" data-en="The Tideline · galley proof" data-es="La Marea · galerada"></i>
</template>
</section>
<main id="pages"></main>
/* The proof desk: the first pass marked in red beside its report, the Markdown under them.
Colours repeat the palette in script.js: newsprint, ink, the proofreader's red. */
#proof {
background: #e6e0d2; color: #1d1d1b; padding: 36px 32px 40px;
font: 15px/1.45 "Charis SIL", Georgia, serif;
}
#proof > * { max-width: 1216px; margin-inline: auto; }
#proof header { margin-bottom: 24px; }
#slug, #passes {
margin: 0; font: 12px/1.2 "Fragment Mono", monospace; letter-spacing: .14em;
text-transform: uppercase;
}
#slug { color: #b81d31; margin-bottom: 10px; }
#passes { color: #5f5b54; margin-top: 12px; text-transform: none; letter-spacing: .04em; }
#verdict { margin: 0; font: 900 clamp(38px, 6vw, 72px)/.95 Chivo, sans-serif; letter-spacing: -.01em; }
.desk { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 32px; align-items: start; }
#galley { display: flex; gap: 14px; }
#galley canvas {
display: block; width: 420px; aspect-ratio: 190 / 253; cursor: text; /* DESK_W */
box-shadow: 0 1px 2px rgb(40 30 20 / .25), 0 18px 36px -18px rgb(40 30 20 / .55);
}
#report { list-style: none; margin: 0; padding: 0; border-top: 3px solid #d7263d; }
#report button {
all: unset; box-sizing: border-box; display: grid; grid-template-columns: 30px minmax(0, 1fr);
column-gap: 12px; width: 100%; padding: 9px 0 10px; border-bottom: 1px solid #bdb8aa; cursor: pointer;
}
#report button:hover span, #report button:focus-visible span { color: #b81d31; }
#report b {
grid-row: span 2; width: 26px; height: 26px; border-radius: 50%; background: #d7263d;
color: #f6f3ea; font: 13px/26px "Fragment Mono", monospace; text-align: center;
}
#report span { font: 400 16px/1.25 Chivo, sans-serif; }
#report code {
font: 12px/1.5 "Fragment Mono", monospace; color: #5f5b54;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.source { margin-top: 28px; }
.source label {
display: block; margin-bottom: 8px; font: 12px/1.3 "Fragment Mono", monospace; color: #5f5b54;
}
#source {
display: block; width: 100%; height: 280px; box-sizing: border-box; padding: 14px 16px;
font: 13px/1.6 "Fragment Mono", monospace; color: #1d1d1b; background: #f6f3ea;
border: 1px solid #bdb8aa; resize: vertical;
}
#source::selection { background: #d7263d; color: #f6f3ea; }
.source p { margin: 12px 0 0; display: flex; gap: 10px; flex-wrap: wrap; }
.source button {
font: 12px/1 "Fragment Mono", monospace; letter-spacing: .12em; text-transform: uppercase;
color: #f6f3ea; background: #1d1d1b; border: 0; padding: 10px 14px; cursor: pointer;
}
.source button + button { color: #1d1d1b; background: none; box-shadow: inset 0 0 0 1px #1d1d1b; }
@media (max-width: 1240px) {
.desk { grid-template-columns: minmax(0, 1fr); }
#galley canvas { width: calc(50% - 7px); }
}
@media (max-width: 560px) {
#proof { padding: 24px 16px 28px; }
#galley { flex-direction: column; }
#galley canvas { width: 100%; }
}
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗
Variations
#Keep the fact box whole
With the default keepTogether, the box moves to page 2 in one piece; page 1 ends 33 mm short and the story runs onto a third page.
- { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
+ { id: 'facts', backgroundEnabled: false, marginBottom: pt(LEAD),#Stop a build that still has faults
In a build script or a test, run proof() on the final copy and throw if it finds anything.
const doc = await build(markdown); // last: the corrected galley, the pages below
+if (proof(markdown, doc).length) throw new Error('The second pass still has faults.');Pitfalls
Pitfall
Most warnings exist only in the Sandbox
Unknown ids, styles and directives, missing fonts and loose lines are checked by the Sandbox, not the engine: a pen only gets doc.warnings and parseMarkdownWithIssues. An unknown style silently falls back and an unknown directive prints as text, so check your ids. Warnings and diagnostics →
Pitfall
An unknown :ref id prints '?' with no engine warning
A :ref to an id no resource has prints "?" and places nothing, and only the Sandbox warns about it. Check that every cited id exists. Citations that place figures →
Pitfall
A bare $ opens maths: write \$
A dollar sign opens inline maths, so a price such as $40 starts a formula. Write \$40. Escapes and literal characters →
Pitfall
'1998. ' or '- ' at a paragraph start opens a list
A paragraph that starts with a number, a period and a space, or with a hyphen and a space, becomes a list item. Put a word joiner (U+2060) before the number, and write dialogue with an em dash. Escapes and literal characters →
Pitfall
:::columns works only inside a box and never splits
:::columns is ignored outside a callout, and a box that splits never cuts inside a columns group. A breaks attribute counts child blocks, with a nested box as one. Columns inside a box →
Pitfall
A config is cached by identity: build a fresh object
The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config(). Pages on a canvas →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
Only 8 locales hyphenate, by exact code
Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English. Hyphenation and document language →
Pitfall
A design text's lineHeight is a multiple, never a dimension
In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →
Pitfall
A runt fix can tighten tracking that is never painted
In postext 1.4.1, when a paragraph ends on a runt, the layout sets it one line shorter: first with tighter word spacing, then with up to maxRuntTracking thousandths of an em of negative tracking. The canvas and PDF renderers paint tracking only above zero, so a tracked paragraph prints untracked: its justified lines lose the difference from their word spaces and look crushed, and its last line can run past the measure and be clipped at the column edge. Set bodyText.maxRuntTracking: 0, which keeps the word-spacing fix, and reword any runt that comes back. Widows, orphans and runts →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Markdown warning · unclosedMath
Unclosed math delimiter
Why. A $ opens inline maths that is never closed, usually because it belongs to a price.
Fix. Write \$ for a literal dollar, or close the formula on the same line. Docs →
Markdown warning · unclosedContainer
Unclosed container
Why. A ::: fence is never closed, so the container runs to the end of the document.
Fix. Add a line holding a bare ::: where the container ends. Docs →
Layout warning · calloutOverflow
Callout overflows its column
Why. A box that no cut can split (a figure, table or :::columns group taller than the column, or too high splitMinLines) was placed overflowing; the engine records it in doc.warnings and the Sandbox lists it.
Fix. Shorten the box, let it split with keepTogether: false, lower splitMinLines, or give it another span. Docs →
Sandbox check · unknownResourceId
Unknown resource
Why. A :ref or ::resource names an id no resource has; the reference prints "?" and nothing is placed.
Fix. Correct the id (double quotes only) or add the resource. Docs →
Layout warning · unknownDirective
Unknown directive
Why. A :::name line is not one of Postext's directives or containers, so it prints as text.
Fix. Check the spelling against the supported names (pagebreak, columnbreak, numbering, space, toc, callout, paragraphs, part, columns). Docs →
Sandbox check · looseLine
Loose line
Why. A justified line stretches its spaces beyond the threshold, usually because of a long word, a URL or a narrow measure.
Fix. Turn on hyphenation in the right language, widen the measure, rephrase, or set the passage ragged. Docs →
- Fit the copy so the fact box fills page 1 to its last line. In 1.4.1 a box that closes a column moves down when the columns are balanced: cut the last sentence of the first paragraph and the box's rule sits 6.4 mm under the fares paragraph instead of 2.4 mm.
- Source offsets run late around web addresses and escapes. In 1.4.1 hyphenation inserts a zero-width space (U+200B) after the slashes of an address and
line.textkeeps it, so a line holding one ends a character late for each, and the next line starts as late: on page 1 the line ending https://aldercounty also selects the dot of .example. The range of a line that opens with an escaped character starts after the backslash, as on the page 1 line that opens with$40.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Charis SIL (SIL OFL 1.1) · Chivo (SIL OFL 1.1) · Fragment Mono (SIL OFL 1.1)


