Lo que vas a componer
El número 12 de Galerada, revista de un taller tipográfico, en A5 y a dos columnas de unos cuarenta caracteres, donde cada línea justificada reparte la holgura entre cinco o seis espacios. La apertura dibuja sobre una banda grafito el modelo de Knuth y Plass: cajas de palabras unidas por muelles amarillos de cola que se estiran hasta llenar la medida. El ensayo explica por qué se aflojan las columnas estrechas e intercala en monoespaciada los ajustes de cada regla. Al pie de la página 3, un banco de pruebas compone un párrafo en bandera, justificado sin partir palabras y justificado partiéndolas. El pen compone además una segunda edición con el corte voraz, sin protecciones de viudas ni huérfanas, y, antes del número, muestra juntas las dos páginas 2, como en la tarjeta: las líneas flojas, en amarillo fosforito; las solas y las cortas, señaladas en el margen.
Esta receta responde a
- ¿Cómo consigo una buena justificación y separación silábica en textos en español, francés o alemán?
- ¿Cómo evito viudas, huérfanas y últimas líneas de una sola palabra, y mantengo cada título con su texto?
- ¿Por qué una línea sale en bandera o demasiado estirada (URL, compuestos largos, palabras largas en celdas)?
- ¿Cómo averiguo qué falla en mi documento (avisos, desbordamientos, composición que no converge)?
La respuesta corta
// Knuth–Plass breaking, hyphenation and the widow, orphan and runt penalties are all on by
// default. A narrow column also needs a tighter fence round the glue, in multiples of a
// normal word space (defaults 0.6 and 2): lines past the upper fence cost more than any
// hyphen, so the breaker hyphenates or re-breaks the paragraph before it stretches that far.
const FENCES = { minWordSpacing: 0.8, maxWordSpacing: 1.6 };
const bodyText = { // config().bodyText
fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
...FENCES,
};
// Hyphenation follows the document's locale, by exact code (gotcha: hyphenation-locales):
const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
// The control: first-fit breaking, which sets each line once and moves on, with the widow and
// orphan guards off (runts are priced inside Knuth–Plass only). Same text, fonts and measure;
// a fresh object on every call, like config() itself, because the engine caches resolved
// configs by identity (gotcha: config-cache-identity).
const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false };
const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } });
Ingredientes
- Funciones
- Corte de líneas óptimo (Knuth–Plass)Viudas, huérfanas y líneas cortasAvisos y diagnósticoSeparación silábica e idioma del documentoSangrías, alineación y separación de párrafosColumnas dentro de un recuadroRecuadros anidadosRecuadrosAperturas diseñadasImágenes en los diseños de páginaAtributos de títuloEstilos de párrafoPáginas en un canvasFiguras y tablas como recursosPaleta de color semánticaCabeceras y foliosCabeceras según el tipo de página
- También usa
- Banda de capítulo a todo el ancho
- Tipografía
- Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Estrecha los límites y conserva las protecciones
El código de este paso es la respuesta corta de arriba. Knuth–Plass, la separación silábica, las penalizaciones de viudas, huérfanas y líneas cortas y los títulos unidos a su texto vienen activados por defecto, así que la edición publicada solo estrecha los límites del espaciado a 0,8 y 1,6 veces un espacio normal (por defecto son 0,6 y 2). Una línea que pasa del límite superior cuesta más que cualquier guion o línea corta, así que el algoritmo prueba antes otros cortes; sin esos dos límites, este ensayo compone ocho líneas por encima de 1,6. La partición depende de locale y de su código exacto: 'es', 'fr' y 'de' tienen sus propios patrones de TeX, mientras que 'es-ES' se parte como inglés estadounidense sin ningún aviso.
#2 · Marca las líneas flojas desde el árbol de composición
// debug.looseLineHighlight is Sandbox-only (gotcha: sandbox-only-warnings), so the pen reads the
// VDT: justified lines carry justifiedSpaceRatio; a paragraph cut by a column is two blocks.
function marks(doc, page) {
const body = doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)).filter((b) =>
b.type === 'paragraph' && b.containerId === undefined && b.textAlign === 'justify');
const out = [];
for (const column of page.columns) {
for (const b of column.blocks.filter((x) => body.includes(x))) {
const parts = body.filter((o) => o.contentIndex === b.contentIndex);
b.lines.forEach((line) => {
const at = { x: b.bbox.x, y: line.bbox.y, w: b.bbox.width, h: line.bbox.height, column };
if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
out.push({ ...at, kind: 'loose' }); // ragged: past 3×, so the engine set it ragged
}
if (b.lines.length === 1 && parts.length > 1) { // Postext's names (see the essay):
out.push({ ...at, kind: b === parts[0] ? 'widow' : 'orphan' }); // foot : head
} else if (line.isLastLine && !/\s/.test(line.text.trim())) {
out.push({ ...at, kind: 'runt' }); // one word alone on a paragraph's last line
}
});
}
}
return out;
}
const TAGS = t({ en: { widow: 'widow', orphan: 'orphan', runt: 'runt' },
es: { widow: 'viuda', orphan: 'huérfana', runt: 'corta' } });
function paintMarks(canvas, list, scale) {
const ctx = canvas.getContext('2d');
ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
for (const m of list) { // loose lines: a wash; lone lines and runts: a tag in the margin
const loose = m.kind === 'loose';
ctx.globalCompositeOperation = loose ? 'multiply' : 'source-over'; // the ink shows through
ctx.fillStyle = loose ? palette.marker : palette.graphite;
if (loose) { ctx.fillRect(m.x - 2, m.y + 1, m.w + 4, m.h - 1); continue; }
ctx.font = `600 ${m.h * 0.48}px "${MONO}"`;
const w = ctx.measureText(TAGS[m.kind]).width + m.h * 0.5;
const x = m.column.index === 0 ? m.x - w - m.h * 0.35 : m.x + m.w + m.h * 0.35;
ctx.fillRect(x, m.y + m.h * 0.12, w, m.h * 0.8);
ctx.fillStyle = palette.marker;
ctx.fillText(TAGS[m.kind], x + m.h * 0.25, m.y + m.h * 0.7);
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
Solo el Sandbox aplica debug.looseLineHighlight, y doc.warnings nunca menciona una línea floja, así que marks() lee directamente el VDT. Cada línea justificada lleva su justifiedSpaceRatio, un párrafo partido entre dos columnas vuelve como un bloque por fragmento, y una línea de más de 3× no tiene proporción, porque el motor la compone en bandera y la marca con line.ragged. Con cuarenta caracteres por línea, los límites no bastan para que todas queden por debajo de 1,6; mientras se ajustaba el texto del ensayo, las marcas señalaron qué frases había que reescribir.
#3 · Pon el control junto a la página publicada
function compare(pairs) {
document.head.insertAdjacentHTML('beforeend', `<style>
#compare { background: ${palette.graphite}; color: ${palette.haze}; padding: 36px 24px 44px;
font: 500 12px/1.4 "${MONO}", monospace; } #compare > * { max-width: 860px; margin: 0 auto; }
#compare h2 { font: 800 clamp(30px, 6vw, 72px)/0.95 "${DISPLAY}", sans-serif; color: #fff;
margin: 6px auto 26px; letter-spacing: -0.01em; } #compare figure { margin: 0; }
#compare .kicker { color: ${palette.marker}; letter-spacing: .16em; text-transform: uppercase; }
#compare .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
#compare canvas { width: 100%; display: block; }
#compare figcaption { margin-bottom: 12px; text-transform: uppercase; letter-spacing: .12em; }
#compare figcaption b { display: block; margin-bottom: 6px; color: #fff; letter-spacing: 0;
font: 800 clamp(18px, 2.4vw, 26px)/1 "${DISPLAY}"; text-transform: none; }
@media (max-width: 640px) { #compare .pair { grid-template-columns: 1fr; } }</style>`);
const section = Object.assign(document.createElement('section'), { id: 'compare' });
section.innerHTML = `<p class="kicker">${t({ en: 'Same text · same design · page 2',
es: 'El mismo texto · el mismo diseño · página 2' })}</p><h2>${t({
en: 'Two line breakers', es: 'Dos formas de cortar' })}</h2><div class="pair"></div>`;
for (const [name, doc] of pairs) {
const page = doc.pages[1];
const list = marks(doc, page); // one walk per edition: the counts and the paint share it
const n = (...kinds) => list.filter((m) => kinds.includes(m.kind)).length;
const counts = `${t({ en: 'loose', es: 'flojas' })} ${n('loose')} · `
+ `${t({ en: 'lone', es: 'solas' })} ${n('widow', 'orphan')} · `
+ `${t({ en: 'runts', es: 'cortas' })} ${n('runt')}`;
const figure = document.createElement('figure');
figure.innerHTML = `<figcaption><b>${name}</b><span>${counts}</span></figcaption>`;
const canvas = figure.appendChild(document.createElement('canvas'));
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', `${name}, ${t({ en: 'page', es: 'página' })} 2: ${counts}`);
renderPageToCanvas(page, doc, canvas, { scale: 1000 / page.width });
paintMarks(canvas, list, 1000 / page.width);
section.querySelector('.pair').append(figure);
}
document.getElementById('pages').before(section); // #pages: the desk showPages() builds
}
Las dos ediciones salen del mismo diseño, así que cualquier diferencia entre las dos páginas 2 se debe al algoritmo de corte y a sus protecciones. En el texto español, el control voraz deja 26 de sus 108 líneas justificadas por encima de 1,6, y seis de ellas pasan de 2; la edición de Knuth–Plass mantiene las 105 entre 0,80 y 1,59. En la página 2 del control, además, un párrafo acaba en una línea corta que solo lleva el final de una palabra partida.
#4 · Dale a cada ajuste su propio recuadro
// A box sets all its :::columns in one body style, so each setting is a nested box; breaks="3"
// counts a nested box as one block (gotcha: callout-columns). The bench floats to a page foot.
const [SLIP_GAP, FRAME] = [3, 4]; // mm: between slips; the bench's frame round them
const slip = (id, body) => ({ id, background: col('paper'), marginBottom: mm(SLIP_GAP),
padding: { top: mm(2.2), right: mm(2.6), bottom: mm(2.4), left: mm(2.6) },
titleStyle: { fontFamily: MONO, fontSize: pt(6.6), fontWeight: 600, color: col('muted'),
gap: mm(1.6) }, // code keeps its case: textAlign, not TEXTALIGN
body: { fontSize: pt(8.6), lineHeight: pt(11.6), firstLineIndent: pt(0), ...body } });
const calloutStyles = [
{ id: 'bench', span: 'page', placement: 'bottom', background: col('graphite'),
columnGap: mm(FRAME), // the foot needs a FRAME too: the last slip's marginBottom is dropped
padding: { top: mm(3.4), right: mm(FRAME), bottom: mm(FRAME), left: mm(FRAME) },
titleStyle: { ...caps(7.5, 600), color: col('marker'), gap: mm(2.4) },
body: { fontSize: pt(8.6), lineHeight: pt(11.6), color: col('paper'), textAlign: 'left',
firstLineIndent: pt(0) } },
slip('ragged', { textAlign: 'left' }), // never hyphenated (gotcha: ragged-no-hyphenation)
slip('unhyphenated', { hyphenation: false }), // justified, like the body text
slip('justified', {}), // justified and hyphenated: the body text's own settings
{ id: 'settings', backgroundEnabled: false, marginTop: pt(3), marginBottom: pt(3), // config
stripe: { enabled: true, side: 'left', width: pt(2), color: col('graphite') },
padding: { top: pt(1), right: pt(0), bottom: pt(1), left: mm(3) },
body: { fontFamily: MONO, fontSize: pt(7), lineHeight: pt(9.5), color: col('graphite'),
firstLineIndent: pt(0) } },
];
Un recuadro compone todas las columnas de un grupo :::columns con un único estilo de texto, así que cada ficha es un recuadro anidado con su propio body, y breaks="3" apila en la primera columna la ficha en bandera y la que no parte palabras. La ficha en bandera no necesita hyphenation: false, porque el texto en bandera nunca se parte. El banco flota al pie de la página 3 con placement: 'bottom', y su relleno inferior repite el marco de 4 mm porque el marginBottom de la última ficha se pierde al final del recuadro.
#5 · Dibuja el modelo en la apertura
const BAND = 104; // mm from the trim's top edge to the band's foot
const DIAGRAM = { y: 64, w: MEASURE + 8, h: 34 }; // mm: its top on the page, width, height
const LABEL = 7; // pt: the diagram's labels, tracked less than caps() so the legend fits
const text = (id, content, family, size, color, x, y, extra) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) },
size: { width: mm(MEASURE) } }, ...extra });
const opener = () => ({ // a function: the diagram's constants are defined further down
enabled: true,
slot: { elements: [
// The band box reserves the opener's height, and the H1's default marginBottom adds a line
// of white: the body starts on the second grid line under the band. The diagram could not
// reserve it, as images never count (gotcha: opener-image-no-reserve).
{ kind: 'box', id: 'band', style: { backgroundColor: col('graphite') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
text('kicker', '{attr.kicker}', MONO, 7.5, 'marker', INNER, TOP, caps(7.5, 600)),
text('title', '{titleText}', DISPLAY, 29, 'paper', INNER, TOP + 5, // one line in both
{ fontWeight: 800, lineHeight: 1.02 }), // a multiple (gotcha: design-lineheight-multiple)
text('standfirst', '{attr.standfirst}', TEXT, 10.5, 'haze', INNER, TOP + 19,
{ italic: true, lineHeight: 1.3 }),
{ kind: 'image', id: 'diagram', resourceId: 'diagram', placement: { anchor: { to: 'page',
edge: 'top-left' }, offset: { x: mm(INNER), y: mm(DIAGRAM.y) },
size: { width: mm(DIAGRAM.w), height: mm(DIAGRAM.h) } } },
// An SVG image cannot use web fonts (gotcha: svg-no-webfonts): its labels are design text.
...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze',
INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })),
] },
});
El diagrama es un elemento de imagen del diseño del H1. Como figura sería un flotante top a todo el ancho, y un flotante top citado en la página 1 abre la página 2. La banda es una caja, y las cajas cuentan para la altura que reserva una apertura, pero las imágenes no, así que el texto empieza bajo la banda sin minHeight, una línea más abajo por el marginBottom que el H1 trae por defecto. Los rótulos son texto de diseño colocado sobre las coordenadas del propio dibujo, porque un SVG pintado como imagen no puede usar las fuentes web de la página.
La receta completa
// ═══ Postext Cookbook · Nº 014 · Justification lab ════════════════════════════════ // https://postext.dev/en/cookbook/justification-lab // Code: MIT · Text: original (CC BY 4.0) · Diagram: generated in code (CC BY 4.0) // Fonts: Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A type journal's essay set twice from one design, Knuth–Plass and greedy: the two page 2s // side by side with their loose lines marked from the layout tree, then the published pages. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseMarkdownWithIssues, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'justification-lab'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Graphite, paper, one highlighter yellow; col() writes hex too (gotcha: palette-skips-designs). const palette = { ink: '#1f2124', // text: a graphite near-black graphite: '#2e3136', // the accent: the opener band, the bench box, folios marker: '#ffe14d', // highlighter yellow: glue and loose lines, never type on white haze: '#c3c7cc', // type on graphite: the standfirst, the diagram's labels muted: '#66686c', // running heads, settings lines, the colophon paper: '#ffffff', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'defaults', value: { hex: palette.graphite, model: 'hex' } }, // no blue ]; const [TEXT, DISPLAY, MONO] = ['Petrona', 'Bricolage Grotesque', 'Source Code Pro']; const [BODY, LEAD] = [9.5, 13]; // pt: body size and leading, the grid both columns share // mm: an A5 trim, mirrored margins and a narrow gutter: two columns of about 40 characters const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [148, 210, 20, 20, 16, 13, 5]; const MEASURE = TRIM_W - INNER - OUTER; // mm: 119, the text width the opener aligns to const caps = (size, weight = 500) => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: weight, letterSpacing: pt(size * 0.16), textTransform: 'uppercase' }); // tracked mono labels // #region answer: one design, two line breakers: Knuth–Plass inside fences, greedy without // Knuth–Plass breaking, hyphenation and the widow, orphan and runt penalties are all on by // default. A narrow column also needs a tighter fence round the glue, in multiples of a // normal word space (defaults 0.6 and 2): lines past the upper fence cost more than any // hyphen, so the breaker hyphenates or re-breaks the paragraph before it stretches that far. const FENCES = { minWordSpacing: 0.8, maxWordSpacing: 1.6 }; const bodyText = { // config().bodyText fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default ...FENCES, }; // Hyphenation follows the document's locale, by exact code (gotcha: hyphenation-locales): const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English // The control: first-fit breaking, which sets each line once and moves on, with the widow and // orphan guards off (runts are priced inside Knuth–Plass only). Same text, fonts and measure; // a fresh object on every call, like config() itself, because the engine caches resolved // configs by identity (gotcha: config-cache-identity). const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false }; const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } }); // #endregion // #region opener: the title on a graphite band, with the diagram drawn in as an image element const BAND = 104; // mm from the trim's top edge to the band's foot const DIAGRAM = { y: 64, w: MEASURE + 8, h: 34 }; // mm: its top on the page, width, height const LABEL = 7; // pt: the diagram's labels, tracked less than caps() so the legend fits const text = (id, content, family, size, color, x, y, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, size: { width: mm(MEASURE) } }, ...extra }); const opener = () => ({ // a function: the diagram's constants are defined further down enabled: true, slot: { elements: [ // The band box reserves the opener's height, and the H1's default marginBottom adds a line // of white: the body starts on the second grid line under the band. The diagram could not // reserve it, as images never count (gotcha: opener-image-no-reserve). { kind: 'box', id: 'band', style: { backgroundColor: col('graphite') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, text('kicker', '{attr.kicker}', MONO, 7.5, 'marker', INNER, TOP, caps(7.5, 600)), text('title', '{titleText}', DISPLAY, 29, 'paper', INNER, TOP + 5, // one line in both { fontWeight: 800, lineHeight: 1.02 }), // a multiple (gotcha: design-lineheight-multiple) text('standfirst', '{attr.standfirst}', TEXT, 10.5, 'haze', INNER, TOP + 19, { italic: true, lineHeight: 1.3 }), { kind: 'image', id: 'diagram', resourceId: 'diagram', placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(INNER), y: mm(DIAGRAM.y) }, size: { width: mm(DIAGRAM.w), height: mm(DIAGRAM.h) } } }, // An SVG image cannot use web fonts (gotcha: svg-no-webfonts): its labels are design text. ...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze', INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })), ] }, }); // #endregion const HEAD_Y = 11; // mm from the top (bottom) edge: running heads in the margin, folios outside const head = (id, content, parity, edge, x, extra) => ({ ...text(id, content, MONO, 7.5, 'muted', 0, 0, caps(7.5)), parity, pages: 'body', align: edge.split('-')[1], // left | right placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEAD_Y : -HEAD_Y) } }, ...extra }); const folio = { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(8), letterSpacing: pt(0), color: col('graphite') }; const header = { elements: [ head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('v-title', '{title} · {subtitle}', 'even', 'top-left', OUTER + 8), head('r-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)), head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -OUTER, { ...folio, pages: 'opener' })] }; // the opener's folio drops to its foot // #region bench: three slips in a 2 × 2 grid, each a nested box with a body style of its own // A box sets all its :::columns in one body style, so each setting is a nested box; breaks="3" // counts a nested box as one block (gotcha: callout-columns). The bench floats to a page foot. const [SLIP_GAP, FRAME] = [3, 4]; // mm: between slips; the bench's frame round them const slip = (id, body) => ({ id, background: col('paper'), marginBottom: mm(SLIP_GAP), padding: { top: mm(2.2), right: mm(2.6), bottom: mm(2.4), left: mm(2.6) }, titleStyle: { fontFamily: MONO, fontSize: pt(6.6), fontWeight: 600, color: col('muted'), gap: mm(1.6) }, // code keeps its case: textAlign, not TEXTALIGN body: { fontSize: pt(8.6), lineHeight: pt(11.6), firstLineIndent: pt(0), ...body } }); const calloutStyles = [ { id: 'bench', span: 'page', placement: 'bottom', background: col('graphite'), columnGap: mm(FRAME), // the foot needs a FRAME too: the last slip's marginBottom is dropped padding: { top: mm(3.4), right: mm(FRAME), bottom: mm(FRAME), left: mm(FRAME) }, titleStyle: { ...caps(7.5, 600), color: col('marker'), gap: mm(2.4) }, body: { fontSize: pt(8.6), lineHeight: pt(11.6), color: col('paper'), textAlign: 'left', firstLineIndent: pt(0) } }, slip('ragged', { textAlign: 'left' }), // never hyphenated (gotcha: ragged-no-hyphenation) slip('unhyphenated', { hyphenation: false }), // justified, like the body text slip('justified', {}), // justified and hyphenated: the body text's own settings { id: 'settings', backgroundEnabled: false, marginTop: pt(3), marginBottom: pt(3), // config stripe: { enabled: true, side: 'left', width: pt(2), color: col('graphite') }, padding: { top: pt(1), right: pt(0), bottom: pt(1), left: mm(3) }, body: { fontFamily: MONO, fontSize: pt(7), lineHeight: pt(9.5), color: col('graphite'), firstLineIndent: pt(0) } }, ]; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale, colorPalette, page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText, headings: { fontFamily: DISPLAY, fontWeight: 800, color: col('ink'), levels: [ // restated: a headings object drops the H1 break (gotcha: headings-drop-h1-break) { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener() }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, paragraphStyles: [ { id: 'colophon', fontFamily: MONO, fontSize: pt(6.6), lineHeight: pt(9), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], calloutStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 86 líneas · content.es.md
title: "Galerada" subtitle: "Cuadernos de composición · n.º 12" author: "Galerada" --- # El problema de los ríos {kicker="Galerada · n.º 12 · Justificación" standfirst="En una columna estrecha y justificada, los espacios entre palabras son lo primero que se abre. Cómo un algoritmo que sopesa el párrafo entero mantiene uniforme el gris, y los ocho ajustes que lo gobiernan."} Sostén una página de periódico con el brazo extendido y entorna los ojos. En una buena columna, el texto se vuelve un gris uniforme. En una mala, unos canales pálidos la recorren de arriba abajo, de un hueco al de la línea siguiente. Los tipógrafos los llaman ríos, o calles. Se forman cuando los espacios se abren en varias líneas a la vez y coinciden en vertical, y nada los abre tanto como una medida estrecha. En una columna de cuarenta caracteres, cada línea tiene cinco o seis espacios entre palabras. Si una palabra larga pasa a la línea siguiente, esos pocos espacios se reparten todo su ancho, y cada uno puede llegar al doble. Si la columna mide el doble, lo reparten el doble de espacios y cada uno crece la mitad. ## Cajas y cola En 1981, Donald E. Knuth y Michael F. Plass describieron un párrafo tal como todavía lo ve TeX. Las palabras son cajas de ancho fijo. Los espacios son cola: tienen un ancho natural y un límite a lo que pueden estirarse o encogerse. Las penalizaciones marcan los puntos donde una línea puede terminar, y su precio: acabar en guion cuesta algo; entre dos palabras, nada. El dibujo que abre el artículo muestra una línea en esos términos, medida y compuesta, con su cola estirada hasta llenar la medida. Después, el algoritmo pone precio a cada línea según lo que se ha movido su cola, lo eleva al cubo, para que una línea muy floja cueste más que varias un poco flojas, y le suma las penalizaciones. De todos los cortes posibles del párrafo, se queda con el de menor coste total, sumadas todas sus líneas. ## Línea a línea o en bloque El método antiguo, el del primer ajuste, sobrevive en la web. Llena cada línea con las palabras que caben y pasa a la siguiente sin volver atrás, así que meter ahora una palabra corta más puede dejar la línea siguiente con un hueco imposible de cerrar. El del párrafo entero afloja un poco una línea para ahorrarle un hueco a la siguiente. Repetido a lo largo de una columna, ese intercambio deja menos líneas flojas que el primer ajuste y, con ellas, menos huecos que se alineen en ríos. ## Límites para la cola La cola tiene dos límites: cada espacio puede encogerse hasta el 80 % de su ancho natural y crecer hasta el 160 %. Si se estrechan, el algoritmo se queda sin maneras de llenar la línea; si se aflojan, el ojo ve los huecos. Pasado el límite superior, cada estirón cuesta más que un guion o una línea final corta, así que el algoritmo prueba antes otra salida: partir una palabra o llevarla a otra línea. :::callout{type="settings"} minWordSpacing: 0.8 maxWordSpacing: 1.6 ::: :::callout{type="bench" title="Banco de pruebas · un párrafo, tres ajustes"} :::columns{count=2 breaks="3"} :::callout{type="ragged" title="bandera · textAlign: 'left'"} Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan. ::: :::callout{type="unhyphenated" title="justificado · hyphenation: false"} Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan. ::: :::callout{type="justified" title="justificado · hyphenation: true"} Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan. ::: Las mismas palabras, compuestas de tres maneras en una medida algo más estrecha que estas columnas. En bandera solo se cortan entre palabras, haya partición o no. Justificadas y sin partir, unos pocos espacios cargan con toda la holgura. Con partición, los espacios se igualan a cambio de unos cuantos guiones. ::: ::: ## Partir palabras La separación silábica le da al algoritmo más puntos donde terminar una línea, y en una medida estrecha es imprescindible. Postext usa los patrones de TeX del idioma del documento, indicado con un código exacto: *es* en esta edición y *en-us* en la inglesa. Con un código sin patrones, como *es-ES*, recurre al inglés estadounidense sin avisar. Los patrones solo actúan en texto justificado: en bandera, las líneas se cortan entre palabras y una columna estrecha queda muy desigual. ## Viudas y huérfanas Una primera línea sola al pie de una columna y una última línea sola en la cabeza de la siguiente son la pareja que los manuales prohíben, aunque nadie se ha puesto de acuerdo en cuál es la viuda. Postext lo resuelve por posición: avoidWidows actúa al pie de cada columna, y avoidOrphans, en su cabeza. :::callout{type="settings"} widowPenalty: 1000 orphanPenalty: 1000 slackWeight: 10 ::: Cada regla es un precio que el algoritmo compara con el blanco que dejaría cumplirla. Una línea sola cuesta su penalización, y las líneas vacías que un corte dejaría al pie de la columna cuestan diez veces el cuadrado de su número. Gana lo más barato. Una línea corta es una última línea que no llega a sostenerse sola: una palabra, o el final de una, bajo un párrafo lleno. Postext le pone precio dentro del algoritmo de corte, así que gana el juego de cortes que baje una segunda palabra, si los límites lo permiten. Si no lo permite ninguno, compone el párrafo con una línea menos: aprieta los espacios y, si hace falta, las letras, sin pasar de diez milésimas de cuadratín. :::callout{type="settings"} runtMinCharacters: 20 runtPenalty: 1000 maxRuntTracking: 10 ::: ## Lo que el ojo perdona Una palabra larga aún puede hacer que una línea de una columna estrecha quede algo más floja que sus vecinas, y un párrafo que ha de acabar en algún sitio a veces acaba en una línea corta. El algoritmo puede pasar de una línea a otra el espacio que sobra, pero no quitarlo. Junto a una palabra larga, lo reparte entre cinco líneas en vez de dejarlo entero en un solo hueco, que se vería incluso con el brazo extendido. Una línea que aún se abre más de la cuenta queda para el corrector, que casi siempre la cierra con cambiar una palabra de la frase por otra más corta o más larga. :::paragraphs{style="colophon"} Galerada se compone en Petrona, Bricolage Grotesque y Source Code Pro (SIL Open Font License). Texto y diagrama: originales, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: boxes, glue and penalties: one line as measured and as set, and its resource const resources = [{ id: 'diagram', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'diagram.svg', width: DIAGRAM.w * 10, height: DIAGRAM.h * 10 }, // 10 px a mm altText: t({ en: 'A line of word boxes whose last word overruns the measure; then the same ' + 'line hyphenated, its springs stretched until it fills the measure exactly.', es: 'Una línea de cajas cuya última palabra rebasa la medida; después, la misma línea ' + 'partida, con los muelles estirados hasta llenar la medida justa.' }) }]; // Word boxes in mm; the long last word may break at a hyphenation penalty after its first part. const WORDS = [[14], [8], [17], [6.5], [13.5], [10], [16.7, 16]]; const [ROW_H, GLUE, HYPHEN, ROWS] = [4.4, 3.6, 2, [7, 20.5]]; // mm; ROWS: the rows' tops const SET = WORDS.reduce((sum, w) => sum + w[0], HYPHEN); // the set line: boxes to the hyphen const STRETCH = (MEASURE - SET) / (WORDS.length - 1) / GLUE; // what fills the measure: ×1.45 const R = (v) => Math.round(v * 100) / 100; function spring(x, y, w) { // a zigzag of eight turns: glue, stretched or at rest const pts = Array.from({ length: 9 }, (_, i) => `${R(x + (i * w) / 8)} ${R(y + (i % 2 ? -1 : 1) * 0.9)}`); return `<path d="M${R(x)} ${R(y)}L${pts.join('L')}L${R(x + w)} ${R(y)}" fill="none" ` + `stroke="${palette.marker}" stroke-width="0.45" stroke-linejoin="round"/>`; } function row(y, glue, broken) { // one line of boxes; `broken`: set up to the penalty let [x, out] = [0, '']; const box = (w, h = ROW_H) => { out += `<rect x="${R(x)}" y="${R(y + (ROW_H - h) / 2)}" ` + `width="${R(w)}" height="${h}" rx="0.5" fill="${palette.paper}"/>`; x += w; }; WORDS.forEach((word, i) => { if (i) { out += spring(x, y + ROW_H / 2, glue); x += glue; } box(word[0]); if (word.length === 1) return; // The penalty: a flagged break inside the word, marked by a yellow wedge. Taken, it sets // a hyphen (a short bar); passed over, the rest of the word runs on past the measure. out += `<path d="M${R(x - 1.1)} ${y - 2.6}h2.2l-1.1 1.9z" fill="${palette.marker}"/>`; if (broken) box(HYPHEN, 1); else box(word[1]); }); return out; } function diagram() { const measure = `<path d="M${MEASURE - 0.2} 3V${ROWS[1] + ROW_H + 2}" ` // stops over the legend + `stroke="${palette.haze}" stroke-width="0.4" stroke-dasharray="0.8 0.8"/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${DIAGRAM.w * 10}" ` + `height="${DIAGRAM.h * 10}" viewBox="0 0 ${DIAGRAM.w} ${DIAGRAM.h}">` + `${row(ROWS[0], GLUE, false)}${row(ROWS[1], GLUE * STRETCH, true)}${measure}</svg>`; } function diagramLabels() { // [id, text, x, y] in mm from the diagram's top-left corner const k = STRETCH.toFixed(2).replace('.', t({ en: '.', es: ',' })); return [ ['l-natural', t({ en: 'As measured: the word overruns', es: 'Medida natural: la palabra no cabe' }), 0, ROWS[0] - 5.5], ['l-set', t({ en: `As set: hyphenated, each space ×${k}`, es: `Compuesta: partida, cada espacio ×${k}` }), 0, ROWS[1] - 5.5], ['l-legend', t({ en: 'Box: a word · glue: a space · penalty: a break · dashes: the measure', es: 'Caja: palabra · cola: espacio · penalización: corte · trazos: la medida' }), 0, ROWS[1] + ROW_H + 3.5], ]; } // #endregion // #region marks: the highlighter: loose lines, lone lines and runts read from the layout tree // debug.looseLineHighlight is Sandbox-only (gotcha: sandbox-only-warnings), so the pen reads the // VDT: justified lines carry justifiedSpaceRatio; a paragraph cut by a column is two blocks. function marks(doc, page) { const body = doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)).filter((b) => b.type === 'paragraph' && b.containerId === undefined && b.textAlign === 'justify'); const out = []; for (const column of page.columns) { for (const b of column.blocks.filter((x) => body.includes(x))) { const parts = body.filter((o) => o.contentIndex === b.contentIndex); b.lines.forEach((line) => { const at = { x: b.bbox.x, y: line.bbox.y, w: b.bbox.width, h: line.bbox.height, column }; if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) { out.push({ ...at, kind: 'loose' }); // ragged: past 3×, so the engine set it ragged } if (b.lines.length === 1 && parts.length > 1) { // Postext's names (see the essay): out.push({ ...at, kind: b === parts[0] ? 'widow' : 'orphan' }); // foot : head } else if (line.isLastLine && !/\s/.test(line.text.trim())) { out.push({ ...at, kind: 'runt' }); // one word alone on a paragraph's last line } }); } } return out; } const TAGS = t({ en: { widow: 'widow', orphan: 'orphan', runt: 'runt' }, es: { widow: 'viuda', orphan: 'huérfana', runt: 'corta' } }); function paintMarks(canvas, list, scale) { const ctx = canvas.getContext('2d'); ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on for (const m of list) { // loose lines: a wash; lone lines and runts: a tag in the margin const loose = m.kind === 'loose'; ctx.globalCompositeOperation = loose ? 'multiply' : 'source-over'; // the ink shows through ctx.fillStyle = loose ? palette.marker : palette.graphite; if (loose) { ctx.fillRect(m.x - 2, m.y + 1, m.w + 4, m.h - 1); continue; } ctx.font = `600 ${m.h * 0.48}px "${MONO}"`; const w = ctx.measureText(TAGS[m.kind]).width + m.h * 0.5; const x = m.column.index === 0 ? m.x - w - m.h * 0.35 : m.x + m.w + m.h * 0.35; ctx.fillRect(x, m.y + m.h * 0.12, w, m.h * 0.8); ctx.fillStyle = palette.marker; ctx.fillText(TAGS[m.kind], x + m.h * 0.25, m.y + m.h * 0.7); } ctx.setTransform(1, 0, 0, 1, 0, 0); } // #endregion // #region compare: the control beside the published page, each with its count of marks function compare(pairs) { document.head.insertAdjacentHTML('beforeend', `<style> #compare { background: ${palette.graphite}; color: ${palette.haze}; padding: 36px 24px 44px; font: 500 12px/1.4 "${MONO}", monospace; } #compare > * { max-width: 860px; margin: 0 auto; } #compare h2 { font: 800 clamp(30px, 6vw, 72px)/0.95 "${DISPLAY}", sans-serif; color: #fff; margin: 6px auto 26px; letter-spacing: -0.01em; } #compare figure { margin: 0; } #compare .kicker { color: ${palette.marker}; letter-spacing: .16em; text-transform: uppercase; } #compare .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; } #compare canvas { width: 100%; display: block; } #compare figcaption { margin-bottom: 12px; text-transform: uppercase; letter-spacing: .12em; } #compare figcaption b { display: block; margin-bottom: 6px; color: #fff; letter-spacing: 0; font: 800 clamp(18px, 2.4vw, 26px)/1 "${DISPLAY}"; text-transform: none; } @media (max-width: 640px) { #compare .pair { grid-template-columns: 1fr; } }</style>`); const section = Object.assign(document.createElement('section'), { id: 'compare' }); section.innerHTML = `<p class="kicker">${t({ en: 'Same text · same design · page 2', es: 'El mismo texto · el mismo diseño · página 2' })}</p><h2>${t({ en: 'Two line breakers', es: 'Dos formas de cortar' })}</h2><div class="pair"></div>`; for (const [name, doc] of pairs) { const page = doc.pages[1]; const list = marks(doc, page); // one walk per edition: the counts and the paint share it const n = (...kinds) => list.filter((m) => kinds.includes(m.kind)).length; const counts = `${t({ en: 'loose', es: 'flojas' })} ${n('loose')} · ` + `${t({ en: 'lone', es: 'solas' })} ${n('widow', 'orphan')} · ` + `${t({ en: 'runts', es: 'cortas' })} ${n('runt')}`; const figure = document.createElement('figure'); figure.innerHTML = `<figcaption><b>${name}</b><span>${counts}</span></figcaption>`; const canvas = figure.appendChild(document.createElement('canvas')); canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `${name}, ${t({ en: 'page', es: 'página' })} 2: ${counts}`); renderPageToCanvas(page, doc, canvas, { scale: 1000 / page.width }); paintMarks(canvas, list, 1000 / page.width); section.querySelector('.pair').append(figure); } document.getElementById('pages').before(section); // #pages: the desk showPages() builds } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages and the comparison paint, loaded before the build (gotcha: fonts-first). const FONTS = { Petrona: ['400', '400i', '700', '700i'], 'Bricolage Grotesque': ['800'], 'Source Code Pro': ['400', '500', '600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('diagram.svg', diagram()); const build = (cfg) => buildWithFonts(() => buildDocument({ markdown, resources }, cfg()), markdown); const greedy = await build(control); // first: the control, for the comparison only const doc = await build(config); // last: the published pages showPages(doc, { title: t({ en: 'Justification lab', es: 'Laboratorio de justificación' }) }); compare([[t({ en: 'Greedy, no guards', es: 'Voraz, sin protecciones' }), greedy], ['Knuth–Plass', doc]]); // The engine's own report: parse issues (a ::: left open) and layout warnings, never loose lines. const { issues } = parseMarkdownWithIssues(markdown); kitStatus(t({ en: `${doc.pages.length} pages · parse issues ${issues.length} · layout warnings `, es: `${doc.pages.length} páginas · problemas de análisis ${issues.length} · avisos ` }) + (doc.warnings?.length ?? 0));Kit · 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 ───────────────────────────────────────────────────────────────────────
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
#Parte un texto en alemán
Los patrones de partición se eligen por código exacto, así que un texto alemán necesita 'de'.
-const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
+const locale = 'de'; // German patterns; 'de-DE' would hyphenate as English#Marca solo las peores líneas
Sube el umbral de 1,6 a 2 y el resaltador marca solo las líneas cuyos espacios pasan del doble.
- if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
+ if (line.justifiedSpaceRatio > 2 || line.ragged) {Errores frecuentes
Error frecuente
avoidWidows vigila el pie de la columna, y avoidOrphans, su cabeza
Postext da nombre propio a las dos líneas solas: avoidWidows (widowMinLines, widowPenalty) evita que la primera línea de un párrafo quede sola al pie de una columna, y avoidOrphans (orphanMinLines, orphanPenalty), que la última quede sola en la cabeza de la siguiente. Muchos manuales de estilo usan los dos nombres al revés, así que elige el ajuste por el lugar donde actúa. Los dos vienen activados y funcionan como penalizaciones: la composición compara cada una con las líneas vacías que dejaría respetarla. Viudas, huérfanas y líneas cortas →
Error frecuente
En el texto en bandera no hay separación silábica
La separación silábica solo se aplica al texto justificado; el texto en bandera corta entre palabras, así que una columna estrecha en bandera queda muy desigual. Justifica el pasaje o ensancha la medida. Separación silábica e idioma del documento →
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
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
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
:::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
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
Un flotante 'top' nunca cae en la página que lo cita
Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita. Colocación de figuras →
Error frecuente
El texto dentro de un SVG <img> no puede usar fuentes web
Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →
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
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
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 →
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 →
- Un recuadro que queda al final de una columna baja hasta el pie cuando se equilibran las columnas, lejos del párrafo al que pertenece. Ajusta el texto para que la columna se llene, como hizo falta con el recuadro de ajustes bajo «Límites para la cola».
- En postext 1.4.1 una línea dentro de un recuadro nunca se compone en bandera, por mucho que se estire: la ficha sin partición conserva una línea con espacios de casi cuatro veces el normal, que en el texto corrido se compondría en bandera.
Créditos
- Receta
- Ignacio Ferro
- Texto
- El ensayo «El problema de los ríos» y su versión inglesa, «The river problem», el banco de pruebas y el diagrama, escritos y dibujados para esta receta · Postext Cookbook · CC BY 4.0
- Fuentes
- Petrona (SIL OFL 1.1) · Bricolage Grotesque (SIL OFL 1.1) · Source Code Pro (SIL OFL 1.1)
- Código
- MIT, como Postext


