Saltar al contingut principal
Recepta número 55

Receptari · Capítol 10 · Sortida i integració

Una galerada amb cada error marcat en vermell

Una galerada revisada dues vegades, amb els errors que assenyala el motor i els que troba el pen marcats en vermell a la pàgina i lligats al seu Markdown.

En aquesta pàgina
Nivell
Avançat
Postext
Provada amb Postext 1.9.2
Requereix ≥ 1.4.1
Llicència
Actualitzada el 27 de set. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en castellà: encara no hi ha edició en català
  • Format 190 × 253 mm
  • Columna i mitja, espai entre columnes de 6 mm
  • Charis SIL 10/14
  • Chivo
  • Fragment Mono
  • 2 pàgines
  • Nivell
  • Postext 1.9.2
  • Compost en 15 ms
  • 250 línies de codi

En poques paraules

Una notícia d'un setmanari local, revisada dues vegades a la recerca d'errors. Mostra com marcar cada error en vermell sobre la pàgina i enllaçar-lo amb la línia del text que l'ha causat.

Què compondràs

El número 41 de La Marea, un setmanari comunal, obre amb la tornada de la barcassa nocturna del Quilén, i el pen revisa la notícia dues vegades. Les primeres proves són el text tal com va arribar a la taula, la notícia corregida amb sis errors reposats, entre els quals un segell escrit com una directiva que no existeix i un requadre que no es tanca mai. Aquests sis errors deixen nou marques. Com a la targeta, cada error se subratlla en vermell a la línia on s'ha compost (una línia fluixa s'ombreja sencera), i el seu número al marge és el de la seva entrada a la llista al costat de les pàgines. En prémer una línia o una entrada se selecciona el seu Markdown. Les pàgines de sota són les segones proves. En aquestes les comprovacions no troben res, i el segell SEGUNDAS PRUEBAS queda fixat a la cantonada superior dreta.

Aquesta recepta respon a

  • Com esbrino què falla en el meu document (avisos, desbordaments, composició que no convergeix)?
  • Com relaciono un clic a la pàgina pintada amb el Markdown d'origen, per construir un editor?
  • Per què una línia surt en bandera o massa estirada (URL, compostos llargs, paraules llargues en cel·les)?

La resposta curta

script.js · línies 170–203al codi complet
const MM = DPI / 25.4; // page px per mm
function proof(md, doc) {
  const faults = [];
  const add = (kind, from, to, detail, at) => faults.push({ kind, from, to, detail, at });
  const { blocks, issues } = parseMarkdownWithIssues(md); // a $, $$ or ::: left open
  for (const i of issues) add(i.kind, i.sourceStart, i.sourceEnd);
  for (const w of doc.warnings ?? []) { // calloutOverflow: a box no cut could split
    add(w.kind, w.sourceStart, w.sourceEnd, Math.round(w.overflowPx / MM), w);
  }
  if (!doc.converged) add('unsettled', 0, 1, doc.iterationCount); // the layout never settled
  // 1.4.1 reports none of these (gotcha: sandbox-only-warnings).
  const ids = new Set(resources.map((r) => r.id)); // an unknown id prints '?'
  for (const m of md.matchAll(/:ref\{id="([^"]*)"|^::resource\{id="([^"]*)"/gm)) {
    if (!ids.has(m[1] ?? m[2])) add('unknownResourceId', m.index, m.index + m[0].length);
  }
  for (const m of md.matchAll(/^:::?([a-z][\w-]*)/gim)) { // an unknown fence prints as text
    const known = KNOWN_CONTAINERS.has(m[1]) || KNOWN_DIRECTIVES.has(m[1]) || m[1] === 'resource';
    if (!known) add('unknownDirective', m.index, m.index + m[0].length);
  }
  for (const b of blocks) { // formulas count as faults only because this story has none
    if (b.startNumber > 999) add('yearList', b.sourceStart, b.sourceEnd); // 1971. opens a list
    for (const s of b.spans ?? []) if (s.math) add('formula', s.math.sourceStart, s.math.sourceEnd);
  }
  // The lines as set: spaces past maxWordSpacing, a line set ragged, a hyphen in an address.
  const max = doc.config.bodyText.maxWordSpacing;
  for (const l of doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks))
    .flatMap((b) => b.lines ?? [])) {
    const ratio = Math.round(l.justifiedSpaceRatio * 100) / 100; // as the report prints it
    if (ratio > max) add('looseLine', l.sourceStart, l.sourceEnd, ratio);
    if (l.ragged) add('raggedLine', l.sourceStart, l.sourceEnd); // past 3×: no ratio left
    if (l.hyphenated && /[./]\S+-$/.test(l.text)) add('addressHyphen', l.sourceStart, l.sourceEnd);
  }
  return faults.sort((a, b) => a.from - b.from);
}

Ingredients

També fa servir
Columna i mitja
Tipografia
Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1)
Recursos
  • 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)

Elaboració

#1 · Llegeix el que retorna el motor i comprova tu la resta

