Vai al contenuto principale
Ricetta numero 55

Ricettario · Capitolo 10 · Output e integrazione

Una bozza con ogni errore segnato in rosso

Una bozza di giornale corretta in due giri, con ogni errore che il motore segnala o che il pen trova segnato in rosso sulla pagina e collegato al suo Markdown.

In questa pagina
  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 190 × 253 mm
  • Colonna e mezza, Intercolonna di 6 mm
  • Charis SIL 10/14
  • Chivo
  • Fragment Mono
  • 2 pagine
  • Livello
  • Postext 1.31.0
  • Impaginato in 8 ms
  • 250 righe di codice

In breve

Un articolo di un settimanale locale, controllato due volte in cerca di errori. Mostra come segnare in rosso sulla pagina ogni errore e collegarlo alla riga di testo che lo ha causato.

Che cosa comporrai

Il numero 41 di The Tideline, un settimanale di contea, apre con il ritorno del traghetto notturno sul Sawyer, e il pen corregge l’articolo in due giri. Il primo giro è il testo come è stato consegnato, cioè l’articolo corretto con sette sviste rimesse al loro posto, tra cui un timbro scritto come una direttiva che non esiste e un riquadro dei dati che non si chiude mai. Quelle sette sviste lasciano undici segni. Come sulla scheda, ogni errore è sottolineato in rosso sulla riga in cui è stato composto (una riga lasca invece viene campita), e il suo numero in margine corrisponde alla sua voce nell’elenco accanto alle pagine. Un clic su una riga o su una voce seleziona il suo Markdown. Le pagine qui sotto sono il secondo giro. Le verifiche non vi trovano nulla, e un timbro «PROOF · 2ND PASS» è fissato nell’angolo in alto a destra.

Questa ricetta risponde a

  • Come scopro che cosa non va nel mio documento (avvisi, sforamenti, impaginazione che non converge)?
  • Come riporto un clic sulla pagina visualizzata alla sorgente Markdown, per costruire un editor?
  • Perché una riga esce in bandiera o troppo spaziata (URL, composti lunghi, parole lunghe nelle celle)?

La risposta breve

script.js · righe 170–203nel codice completo
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);
}

Ingredienti

Tipo
Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1)
Risorse
  • crossing-1590.jpg
  • The night crossing at the head of page 1 (Generated With Diffusion Models, originale)
  • The route map, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

Preparazione

#1 · Leggi ciò che il motore segnala, poi controlla il resto

Il codice è nella risposta breve qui sopra. parseMarkdownWithIssues() restituisce un problema per ogni $, $$ o ::: lasciato aperto, e doc.warnings contiene un calloutOverflow per ogni riquadro che nessun taglio è riuscito a dividere. doc.converged e doc.iterationCount dicono se l’impaginazione si è assestata, e questo articolo si assesta dopo un’iterazione con o senza i suoi errori. Il motore registra anche id e direttive sconosciuti in doc.contentWarnings, e findLooseLines restituisce le righe lasche: le verifiche che mostra il pannello Verifiche della Sandbox (Pannelli della barra laterale). proof() fa da sé quelle letture, così ogni errore finisce in un solo elenco con una sola forma: un tipo, un intervallo del sorgente e un dettaglio. Confronta gli id di :ref e ::resource con le resources della costruzione, e i nomi dei blocchi con KNOWN_CONTAINERS e KNOWN_DIRECTIVES. Per questo articolo segnala anche un blocco di elenco il cui startNumber è un anno e qualunque formula, perché l’articolo non ne ha, e legge le righe composte cercando spazi allargati oltre maxWordSpacing, righe composte in bandiera e trattini aggiunti dentro un indirizzo.

#2 · Rimetti gli errori nel testo corretto

script.js · righe 157–166nel codice completo
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);

