Lo que vas a componer
El número 41 de La Marea, un semanario comunal, abre con la vuelta de la barcaza nocturna del Quilén, y el pen revisa la noticia dos veces. Las primeras pruebas son el texto tal como llegó a la mesa, la noticia corregida con seis fallos repuestos, entre ellos un sello escrito como una directiva que no existe y un recuadro que nunca se cierra. Esos seis fallos dejan nueve marcas. Como en la tarjeta, cada fallo se subraya en rojo en la línea donde se compuso (una línea floja se sombrea entera), y su número en el margen es el de su entrada en la lista junto a las páginas. Al pulsar una línea o una entrada se selecciona su Markdown. Las páginas de abajo son las segundas pruebas. En ellas las comprobaciones no encuentran nada, y el sello SEGUNDAS PRUEBAS queda fijado en la esquina superior derecha.
Esta receta responde a
- ¿Cómo averiguo qué falla en mi documento (avisos, desbordamientos, composición que no converge)?
- ¿Cómo relaciono un clic en la página pintada con el Markdown de origen, para construir un editor?
- ¿Por qué una línea sale en bandera o demasiado estirada (URL, compuestos largos, palabras largas en celdas)?
La respuesta corta
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);
}
Ingredientes
- Funciones
- Avisos y diagnósticoEscapes y caracteres literalesRecuadros que se parten o noCorte de líneas óptimo (Knuth–Plass)Recuadros fijos e insigniasCitas que colocan las figurasColumna al margen para flotantesNotas al margenColumnas dentro de un recuadroRecuadrosAperturas diseñadasBanda de capítulo a todo el anchoAtributos de títuloCabeceras y foliosCabeceras según el tipo de páginaPáginas en un canvasFiguras y tablas como recursosTipos de recurso propiosPaleta de color semánticaColor del papel
- También usa
- Columna y media
- Tipografía
- Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Lee lo que devuelve el motor y comprueba tú el resto
El código es la respuesta corta de arriba. parseMarkdownWithIssues() devuelve una incidencia por cada $, $$ o ::: que queda abierto, y doc.warnings recoge un calloutOverflow por cada recuadro que ningún corte pudo partir; en 1.4.1 el motor no informa de nada más. doc.converged y doc.iterationCount dicen si la composición se asentó, y esta noticia se asienta en una iteración, con fallos o sin ellos. El panel Revisión del Sandbox (en Paneles laterales) muestra también los ids y las directivas desconocidos y las líneas flojas, pero esos avisos los calcula el propio Sandbox, así que proof() los busca por su cuenta. Compara los ids de :ref y ::resource con los resources de la composición, y los nombres de valla con KNOWN_CONTAINERS y KNOWN_DIRECTIVES. Para esta noticia marca además los bloques de lista cuyo startNumber es un año y cualquier fórmula, porque la noticia no lleva ninguna, y en las líneas ya compuestas busca espacios que pasan de maxWordSpacing, líneas en bandera y guiones añadidos dentro de una dirección.
#2 · Reintroduce los fallos en la versión corregida
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 guarda un solo archivo Markdown, la noticia corregida, y saca de él las primeras pruebas deshaciendo seis correcciones; así las dos versiones no pueden desfasarse y cada marca tiene una causa conocida. La versión corregida se compone al final, y es la que muestran las páginas de abajo. \$ escapa el signo de dólar de un precio para que no abra una fórmula. Un unidor de palabras (U+2060) delante de cada año de la cronología de la página 2 impide que una entrada como 1971. Entra en servicio… abra una lista numerada desde ese año; las primeras pruebas solo quitan el de 1971. Con el esquema https://, la versión 1.4.1 compone la dirección web como tal: nunca la parte por sílabas, solo por sus juntas, tras una barra o antes de un punto. Sin el esquema es una palabra más, y las primeras pruebas la parten como puertoaliso.exam-ple.
#3 · Marca cada fallo donde se compuso
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ínea del VDT lleva sourceStart y sourceEnd, de modo que el rango de origen de un fallo lleva a la línea que lo imprimió. La valla de un contenedor no imprime ninguna línea, y su marca va a la primera línea que empieza en los REACH (80) caracteres siguientes. Un calloutOverflow recibe una barra al pie de la columna que indica. Un solo descuido puede dejar varias marcas. Los dólares sin escapar convierten $1.200 a pie, $ en una fórmula, que no imprime nada porque este pen no carga el motor matemático, y el tercer dólar abre una fórmula que no se cierra; en la edición inglesa, además, el párrafo de las tarifas queda con una línea cuyos espacios miden 1,9 veces su ancho normal. Las dos vallas que faltan dejan abiertos el recuadro y su grupo :::columns hasta el final de la noticia, y como un grupo de columnas nunca se parte, el recuadro desborda su columna en 258 mm. proof() da una línea por floja cuando su justifiedSpaceRatio supera maxWordSpacing (1,6 aquí). Una línea cuyos espacios pasarían de 3× no tiene proporción que comparar, porque 1.4.1 la compone en bandera, y por eso proof() comprueba también ragged.
#4 · Selecciona el Markdown de una línea
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 llega en píxeles CSS. onPageClick() lo pasa a píxeles de página, busca la línea en cuyo bbox cae y selecciona en el textarea el rango de origen de esa línea; una entrada de la lista selecciona del mismo modo el rango de su fallo. selectSource() enfoca el textarea sin mover la página y fija su scrollTop con la altura del texto anterior a la selección. En 1.4.1, el bbox de una línea justificada conserva el ancho natural de la línea, no el ancho al que se pinta, así que linesOf() lo alarga hasta el borde derecho del bloque. Sin eso, el sombreado se quedaría antes del margen y un clic cerca del borde derecho no seleccionaría nada.
#5 · Deja que el recuadro se parta y fija el sello
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 permite que el recuadro de datos empiece al pie de la página 1 y siga en la página 2 bajo su filete rojo, sin título. El recuadro no tiene fondo ni filete al pie, y en la página 2 solo el blanco lo separa de la noticia; por eso marginBottom mide una línea entera (14 pt), y con 4 pt el horario quedaría a 1,5 mm del párrafo siguiente. El sello es placement: 'fixed', fuera del flujo y fijado a 8 mm del borde superior, y width: 'auto' lo ajusta a su título, con 42 mm de ancho en lugar de los 110 mm de la columna principal.
La receta completa
// ═══ Postext Cookbook · Nº 055 · A galley proof with every fault marked in red ══════ // https://postext.dev/en/cookbook/proof-sheet-diagnostics // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseMarkdownWithIssues, KNOWN_DIRECTIVES, KNOWN_CONTAINERS, } from 'https://esm.sh/postext'; const LANG = '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: the text's leading; mm: the drawing; page px/inch const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; const caps = (size) => ({ fontFamily: MONO, fontSize: pt(size), letterSpacing: pt(size * 0.12), textTransform: 'uppercase', fontWeight: 400 }); const below = (id, gap, width = MEASURE) => ({ anchor: { to: id, edge: id === 'container' ? 'top-left' : 'below' }, offset: { x: mm(0), y: mm(gap) }, size: { width: mm(width) } }); const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement, ...extra }); const opener = { enabled: true, slot: { elements: [ // the drawing, then the words under it { kind: 'image', id: 'art', resourceId: 'crossing', placement: { ...below('container', 0), size: { width: mm(MEASURE), height: mm(ART_H) } } }, // The words reserve the drawing's height; an image never does (gotcha: opener-image-no-reserve). text('kicker', '{attr.kicker}', MONO, 8, 'proof', below('container', ART_H + 6), caps(8)), text('title', '{titleText}', DISPLAY, 50, 'ink', below('#kicker', 1.4), { fontWeight: 900, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple) text('standfirst', '{attr.standfirst}', TEXT, 11.5, 'ink', below('#title', 4, 136), { italic: true, lineHeight: 1.3 }), text('byline', '{attr.byline}', MONO, 7.5, 'muted', below('#standfirst', 3), caps(7.5))] } }; const head = (id, content, parity, edge, x, y = 12) => text(id, content, MONO, 7.5, 'muted', { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, { ...caps(7.5), parity, pages: 'body', align: edge.split('-')[1] ?? 'center' }); // y: mm from the trim const header = { elements: [ head('verso', t({ en: '{pageNumber} The Tideline · issue 41', es: '{pageNumber} La Marea · número 41' }), 'even', 'top-left', OUTER), head('recto', t({ en: 'News · the night ferry {pageNumber}', es: 'Noticias · la barcaza nocturna {pageNumber}' }), 'odd', 'top-right', -OUTER)] }; const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, -11), pages: 'opener' }] }; // the opener's folio drops to its foot // #region boxes: the fact box splits where the page ends; the stamp is pinned to a corner const boxes = [ // keepTogether: false: cut between two blocks at the page foot; a line of white closes it. { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD), stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') }, padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') }, body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) }, lists: { bulletChar: '■', color: col('proof'), bulletFontSize: pt(5) } }, // 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title. { id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false, title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }), fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } }, border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1), padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) }, titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } }, ]; // #endregion const side = (id, body, fontFamily = DISPLAY) => ({ id, span: 'side', backgroundEnabled: false, padding: NONE, titleStyle: { ...caps(7), color: col('proof'), gap: mm(2) }, // outer column body: { fontFamily, textAlign: 'left', firstLineIndent: pt(0), ...body } }); const calloutStyles = [...boxes, side('quote', { fontSize: pt(12.5), lineHeight: pt(15) }), side('dates', { fontSize: pt(9), lineHeight: pt(12), paragraphSpacing: true }), side('colophon', { fontSize: pt(6.5), lineHeight: pt(9.5), color: col('muted') }, MONO)]; const config = () => ({ // a fresh object per build (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), colorPalette, // exact codes (gotcha: hyphenation-locales) resourceTypes: [{ id: 'figure', name: t({ en: 'Map', es: 'Mapa' }), captionPrefix: t({ en: 'Map', es: 'Mapa' }), shortLabel: t({ en: 'map', es: 'mapa' }), numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: DPI, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'oneAndHalf', sideColumnPercent: 27, sideColumnRole: 'floats', sideColumnSide: 'outer', gutterWidth: mm(6) }, bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // a :ref (map 1) takes boldColor firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6, maxRuntTracking: 0 }, // gotcha: runt-tracking-unpainted headings: { fontFamily: DISPLAY, color: col('ink'), levels: [ // the H1 break restated, as a { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, // headings object advancedDesign: opener }, // drops it (gotcha: headings-drop-h1-break) ] }, calloutStyles, header, footer, captionStyle: { fontFamily: DISPLAY, fontSize: pt(8), lineHeight: pt(11), color: col('ink'), labelBold: true, labelColor: col('proof') }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`# 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"}Muestra en Markdown · 52 líneas · 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 dibujos CC BY 4.0. 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 night crossing and the route map, drawn in the page's palette function rng(seed) { // Mulberry32: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const R = (v) => Math.round(v * 100) / 100; const rect = (x, y, w, h, fill, opacity = 1) => `<rect x="${R(x)}" y="${R(y)}" width="${R(w)}" ` + `height="${R(h)}" fill="${fill}" opacity="${R(opacity)}"/>`; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` + `viewBox="0 0 ${w} ${h}">${body}</svg>`; function crossing() { // 159 × 50 mm, 10 units a mm const [W, H, SEA] = [MEASURE * 10, ART_H * 10, 290]; const rand = rng(41); const { ink, paper, proof, rule } = palette; const land = '#34332f'; // the far bank, one step up from the ink sky let s = rect(0, 0, W, H, ink) + `<circle cx="${W * 0.8}" cy="92" r="40" fill="${paper}"/>`; let shore = `M0 ${SEA}`; // the far bank: Crane Landing, the cannery and its stack for (let x = 0; x <= W; x += 40) shore += `L${x} ${R(SEA - 18 - rand() * 22)}`; s += `<path d="${shore}L${W} ${SEA}Z" fill="${land}"/>`; s += `<path d="M1040 ${SEA}V226h120v-34h64v34h56V${SEA}Z M1172 192V120h13v72Z" ` + `fill="${land}"/>`; for (let i = 0; i < 6; i++) s += rect(1056 + i * 34, 240, 12, 8, paper, 0.8); for (let y = SEA + 12; y < H; y += 13 + (y - SEA) * 0.06) { // the water: broken lines for (let x = rand() * 60; x < W; x += 60 + rand() * 90) { s += rect(x, y, 20 + rand() * 60, 2.4, rule, 0.16 + rand() * 0.24); } } for (let y = SEA + 8; y < H - 6; y += 11) { // the moon's path on the water const w = 26 + rand() * 54; s += rect(W * 0.8 - w / 2 + (rand() - 0.5) * 28, y, w, 3, paper, 0.7); } const [fx, fy] = [380, SEA + 76]; // the Marram, a double-ended ferry, from abeam s += `<path d="M${fx} ${fy}h400l-28 30h-344Z M${fx + 56} ${fy}v-40h288v40Z ` + `M${fx + 146} ${fy - 40}v-26h108v26Z" fill="${paper}"/>`; for (let i = 0; i < 9; i++) s += rect(fx + 72 + i * 30, fy - 29, 15, 13, ink); s += rect(fx + 197, fy - 90, 6, 24, paper) // the mast and its light, then the port light + `<circle cx="${fx + 200}" cy="${fy - 96}" r="7" fill="${paper}"/>` + `<circle cx="${fx + 30}" cy="${fy - 6}" r="8" fill="${proof}"/>`; for (let i = 0; i < 6; i++) { // the ferry's lights on the water s += rect(fx + 40 + rand() * 320, fy + 40 + i * 12, 20 + rand() * 50, 3, paper, 0.5); } return svg(W, H, s + rect(fx + 24, fy + 40, 12, 34, proof, 0.7)); } function routeMap() { // 42 × 44 mm, 10 units a mm: south bank at the foot, the sea to the right const [W, H] = [420, 440]; const { ink, paper, proof, rule } = palette; const line = (d, color, extra = '') => `<path d="${d}" fill="none" stroke="${color}" stroke-width="5" ${extra}/>`; return svg(W, H, rect(0, 0, W, H, paper) + `<path d="M0 0H${W}V58C340 76 250 50 170 68S60 56 0 80Z" fill="${rule}"/>` // the banks + `<path d="M0 ${H}H${W}V372C330 356 250 388 170 370S60 384 0 360Z" fill="${rule}"/>` + `<ellipse cx="262" cy="214" rx="58" ry="26" fill="none" stroke="${ink}" stroke-width="3"` + ' stroke-dasharray="6 7"/>' // the shoal + line('M164 370V70', ink) // the flood: straight across + line('M164 370C150 300 84 270 86 214S150 110 164 70', proof, 'stroke-dasharray="14 9"') + rect(150, 362, 28, 20, ink) + rect(150, 56, 28, 20, ink) // the two slips + line('M300 318h72', ink) + `<path d="M394 318l-26-11v22Z" fill="${ink}"/>` // the ebb + line('M380 150v48', ink) + `<path d="M380 128l-11 26h22Z" fill="${ink}"/>` // north, + line('M371 118V92L389 118V92', ink, 'stroke-linejoin="miter"')); // under its N } const resources = [{ id: 'route', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'route.svg', width: 420, height: 440 }, placement: { span: 'side' }, caption: t({ en: 'Night route, north up. Solid: straight across on the flood, 12 minutes. ' + 'Dashed red: on the ebb, when the current runs out to sea (arrow), bowed upstream of the ' + 'Coffin Rock shoal (dotted), 15 minutes.', es: 'Ruta nocturna, norte arriba. Continua: en línea recta con la llenante, 12 minutos. ' + 'Roja discontinua: con la vaciante (flecha), aguas arriba del bajo Piedra Negra ' + '(punteado), 15 minutos.' }), altText: t({ en: 'A plan of the crossing: two banks, a dotted shoal, and between two slips a ' + 'straight black track and a dashed red track bowed away from the shoal; an arrow marked N ' + 'points north, another points out to sea.', es: 'Plano del cruce: dos orillas, un bajo punteado y, entre dos rampas, una ruta negra recta ' + 'y una ruta roja discontinua que se abre lejos del bajo; una flecha con una N señala el ' + 'norte y otra, el mar.' }) }, { id: 'crossing', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'crossing.svg', width: MEASURE * 10, height: ART_H * 10 }, altText: t({ en: 'A ferry with lit windows and a red port light crossing a dark estuary under ' + 'a full moon, a cannery on the far bank.', es: 'Una barcaza con las ventanas encendidas y la luz roja de babor cruza de noche un estuario ' + 'bajo la luna llena, con una planta en la otra orilla.' }) }]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Charis SIL': ['400', '400i', '700', '700i'], Chivo: ['400', '700', '900'], 'Fragment Mono': ['400'] }; // every face, loaded before the first build (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), loadSvg('crossing.svg', crossing()), loadSvg('route.svg', routeMap())]); const $ = (id) => document.getElementById(id); // the proof desk of index.html const source = $('source'); for (const el of document.querySelectorAll('#proof [data-en]')) el.textContent = el.dataset[LANG]; const build = (m) => buildWithFonts(() => buildDocument({ markdown: m, resources }, config()), m); const proofAgain = async (md) => proofDesk(source.value = md, await build(md)); const first = await proofAgain(firstPass(markdown)); // first: the galley as it came in const doc = await build(markdown); // last: the corrected galley, the pages below showPages(doc, { title: say('title') }); $('again').onclick = () => proofAgain(source.value); $('fixed').onclick = () => proofAgain(markdown); selectSource(first[0].from, first[0].to); // the first fault, selected in the MarkdownKit · core, fonts, viewer, images: igual en todas las recetas · 270 líneas
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
<section id="proof">
<header>
<p id="slug" data-en="The Tideline 41 · news galley · proof desk" data-es="La Marea 41 · galerada · mesa de pruebas"></p>
<h2 id="verdict"></h2>
<p id="passes"></p>
</header>
<div class="desk">
<div id="galley"></div>
<ol id="report"></ol>
</div>
<div class="source">
<label for="source" data-en="The galley’s Markdown: click a line on a page to select it here" data-es="El Markdown de la galerada: pulsa una línea de una página para seleccionarla aquí"></label>
<textarea id="source" spellcheck="false"></textarea>
<p>
<button id="again" type="button" data-en="Proof again" data-es="Revisar de nuevo"></button>
<button id="fixed" type="button" data-en="Load the second pass" data-es="Cargar las segundas pruebas"></button>
</p>
</div>
<template id="words"><!-- the report's words, in the language of the sample -->
<i data-key="unclosedMath" data-en="Unclosed maths" data-es="Fórmula sin cerrar"></i>
<i data-key="unclosedMathBlock" data-en="Unclosed display maths" data-es="Fórmula en bloque sin cerrar"></i>
<i data-key="formula" data-en="Stray formula" data-es="Fórmula perdida"></i>
<i data-key="unclosedContainer" data-en="Unclosed fence" data-es="Valla sin cerrar"></i>
<i data-key="calloutOverflow" data-en="Box runs {} mm past its column" data-es="El recuadro desborda su columna en {} mm"></i>
<i data-key="unknownResourceId" data-en="Unknown id: prints “?”" data-es="Id desconocido: imprime «?»"></i>
<i data-key="unknownDirective" data-en="Unknown directive: prints as text" data-es="Directiva desconocida: sale como texto"></i>
<i data-key="yearList" data-en="A year opened a numbered list" data-es="Un año abrió una lista numerada"></i>
<i data-key="looseLine" data-en="Loose line, {}×" data-es="Línea floja, {}×"></i>
<i data-key="raggedLine" data-en="Line set ragged: its spaces would pass 3×" data-es="Línea en bandera: sus espacios pasarían de 3×"></i>
<i data-key="addressHyphen" data-en="Hyphen added inside an address" data-es="Guion añadido dentro de una dirección"></i>
<i data-key="unsettled" data-en="Layout did not settle in {} passes" data-es="La composición no se asentó en {} pasadas"></i>
<i data-key="faults" data-en="{} faults" data-es="{} fallos"></i>
<i data-key="fault" data-en="{} fault" data-es="{} fallo"></i>
<i data-key="clean" data-en="Nothing to mark" data-es="Nada que marcar"></i>
<i data-key="first" data-en="on the first pass" data-es="en las primeras pruebas"></i>
<i data-key="second" data-en="on the second pass" data-es="en las segundas pruebas"></i>
<i data-key="edited" data-en="in the edited copy" data-es="en la copia editada"></i>
<i data-key="page" data-en="Proof, page {}" data-es="Prueba, página {}"></i>
<i data-key="title" data-en="The Tideline · galley proof" data-es="La Marea · galerada"></i>
</template>
</section>
<main id="pages"></main>
/* The proof desk: the first pass marked in red beside its report, the Markdown under them.
Colours repeat the palette in script.js: newsprint, ink, the proofreader's red. */
#proof {
background: #e6e0d2; color: #1d1d1b; padding: 36px 32px 40px;
font: 15px/1.45 "Charis SIL", Georgia, serif;
}
#proof > * { max-width: 1216px; margin-inline: auto; }
#proof header { margin-bottom: 24px; }
#slug, #passes {
margin: 0; font: 12px/1.2 "Fragment Mono", monospace; letter-spacing: .14em;
text-transform: uppercase;
}
#slug { color: #b81d31; margin-bottom: 10px; }
#passes { color: #5f5b54; margin-top: 12px; text-transform: none; letter-spacing: .04em; }
#verdict { margin: 0; font: 900 clamp(38px, 6vw, 72px)/.95 Chivo, sans-serif; letter-spacing: -.01em; }
.desk { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 32px; align-items: start; }
#galley { display: flex; gap: 14px; }
#galley canvas {
display: block; width: 420px; aspect-ratio: 190 / 253; cursor: text; /* DESK_W */
box-shadow: 0 1px 2px rgb(40 30 20 / .25), 0 18px 36px -18px rgb(40 30 20 / .55);
}
#report { list-style: none; margin: 0; padding: 0; border-top: 3px solid #d7263d; }
#report button {
all: unset; box-sizing: border-box; display: grid; grid-template-columns: 30px minmax(0, 1fr);
column-gap: 12px; width: 100%; padding: 9px 0 10px; border-bottom: 1px solid #bdb8aa; cursor: pointer;
}
#report button:hover span, #report button:focus-visible span { color: #b81d31; }
#report b {
grid-row: span 2; width: 26px; height: 26px; border-radius: 50%; background: #d7263d;
color: #f6f3ea; font: 13px/26px "Fragment Mono", monospace; text-align: center;
}
#report span { font: 400 16px/1.25 Chivo, sans-serif; }
#report code {
font: 12px/1.5 "Fragment Mono", monospace; color: #5f5b54;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.source { margin-top: 28px; }
.source label {
display: block; margin-bottom: 8px; font: 12px/1.3 "Fragment Mono", monospace; color: #5f5b54;
}
#source {
display: block; width: 100%; height: 280px; box-sizing: border-box; padding: 14px 16px;
font: 13px/1.6 "Fragment Mono", monospace; color: #1d1d1b; background: #f6f3ea;
border: 1px solid #bdb8aa; resize: vertical;
}
#source::selection { background: #d7263d; color: #f6f3ea; }
.source p { margin: 12px 0 0; display: flex; gap: 10px; flex-wrap: wrap; }
.source button {
font: 12px/1 "Fragment Mono", monospace; letter-spacing: .12em; text-transform: uppercase;
color: #f6f3ea; background: #1d1d1b; border: 0; padding: 10px 14px; cursor: pointer;
}
.source button + button { color: #1d1d1b; background: none; box-shadow: inset 0 0 0 1px #1d1d1b; }
@media (max-width: 1240px) {
.desk { grid-template-columns: minmax(0, 1fr); }
#galley canvas { width: calc(50% - 7px); }
}
@media (max-width: 560px) {
#proof { padding: 24px 16px 28px; }
#galley { flex-direction: column; }
#galley canvas { width: 100%; }
}
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗
Variantes
#Mantén entero el recuadro
Con el keepTogether por defecto, el recuadro pasa entero a la página 2; la página 1 termina 33 mm antes y la noticia ocupa una tercera página.
- { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
+ { id: 'facts', backgroundEnabled: false, marginBottom: pt(LEAD),#Detén una composición que aún tiene fallos
En un script de compilación o en un test, pasa proof() por la versión final y lanza un error si encuentra algo.
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.');Errores frecuentes
Error frecuente
La mayoría de los avisos solo existen en el Sandbox
Los ids, estilos y directivas desconocidos, las fuentes que faltan y las líneas flojas los comprueba el Sandbox, no el motor: un pen solo recibe doc.warnings y parseMarkdownWithIssues. Un estilo desconocido se sustituye sin aviso por otro y una directiva desconocida se imprime como texto, así que revisa tus ids. Avisos y diagnóstico →
Error frecuente
Un :ref desconocido imprime «?» sin aviso del motor
Un :ref a un id que no tiene ningún recurso imprime «?» y no coloca nada, y solo el Sandbox avisa. Comprueba que existe cada id que citas. Citas que colocan las figuras →
Error frecuente
Un $ suelto abre matemáticas: escribe \$
El signo de dólar abre matemáticas en línea, así que un precio como $40 empieza una fórmula. Escribe \$40. Escapes y caracteres literales →
Error frecuente
«1998. » o «- » al principio de un párrafo abren una lista
Un párrafo que empieza por un número, un punto y un espacio, o por un guion y un espacio, se convierte en un elemento de lista. Pon un unidor de palabras (U+2060) antes del número y escribe los diálogos con raya. Escapes y caracteres literales →
Error frecuente
:::columns solo funciona dentro de un recuadro y no se parte
:::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno. Columnas dentro de un recuadro →
Error frecuente
Una configuración se cachea por identidad: crea un objeto nuevo
El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →
Error frecuente
Las imágenes de una apertura no cuentan para la altura que reserva
En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto. Aperturas diseñadas →
Error frecuente
Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones
postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Solo 8 idiomas tienen separación silábica, con el código exacto
La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →
Error frecuente
El lineHeight de un texto de diseño es un múltiplo, nunca una medida
En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →
Error frecuente
El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta
En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta. Viudas, huérfanas y líneas cortas →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
Aviso del Markdown · unclosedMath
Delimitador matemático sin cerrar
Por qué. Un $ abre una fórmula en línea que nunca se cierra; suele ser el de un precio.
Solución. Escribe \$ para un dólar literal, o cierra la fórmula en la misma línea. Documentación →
Aviso del Markdown · unclosedContainer
Contenedor sin cerrar
Por qué. Una valla ::: nunca se cierra, así que el contenedor llega hasta el final del documento.
Solución. Añade una línea con solo ::: donde termina el contenedor. Documentación →
Aviso de maquetación · calloutOverflow
El recuadro desborda su columna
Por qué. Una caja que ningún corte puede partir (una figura, una tabla o un grupo :::columns más alto que la columna, o un splitMinLines demasiado alto) se colocó desbordada; el motor lo anota en doc.warnings y el Sandbox lo muestra.
Solución. Acorta la caja, deja que se parta con keepTogether: false, baja splitMinLines o dale otro span. Documentación →
Comprobación del Sandbox · unknownResourceId
Recurso desconocido
Por qué. Un :ref o un ::resource nombra un id que no tiene ningún recurso; la referencia imprime «?» y no se coloca nada.
Solución. Corrige el id (solo con comillas dobles) o añade el recurso. Documentación →
Aviso de maquetación · unknownDirective
Directiva desconocida
Por qué. Una línea :::nombre no es ninguna de las directivas ni de los contenedores de Postext, así que se imprime como texto.
Solución. Compara el nombre con los admitidos (pagebreak, columnbreak, numbering, space, toc, callout, paragraphs, part, columns). Documentación →
Comprobación del Sandbox · looseLine
Línea floja
Por qué. Una línea justificada estira sus espacios por encima del umbral, casi siempre por una palabra larga, una URL o una medida estrecha.
Solución. Activa la separación silábica en el idioma correcto, ensancha la medida, reformula o compón ese pasaje en bandera. Documentación →
- Ajusta el texto para que el recuadro llene la página 1 hasta su última línea. En 1.4.1, un recuadro que cierra una columna baja cuando se equilibran las columnas: si quitas la última frase del primer párrafo, el filete del recuadro queda a 6,4 mm del párrafo de las tarifas en lugar de a 2,4 mm.
- Los rangos de origen se retrasan junto a las direcciones web y los escapes. En 1.4.1, la separación silábica inserta un espacio de anchura cero (U+200B) tras las barras de una dirección, y
line.textlo conserva, así que una línea que contiene alguno acaba un carácter más tarde por cada uno, y la siguiente empieza con el mismo retraso: en la página 1 inglesa, la línea que acaba en https://aldercounty selecciona también el punto de .example. El rango de una línea que empieza por un carácter escapado arranca tras la barra invertida, como en la línea de la página 1 inglesa que empieza por$40.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Charis SIL (SIL OFL 1.1) · Chivo (SIL OFL 1.1) · Fragment Mono (SIL OFL 1.1)
- Código
- MIT, como Postext


