かんたんな説明
地方週刊紙のニュース記事を、2度にわたって誤りがないか確かめます。不具合をページ上に赤で示し、それを生んだテキストの行に戻れるようにする方法を示します。
できあがり
郡の週刊紙『The Tideline』の第41号は、ソーヤー川を渡る夜行フェリーの復活をトップに据えています。CodePenのサンプルはこの記事を2度校正します。初校は入稿時の原稿で、校正済みの記事に6つの誤りを戻したものです。その中には、存在しないディレクティブとして打たれたスタンプや、閉じられないデータの囲みがあります。この6つの誤りで、10個の印が付きます。カードと同じく、各不具合はそれが組まれた行に赤い下線で示され(ゆるい行は下線ではなく地色で示します)、余白の番号はページの横のリストの項目番号と一致します。行や項目をクリックすると、その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);
}
材料
- 機能
- 警告と診断エスケープとそのままの文字分割する囲み、分割しない囲み最適な改行(Knuth–Plass)固定位置の囲みとバッジ図を配置する参照フロート用の余白段欄外の注囲みの中の段組み囲みデザインした章扉段抜きの章見出し帯見出しの属性柱とノンブルページの役割ごとの柱キャンバス上のページリソースとしての図と表独自のリソースの種類セマンティックカラーパレット用紙の色
- 併用する機能
- 1段半組み
- 種類
- Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1)
- 素材
crossing-1590.jpg- The night crossing at the head of page 1 (Generated With Diffusion Models, オリジナル)
- The route map, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
作り方
#1 · エンジンの報告を読み、残りを自分で確かめる
コードは前出の手短な答えにあります。parseMarkdownWithIssues()は、開いたままの$、$$、:::ごとに問題を1件返し、doc.warningsには、どの分割位置でも分けられなかった囲みごとにcalloutOverflowが入ります。1.4.1のエンジンが報告するのはこれだけです。doc.convergedとdoc.iterationCountはレイアウトが収束したかどうかを示し、この記事は不具合の有無にかかわらず1回の反復で収束します。サイドバーのパネルで説明しているSandboxの「検査」パネルは、未知のid、未知のディレクティブ、ゆるい行も一覧にしますが、これらはSandboxが自分で計算しているので、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);
CodePenのサンプルが持つMarkdownのファイルは校正済みの記事ひとつだけで、6つの修正を取り消して初校を作ります。こうすれば2つの版が食い違うことはなく、どの印も原因がわかっています。校正済みの原稿は最後にビルドし、下のページはそのビルドです。\$は価格のドル記号をエスケープするので、数式が開きません。2ページ目の年表の各年の前に置いたワードジョイナー(U+2060)は、1971. The Tern…のような項目が、その年から番号を振るリストとして始まるのを防ぎます。初校で外すのは1971の前のものだけです。1.4.1は、https://のスキームが付いたWebアドレスをアドレスとして組みます。ハイフネーションは行わず、改行は区切りの位置だけで、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, $を数式に変えますが、このサンプルは数式エンジンを読み込まないので何も刷られません。3つ目のドル記号は閉じられない数式を開きます。この語句が抜けると、運賃の段落の1行が、スペースを通常の1.9倍に広げて組まれます。2つのフェンスが欠けていると、囲みとその:::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がその点を含む行を見つけて、その行のソース範囲をテキストエリアで選択します。リストの項目も同じように、その不具合の範囲を選択します。selectSource()はページをスクロールさせずにテキストエリアにフォーカスを移し、選択範囲より前のテキストの高さから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を行送りまるごと1行、14 ptにします。4 ptでは、時刻表が次の段落の1.5 mm上で終わってしまいます。スタンプはplacement: 'fixed'で、本文の流れから外れて天の端から8 mm下に留められます。width: 'auto'はスタンプをタイトルの幅まで縮め、主段の110 mmではなく42 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) · Map: code (CC BY 4.0) · Photo: diffusion models // 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: leading; mm: the photograph; 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 map CC BY 4.0; generated photograph. 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 route map, drawn in the page's palette; the crossing is a photograph 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 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: 'bitmap', createdAt: 0, updatedAt: 0, // 159 × 50 mm bitmap: { fileId: 'crossing-1590.jpg', format: 'jpeg', width: 1590, height: 500 }, altText: t({ en: 'A ferry with lit windows and a red port light crossing a dark estuary under ' + 'a full moon, a cannery and its stack 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('route.svg', routeMap()), loadImage('crossing-1590.jpg', asset('crossing-1590.jpg'))]); 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足りずに終わり、記事は3ページ目まで続きます。
- { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
+ { id: 'facts', backgroundEnabled: false, marginBottom: pt(LEAD),#不具合が残るビルドを止める
ビルドスクリプトやテストでは、最終原稿にproof()を実行し、何か見つかったら例外を投げます。
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.');よくあるつまずき
つまずき
警告の多くはSandboxにしかない
不明なid、スタイル、ディレクティブ、欠けたフォント、ゆるい行を検査するのはSandboxで、エンジンではありません。CodePenのサンプルで得られるのはdoc.warningsとparseMarkdownWithIssuesだけです。不明なスタイルは何の知らせもなくフォールバックし、不明なディレクティブはテキストとして印字されるので、idを確認してください。 警告と診断 →
つまずき
不明なidの:refは'?'を印字し、エンジンは警告しない
どのリソースにもないidへの:refは「?」を印字して何も配置せず、警告するのはSandboxだけです。参照するidがすべて存在するか確認してください。 図を配置する参照 →
つまずき
段落の頭の'1998. 'や'- 'はリストになる
数字、ピリオド、スペースで始まる段落や、ハイフンとスペースで始まる段落はリスト項目になります。数字の前にワードジョイナー(U+2060)を入れ、会話文はemダッシュで書いてください。 エスケープとそのままの文字 →
つまずき
:::columnsは囲みの中でしか効かず、分割されない
:::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。 囲みの中の段組み →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
つまずき
章扉の画像は確保する高さに数えられない
postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。 デザインした章扉 →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
ハイフネーションできるのは8つのロケールだけで、コードは完全一致
ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →
つまずき
デザインのテキストのlineHeightは倍率で、寸法ではない
デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です(lineHeight: 1.05)。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →
つまずき
ラントの修正が、描かれないトラッキングを詰めることがある
postext 1.4.1では、段落がラント(最終行に残った短い一語)で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking(1000分の1 em単位)までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。 ウィドウ、オーファン、ラント →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
Markdown警告 · unclosedMath
閉じられていない数式の区切り記号
原因. $でインライン数式が始まっていますが、閉じられていません。たいていは価格に付いたドル記号です。
対処. ドル記号そのものは\$と書くか、同じ行で数式を閉じてください。 ドキュメント →
Markdown警告 · unclosedContainer
閉じられていないコンテナー
原因. :::の囲いが閉じられていないため、コンテナーが文書の末尾まで続きます。
対処. コンテナーの終わる位置に、:::だけを書いた行を加えてください。 ドキュメント →
レイアウト警告 · calloutOverflow
段からはみ出す囲み
原因. どこでも分割できないボックス(段より高い図、表、:::columnsグループ、または大きすぎるsplitMinLines)が、はみ出した状態で配置されました。エンジンはこれをdoc.warningsに記録し、Sandboxが一覧に表示します。
対処. ボックスを短くする、keepTogether: falseで分割を許す、splitMinLinesを下げる、または別のスパンを指定してください。 ドキュメント →
Sandboxの検査 · unknownResourceId
不明なリソース
原因. :refまたは::resourceが、どのリソースにもないidを指定しています。参照は「?」と印字され、何も配置されません。
対処. idを修正する(二重引用符のみ使えます)か、リソースを追加してください。 ドキュメント →
レイアウト警告 · unknownDirective
不明なディレクティブ
原因. :::nameの行がPostextのディレクティブやコンテナーのどれにも当たらないため、テキストとして印字されます。
対処. 対応している名前(pagebreak、columnbreak、numbering、space、toc、callout、paragraphs、part、columns、paper)と綴りを照らし合わせてください。 ドキュメント →
Sandboxの検査 · looseLine
ゆるい行
原因. 両端そろえの行で、語間がしきい値を超えて広がっています。原因はたいてい長い単語、URL、または狭い行長です。
対処. 正しい言語でハイフネーションを有効にする、行長を広げる、文を書き換える、またはその箇所を左そろえ(行末不ぞろい)で組んでください。 ドキュメント →
- データの囲みが1ページ目を最後の行まで埋めるよう、原稿の長さを調整してください。1.4.1では、段を締めくくる囲みが段末そろえのときに下へ動きます。最初の段落の最後の文を削ると、囲みの罫は運賃の段落の下2.4 mmではなく6.4 mmに来ます。
- Webアドレスとエスケープの周りでは、ソースのオフセットが後ろにずれます。1.4.1のハイフネーションは、アドレスのスラッシュの後にゼロ幅スペース(U+200B)を挿入し、
line.textはそれを保持します。そのためアドレスを含む行は、ゼロ幅スペース1つにつき1字遅れて終わり、次の行も同じだけ遅れて始まります。1ページ目のhttps://aldercountyで終わる行は、.exampleの点まで選択します。エスケープした文字で始まる行の範囲はバックスラッシュの後から始まります。1ページ目の$40で始まる行がその例です。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- 画像
- The night crossing at the head of page 1 · Generated With Diffusion Models · オリジナル
- The route map, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- フォント
- Charis SIL (SIL OFL 1.1) · Chivo (SIL OFL 1.1) · Fragment Mono (SIL OFL 1.1)