El codi és la resposta curta de més amunt. parseMarkdownWithIssues() retorna una incidència per cada $, $$ o ::: que queda obert, i doc.warnings recull un calloutOverflow per cada requadre que cap tall no ha pogut partir; a 1.4.1 el motor no informa de res més. doc.converged i doc.iterationCount diuen si la composició s'ha assentat, i aquesta notícia s'assenta en una iteració, amb errors o sense. El tauler Revisió del Sandbox (a Taulers laterals) mostra també els ids i les directives desconeguts i les línies fluixes, però aquests avisos els calcula el mateix Sandbox, així que proof() els busca pel seu compte. Compara els ids de :ref i ::resource amb els resources de la composició, i els noms de tanca amb KNOWN_CONTAINERS i KNOWN_DIRECTIVES. Per a aquesta notícia marca a més els blocs de llista el startNumber dels quals és un any i qualsevol fórmula, perquè la notícia no en porta cap, i a les línies ja compostes busca espais que passen de maxWordSpacing, línies en bandera i guionets afegits dins d'una adreça.

#2 · Torna a introduir els errors a la versió corregida

script.js · línies 158–166al codi complet
const FIRST_PASS = [ // [corrected, faulty], replaced wherever it occurs
  [':::callout{type="stamp"}\n:::', t({ en: ':::stamp', es: ':::sello' })], // no such directive
  [':ref{id="route"}', ':ref{id="route-map"}'], // an id no resource has
  ['\\$', '$'], // bare dollars (gotcha: dollar-math)
  [' https://', ' '], // a web address without its scheme
  [':::\n:::\n', ''], // the fact box's two closing fences
  ['\u20601971.', '1971.'], // the word joiner before 1971 (gotcha: digit-period-list)
];
const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md);

El pen desa un sol fitxer Markdown, la notícia corregida, i en treu les primeres proves desfent sis correccions; així les dues versions no es poden desfasar i cada marca té una causa coneguda. La versió corregida es compon al final, i és la que mostren les pàgines de sota. \$ escapa el signe de dòlar d'un preu perquè no obri una fórmula. Un unidor de paraules (U+2060) davant de cada any de la cronologia de la pàgina 2 impedeix que una entrada com 1971. Entra en servicio… obri una llista numerada des d'aquest any; les primeres proves només treuen el de 1971. Amb l'esquema https://, la versió 1.4.1 compon l'adreça web com a tal: no la parteix mai per síl·labes, només per les seves juntures, després d'una barra o abans d'un punt. Sense l'esquema és una paraula més, i les primeres proves la parteixen com puertoaliso.exam-ple.

#3 · Marca cada error on s'ha compost

script.js · línies 207–245al codi complet
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);
  });
}

Cada línia del VDT porta sourceStart i sourceEnd, de manera que el rang d'origen d'un error porta a la línia que l'ha imprès. La tanca d'un contenidor no imprimeix cap línia, i la seva marca va a la primera línia que comença en els REACH (80) caràcters següents. Un calloutOverflow rep una barra al peu de la columna que indica. Un sol descuit pot deixar diverses marques. Els dòlars sense escapar converteixen $1.200 a pie, $ en una fórmula, que no imprimeix res perquè aquest pen no carrega el motor matemàtic, i el tercer dòlar obre una fórmula que no es tanca; a l'edició anglesa, a més, el paràgraf de les tarifes queda amb una línia els espais de la qual mesuren 1,9 vegades la seva amplada normal. Les dues tanques que falten deixen oberts el requadre i el seu grup :::columns fins al final de la notícia, i com que un grup de columnes no es parteix mai, el requadre desborda la seva columna en 258 mm. proof() marca una línia com a fluixa quan el seu justifiedSpaceRatio supera maxWordSpacing (1,6 aquí). Una línia els espais de la qual passarien de 3× no té cap proporció amb què comparar, perquè 1.4.1 la compon en bandera, i per això proof() comprova també ragged.

#4 · Selecciona el Markdown d'una línia

script.js · línies 249–267al codi complet
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 arriba en píxels CSS. onPageClick() el passa a píxels de pàgina, busca la línia en el bbox de la qual cau i selecciona al textarea el rang d'origen d'aquesta línia; una entrada de la llista selecciona de la mateixa manera el rang del seu error. selectSource() enfoca el textarea sense moure la pàgina i fixa el seu scrollTop amb l'alçada del text anterior a la selecció. A 1.4.1, el bbox d'una línia justificada conserva l'amplada natural de la línia, no l'amplada a què es pinta, així que linesOf() l'allarga fins a la vora dreta del bloc. Sense això, l'ombrejat es quedaria abans del marge i un clic a prop de la vora dreta no seleccionaria res.

#5 · Deixa que el requadre es parteixi i fixa el segell

script.js · línies 55–69al codi complet
const boxes = [
  // keepTogether: false: cut between two blocks at the page foot; a line of white closes it.
  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') },
    padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') },
    body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) },
    lists: { bulletChar: '■', color: col('proof'), bulletFontSize: pt(5) } },
  // 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title.
  { id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false,
    title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }),
    fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } },
    border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1),
    padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) },
    titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } },
];

