成品一览
县里的周报《The Tideline》第41期,头条是夜班渡轮恢复横渡Sawyer河,示例把这篇稿子校对两遍。第一遍是原始来稿:以改好的稿件为底,放回六处笔误,其中包括一个被写成不存在的指令的印章,以及一个始终没有闭合的资料框。这六处笔误留下十个标记。和卡片上一样,每处错误都在它所在的行下画红线(松行改为整行铺底色),页边的编号对应页面旁列表中的条目。点击某一行或某个条目,就会选中它的Markdown。下面的页面是第二遍。检查什么也没发现,右上角钉着一个PROOF · 2ND PASS印章。
这道食谱解答
- 怎样找出文档哪里出了问题(警告、溢出、版面不收敛)?
- 怎样把渲染页面上的点击映射回Markdown源文,用来做编辑器?
- 为什么有一行排得不齐或被拉得过宽(URL、长复合词、单元格里的长词)?
简短回答
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);
}
用料
做法
#1 · 先读引擎的报告,再检查其余部分
代码见上文的简短回答。parseMarkdownWithIssues()会为每个未闭合的$、$$或:::返回一条问题,doc.warnings为每个无法拆分的框记录一条calloutOverflow;在1.4.1中,引擎只报告这些。doc.converged和doc.iterationCount说明版面是否已稳定,这篇稿子无论有没有错误,都在一次迭代后稳定。沙盒的检查面板(见侧边栏面板)还会列出未知id、未知指令和松行,但这些是沙盒自己算出来的,所以proof()要重做一遍。它把:ref和::resource的id与这次构建的resources比对,把围栏名称与KNOWN_CONTAINERS和KNOWN_DIRECTIVES比对。针对这篇稿子,它还会标出startNumber是年份的列表块,以及任何公式,因为这篇稿子本来没有公式;它还会检查排好的行,找出空格被拉宽到超过maxWordSpacing的行、排成齐左的行,以及网址中被加了连字符的地方。
#2 · 把错误放回改好的稿件
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);
示例只保留一个Markdown文件,即改好的稿件,第一遍由它撤销六处修改得到,所以两个版本不会彼此脱节,每个标记都有已知的原因。改好的稿件最后构建,下面的页面就是这次构建的结果。\$对价格中的美元符号进行转义,这样就不会开启公式。第2页年表中每个年份前的单词连接符(U+2060)防止1971. The Tern…这样的条目开启一个从该年份起编号的列表;第一遍只删掉了1971前的那一个。带上https://协议头时,1.4.1把网址当作网址来排,从不断词,只在其连接处断开,比如第1页上.example前的断点。没有协议头,网址就是一个普通单词,第一遍把它断成了night-ferry。
#3 · 在错误所在处做标记
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);
});
}
VDT中的每一行都带有sourceStart和sourceEnd,所以错误的源码范围能找到印出它的那一行。容器围栏本身不印出任何行,它的标记放在其后REACH(80)个字符以内开始的第一行上。calloutOverflow在它指明的那一栏底部画一条横杠。一处笔误可能留下好几个标记。未转义的美元符号把$2.50 on foot, $变成一个公式,因为这个示例从不加载数学引擎,公式什么也不印;第三个美元符号又开启了一个永不闭合的公式。这段文字被吞掉后,票价段落中有一行的空格变成正常宽度的1.9倍。缺失的两个围栏让资料框和它的:::columns组一直开到稿件末尾,而分栏组从不拆分,所以资料框超出所在栏256 mm。proof()把justifiedSpaceRatio超过maxWordSpacing(这里是1.6)的行判为松行。空格会超过3倍的行没有比值可比,因为1.4.1改为把它排成齐左,所以proof()还要检查ragged。
#4 · 选中一行背后的Markdown
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);
};
}
点击坐标以CSS像素给出。onPageClick()把它换算成页面像素,找到bbox包含该点的那一行,并在textarea中选中那一行的源码范围;列表中的条目也以同样方式选中其错误的范围。selectSource()让textarea获得焦点而不滚动页面,并根据选区之前文本的高度设置它的scrollTop。在1.4.1中,两端对齐行的bbox保留该行的自然宽度,而不是绘制时的宽度,所以linesOf()把它延伸到所在块的右边缘。否则底色会在页边距之前就停下,在右边缘附近点击也选不中任何东西。
#5 · 让资料框拆分,把印章钉住
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让资料框从第1页底部开始,在下一页的红线下接续,不带标题。这个框没有背景,底部也没有线,所以在第2页上只有空白把时刻表和正文隔开;marginBottom是整整一行行距,即14 pt;如果用4 pt,时刻表会在下一段上方1.5 mm处结束。印章设为placement: 'fixed',脱离文本流,钉在距上边缘8 mm处;width: 'auto'让它收缩到标题的宽度,42 mm,而不是主栏的110 mm。
完整食谱
// ═══ 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样例 · 52行 · 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 Markdown工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行
// ─── 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%; }
}
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#让资料框保持完整
使用默认的keepTogether,资料框会整体移到第2页;第1页在离底部33 mm处结束,稿件排到第三页。
- { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
+ { id: 'facts', backgroundEnabled: false, marginBottom: pt(LEAD),#仍有错误时中止构建
在构建脚本或测试中,对最终稿件运行proof(),只要发现问题就抛出异常。
const doc = await build(markdown); // 最后构建:改好的长条校样,即下面的页面
+if (proof(markdown, doc).length) throw new Error('The second pass still has faults.');常见问题
易错点
大多数警告只在沙盒中出现
未知的id、样式和指令,缺失的字体和过松的行,都由沙盒检查,而不是引擎:CodePen示例只能得到doc.warnings和parseMarkdownWithIssues。未知样式会悄悄退回默认,未知指令会作为文字打印出来,所以要核对你的id。 警告与诊断 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
易错点
章首页的图片从不计入它预留的高度
在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
消除孤字时收紧的字距可能根本不绘制
在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
版面警告 · calloutOverflow
标注框溢出所在栏
原因. 某个无法在任何位置切开的框(比栏还高的图、表或:::columns组,或splitMinLines设得太高)被放置后溢出;引擎把它记在doc.warnings中,沙盒会列出它。
解决. 缩短这个框、用keepTogether: false让它可以拆分、调低splitMinLines,或换一种通栏设置。 文档 →
沙盒检查 · unknownResourceId
未知资源
原因. 某个:ref或::resource指定的id没有对应的资源;引用处印出“?”,也不会放置任何内容。
解决. 改正id(只能用双引号),或添加该资源。 文档 →
版面警告 · unknownDirective
未知指令
原因. 某个:::name行不是Postext的指令或容器,因此被当作文字印出。
解决. 对照支持的名称检查拼写(pagebreak、columnbreak、numbering、space、toc、callout、paragraphs、part、columns)。 文档 →
沙盒检查 · looseLine
松散行
原因. 某个两端对齐的行把空格拉得超过了阈值,通常是长单词、URL或行长太窄造成的。
解决. 按正确的语言开启断词、加大行长、改写句子,或把这段排成齐左。 文档 →
- 调整稿件,让资料框把第1页填到最后一行。在1.4.1中,收尾一栏的框会在各栏齐底时下移:删掉第一段的最后一句,框的线就会落在票价段落下方6.4 mm处,而不是2.4 mm。
- 在网址和转义字符附近,源码偏移会滞后。在1.4.1中,断词会在网址的斜杠后插入零宽空格(U+200B),而
line.text保留了它们,所以含有这种空格的行每有一个就晚一个字符结束,下一行也晚同样多开始:第1页以https://aldercounty结尾的那一行还会选中*.example*的点号。以转义字符开头的行,其范围从反斜杠之后开始,比如第1页以$40开头的那一行。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Charis SIL (SIL OFL 1.1) · Chivo (SIL OFL 1.1) · Fragment Mono (SIL OFL 1.1)


