What you'll build
The opening of Galdós’s Marianela (1878) in an 11.5 × 18 cm pocket edition, with a conjectural map of the Socartes mines facing chapter I. Above the chapter’s italic Libre Bodoni title, capítulo primero is spelled out in small capitals; the text, in Gentium Book Plus, starts a quarter of the way down with a raised oxblood initial. Words break at Spanish syllables (hie-rro, esta-blecimiento) and word spaces stay under 1.7 times their normal width; no paragraph ends on the tail of a hyphenated word. Dialogue is set with rayas, the em dashes that also open and close the narrator’s incisos (asides). The traveller’s watchword is in «comillas latinas», and in English double quotes where the edition’s note quotes it inside a quotation. The map is Figura 1, cited in the text as [fig. 1]. Running heads in Marcellus SC name the author on versos and the title on rectos.
This recipe answers
- How do I get good justification and hyphenation for Spanish, French or German text?
- How do I avoid widows, orphans and one-word last lines (runts), and keep a heading with its text?
- How do I write dialogue dashes, years at a paragraph start, prices and literal symbols without Markdown misreading them?
- How do I get "Figure" and "Table" labels in my document's language?
- How do I add an author line, a standfirst or a lead with a drop cap to an opener?
The short answer
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' }));
Spanish syllables, word spaces under 1.7×, captions that say Figura
Ingredients
- Features
- Hyphenation and document languageOptimal line breaking (Knuth–Plass)Drop caps in openersDesigned openersHeading attributesText, rules and boxes in page designsWidows, orphans and runtsMirrored marginsChapters that open on a rectoHeads by page roleRunning heads and foliosCitations that place figuresNumbered captionsFigure and Table in your languageFigures exactly hereHeading stylesExplicit vertical spaceParagraph stylesEscapes and literal charactersPaper colour
- Type
- Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1)
- Assets
- The conjectural map of the Socartes mines, drawn in code in the page’s palette (Ignacio Ferro, MIT)
Method
#1 · Spanish syllables, spaces held in
The code is the short answer above. locale: 'es' picks the Spanish hyphenation patterns, and only that exact code does: with 'es-ES' these pages break cabal-los, hi-erro and establec-imiento by the American rules, and one line’s spaces open to 2.13× (supported locales). Justification, Knuth–Plass and the widow and orphan rules are on by default. The 86 mm measure also needs its spaces held in: at the default maxWordSpacing: 2, eight lines open past 1.6× and the widest reaches 1.89×; at 1.7 the widest is 1.65×, because the breaker hyphenates hie-rro and ca-lles instead (word spacing bounds). Past the limit a line costs far more but stays legal, so on a narrower measure with no better break it can still go over. runtMinCharacters: 26 catches the hyphenated scraps that the default of 20 space widths lets through: siem- / pre adelante. becomes Adelante, / siempre adelante., and So- / cartes [fig. 1]. becomes de / Socartes [fig. 1]. (runts).
#2 · A pocket page of 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
};
The bottom margin is computed from the trim, the top margin and LINES, so the text block holds exactly 30 lines of 13.8 pt and every full page ends on the same baseline, as pages 10 and 11 do. With mirror: true the 15 mm inner margin stays at the spine and the 14 mm outer one at the fore-edge on both pages of a spread (mirrored margins), and the 86 mm measure holds about 57 characters of Gentium Book Plus at 10 pt.
#3 · The chapter in words and 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 };
Numbering templates print only numerals (1, 01, I, i, A, a), never words, so capítulo primero comes from the heading’s own ordinal attribute (heading attributes). Postext draws an initial only in design text, which is set ragged, and only when that text has overflow: 'wrap'. To hide the ragged edge, the initial gets a single line: lines: 1 raises it beside the one line held in the lead attribute, and INITIAL_GAP was adjusted until that line ends flush, 0.1 mm short of the measure. The paragraph carries on in the Markdown with no indent (text elements). The chapter level’s marginBottom replaces the default margin, which would add a blank line; its value, -LEAD, also takes back the extra line that the initial’s 1.2 em font box adds to the opener’s height.