keepTogether: false permet que el requadre de dades comenci al peu de la pàgina 1 i continuï a la pàgina 2 sota el seu filet vermell, sense títol. El requadre no té fons ni filet al peu, i a la pàgina 2 només el blanc el separa de la notícia; per això marginBottom mesura una línia sencera (14 pt), i amb 4 pt l'horari quedaria a 1,5 mm del paràgraf següent. El segell és placement: 'fixed', fora del flux i fixat a 8 mm de la vora superior, i width: 'auto' l'ajusta al seu títol, amb 42 mm d'amplada en lloc dels 110 mm de la columna principal.

La recepta 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.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseMarkdownWithIssues, KNOWN_DIRECTIVES, KNOWN_CONTAINERS,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'proof-sheet-diagnostics';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// col() writes the hex beside each paletteId (gotcha: palette-skips-designs).
const palette = { // proof: the one accent and the marks; rule: the map's banks; muted: furniture
  ink: '#1d1d1b', proof: '#d7263d', paper: '#f6f3ea', rule: '#bdb8aa', muted: '#76726a' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.proof]] // every default
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // on main-color: red
const [TEXT, DISPLAY, MONO] = ['Charis SIL', 'Chivo', 'Fragment Mono'];
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [190, 253, 22, 20, 17, 14]; // mm, mirrored
const MEASURE = TRIM_W - INNER - OUTER; // mm: 159, the text block the opener spans
const [LEAD, ART_H, DPI] = [14, 50, 150]; // pt: leading; mm: the photograph; page px/inch
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const caps = (size) => ({ fontFamily: MONO, fontSize: pt(size), letterSpacing: pt(size * 0.12),
  textTransform: 'uppercase', fontWeight: 400 });

const below = (id, gap, width = MEASURE) => ({ anchor: { to: id, edge: id === 'container'
  ? 'top-left' : 'below' }, offset: { x: mm(0), y: mm(gap) }, size: { width: mm(width) } });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...extra });
const opener = { enabled: true, slot: { elements: [ // the drawing, then the words under it
  { kind: 'image', id: 'art', resourceId: 'crossing', placement: { ...below('container', 0),
    size: { width: mm(MEASURE), height: mm(ART_H) } } },
  // The words reserve the drawing's height; an image never does (gotcha: opener-image-no-reserve).
  text('kicker', '{attr.kicker}', MONO, 8, 'proof', below('container', ART_H + 6), caps(8)),
  text('title', '{titleText}', DISPLAY, 50, 'ink', below('#kicker', 1.4),
    { fontWeight: 900, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple)
  text('standfirst', '{attr.standfirst}', TEXT, 11.5, 'ink', below('#title', 4, 136),
    { italic: true, lineHeight: 1.3 }),
  text('byline', '{attr.byline}', MONO, 7.5, 'muted', below('#standfirst', 3), caps(7.5))] } };
const head = (id, content, parity, edge, x, y = 12) => text(id, content, MONO, 7.5, 'muted',
  { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, { ...caps(7.5), parity,
    pages: 'body', align: edge.split('-')[1] ?? 'center' }); // y: mm from the trim
const header = { elements: [
  head('verso', t({ en: '{pageNumber}   The Tideline · issue 41',
    es: '{pageNumber}   La Marea · número 41' }), 'even', 'top-left', OUTER),
  head('recto', t({ en: 'News · the night ferry   {pageNumber}',
    es: 'Noticias · la barcaza nocturna   {pageNumber}' }), 'odd', 'top-right', -OUTER)] };
const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, -11),
  pages: 'opener' }] }; // the opener's folio drops to its foot

// #region boxes: the fact box splits where the page ends; the stamp is pinned to a corner
const boxes = [
  // keepTogether: false: cut between two blocks at the page foot; a line of white closes it.
  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') },
    padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') },
    body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) },
    lists: { bulletChar: '■', color: col('proof'), bulletFontSize: pt(5) } },
  // 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title.
  { id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false,
    title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }),
    fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } },
    border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1),
    padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) },
    titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } },
];
// #endregion
const side = (id, body, fontFamily = DISPLAY) => ({ id, span: 'side', backgroundEnabled: false,
  padding: NONE, titleStyle: { ...caps(7), color: col('proof'), gap: mm(2) }, // outer column
  body: { fontFamily, textAlign: 'left', firstLineIndent: pt(0), ...body } });
const calloutStyles = [...boxes, side('quote', { fontSize: pt(12.5), lineHeight: pt(15) }),
  side('dates', { fontSize: pt(9), lineHeight: pt(12), paragraphSpacing: true }),
  side('colophon', { fontSize: pt(6.5), lineHeight: pt(9.5), color: col('muted') }, MONO)];