Il pen tiene un solo file Markdown, l’articolo corretto, e ne ricava il primo giro annullando sette correzioni, così le due versioni non possono divergere e ogni segno ha una causa nota. Il testo corretto viene costruito per ultimo, e le pagine qui sotto mostrano quella costruzione. \$ fa l’escape del simbolo del dollaro di un prezzo, così non si apre nessuna formula. Un word joiner (U+2060) prima di ogni anno nella cronologia di pagina 2 impedisce a una voce come 1971. The Tern… di aprire un elenco numerato a partire dal suo anno; il primo giro toglie solo quello prima del 1971. Con lo schema https://, un indirizzo web viene composto come indirizzo, mai sillabato e diviso solo nei suoi punti di giunzione, come a pagina 1 prima di .example. Senza di esso l’indirizzo è una parola qualsiasi, e il primo giro lo sillaba come night-ferry. La settima voce toglie il delimitatore di chiusura del riquadro della cronologia, e il passo 3 mostra che cosa ne fa l’impaginazione.

#3 · Segna ogni errore dove è stato composto

script.js · righe 207–245nel codice completo
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);
  });
}

Ogni riga del VDT porta sourceStart e sourceEnd, così l’intervallo del sorgente di un errore porta alla riga che lo ha stampato. Il delimitatore di un contenitore non stampa una riga propria, e il suo segno va sulla prima riga che comincia entro REACH (80) caratteri dopo di esso. Un calloutOverflow riceve una barra sotto il piede della colonna che nomina. Una sola svista può lasciare più segni. I dollari senza escape trasformano $2.50 on foot, $ in una formula, che non stampa nulla perché questo pen non carica mai il motore matematico, e il terzo dollaro apre una formula che non si chiude mai. Senza quella frase, una riga del paragrafo delle tariffe esce con spazi larghi 1,9 volte il normale. I due delimitatori mancanti del riquadro dei dati lasciano aperti lui e il suo gruppo :::columns fino alla fine dell’articolo, e ciò che segue si compone al loro interno, nella seconda colonna del gruppo. Un gruppo di colonne si taglia tra le sue colonne come il riquadro che lo contiene, quindi con quei due soli delimitatori nulla trabocca. È il delimitatore della cronologia a farlo. Il suo riquadro, lasciato aperto dentro il riquadro dei dati, ingloba gli ultimi cinque paragrafi e il colophon, e un riquadro dentro un altro riquadro non si taglia mai. Viene composto a pagina 3 e va 69 mm oltre il piede della colonna; ciò che sta sotto quel piede non si stampa, e il primo giro ha tre pagine invece di due. L’avviso porta l’intervallo del sorgente del riquadro dei dati, il riquadro che l’impaginazione ha collocato, quindi la voce 8 seleziona il riquadro esterno, dal suo delimitatore alla fine dell’articolo. proof() considera lasca una riga quando la sua spaziatura tra parole, lineWordSpacing(line), supera maxWordSpacing (qui 1.6). Una riga i cui spazi supererebbero 3× non ha un rapporto da confrontare, perché la divisione in righe la compone in bandiera, quindi proof() controlla anche ragged.

#4 · Seleziona il Markdown dietro una riga

script.js · righe 249–267nel codice completo
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 arriva in pixel CSS. onPageClick() lo scala in pixel della pagina, trova la riga il cui bbox lo contiene e seleziona nella textarea l’intervallo del sorgente di quella riga; una voce dell’elenco seleziona allo stesso modo l’intervallo del suo errore. selectSource() dà il focus alla textarea senza far scorrere la pagina e imposta il suo scrollTop in base all’altezza del testo che precede la selezione. Il bbox di una riga giustificata conserva la larghezza naturale della riga, non quella a cui è dipinta, quindi linesOf() lo estende fino al bordo destro del suo blocco. Altrimenti la campitura si fermerebbe prima del margine, e un clic vicino al bordo destro non selezionerebbe nulla.

#5 · Lascia dividere il riquadro dei dati, fissa il timbro

script.js · righe 54–68nel codice completo
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 permette al riquadro dei dati di cominciare al piede di pagina 1 e di proseguire a tergo sotto il suo filetto rosso, senza titolo. Il riquadro non ha sfondo né filetto al piede, quindi a pagina 2 solo lo spazio bianco separa l’orario dall’articolo, e marginBottom è un’intera interlinea, 14 pt; con 4 pt l’orario finirebbe 1,5 mm sopra il paragrafo successivo. Il timbro è placement: 'fixed', fuori dal flusso e fissato 8 mm sotto il bordo superiore, e width: 'auto' lo riduce al suo titolo, largo 42 mm invece dei 110 mm della colonna principale.

La ricetta completa