#4 · The map faces the chapter as 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' sets the map where ::resource{id="plano"} stands, under a heading of its own. That heading makes book page 8 an opener, and the running heads skip openers. Its style empties the footer and sets its own break, which keeps the plate opposite chapter I, with no head or folio, even when chapters open on rectos (heading styles). The Spanish types of the short answer print Figura 1. under the map (with no resourceTypes it reads Figure 1.1, whatever the locale) and Fig. 1 in the text, which case="lower" in the Markdown’s :ref turns into fig. 1. The reference sits in square brackets, which Spanish editions use to mark what the editor adds to the author’s text.
#5 · The author on the verso, the title on the recto
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' puts the author on versos and 'odd' the title on rectos, and pages: 'body' keeps both off the opener, where the only furniture is the drop folio set with pages: 'opener'. Each head is anchored to the page and moved by SHIFT onto the centre of the text block, which sits half a millimetre off the page’s centre because the inner and outer margins differ. The drop folio comes in two parities for the same reason. The folios are set in Gentium, because Marcellus SC draws 1 and 0 like I and O.
#6 · Spanish punctuation in the 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) },
];
Page 12, the last, ends with three paragraph styles. The asterisks, in oxblood Bodoni, are centred after a :::space{lines=2}, since blank lines add no space. The edition’s note is 8.3 pt on 11 pt leading, and the colophon is 7.5 pt in the grey of the running heads. The note stays in ink: a paragraph style has no italic colour, so the italic Marianela in a grey note would print darker than the words around it. In the Markdown, dialogue opens with a raya (—), never a hyphen, because a hyphen and a space at the start of a line open a list. 1878. at the start of the note would open a numbered list, so a word joiner (U+2060) goes before the year. The asterisks are escaped (\*); unescaped, the first would open a bullet list and the other two an italic run, and the line would vanish.
The whole recipe
// ═══ 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`---Markdown sample · 45 lines · 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: the same in every recipe · 270 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗
Variations
#Open every chapter on a recto
With 'odd', a chapter that would start on a verso gets a blank page before it, as in a roomier edition. Chapter I already opens on a recto, and the plate keeps its page because its style sets its own break.
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },#Number the figures by chapter
Left as they come, the built-in Spanish types number figures within each first-level heading: the map becomes Figura 1.1 and the reference fig. 1.1.
-const resourceTypes = defaultResourceTypes(LOCALE)
- .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);Pitfalls
Pitfall
Only 8 locales hyphenate, by exact code
Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English. Hyphenation and document language →
Pitfall
Localise Figure/Table with defaultResourceTypes(locale)
The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes. Figure and Table in your language →
Pitfall
Design text is never justified, so a drop-cap lead is ragged
In postext 1.4.1 a design text element aligns left, centre or right and wraps word by word: there is no justified alignment, and hyphenate: true only splits a word too long for a whole line. The lines of a lead set beside a dropCap therefore end ragged next to justified body text. Keep the lead to the lines beside the initial and fit them by hand: with lines: 1 (a raised initial) the lead is one line, which the dropCap gap can fit flush; the paragraph goes on in the Markdown. Drop caps in openers →
Pitfall
A drop cap's font box can add a blank line under the lead
In postext 1.4.1 an opener reserves the height of every element's box, and a dropCap initial's box is 1.2 em tall, so it reaches below the baseline of the last line it spans. When the lead has no line under the initial, that overhang rounds the reserved height up to one more grid line, and the body starts after a blank line. Give the heading level marginBottom: minus one lead, which the grid snap subtracts, and reset it to 0 in any heading style of that level that draws no initial. Drop caps in openers →
Pitfall
'1998. ' or '- ' at a paragraph start opens a list
A paragraph that starts with a number, a period and a space, or with a hyphen and a space, becomes a list item. Put a word joiner (U+2060) before the number, and write dialogue with an em dash. Escapes and literal characters →
Pitfall
Attribute values: no { or }; single-quote a value with "
An attribute value ends at the closing brace, so it cannot hold { or }. A value that contains a double quote goes in single quotes; a dollar sign is fine. Heading attributes →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
A design text's lineHeight is a multiple, never a dimension
In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →
Pitfall
Design text overflow defaults to 'ellipsis-end'
A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines. Text, rules and boxes in page designs →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
A paragraph style has no italic colour
In postext 1.4.1 a paragraph style sets color and boldColor but no italicColor: its italic runs take bodyText.italicColor. A muted style (small print, a source line) prints its italic titles darker than the words around them. Keep such styles in the body's ink, or avoid italics in them. Paragraph styles →
Pitfall
A heading style inherits its level's page break
A headingStyles entry takes every field it leaves out from its heading level, breakBefore included. A contents page or a colophon styled on an H1 after a :::pagebreak inherits parity 'odd' and lands behind a blank page. Give such a style breakBefore: { enabled: false }. Heading styles →
Pitfall
Text inside an SVG <img> cannot use web fonts
An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →
Pitfall
Quote every frontmatter value
YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984". Document metadata →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
- A sink of nine lines instead of eight left page 11 one line short, because
avoidWidowswould not start a paragraph on the last line of the page. After any change to the settings or the text, check the foot of every page. - The
leadattribute stops mid-sentence (…vino tranquila) where the Markdown paragraph picks up (y oscura la noche), because it holds exactly the words that fit beside the initial. Another face, size, measure orINITIAL_GAPbreaks the flush fit: move words between the two until the line ends flush again. - None of these faces has the asterism ⁂ (U+2042) in its Fontsource latin file, so the browser would take it from a system font. Spanish books mark the break with three spaced asterisks in any case.
Credits
- Recipe
- Ignacio Ferro
- Text
- Marianela (1878), the opening of chapter I, “Perdido”, with today’s spelling and punctuation · Benito Pérez Galdós · public domain
- The edition’s note and the map’s caption · Postext Cookbook · original
- Images
- The conjectural map of the Socartes mines, drawn in code in the page’s palette · Ignacio Ferro · MIT
- Fonts
- Gentium Book Plus (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Marcellus SC (SIL OFL 1.1)