const config = () => ({ // a fresh object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), colorPalette, // exact codes (gotcha: hyphenation-locales)
  resourceTypes: [{ id: 'figure', name: t({ en: 'Map', es: 'Mapa' }),
    captionPrefix: t({ en: 'Map', es: 'Mapa' }), shortLabel: t({ en: 'map', es: 'mapa' }),
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: DPI,
    backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM),
      left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: 27, sideColumnRole: 'floats',
    sideColumnSide: 'outer', gutterWidth: mm(6) },
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), // a :ref (map 1) takes boldColor
    firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6,
    maxRuntTracking: 0 }, // gotcha: runt-tracking-unpainted
  headings: { fontFamily: DISPLAY, color: col('ink'), levels: [ // the H1 break restated, as a
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, // headings object
      advancedDesign: opener }, // drops it (gotcha: headings-drop-h1-break)
  ] },
  calloutStyles, header, footer,
  captionStyle: { fontFamily: DISPLAY, fontSize: pt(8), lineHeight: pt(11), color: col('ink'),
    labelBold: true, labelColor: col('proof') },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# Vuelve la barcaza nocturna {kicker="Estuario · Transporte" standfirst="Tras once inviernos sin cruces nocturnos, la barcaza de Puerto Aliso vuelve a zarpar de noche desde el viernes, con una nave eléctrica y un horario hecho para el turno nocturno de la planta." byline="Carmen Vidal Oyarzún · Galerada del número 41, 1 de octubre de 2026"}
