En bref
Un article d’un hebdomadaire local, relu deux fois pour y chercher les erreurs. On y voit comment marquer chaque faute en rouge sur la page et la relier à la ligne de texte qui l’a causée.
Ce que vous allez composer
Le numéro 41 de The Tideline, un hebdomadaire de comté, ouvre sur le retour du ferry de nuit sur la Sawyer, et le pen relit l’article deux fois. Le premier tour est la copie telle qu’elle a été remise : l’article corrigé où l’on a réintroduit sept coquilles, parmi lesquelles un tampon tapé comme une directive qui n’existe pas et un encadré qui ne se ferme jamais. Ces sept coquilles laissent onze marques. Comme sur la fiche, chaque faute est soulignée en rouge sur la ligne où elle a été composée (une ligne lâche est surlignée à la place), et son numéro en marge renvoie à son entrée dans la liste à côté des pages. Un clic sur une ligne ou sur une entrée sélectionne son Markdown. Les pages ci-dessous sont le second tour. Les vérifications n’y trouvent rien, et un tampon « PROOF · 2ND PASS » est épinglé dans l’angle supérieur droit.
Cette recette répond à
- Comment savoir ce qui ne va pas dans mon document (avertissements, débordements, mise en page qui ne converge pas) ?
- Comment relier un clic sur la page rendue à la source Markdown, pour construire un éditeur ?
- Pourquoi une ligne est-elle composée en drapeau ou trop étirée (URL, longs mots composés, mots longs dans les cellules) ?
La réponse courte
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
// The pen's own readings of the source and the lines, in the same list and shape.
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(lineWordSpacing(l) * 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);
}
Ingrédients
- Fonctionnalités
- Avertissements et diagnosticsÉchappements et caractères littérauxEncadrés qui se coupent ou restent entiersCoupure optimale des lignes (Knuth–Plass)Encadrés et badges épinglésDes citations qui placent les figuresColonne marginale pour les flottantsNotes marginalesColonnes dans un encadré ou dans le texteEncadrésOuvertures composéesBandeau de chapitre sur toute la largeurAttributs de titreTitres courants et foliosTitres courants selon le rôle de la pagePages sur un canvasFigures et tableaux comme ressourcesTypes de ressource personnalisésPalette de couleurs sémantiqueCouleur du papier
- Utilise aussi
- Colonne et demie
- Typographie
- Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1)
- Ressources
crossing-1590.jpg- The night crossing at the head of page 1 (Generated With Diffusion Models, original)
- The route map, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
Préparation
#1 · Lire ce que signale le moteur, puis vérifier le reste
Le code est dans la réponse courte plus haut. parseMarkdownWithIssues() renvoie un problème pour chaque $, $$ ou ::: resté ouvert, et doc.warnings contient un calloutOverflow pour chaque encadré qu’aucune coupe n’a pu diviser. doc.converged et doc.iterationCount disent si la mise en page s’est stabilisée, et cet article se stabilise après une itération, avec ou sans ses fautes. Le moteur consigne aussi les ids et les directives inconnus dans doc.contentWarnings, et findLooseLines renvoie les lignes lâches : ce sont les vérifications qu’affiche le panneau Vérifications du Sandbox (panneaux latéraux). proof() fait ces relevés lui-même, pour que chaque faute arrive dans une seule liste sous une seule forme : un type, une plage de source et un détail. Il compare les ids de :ref et de ::resource aux resources du rendu, et les noms de blocs à KNOWN_CONTAINERS et KNOWN_DIRECTIVES. Pour cet article, il signale aussi un bloc de liste dont le startNumber est une année et toute formule, puisque l’article n’en contient aucune, et il lit les lignes composées pour y trouver des espaces étirées au-delà de maxWordSpacing, des lignes composées en drapeau et des traits d’union ajoutés à l’intérieur d’une adresse.
#2 · Remettre les fautes dans la copie corrigée
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)
[t({ en: 'October 2.\n:::', es: 'octubre.\n:::' }), t({ en: 'October 2.', es: 'octubre.' })],
]; // last: the timeline's closing fence, so its box stays open inside the open fact box
const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md);
Le pen garde un seul fichier Markdown, l’article corrigé, et en tire le premier tour en défaisant sept corrections : les deux versions ne peuvent pas diverger, et chaque marque a une cause connue. La copie corrigée est composée en dernier, et les pages ci-dessous montrent ce rendu. \$ échappe le signe dollar d’un prix, si bien qu’aucune formule ne s’ouvre. Un gluon de mots (U+2060) devant chaque année de la chronologie de la page 2 empêche une entrée comme 1971. The Tern… d’ouvrir une liste numérotée à partir de son année ; le premier tour ne retire que celui qui précède 1971. Avec le schéma https://, une adresse web est composée comme une adresse, jamais coupée par césure et brisée seulement à ses jointures, comme en page 1 devant .example. Sans lui, l’adresse est un mot ordinaire, et le premier tour la coupe en night-ferry. La septième entrée retire la fermeture du bloc de l’encadré de la chronologie, et l’étape 3 montre ce que la mise en page en fait.
#3 · Marquer chaque faute là où elle a été composée
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);
});
}
Chaque ligne du VDT porte sourceStart et sourceEnd : la plage de source d’une faute mène donc à la ligne qui l’a imprimée. Une clôture de conteneur n’imprime aucune ligne propre, et sa marque va sur la première ligne qui commence dans les REACH (80) caractères qui la suivent. Un calloutOverflow reçoit une barre sous le pied de la colonne qu’il nomme. Une seule coquille peut laisser plusieurs marques. Les dollars nus font de $2.50 on foot, $ une formule, qui n’imprime rien parce que ce pen ne charge jamais le moteur mathématique, et le troisième dollar ouvre une formule qui ne se ferme jamais. Sans cette expression, une ligne du paragraphe des tarifs sort avec des espaces 1,9 fois plus larges que la normale. Les deux clôtures manquantes de l’encadré le laissent ouvert, avec son groupe :::columns, jusqu’à la fin de l’article, et la suite est composée à l’intérieur, dans la seconde colonne du groupe. Un groupe de colonnes se coupe entre ses colonnes comme l’encadré qui l’entoure : avec ces deux seules clôtures, rien ne déborde. C’est la clôture de la chronologie qui fait déborder. Son encadré, resté ouvert à l’intérieur de l’encadré principal, absorbe les cinq derniers paragraphes et le colophon, et un encadré placé dans un autre n’est jamais coupé. Il est composé en page 3 et dépasse de 69 mm le pied de la colonne ; ce qui se trouve sous ce pied n’est pas imprimé, et le premier tour compte trois pages au lieu de deux. L’avertissement porte la plage de source de l’encadré principal, celui que la mise en page a placé : l’entrée 8 sélectionne donc l’encadré extérieur, de sa clôture d’ouverture jusqu’à la fin de l’article. proof() juge une ligne lâche quand son espacement des mots, lineWordSpacing(line), dépasse maxWordSpacing (1,6 ici). Une ligne dont les espaces dépasseraient 3× n’a pas de rapport à comparer, parce que l’algorithme de coupure la compose en drapeau à la place : proof() vérifie donc aussi ragged.
#4 · Sélectionner le Markdown derrière une ligne
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);
};
}
Un clic arrive en pixels CSS. onPageClick() le ramène en pixels de page, trouve la ligne dont la bbox le contient et sélectionne la plage de source de cette ligne dans la zone de texte ; une entrée de la liste sélectionne de la même façon la plage de sa faute. selectSource() donne le focus à la zone de texte sans faire défiler la page et règle son scrollTop d’après la hauteur du texte qui précède la sélection. La bbox d’une ligne justifiée garde la largeur naturelle de la ligne, pas la largeur à laquelle elle est peinte : linesOf() l’étend donc jusqu’au bord droit de son bloc. Sinon le surlignage s’arrêterait avant la marge, et un clic près du bord droit ne sélectionnerait rien.
#5 · Laisser l’encadré se couper, épingler le tampon
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') } },
// '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 laisse l’encadré commencer au pied de la page 1 et continuer au verso sous son filet rouge, sans son titre. L’encadré n’a ni fond ni filet de pied : en page 2, seul un blanc sépare l’horaire de l’article, et marginBottom vaut une ligne entière d’interlignage, 14 pt ; avec 4 pt, l’horaire finirait 1,5 mm au-dessus du paragraphe suivant. Le tampon est en placement: 'fixed', hors du flux et épinglé 8 mm sous le bord supérieur, et width: 'auto' le réduit à son titre, 42 mm de large au lieu des 110 mm de la colonne principale.
La recette complète
// ═══ 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.28.0 import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, parseMarkdownWithIssues, KNOWN_DIRECTIVES, KNOWN_CONTAINERS, lineWordSpacing, } 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() links a colour to its palette entry; the hex is the value the entry holds. 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 start 6 mm under the drawing, and the opener reserves down to the byline. 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 of the size 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') } }, // '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 = () => ({ locale: t({ en: 'en-us', es: 'es' }), colorPalette, 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 }, // runts close by spaces alone: tracked, the first pass loses a fault headings: { fontFamily: DISPLAY, color: col('ink'), levels: [ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, // a recto advancedDesign: opener }, ] }, 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"}Exemple Markdown · 52 lignes · 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) [t({ en: 'October 2.\n:::', es: 'octubre.\n:::' }), t({ en: 'October 2.', es: 'octubre.' })], ]; // last: the timeline's closing fence, so its box stays open inside the open fact box 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 // The pen's own readings of the source and the lines, in the same list and shape. 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(lineWordSpacing(l) * 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 // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)), 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) => buildDocumentWithFonts({ markdown: m, resources }, config(), kitFonts(FONTS)); const proofAgain = async (md) => proofDesk(source.value = md, await build(md)); const first = await proofAgain(firstPass(markdown)); // first: the galley as it came in const doc = await build(markdown); // last: the corrected galley, the pages below showPages(doc, { title: say('title') }); $('again').onclick = () => proofAgain(source.value); $('fixed').onclick = () => proofAgain(markdown); selectSource(first[0].from, first[0].to); // the first fault, selected in the MarkdownKit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
<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; flex-wrap: wrap; gap: 14px; max-width: 854px; } /* two pages a row */
#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 { max-width: none; }
#galley canvas { width: calc(50% - 7px); }
}
@media (max-width: 560px) {
#proof { padding: 24px 16px 28px; }
#galley { flex-direction: column; }
#galley canvas { width: 100%; }
}
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Garder l’encadré d’un seul tenant
Avec le keepTogether par défaut, l’encadré passe en page 2 d’un seul bloc ; la page 1 finit 33 mm trop tôt et l’article déborde sur une troisième page.
- { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
+ { id: 'facts', backgroundEnabled: false, marginBottom: pt(LEAD),#Arrêter un rendu qui a encore des fautes
Dans un script de construction ou un test, lancez proof() sur la copie finale et levez une erreur s’il trouve quoi que ce soit.
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.');Erreurs fréquentes
Erreur fréquente
Un $ seul ouvre des maths : écrivez \$
Un signe dollar ouvre des maths en ligne : un prix comme $40 commence donc une formule. Écrivez \$40. Échappements et caractères littéraux →
Erreur fréquente
'1998. ' ou '- ' en début de paragraphe ouvre une liste
Un paragraphe qui commence par un nombre, un point et une espace, ou par un trait d’union et une espace, devient un élément de liste. Mettez un word joiner (U+2060) devant le nombre, et écrivez les dialogues avec un tiret cadratin. Échappements et caractères littéraux →
Avertissement Markdown · unclosedMath
Délimiteur mathématique non fermé
Pourquoi. Un $ ouvre des mathématiques en ligne qui ne sont jamais fermées, en général parce qu’il appartient à un prix.
Correction. Écrivez \$ pour un dollar littéral, ou fermez la formule sur la même ligne. Docs →
Avertissement Markdown · unclosedContainer
Conteneur non fermé
Pourquoi. Une clôture ::: n’est jamais fermée : le conteneur s’étend jusqu’à la fin du document.
Correction. Ajoutez une ligne contenant seulement ::: là où le conteneur se termine. Docs →
Avertissement de mise en page · calloutOverflow
Encadré plus haut que sa colonne
Pourquoi. Un encadré qu’aucune coupure ne peut diviser (une figure, un tableau ou un encadré imbriqué plus haut que la colonne, ou un splitMinLines trop élevé) a été placé en débordement ; le moteur l’enregistre dans doc.warnings et le Sandbox le liste. Un encadré span: 'side' plus haut qu’une colonne latérale vide est lui aussi placé en débordement.
Correction. Raccourcissez l’encadré, laissez-le se couper avec keepTogether: false, baissez splitMinLines, ou donnez-lui un autre span. Docs →
Vérification du Sandbox · unknownResourceId
Ressource inconnue
Pourquoi. Un :ref ou un ::resource nomme un identifiant qu’aucune ressource ne porte ; le renvoi imprime "?" et rien n’est placé.
Correction. Corrigez l’identifiant (guillemets doubles uniquement) ou ajoutez la ressource. Docs →
Avertissement de mise en page · unknownDirective
Directive inconnue
Pourquoi. Une ligne :::name n’est ni une directive ni un conteneur de Postext : elle s’imprime comme du texte.
Correction. Vérifiez l’orthographe par rapport aux noms pris en charge (pagebreak, columnbreak, numbering, space, toc, callout, paragraphs, part, columns, paper). Docs →
Vérification du Sandbox · looseLine
Ligne lâche
Pourquoi. Une ligne justifiée étire ses espaces au-delà du seuil, en général à cause d’un mot long, d’une URL ou d’une justification étroite.
Correction. Activez la césure dans la bonne langue, élargissez la justification, reformulez, ou composez le passage en drapeau. Docs →
- Ajustez la copie pour que l’encadré remplisse la page 1 jusqu’à sa dernière ligne. Un encadré qui termine une page descend jusqu’au pied quand les colonnes sont équilibrées (
headings.balancing.closingBox,'first'par défaut) : coupez la dernière phrase du premier paragraphe, et le filet de l’encadré se place 6,4 mm sous le paragraphe des tarifs au lieu de 2,4 mm.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Images
- The night crossing at the head of page 1 · Generated With Diffusion Models · original
- The route map, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- Polices
- Charis SIL (SIL OFL 1.1) · Chivo (SIL OFL 1.1) · Fragment Mono (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


