Lo que vas a componer
El comienzo de Marianela (1878), de Galdós, en una edición de bolsillo de 11,5 × 18 cm, con un plano conjetural de las minas de Socartes frente al capítulo I. Sobre el título, en Libre Bodoni cursiva, va capítulo primero en versalitas y escrito con letras; el texto, en Gentium Book Plus, empieza a un cuarto de la altura de la página con una capitular alzada color sangre de toro. Las palabras se parten por sílabas (hie-rro, esta-blecimiento) y los espacios no pasan de 1,7 veces su ancho normal; ningún párrafo termina con el resto de una palabra partida. Los diálogos llevan raya, la misma que abre y cierra los incisos del narrador. La consigna del viajero va entre «comillas latinas», y entre “inglesas” en la nota final, que la cita dentro de otra cita. El plano es la Figura 1, citada en el texto como [fig. 1]. Las cabeceras, en Marcellus SC, llevan el autor en las pares y el título en las impares.
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?
- ¿Cómo escribo rayas de diálogo, años al principio de párrafo, precios y símbolos literales sin que el Markdown los malinterprete?
- ¿Cómo consigo las etiquetas «Figura» y «Tabla» en el idioma de mi documento?
- ¿Cómo añado a una apertura una línea de autor, una entradilla o un primer párrafo con capitular?
La respuesta corta
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
// The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
// Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
// the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
// A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
.map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
Ingredientes
- Funciones
- Separación silábica e idioma del documentoCorte de líneas óptimo (Knuth–Plass)Capitulares en las aperturasAperturas diseñadasAtributos de títuloTextos, filetes y cajas en los diseños de páginaViudas, huérfanas y líneas cortasMárgenes simétricosCapítulos que abren en página imparCabeceras según el tipo de páginaCabeceras y foliosCitas que colocan las figurasPies numeradosFigura y Tabla en tu idiomaFiguras justo aquíEstilos de títuloEspacio vertical explícitoEstilos de párrafoEscapes y caracteres literalesColor del papel
- Tipografía
- Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1)
- Recursos
- El plano conjetural de las minas de Socartes, dibujado en código con la paleta de la página (Ignacio Ferro, MIT)
Elaboración
#1 · Sílabas del español, espacios contenidos
El código está en la respuesta corta, más arriba. Los patrones de separación silábica del español los elige locale: 'es', y solo ese código exacto: con 'es-ES', estas páginas parten cabal-los, hi-erro y establec-imiento con las reglas del inglés americano, y en una línea los espacios llegan a 2,13× (idiomas soportados). La justificación, Knuth–Plass y las reglas de viudas y huérfanas vienen activadas de serie. Además, en una medida de 86 mm hay que contener los espacios: con el maxWordSpacing: 2 por defecto, ocho líneas pasan de 1,6× y la más abierta llega a 1,89×; con 1,7, la más abierta se queda en 1,65×, porque entonces el algoritmo parte hie-rro y ca-lles (límites de espaciado entre palabras). Por encima del límite una línea cuesta mucho más, pero sigue siendo válida, así que en una medida más estrecha, si no hay un corte mejor, puede pasarse. runtMinCharacters: 26 evita los restos de palabra partida que deja pasar el valor por defecto, 20 anchos de espacio: siem- / pre adelante. pasa a ser Adelante, / siempre adelante., y So- / cartes [fig. 1]., de / Socartes [fig. 1]. (líneas cortas).
#2 · Una página de bolsillo de líneas enteras
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
El margen inferior se calcula a partir del formato, del margen superior y de LINES, así que la caja tiene exactamente 30 líneas de 13,8 pt y toda página llena acaba en la misma línea de base, como se ve en las páginas 10 y 11. Con mirror: true, el margen interior, de 15 mm, queda del lado del lomo en las dos páginas, y el exterior, de 14 mm, del lado del corte (márgenes simétricos), y en la medida de 86 mm caben unos 57 caracteres de Gentium Book Plus a 10 pt.
#3 · El capítulo en letras y una capitular alzada
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
enabled: true,
slot: { elements: [
// Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
// from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
{ kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
placement: centred(LABEL_Y) },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
placement: centred(TITLE_Y) },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own; -LEAD also takes back
// the line the initial's font box adds below the lead (gotcha: drop-cap-extra-line).
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
marginBottom: pt(-LEAD), advancedDesign: opener };
Las plantillas de numeración solo imprimen números y letras (1, 01, I, i, A, a), nunca palabras, así que capítulo primero sale del atributo ordinal del propio título (atributos de encabezado). Postext solo dibuja capitulares en el texto de diseño, que se compone en bandera, y solo si ese texto lleva overflow: 'wrap'. Para que la bandera no se note, la capitular acompaña a una sola línea: lines: 1 la alza junto a la línea que guarda el atributo lead, e INITIAL_GAP se ha ido ajustando hasta que esa línea acaba a ras, a 0,1 mm de la medida. El párrafo sigue en el Markdown, sin sangría (elementos de texto). El marginBottom del nivel del capítulo sustituye al margen por defecto, que añadiría una línea en blanco; su valor, -LEAD, recupera además la línea de más que la caja de la capitular, de 1,2 em de alto, suma a la altura de la apertura.