Mostra en Markdown · 52 línies · content.es.md :::callout{type="stamp"} ::: Por primera vez desde 2015, la última barcaza que cruza el río Quilén saldrá después del último bus. Desde el viernes 2 de octubre, la *Chilco* suma ocho cruces nocturnos a su horario de día: el último sale de calle Prat, en Puerto Aliso, a las 23:40, y el último de vuelta, a medianoche, de Caleta Grulla por la ruta del :ref{id="route"}. Hay cruces todas las noches salvo el 25 de diciembre; el primero, a las 20:20. Las tarifas no cambian: \$1.200 a pie, \$6.500 con auto y conductor, y \$22.000 el pase mensual, con la mitad para mayores de 65 y menores de 18 años. El horario de invierno está en las dos rampas y en https://puertoaliso.example/barcazanocturna. :::callout{type="facts" title="La travesía en cifras"} - **Nave.** *Chilco*, eléctrica a batería, 25 metros, construida en 2026. - **Capacidad.** 149 pasajeros, 18 autos, 12 bicicletas. - **Cruce.** 2,3 kilómetros; 12 minutos con la llenante, 15 con la vaciante. - **Tripulación.** Capitana, marinero y maquinista en cada cruce nocturno. :::columns{count=2 breaks="2"} **Desde calle Prat** 20:20, 21:40, 22:40 y 23:40. **Desde Caleta Grulla** 20:40, 22:00, 23:00 y medianoche. ::: ::: :::callout{type="quote" title="Marisol Uribe"} «En el bus llegaba a la una y diez. Con la barcaza voy a llegar a las doce y media». ::: El viejo *Gaviotín*, una barcaza diésel de 1971, rompió el eje de babor dos veces en seis semanas en el invierno de 2015, y la municipalidad dejó de hacerlo navegar de noche. Desde entonces, la planta salmonera de Caleta Grulla lleva en bus a su turno de noche por el puente de la ruta costera, 30 kilómetros en cada sentido. La *Chilco* llegó del astillero en junio y cubre el horario de día desde el 3 de agosto. Carga electricidad de tierra en las dos rampas mientras embarca, y así se pagan los cruces nocturnos: la electricidad de una noche de ocho cruces cuesta menos que el diésel de dos del *Gaviotín*. Su capitana, Ilse Brandauer, pasó nueve años en los barcos de pasajeros del Danubio, en Passau, antes de casarse con un carpintero de ribera de Puerto Aliso. Echa de menos una palabra, *Donaudampfschifffahrtsgesellschaftskapitän*, el «capitán de la compañía de vapores del Danubio» con que los niños alemanes se traban la lengua. :::callout{type="dates" title="Cronología"} ⁠1971. Entra en servicio el *Gaviotín*, una barcaza diésel. ⁠2015. Rompe dos veces el eje de babor en seis semanas; se acaban los cruces nocturnos. ⁠2026. La *Chilco* cubre el horario de día desde el 3 de agosto y el nocturno desde el 2 de octubre. ::: La ruta se abre aguas arriba del bajo Piedra Negra con la vaciante, cuando la corriente arrastraría la nave hacia él, y cruza en línea recta con la llenante; por eso hay dos tiempos de cruce. Si la niebla suspende un zarpe, se avisa por mensaje de texto media hora antes. Marisol Uribe envasa salmón en el turno de noche y viaja en el bus de la planta desde que el *Gaviotín* dejó de navegar. «En el bus llegaba a la una y diez. Con la barcaza voy a llegar a las doce y media», dice. La planta adelantó el fin del turno de las 23:45 a las 23:30 para que sus trabajadores alcancen la última barcaza. Seis familias de calle Prat, del lado de la rampa, escribieron al concejo por los autos que hacen fila de noche. Según la oficina de la barcaza, esperarán en el sitio tras la antigua oficina de la planta, no en la calle. El concejo aprobó el servicio nocturno por tres años en su sesión de septiembre, por cuatro votos contra uno. Hernán Solís, el concejal que votó en contra, pidió contar los pasajeros nocturnos hasta fines de enero antes de comprometer un cuarto año. La oficina de la barcaza contará cada cruce desde la primera noche. El *Gaviotín*, en el varadero municipal desde agosto, está a la venta. Hay dos ofertas, ambas de mitilicultores de la costa norte que lo quieren como barco de trabajo; la oficina decidirá en noviembre. :::callout{type="colophon"} La Marea, número 41, compuesto en Charis SIL, Chivo y Fragment Mono (SIL OFL). Texto y mapa CC BY 4.0; fotografía generada. Lugares y personas de ficción. :::
`; // content.<lang>.md, inlined by the Cookbook // #region first-pass: the galley as filed, the corrected text with its faults put back const FIRST_PASS = [ // [corrected, faulty], replaced wherever it occurs [':::callout{type="stamp"}\n:::', t({ en: ':::stamp', es: ':::sello' })], // no such directive [':ref{id="route"}', ':ref{id="route-map"}'], // an id no resource has ['\\$', '$'], // bare dollars (gotcha: dollar-math) [' https://', ' '], // a web address without its scheme [':::\n:::\n', ''], // the fact box's two closing fences ['\u20601971.', '1971.'], // the word joiner before 1971 (gotcha: digit-period-list) ]; const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md); // #endregion // #region answer: the checks: what the parser and the layout report, and what they leave to you const MM = DPI / 25.4; // page px per mm function proof(md, doc) { const faults = []; const add = (kind, from, to, detail, at) => faults.push({ kind, from, to, detail, at }); const { blocks, issues } = parseMarkdownWithIssues(md); // a $, $$ or ::: left open for (const i of issues) add(i.kind, i.sourceStart, i.sourceEnd); for (const w of doc.warnings ?? []) { // calloutOverflow: a box no cut could split add(w.kind, w.sourceStart, w.sourceEnd, Math.round(w.overflowPx / MM), w); } if (!doc.converged) add('unsettled', 0, 1, doc.iterationCount); // the layout never settled // 1.4.1 reports none of these (gotcha: sandbox-only-warnings). const ids = new Set(resources.map((r) => r.id)); // an unknown id prints '?' for (const m of md.matchAll(/:ref\{id="([^"]*)"|^::resource\{id="([^"]*)"/gm)) { if (!ids.has(m[1] ?? m[2])) add('unknownResourceId', m.index, m.index + m[0].length); } for (const m of md.matchAll(/^:::?([a-z][\w-]*)/gim)) { // an unknown fence prints as text const known = KNOWN_CONTAINERS.has(m[1]) || KNOWN_DIRECTIVES.has(m[1]) || m[1] === 'resource'; if (!known) add('unknownDirective', m.index, m.index + m[0].length); } for (const b of blocks) { // formulas count as faults only because this story has none if (b.startNumber > 999) add('yearList', b.sourceStart, b.sourceEnd); // 1971. opens a list for (const s of b.spans ?? []) if (s.math) add('formula', s.math.sourceStart, s.math.sourceEnd); } // The lines as set: spaces past maxWordSpacing, a line set ragged, a hyphen in an address. const max = doc.config.bodyText.maxWordSpacing; for (const l of doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)) .flatMap((b) => b.lines ?? [])) { const ratio = Math.round(l.justifiedSpaceRatio * 100) / 100; // as the report prints it if (ratio > max) add('looseLine', l.sourceStart, l.sourceEnd, ratio); if (l.ragged) add('raggedLine', l.sourceStart, l.sourceEnd); // past 3×: no ratio left if (l.hyphenated && /[./]\S+-$/.test(l.text)) add('addressHyphen', l.sourceStart, l.sourceEnd); } return faults.sort((a, b) => a.from - b.from); } // #endregion // #region marks: each fault underlined in red where it was set, numbered in the nearest margin const linesOf = (page) => [...page.columns.flatMap((c) => c.blocks), ...(page.floats ?? [])] .flatMap((b) => (b.lines ?? []).map((l) => ({ ...l.bbox, from: l.sourceStart, to: l.sourceEnd, width: l.justifiedSpaceRatio // a bbox is the natural width; a justified line fills the block ? b.bbox.x + b.bbox.width - l.bbox.x : l.bbox.width }))); const REACH = 80; // characters after a fence where its first line may start function spotOf(page, f) { // where a fault shows on this page: its first line, in page px if (f.at) { // a box that ran off its column: a bar under the column's foot const c = page.columns[f.at.columnIndex]?.bbox; return f.at.pageIndex === page.index ? { ...c, y: c.y + c.height, height: 1.6 * MM } : null; } const lines = linesOf(page); // a fence sets no line of its own: then the first line after it return lines.find((r) => r.from < f.to && r.to > f.from) ?? lines.find((r) => r.from >= f.from && r.from - f.to < REACH); } function paintMarks(canvas, page, faults, scale) { const ctx = canvas.getContext('2d'); ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on Object.assign(ctx, { font: `${3 * MM}px "${MONO}"`, textAlign: 'center' }); const taken = []; // the numbers set so far: two on one line sit side by side faults.forEach((f, n) => { const r = spotOf(page, f); if (!r) return; const wash = f.kind === 'looseLine'; // a loose line is washed, any other fault underlined Object.assign(ctx, { fillStyle: palette.proof, globalAlpha: wash ? 0.2 : 1 }); ctx.fillRect(r.x, wash ? r.y : r.y + r.height, r.width, wash ? r.height : 0.45 * MM); const [left, y] = [r.x + r.width / 2 < page.width / 2, r.y + r.height / 2]; // nearest margin const shift = taken.filter((ty) => Math.abs(ty - y) < 4.5 * MM).length * 5.2 * MM; const [x, edge] = [left ? 6.5 * MM + shift : page.width - 6.5 * MM - shift, left ? r.x : r.x + r.width]; // the number, and a leader from it to the text taken.push(y); ctx.globalAlpha = 1; ctx.fillRect(Math.min(x, edge), y - 0.12 * MM, Math.abs(x - edge), 0.24 * MM); ctx.beginPath(); ctx.arc(x, y, 2.4 * MM, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = palette.paper; ctx.fillText(String(n + 1), x, y + 1.05 * MM); }); } // #endregion // #region source: a click on a proof page selects the Markdown that set the line function selectSource(from, to) { const all = source.value; // measure the wrapped height of the text before the selection source.value = all.slice(0, from); const top = source.scrollHeight; source.value = all; source.focus({ preventScroll: true }); source.setSelectionRange(from, to); // the offsets the parser and the layout give source.scrollTop = top > source.clientHeight ? top - source.clientHeight / 3 : 0; } function onPageClick(canvas, page) { canvas.onclick = ({ clientX, clientY }) => { const box = canvas.getBoundingClientRect(); // CSS px to page px const [x, y] = [(clientX - box.left) * (page.width / box.width), (clientY - box.top) * (page.height / box.height)]; const hit = linesOf(page).find((r) => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height); if (hit?.from !== undefined) selectSource(hit.from, hit.to); }; } // #endregion const DESK_W = 420; // CSS px: a page's width on the proof desk (style.css); words: index.html const say = (key, value = '') => $('words').content.querySelector(`[data-key="${key}"]`) .dataset[LANG].replace('{}', value.toLocaleString(LANG)); const passOf = (md) => md === markdown ? 'second' : md === firstPass(markdown) ? 'first' : 'edited'; function proofDesk(md, draft) { const faults = proof(md, draft); $('galley').replaceChildren(...draft.pages.map((page) => { const canvas = Object.assign(document.createElement('canvas'), { role: 'img', ariaLabel: say('page', page.index + 1) }); const scale = (Math.min(devicePixelRatio, 2) * DESK_W) / page.width; renderPageToCanvas(page, draft, canvas, { scale }); paintMarks(canvas, page, faults, scale); onPageClick(canvas, page); return canvas; })); const [n, copy] = [faults.length, say(passOf(md))]; // copy: first pass, second pass, an edit $('verdict').textContent = `${say(n > 1 ? 'faults' : n ? 'fault' : 'clean', n)} ${copy}`; $('passes').textContent = `iterationCount ${draft.iterationCount} · converged ${draft.converged}`; $('report').replaceChildren(...faults.map((f, n) => { const li = document.createElement('li'); li.innerHTML = '<button type="button"><b></b><span></span><code></code></button>'; const [b, span, code] = li.firstChild.children; [b.textContent, span.textContent] = [n + 1, say(f.kind, f.detail)]; code.textContent = `${f.kind} · ${md.slice(f.from, f.to).split('\n')[0]}`; li.firstChild.onclick = () => selectSource(f.from, f.to); return li; })); return faults; } // #region art: the route map, drawn in the page's palette; the crossing is a photograph const R = (v) => Math.round(v * 100) / 100; const rect = (x, y, w, h, fill, opacity = 1) => `<rect x="${R(x)}" y="${R(y)}" width="${R(w)}" ` + `height="${R(h)}" fill="${fill}" opacity="${R(opacity)}"/>`; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` + `viewBox="0 0 ${w} ${h}">${body}</svg>`; function routeMap() { // 42 × 44 mm, 10 units a mm: south bank at the foot, the sea to the right const [W, H] = [420, 440]; const { ink, paper, proof, rule } = palette; const line = (d, color, extra = '') => `<path d="${d}" fill="none" stroke="${color}" stroke-width="5" ${extra}/>`; return svg(W, H, rect(0, 0, W, H, paper) + `<path d="M0 0H${W}V58C340 76 250 50 170 68S60 56 0 80Z" fill="${rule}"/>` // the banks + `<path d="M0 ${H}H${W}V372C330 356 250 388 170 370S60 384 0 360Z" fill="${rule}"/>` + `<ellipse cx="262" cy="214" rx="58" ry="26" fill="none" stroke="${ink}" stroke-width="3"` + ' stroke-dasharray="6 7"/>' // the shoal + line('M164 370V70', ink) // the flood: straight across + line('M164 370C150 300 84 270 86 214S150 110 164 70', proof, 'stroke-dasharray="14 9"') + rect(150, 362, 28, 20, ink) + rect(150, 56, 28, 20, ink) // the two slips + line('M300 318h72', ink) + `<path d="M394 318l-26-11v22Z" fill="${ink}"/>` // the ebb + line('M380 150v48', ink) + `<path d="M380 128l-11 26h22Z" fill="${ink}"/>` // north, + line('M371 118V92L389 118V92', ink, 'stroke-linejoin="miter"')); // under its N } const resources = [{ id: 'route', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'route.svg', width: 420, height: 440 }, placement: { span: 'side' }, caption: t({ en: 'Night route, north up. Solid: straight across on the flood, 12 minutes. ' + 'Dashed red: on the ebb, when the current runs out to sea (arrow), bowed upstream of the ' + 'Coffin Rock shoal (dotted), 15 minutes.', es: 'Ruta nocturna, norte arriba. Continua: en línea recta con la llenante, 12 minutos. ' + 'Roja discontinua: con la vaciante (flecha), aguas arriba del bajo Piedra Negra ' + '(punteado), 15 minutos.' }), altText: t({ en: 'A plan of the crossing: two banks, a dotted shoal, and between two slips a ' + 'straight black track and a dashed red track bowed away from the shoal; an arrow marked N ' + 'points north, another points out to sea.', es: 'Plano del cruce: dos orillas, un bajo punteado y, entre dos rampas, una ruta negra recta ' + 'y una ruta roja discontinua que se abre lejos del bajo; una flecha con una N señala el ' + 'norte y otra, el mar.' }) }, { id: 'crossing', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, // 159 × 50 mm bitmap: { fileId: 'crossing-1590.jpg', format: 'jpeg', width: 1590, height: 500 }, altText: t({ en: 'A ferry with lit windows and a red port light crossing a dark estuary under ' + 'a full moon, a cannery and its stack on the far bank.', es: 'Una barcaza con las ventanas encendidas y la luz roja de babor cruza de noche un estuario ' + 'bajo la luna llena, con una planta en la otra orilla.' }) }]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Charis SIL': ['400', '400i', '700', '700i'], Chivo: ['400', '700', '900'], 'Fragment Mono': ['400'] }; // every face, loaded before the first build (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), loadSvg('route.svg', routeMap()), loadImage('crossing-1590.jpg', asset('crossing-1590.jpg'))]); const $ = (id) => document.getElementById(id); // the proof desk of index.html const source = $('source'); for (const el of document.querySelectorAll('#proof [data-en]')) el.textContent = el.dataset[LANG]; const build = (m) => buildWithFonts(() => buildDocument({ markdown: m, resources }, config()), m); const proofAgain = async (md) => proofDesk(source.value = md, await build(md)); const first = await proofAgain(firstPass(markdown)); // first: the galley as it came in const doc = await build(markdown); // last: the corrected galley, the pages below showPages(doc, { title: say('title') }); $('again').onclick = () => proofAgain(source.value); $('fixed').onclick = () => proofAgain(markdown); selectSource(first[0].from, first[0].to); // the first fault, selected in the Markdown
Kit · core, fonts, viewer, images: igual a totes les receptes · 270 línies// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Mantén sencer el requadre

Amb el keepTogether per defecte, el requadre passa sencer a la pàgina 2; la pàgina 1 acaba 33 mm abans i la notícia ocupa una tercera pàgina.

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

#Atura una composició que encara té errors

En un script de compilació o en un test, passa proof() per la versió final i llança un error si troba alguna cosa.

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

Errors freqüents

Error freqüent

La majoria dels avisos només existeixen al Sandbox

Els ids, estils i directives desconeguts, les fonts que falten i les línies fluixes els comprova el Sandbox, no el motor: un pen només rep doc.warnings i parseMarkdownWithIssues. Un estil desconegut se substitueix per un altre sense avís i una directiva desconeguda s'imprimeix com a text, així que revisa els teus ids. Avisos i diagnòstic →

Error freqüent

Un :ref desconegut imprimeix «?» sense avís del motor

Un :ref a un id que no té cap recurs imprimeix «?» i no col·loca res, i només el Sandbox n'avisa. Comprova que existeix cada id que cites. Cites que col·loquen les figures →

Error freqüent

Un $ sol obre matemàtiques: escriu \$

El signe de dòlar obre matemàtiques en línia, de manera que un preu com $40 comença una fórmula. Escriu \$40. Escapaments i caràcters literals →

Error freqüent

«1998. » o «- » al començament d'un paràgraf obren una llista

Un paràgraf que comença per un número, un punt i un espai, o per un guionet i un espai, es converteix en un element de llista. Posa un unidor de paraules (U+2060) davant del número i escriu els diàlegs amb guió llarg. Escapaments i caràcters literals →

Error freqüent

:::columns només funciona dins d'un requadre i no es parteix

:::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un. Columnes dins d'un requadre →

Error freqüent

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

Error freqüent

Les imatges d'una obertura no compten per a l'alçada que reserva

A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →

Error freqüent

Una paleta canviada no arriba als elements de disseny ni al color de les remissions

postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

Només 8 llengües tenen partició de mots, amb el codi exacte

La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →

Error freqüent

El lineHeight d'un text de disseny és un múltiple, mai una mesura

En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →

Error freqüent

La correcció de les línies curtes pot estrènyer un espaiat entre lletres que no es pinta mai

A postext 1.4.1, quan un paràgraf acaba en una línia curta, el motor el compon amb una línia menys: primer estreny l'espai entre paraules i després aplica fins a maxRuntTracking mil·lèsimes d'em d'espaiat negatiu entre lletres. Els renderitzadors de canvas i PDF només pinten l'espaiat entre lletres més gran que zero, així que el paràgraf s'imprimeix sense aquest espaiat: les seves línies justificades perden aquesta diferència en els espais entre paraules, que surten aixafats, i l'última línia pot passar-se de la mesura i quedar tallada a la vora de la columna. Posa bodyText.maxRuntTracking: 0, que conserva la correcció amb l'espai entre paraules, i reescriu els paràgrafs que tornin a acabar en una línia curta. Vídues, òrfenes i línies curtes →

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

Avís del Markdown · unclosedMath

Delimitador matemàtic sense tancar

Per què. Un $ obre una fórmula en línia que no es tanca mai; sol ser el d'un preu.

Solució. Escriu \$ per a un dòlar literal, o tanca la fórmula a la mateixa línia. Documentació →

Avís del Markdown · unclosedContainer

Contenidor sense tancar

Per què. Una tanca ::: no es tanca mai, de manera que el contenidor arriba fins al final del document.

Solució. Afegeix una línia només amb ::: on acaba el contenidor. Documentació →

Avís de maquetació · calloutOverflow

El requadre desborda la seva columna

Per què. Una caixa que cap tall no pot partir (una figura, una taula o un grup :::columns més alt que la columna, o un splitMinLines massa alt) s'ha col·locat desbordada; el motor ho anota a doc.warnings i el Sandbox ho mostra.

Solució. Escurça la caixa, deixa que es parteixi amb keepTogether: false, abaixa splitMinLines o dona-li un altre span. Documentació →

Comprovació del Sandbox · unknownResourceId

Recurs desconegut

Per què. Un :ref o un ::resource anomena un id que no té cap recurs; la referència imprimeix «?» i no es col·loca res.

Solució. Corregeix l'id (només amb cometes dobles) o afegeix el recurs. Documentació →

Avís de maquetació · unknownDirective

Directiva desconeguda

Per què. Una línia :::nom no és cap de les directives ni dels contenidors de Postext, de manera que s'imprimeix com a text.

Solució. Compara el nom amb els admesos (pagebreak, columnbreak, numbering, space, toc, callout, paragraphs, part, columns, paper). Documentació →

Comprovació del Sandbox · looseLine

Línia fluixa

Per què. Una línia justificada estira els espais per sobre del llindar, gairebé sempre per una paraula llarga, un URL o una mesura estreta.

Solució. Activa la partició de mots en la llengua correcta, eixampla la mesura, reformula o compon aquest passatge en bandera. Documentació →

  • Ajusta el text perquè el requadre ompli la pàgina 1 fins a la seva última línia. A 1.4.1, un requadre que tanca una columna baixa quan s'equilibren les columnes: si treus l'última frase del primer paràgraf, el filet del requadre queda a 6,4 mm del paràgraf de les tarifes en lloc de quedar a 2,4 mm.
  • Els rangs d'origen es retarden al costat de les adreces web i els escapaments. A 1.4.1, la partició de mots insereix un espai d'amplada zero (U+200B) després de les barres d'una adreça, i line.text el conserva, així que una línia que en conté algun acaba un caràcter més tard per cadascun, i la següent comença amb el mateix retard: a la pàgina 1 anglesa, la línia que acaba en https://aldercounty selecciona també el punt de .example. El rang d'una línia que comença per un caràcter escapat arrenca després de la barra inversa, com a la línia de la pàgina 1 anglesa que comença per $40.

Crèdits

Text
Text original, CC BY 4.0
Imatges
  • 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
Fonts
Charis SIL (SIL OFL 1.1) · Chivo (SIL OFL 1.1) · Fragment Mono (SIL OFL 1.1)
Sandbox