Sandbox
// ═══ 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"}
Esempio Markdown · 52 righe · 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 Markdown
Kit · core, fonts, viewer, images: uguale in ogni ricetta · 270 righe// ─── 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 ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Varianti

#Tieni intero il riquadro dei dati

Con il keepTogether predefinito, il riquadro passa a pagina 2 in un solo pezzo; la pagina 1 finisce 33 mm prima e l’articolo arriva a una terza pagina.

-  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
+  { id: 'facts', backgroundEnabled: false, marginBottom: pt(LEAD),

#Ferma una costruzione che ha ancora errori

In uno script di costruzione o in un test, esegui proof() sul testo finale e lancia un errore se trova qualcosa.

 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.');

Errori frequenti

Errore frequente

Un $ da solo apre la matematica: scrivi \$

Il simbolo del dollaro apre la matematica in linea, quindi un prezzo come $40 fa cominciare una formula. Scrivi \$40. Escape e caratteri letterali →

Errore frequente

'1998. ' o '- ' a inizio paragrafo aprono un elenco

Un paragrafo che comincia con un numero, un punto e uno spazio, o con un trattino e uno spazio, diventa una voce di elenco. Metti un word joiner (U+2060) prima del numero, e scrivi i dialoghi con la lineetta. Escape e caratteri letterali →

Avviso Markdown · unclosedMath

Delimitatore matematico non chiuso

Perché. Un $ apre una formula in linea che non viene mai chiusa, di solito perché fa parte di un prezzo.

Soluzione. Scrivi \$ per un dollaro letterale, oppure chiudi la formula sulla stessa riga. Documentazione →

Avviso Markdown · unclosedContainer

Contenitore non chiuso

Perché. Un delimitatore ::: non viene mai chiuso, quindi il contenitore arriva fino alla fine del documento.

Soluzione. Aggiungi una riga con un semplice ::: dove finisce il contenitore. Documentazione →

Avviso di impaginazione · calloutOverflow

Il riquadro esce dalla colonna

Perché. Un riquadro che nessun taglio può dividere (una figura, una tabella o un riquadro annidato al suo interno più alto della colonna, o uno splitMinLines troppo alto) è stato collocato sforando; il motore lo registra in doc.warnings e la Sandbox lo elenca. Anche un riquadro span: 'side' più alto di una colonna laterale vuota viene collocato sforando.

Soluzione. Accorcia il riquadro, lascia che si divida con keepTogether: false, abbassa splitMinLines, oppure dagli un altro span. Documentazione →

Verifica della Sandbox · unknownResourceId

Risorsa sconosciuta

Perché. Un :ref o un ::resource indica un id che nessuna risorsa ha; il rimando stampa «?» e non viene collocato nulla.

Soluzione. Correggi l’id (solo virgolette doppie) oppure aggiungi la risorsa. Documentazione →

Avviso di impaginazione · unknownDirective

Direttiva sconosciuta

Perché. Una riga :::nome non è una delle direttive o dei contenitori di Postext, quindi si stampa come testo.

Soluzione. Controlla l’ortografia rispetto ai nomi supportati (pagebreak, columnbreak, numbering, space, toc, callout, paragraphs, part, columns, paper). Documentazione →

Verifica della Sandbox · looseLine

Riga lasca

Perché. Una riga giustificata allarga i suoi spazi oltre la soglia, di solito a causa di una parola lunga, di un URL o di una giustezza stretta.

Soluzione. Attiva la sillabazione nella lingua giusta, allarga la giustezza, riformula, oppure componi il passaggio in bandiera. Documentazione →

  • Adatta il testo in modo che il riquadro dei dati riempia la pagina 1 fino all’ultima riga. Un riquadro che chiude una pagina scende al suo piede quando le colonne vengono bilanciate (headings.balancing.closingBox, 'first' per impostazione predefinita): taglia l’ultima frase del primo paragrafo e il filetto del riquadro sta 6,4 mm sotto il paragrafo delle tariffe invece di 2,4 mm.

Crediti

Testo
Testo originale, CC BY 4.0
Immagini
  • The night crossing at the head of page 1 · Generated With Diffusion Models · originale
  • The route map, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
Font
Charis SIL (SIL OFL 1.1) · Chivo (SIL OFL 1.1) · Fragment Mono (SIL OFL 1.1)
Sandbox