#4 · El plano, enfrente del capítulo, como Figura 1
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
+ 'las minas de Socartes.',
note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
+ 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), // no -LEAD
footer: { elements: [] }, // no drop folio
advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
position: 'here' coloca el plano donde está ::resource{id="plano"}, bajo un título propio. Ese título convierte la página 8 del libro en una apertura, y en las aperturas no se imprimen cabeceras. Su estilo deja vacío el pie de página y fija su propio salto, de modo que la lámina queda frente al capítulo I, sin cabecera ni folio, aunque los capítulos empiecen en página impar (estilos de encabezado). Los tipos en español de la respuesta corta imprimen Figura 1. en el pie del plano (sin resourceTypes diría Figure 1.1, sea cual sea el idioma) y Fig. 1 en el texto, que el case="lower" del :ref del Markdown convierte en fig. 1. La referencia va entre corchetes, que en las ediciones españolas señalan lo que el editor añade al texto del autor.
#5 · El autor en la página par, el título en la impar
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
kind: 'text', id, content, parity, pages, // running heads skip the openers
fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
{ ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
head('verso-author', '{author}', 'even', 'top', -SHIFT),
head('recto-title', '{title}', 'odd', 'top', SHIFT),
{ ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
parity: 'even' pone el autor en las pares y 'odd' el título en las impares, y pages: 'body' los deja fuera de la apertura, donde solo aparece el folio al pie, el elemento con pages: 'opener'. Cada cabecera se ancla a la página y SHIFT la desplaza al centro de la caja, que queda medio milímetro fuera del centro de la página porque el margen interior y el exterior no miden lo mismo. Por eso mismo el folio al pie tiene una versión para cada paridad. Los folios van en Gentium, porque en Marcellus SC el 1 y el 0 se confunden con la I y la O.
#6 · La puntuación española, en el Markdown
const paragraphStyles = [
{ id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
textAlign: 'center' },
// In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
{ id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
marginTop: pt(LEAD) },
{ id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
La página 12, la última, se cierra con tres estilos de párrafo. Los asteriscos, de Bodoni y en color sangre de toro, van centrados tras un :::space{lines=2}, porque las líneas en blanco no añaden espacio. La nota de la edición va en cuerpo 8,3 con 11 pt de interlineado, y el colofón, en cuerpo 7,5 y en el gris de las cabeceras. La nota se queda en el color del texto: un estilo de párrafo no tiene color de cursiva, y en una nota gris el título Marianela, en cursiva, saldría más oscuro que el resto. En el Markdown, el diálogo empieza con raya (—) y nunca con guion, porque un guion seguido de un espacio al principio de la línea abre una lista. 1878. al comienzo de la nota abriría una lista numerada, y por eso lleva delante un unidor de palabras (U+2060). Los asteriscos van escapados (\*); sin la barra, el primero abriría una lista con viñetas y los otros dos una cursiva, y la línea desaparecería.
La receta completa
// ═══ Postext Cookbook · Nº 016 · Justified Spanish in a pocket novel ═════════════════ // https://postext.dev/en/cookbook/spanish-pocket-novel // Code: MIT · Text: B. Pérez Galdós, Marianela, 1878 (PD, Gutenberg #17340) · Map: drawn in code // Fonts: Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only) const RECIPE = 'spanish-pocket-novel'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1e1b18', // text: a warm near-black oxblood: '#8b2e2a', // the one accent: chapter label, rule, initial, asterisks, route muted: '#6d645a', // running heads, the map's paths, the caption's credit paper: '#f7f2e8', // an ivory book paper slag: '#c8553d', moss: '#6f7a4f', water: '#58707f', // the map: mined earth, woods, river }; // Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs). 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: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } }, ]; const TEXT = 'Gentium Book Plus'; // the text face const DISPLAY = 'Libre Bodoni'; // the chapter's title and the initial const LABEL = 'Marcellus SC'; // small capitals: chapter label, running heads, map names const [BODY, LEAD] = [10, 13.8]; // pt: the body size, and its leading: the grid's pitch const smallCaps = { fontFamily: LABEL, fontSize: pt(9), letterSpacing: pt(1.6), // 2 labels color: col('oxblood'), align: 'center' }; // #region page: a pocket paperback; the text block holds 30 whole lines const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight const LINES = 30; // lines of LEAD per page, so every full page ends on the same line const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt const page = { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72), left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto }; // #endregion // #region answer: Spanish syllables, word spaces under 1.7×, captions that say Figura const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales) const bodyText = { // config().bodyText fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), referenceBold: false, // '[fig. 1]' reads in roman, like the words around it firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out. // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles. // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through. maxWordSpacing: 1.7, runtMinCharacters: 26, }; // config().resourceTypes, as the locale leaves captions in English (gotcha: // resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1. const resourceTypes = defaultResourceTypes(LOCALE) .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' })); // #endregion // #region opener: the chapter spelled out, its title, and the lead under a raised initial const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page const INITIAL = 3 * LEAD; // pt: the initial's size, three leads // Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside // the initial, fitted flush by the gap; the paragraph goes on in the Markdown. const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } }); const opener = { enabled: true, slot: { elements: [ // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"} { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps, placement: centred(LABEL_Y) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6), color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true, fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center', overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default) placement: centred(TITLE_Y) }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY), lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple) color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL), color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } }, ] }, }; // The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do. // marginBottom replaces the level's default, a blank line of its own; -LEAD also takes back // the line the initial's font box adds below the lead (gotcha: drop-cap-extra-line). const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(-LEAD), advancedDesign: opener }; // #endregion // #region heads: the author on the verso, the title on the recto; a drop folio on the opener const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({ kind: 'text', id, content, parity, pages, // running heads skip the openers fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, }); const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre // Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline. const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') }; const header = { elements: [ { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio }, head('verso-author', '{author}', 'even', 'top', -SHIFT), head('recto-title', '{title}', 'odd', 'top', SHIFT), { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio }, ] }; const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x, DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] }; // #endregion // #region figure: the map faces chapter I as Figura 1; the text cites it as [fig. 1] const MAP_H = 112; // mm: a frontispiece map, the measure wide const resources = [{ id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de ' + 'las minas de Socartes.', note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.', altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, ' + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.', }]; // The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip. const frontispiece = { id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break) breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), // no -LEAD footer: { elements: [] }, // no drop folio advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name', content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } }, }; const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2), labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } }; // #endregion // #region styles: the asterisks that close the excerpt, the edition's note, the colophon const paragraphStyles = [ { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'), textAlign: 'center' }, // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour). { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0), marginTop: pt(LEAD) }, { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2) }, ]; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: LOCALE, resourceTypes, colorPalette, page, layout: { layoutType: 'single' }, bodyText, headings: { fontFamily: DISPLAY, // the designs paint the titles; this keeps Open Sans unloaded levels: [chapter], }, headingStyles: [frontispiece], paragraphStyles, captionStyle, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 45 líneas · content.es.md
title: "Marianela" author: "Benito Pérez Galdós" --- # Las minas de Socartes {style="lamina"} ::resource{id="plano"} # Perdido {ordinal="primero" lead="Se puso el sol. Tras el breve crepúsculo vino tranquila"} y oscura la noche, en cuyo negro seno murieron poco a poco los últimos rumores de la tierra soñolienta, y el viajero siguió adelante en su camino, apresurando su paso a medida que avanzaba la noche. Iba por angosta vereda, de esas que sobre el césped traza el constante pisar de hombres y brutos, y subía sin cansancio por un cerro en cuyas vertientes se alzaban pintorescos grupos de guinderos, hayas y robles. (Ya se ve que estamos en el Norte de España.) Era un hombre de mediana edad, de complexión recia, buena talla, ancho de espaldas, resuelto de ademanes, firme de andadura, basto de facciones, de mirar osado y vivo, ligero a pesar de su regular obesidad, y (dígase de una vez aunque sea prematuro) excelente persona por doquiera que se le mirara. Vestía el traje propio de los señores acomodados que viajan en verano, con el redondo sombrerete, que debe a su fealdad el nombre de hongo, gemelos de campo pendientes de una correa, y grueso bastón que, entre paso y paso, le servía para apalear las zarzas cuando extendían sus ramas llenas de afiladas uñas para atraparle la ropa. Detúvose, y mirando a todo el círculo del horizonte, parecía impaciente y desasosegado. Sin duda no tenía gran confianza en la exactitud de su itinerario y aguardaba el paso de algún aldeano que le diese buenos informes topográficos para llegar pronto y derechamente a su destino. —No puedo equivocarme —murmuró—. Me dijeron que atravesara el río por la pasadera… así lo hice. Después que marchara adelante, siempre adelante. En efecto, allá, detrás de mí queda esa apreciable villa, a quien yo llamaría *Villafangosa* por el buen surtido de lodos que hay en sus calles y caminos… De modo que por aquí, adelante, siempre adelante (me gusta esta frase, y si yo tuviera escudo no le pondría otra divisa) he de llegar a las famosas minas de Socartes [:ref{id="plano" case="lower"}]. Después de andar largo trecho, añadió: —Me he perdido, no hay duda de que me he perdido… Aquí tienes, Teodoro Golfín, el resultado de tu «adelante, siempre adelante». Estos palurdos no conocen el valor de las palabras. O han querido burlarse de ti, o ellos mismos ignoran dónde están las minas de Socartes. Un gran establecimiento minero ha de anunciarse con edificios, chimeneas, ruido de arrastres, resoplido de hornos, relincho de caballos, trepidación de máquinas, y yo no veo, ni huelo, ni oigo nada… Parece que estoy en un desierto… ¡qué soledad! Si yo creyera en brujas, pensaría que mi destino me proporcionaba esta noche el honor de ser presentado a ellas… ¡Demonio!, ¿pero no hay gente en estos lugares?… Aún falta media hora para la salida de la luna. ¡Ah!, bribona, tú tienes la culpa de mi extravío… Si al menos pudiera conocer el sitio donde me encuentro… ¿Pero qué más da? (Al decir esto, hizo un gesto propio del hombre esforzado que desprecia los peligros). Golfín, tú que has dado la vuelta al mundo, ¿te acobardarás ahora?… ¡Ah!, los aldeanos tenían razón: adelante, siempre adelante. La ley universal de la locomoción no puede fallar en este momento. Y puesta denodadamente en ejecución aquella osada ley, recorrió un kilómetro, siguiendo a capricho las veredas que le salían al paso y se cruzaban y se quebraban en ángulos mil, cual si quisiesen engañarle y confundirle más. Por grande que fuera su resolución e intrepidez, al fin tuvo que pararse. Las veredas, que al principio subían, luego empezaron a bajar, enlazándose; y al fin bajaron tanto, que nuestro viajero hallose en un talud, por el cual solo habría podido descender echándose a rodar. —¡Bonita situación! —exclamó sonriendo y buscando en su buen humor lenitivo a la enojosa contrariedad—. ¿En dónde estás, querido Golfín? Esto parece un abismo. ¿Ves algo allá abajo? Nada, absolutamente nada… pero el césped ha desaparecido, el terreno está removido. Todo es aquí pedruscos y tierra sin vegetación, teñida por el óxido de hierro… Sin duda estoy en las minas… pero ni alma viviente, ni chimeneas humeantes, ni ruido, ni un tren que murmure a lo lejos, ni siquiera un perro que ladre… ¿Qué haré?, hay por aquí una vereda que vuelve a subir. ¿Seguirela? ¿Desandaré lo andado?… ¡Retroceder! ¡Qué absurdo! O yo dejo de ser quien soy, o llegaré esta noche a las famosas minas de Socartes y abrazaré a mi querido hermano. Adelante, siempre adelante. Dio un paso y hundiose en la frágil tierra movediza. —¿Esas tenemos, señor planeta?… ¿Conque quiere usted tragarme?… Si ese holgazán satélite quisiera alumbrar un poco, ya nos veríamos las caras usted y yo… Y a fe que por aquí abajo no hemos de ir a ningún paraíso. Parece esto el cráter de un volcán apagado… Hay que andar suavemente por tan delicioso precipicio. ¿Qué es esto? ¡Ah! Una piedra; magnífico asiento para echar un cigarro, esperando a que salga la luna. El discreto Golfín se sentó tranquilamente como podría haberlo hecho en el banco de un paseo; y ya se disponía a fumar, cuando sintió una voz… sí, indudablemente era una voz humana que lejos sonaba, un quejido patético, mejor dicho, melancólico canto, formado de una sola frase, cuya última cadencia se prolongaba apianándose en la forma que los músicos llamaban *morendo*, y que se apagaba al fin en el plácido silencio de la noche, sin que el oído pudiera apreciar su vibración postrera. :::space{lines=2} :::paragraphs{style="asterismo"} \* \* \* ::: :::paragraphs{style="nota"} 1878. Galdós publica *Marianela* en Madrid, en la Imprenta y Litografía de La Guirnalda. Seguimos el comienzo de su capítulo primero en el texto de dominio público de Project Gutenberg (eBook 17340), con la ortografía y la puntuación actuales: raya en los diálogos y comillas latinas para la consigna del viajero, que se vuelven inglesas cuando la cita va dentro de otra: «el resultado de tu “adelante, siempre adelante”». Los corchetes señalan lo que añade el editor. ::: :::paragraphs{style="colofon"} Compuesto en Gentium Book Plus, Libre Bodoni y Marcellus SC (SIL OFL) :::`; // content.es.md, inlined by the Cookbook // #region art: the map of Socartes, in millimetres at its printed size, in the palette function mulberry32(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const n2 = (v) => +v.toFixed(2); // A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed. function smooth(list, close = false) { const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)]; let d = `M${n2(p[1][0])} ${n2(p[1][1])}`; for (let i = 1; i < p.length - 2; i++) { const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]]; d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} ` + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`; } return close ? `${d}Z` : d; } async function labelFace() { // Marcellus SC inside the SVG (gotcha: svg-no-webfonts) const url = 'https://cdn.jsdelivr.net/npm/@fontsource/marcellus-sc@5/files/' + 'marcellus-sc-latin-400-normal.woff2'; const res = await fetch(url); if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`; } function drawMap(face, W, H) { // drawn for W 86 × H 112: the mines above, the town below const rnd = mulberry32(1878); const C = { ground: mix(palette.paper, palette.moss, 0.16), hill: mix(palette.moss, palette.paper, 0.45), wood: palette.moss, shade: mix(palette.moss, palette.ink, 0.35), earth: mix(palette.slag, palette.paper, 0.3), bench: palette.slag, deep: mix(palette.slag, palette.ink, 0.2), pit: mix(palette.slag, palette.ink, 0.45), edge: mix(palette.slag, palette.ink, 0.5), water: palette.water, ink: palette.ink, path: palette.muted, route: palette.oxblood, stream: mix(palette.water, palette.ink, 0.3), // the river's name: 5.7:1 on the ground }; const out = [`<rect width="${W}" height="${H}" fill="${C.ground}"/>`]; const fill = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`); const stroke = (d, color, w, extra = '') => out.push(`<path d="${d}" fill="none" ` + `stroke="${color}" stroke-width="${w}" stroke-linecap="round" ` + `stroke-linejoin="round"${extra}/>`); // The names, set first so that no tree grows over them. const labels = [[33, 30.5, 'minas de', 'end'], [33, 34, 'Socartes', 'end'], [34.4, 19, 'talleres', 'end'], [43.6, 52.4, 'talud', 'end', C.route], [12, 70.5, 'cerro', 'middle'], [25.5, 96.4, 'pasadera'], [70, 94.5, 'río', 'middle', C.stream], [3, 109.6, 'Villafangosa'], [5, 11.4, 'N', 'middle']]; const box = ([x, y, text, anchor = 'start']) => { const w = text.length * 1.45; const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x; return [left - 0.8, y - 2.6, left + w + 0.8, y + 0.8]; }; const clear = (x, y) => labels.map(box).every(([a, b, c, d]) => x < a || x > c || y < b || y > d); // The mines: iron-stained earth cut in benches down to the pit, the "extinct crater". const mine = [[40, 6], [58, 3], [76, 5], [84, 17], [83, 35], [74, 47], [58, 52], [45, 50], [36, 40], [35, 20]]; [[1, C.earth], [0.76, C.bench], [0.52, C.deep], [0.28, C.pit]].forEach(([k, color], i) => { const [cx, cy] = [60 + i * 1.6, 27 - i * 1.4]; // each bench deeper, the pit off-centre const ring = mine.map(([x, y]) => [cx + (x - 60) * k + (rnd() - 0.5) * 2.4 * k, cy + (y - 27) * k + (rnd() - 0.5) * 2.4 * k]); out.push(`<path d="${smooth(ring, true)}" fill="${color}" stroke="${C.edge}" ` + 'stroke-width="0.25"/>'); }); fill('M28.4 11.6h6.5v3.2h-6.5zM33 7h0.9v4.7h-0.9z', C.ink); // workshops, chimney, by the rim // The hill of cherry, beech and oak, and the paths that cross it at a thousand angles. out.push(`<path d="${smooth([[19, 73], [23, 61], [36, 56], [50, 59], [55, 72], [49, 85], [35, 90], [23, 84]], true)}" fill="${C.hill}" stroke="${C.shade}" stroke-width="0.25"/>`); for (let i = 0; i < 100; i++) { const a = rnd() * Math.PI * 2; const r = Math.sqrt(rnd()) * 16; const [x, y] = [37 + Math.cos(a) * r, 73 + Math.sin(a) * r]; const [size, shade] = [0.55 + rnd() * 0.55, rnd() < 0.3 ? C.shade : C.wood]; if (clear(x, y)) { out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(size)}" fill="${shade}"/>`); } } [[[22, 80], [30, 74], [28, 64], [38, 58]], [[26, 62], [36, 70], [46, 64], [52, 70]], [[27, 87], [33, 79], [42, 82], [50, 76]], [[38, 57], [40, 67], [33, 76], [37, 89]]] .forEach((path) => stroke(smooth(path), C.path, 0.3, ' stroke-dasharray="1 0.8"')); // The river, the footbridge, and the town the traveller left behind. stroke(smooth([[0, 92], [12, 93], [22, 99], [34, 100], [50, 96], [66, 98], [W + 1, 102]]), C.water, 2.4); stroke('M20.4 95.2L18.6 101.2', C.ink, 0.7); for (let i = 0; i < 18; i++) { const [x, y] = [3 + (i % 6) * 2.2 + rnd() * 0.5, 98.6 + Math.floor(i / 6) * 2.3 + rnd() * 0.5]; fill(`M${n2(x)} ${n2(y)}h1.5v1.2h-1.5z`, C.ink); } fill('M5.2 94.4h1v2.4h-1zM4 96.2h3.4v2h-3.4z', C.ink); // the church // Golfín's walk: over the footbridge, up the hill by its paths, down to the edge. stroke(smooth([[12, 101], [17, 99.4], [21, 94], [25, 88], [31, 84], [27, 78], [33, 72], [40, 75], [45, 68], [40, 62], [45.3, 53.6]]), C.route, 0.75, ' stroke-dasharray="0.05 1.3"'); out.push(`<circle cx="46.4" cy="51.6" r="1.4" fill="none" stroke="${C.route}" ` + 'stroke-width="0.6"/>'); // North, up: a half-inked arrowhead over an N. fill('M5 3.2L6.6 8.2L5 7.2Z', C.ink); out.push(`<path d="M5 3.2L3.4 8.2L5 7.2Z" fill="none" stroke="${C.ink}" stroke-width="0.2"/>`); labels.forEach(([x, y, text, anchor = 'start', color = C.ink]) => out.push(`<text x="${x}" ` + `y="${y}" text-anchor="${anchor}" fill="${color}">${text}</text>`)); out.push(`<rect x="0.2" y="0.2" width="${W - 0.4}" height="${H - 0.4}" fill="none" ` + `stroke="${C.ink}" stroke-width="0.4"/>`); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" ` + `viewBox="0 0 ${W} ${H}"><style>${face}text{font-family:L;font-size:2.35px}</style>` + `${out.join('')}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Gentium Book Plus': ['400', '400i', '700'], // text, captions (700: 'Figura 1.'), the note 'Libre Bodoni': ['400', '400i', '700'], // asterisks, the chapter's title, the initial 'Marcellus SC': ['400'], // chapter label, plate name, running heads }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('plano.svg', drawMap(await labelFace(), MEASURE, MAP_H)); // The map is book page 8, a verso, facing chapter I: folios and parity follow the book. const continuation = { pageIndexOffset: 7, pageNumbering: { startAt: 8 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Español justificado en una novela de bolsillo' });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
#Abre cada capítulo en página impar
Con 'odd', delante de un capítulo que fuera a empezar en página par se añade una página en blanco, como en una edición menos apretada. El capítulo I ya empieza en impar, y la lámina conserva su página porque su estilo fija su propio salto.
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },#Numera las figuras por capítulo
Si se dejan los tipos en español tal como vienen, las figuras se numeran dentro de cada título de primer nivel: el plano pasa a ser la Figura 1.1, y la referencia, fig. 1.1.
-const resourceTypes = defaultResourceTypes(LOCALE)
- .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);Errores frecuentes
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
Traduce Figura y Tabla con defaultResourceTypes(locale)
El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →
Error frecuente
El texto de diseño nunca se justifica, así que la entradilla de una capitular va en bandera
En postext 1.4.1, un elemento de texto de diseño se alinea a la izquierda, al centro o a la derecha y corta palabra a palabra: no hay alineación justificada, y hyphenate: true solo parte una palabra que no cabe en una línea entera. Las líneas de una entradilla junto a una dropCap acaban, por tanto, en bandera al lado del texto justificado. Limita la entradilla a las líneas que acompañan a la capitular y ajústalas a mano: con lines: 1 (una capitular alzada) la entradilla es una sola línea, que el gap de la dropCap puede dejar a ras; el párrafo sigue en el Markdown. Capitulares en las aperturas →
Error frecuente
La caja de una capitular puede dejar una línea en blanco bajo la entradilla
En postext 1.4.1, una apertura reserva la altura de la caja de cada elemento, y la caja de una capitular dropCap mide 1,2 em de alto, así que baja por debajo de la línea de base de la última línea que abarca. Cuando la entradilla no tiene ninguna línea bajo la capitular, ese sobrante redondea la altura reservada a una línea más de la rejilla y el texto empieza tras una línea en blanco. Da al nivel del título un marginBottom de menos un interlineado, que el ajuste a la rejilla resta, y devuélvelo a 0 en los estilos de ese nivel que no dibujen capitular. Capitulares en las aperturas →
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
Valores de atributo: sin { ni }; comillas simples si llevan "
Un valor de atributo termina en la llave de cierre, así que no puede contener { ni }. Un valor que lleve comillas dobles va entre comillas simples; el signo de dólar no da problemas. Atributos de título →
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
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 desbordamiento del texto de diseño es 'ellipsis-end' por defecto
Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. 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
Un estilo de párrafo no tiene color de cursiva
En postext 1.4.1 un estilo de párrafo fija color y boldColor, pero no italicColor: sus cursivas toman bodyText.italicColor. Un estilo atenuado (la letra pequeña, la línea «Fuente:» de una tabla) imprime sus títulos en cursiva más oscuros que el texto que los rodea. Deja esos estilos en el color del texto o evita en ellos las cursivas. Estilos de párrafo →
Error frecuente
Un estilo de título hereda el salto de página de su nivel
Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }. Estilos de título →
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
Entrecomilla cada valor del frontmatter
YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →
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 →
- Con una caída de nueve líneas en lugar de ocho, la página 11 se quedaba una línea corta, porque
avoidWidowsno deja que un párrafo empiece en la última línea de la página. Después de cualquier cambio en los ajustes o en el texto, revisa el pie de todas las páginas. - El atributo
leadse corta a media frase (…vino tranquila) y el párrafo del Markdown lo retoma (y oscura la noche), porque el atributo guarda solo las palabras que caben junto a la capitular. Con otra letra, otro cuerpo, otra medida u otroINITIAL_GAP, la línea deja de acabar a ras: pasa palabras de uno a otro hasta que vuelva a hacerlo. - Ninguna de estas fuentes trae el asterismo ⁂ (U+2042) en su archivo latin de Fontsource, así que el navegador lo sacaría de una fuente del sistema. En los libros españoles, además, lo habitual son tres asteriscos espaciados.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Marianela (1878), comienzo del capítulo I, «Perdido», con la ortografía y la puntuación actuales · Benito Pérez Galdós · dominio público
- La nota a la edición y el pie del plano · Postext Cookbook · original
- Imágenes
- El plano conjetural de las minas de Socartes, dibujado en código con la paleta de la página · Ignacio Ferro · MIT
- Fuentes
- Gentium Book Plus (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Marcellus SC (SIL OFL 1.1)
- Código
- MIT, como Postext


