What you'll build
Five pages of text from near the end of Dom Casmurro, Machado de Assis’s novel of 1899, set in the original Portuguese as a 110 × 178 mm pocket book in an invented series, Coleção Casuarina. A cover in plum, cream and plum bands carries the title in Abril Fatface and the series’ saffron roundel, and a plate of the heavy sea off Flamengo faces page 213. The text runs from the end of chapter CXVIII to the end of CXXIII, and no chapter opens a page. Each starts two lines below the end of the one before (three at CXIX, to fill page 213), under a plum roman numeral, its title in tracked capitals and a short saffron rule. Page 214 has two chapter heads, 215 and 216 one each. Every page of text but the last ends on its 33rd line, and none opens on a single line.
This recipe answers
- How do I avoid widows, orphans and one-word last lines (runts), and keep a heading with its text?
- How do I get flush column bottoms and a balanced last page (vertical justification)?
- How do I number headings (1, 1.1, 1.1.1) and style each level differently?
- How do I set running heads: book title on the left page, chapter title on the right, page number outside?
The short answer
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
{ kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
lineHeight: 1, color: col('plum'), align: 'center',
placement: { ...at('container', 'top'), size: { width: 'fill' } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
Chapters that run on, each under a centred head drawn in the column
Ingredients
- Features
- Widows, orphans and runtsDesigned openersChapters that open on a rectoNumbered headingsColumn balancingHyphenation and document languageIndents, alignment and paragraph spacingRunning heads and foliosHeads by page roleHeading stylesUnnumbered chaptersCovers, title pages and colophonsPage and column breaksPictures in page designsText, rules and boxes in page designsHeading attributesMirrored marginsFloated boxesDocument metadata
- Also uses
- Box icons and corner badgesCallout boxesPaper colourFigures and tables as resourcesRoman front matterLine breaks in titles
- Type
- Tinos (Apache 2.0) · Abril Fatface, League Spartan (SIL OFL 1.1)
- Assets
- The plate of the sea off Flamengo under the Sugarloaf, and the casuarina roundel, drawn in code in the book’s palette (Ignacio Ferro, MIT)
Method
#1 · Run the chapters on, each head kept with its text
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
{ kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
lineHeight: 1, color: col('plum'), align: 'center',
placement: { ...at('container', 'top'), size: { width: 'fill' } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
The chapter level has its break turned off, and marginTop sets each head two lines under the last line of the chapter before. Its design has no span, so the head is drawn in the column, in a block three lines deep, and the text under it stays on the grid (break before, span and advanced design). The keep rules are left at their defaults. The four-line paragraphs under CXIX and CXXI each leave two lines at the foot of their page and carry two over; with avoidOrphans: false they would split three and one, and pages 214 and 215 would open on a single line (orphans, widows, runts). Column balancing adds a third line of space above CXIX, so that page 213 ends on line 33 (column balancing).

#2 · Set a page of 33 lines, with Portuguese hyphenation
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
color: col('ink'), referenceColor: col('ink'), // for a :ref added later: main-color does
// not reach it in 1.4.1, and it would print blue
firstLineIndent: mm(5), // every paragraph indented, the first after a head too
maxRuntTracking: 0 }; // 1.4.1 measures a runt fix's tracking but never paints it
// (gotcha: runt-tracking-unpainted); the fix keeps its word spacing
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
backgroundColor: col('paper'),
margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese patterns, by their exact code (gotcha: hyphenation-locales)
The bottom margin is 16.3 mm, what the 178 mm height leaves after the 15 mm top margin and 33 lines of 12.6 pt, so every full page ends on the same line. Tinos, which has the widths of Times New Roman, sets about 59 characters a line on the 84 mm measure at 9.5 pt. locale: 'pt' hyphenates with the Portuguese patterns: at-tracção, af-fluencia, genealogica-mente (supported locales). Every paragraph is indented, the first after a head too, as Portuguese and Brazilian editions usually are, so indentAfterHeading keeps its default.
#3 · Name the book over the verso and the chapter over the recto
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
pages: 'body', // never on the cover or the plate, which are opener pages
...caps(7.5, TRACK), color: col('muted'), align: 'center',
placement: at('page', 'top', x + (TRACK / 2) * PT, HEAD_Y) }); // tracking, as in the answer
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
{ ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };
{title} prints the title in the frontmatter, and {chapterTitle} the last chapter that starts on or before the page, so page 217 names CXXIII, which began on page 216 (text elements). Page 213 names CXIX, which begins at its foot, though its first 25 lines close CXVIII: 1.4.1 has no placeholder for the chapter a page opens in. SHIFT moves the heads onto the centre of the text block, 1 mm off the page’s centre because the inner margin is 2 mm wider than the outer one.
#4 · Draw the cover and the plate as headings
// span: 'page', in one column too, paints their art whole and keeps the \\ in the title
// (gotcha: opener-clipped-at-top). A :::pagebreak follows each in the text (gotcha:
// cover-pagebreak): 1.4.1 drops the cover's reserved room, since its foot band runs past the
// column (gotcha: opener-taller-than-column), and the plate reserves room down to its caption
// only, since pictures do not count (gotcha: opener-image-no-reserve).
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
{ kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
align: 'center', placement: at('page', 'top', 1.2 * PT, 44) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
placement: onPage(72, { width: 'fill' }) },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
{ kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
{ kind: 'image', id: 'roundel', resourceId: 'roundel',
placement: onPage(133, { width: mm(20) }) },
{ kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
color: col('saffron'), align: 'center', placement: at('page', 'top', 0.9 * PT, 159) },
] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'sea', resourceId: 'sea',
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
{ kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
align: 'center', placement: at('page', 'top', 0.8 * PT, 146) },
{ kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
placement: onPage(152, { width: mm(78) }) },
] } } };
const resources = [
{ id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'roundel.svg', width: 60, height: 60 },
altText: 'The series mark: a casuarina tree in a ring.' },
{ id: 'sea', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'sea.svg', width: TRIM.width, height: TRIM.height },
altText: 'A heavy morning sea under the Sugarloaf, with two canoes rowing out.' },
];
Both are heading styles with numbered: false, so the first numbered chapter is still CXIX. span: 'page' makes each an opener page. Without it the cover’s bands stop at the top and foot of the text block, the running head and folio 1 print on the cover, and the title sets on one line. The plate’s caption comes from its heading: CAPÍTULO CXXIII is the heading’s text, and the italic quotation is its quote attribute (heading attributes). The :::pagebreak after each keeps the text off the art, since 1.4.1 reserves no room for the cover’s foot band or the plate’s picture.
#5 · Tell the excerpt where it stands in the book
const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } };
continuation.headings gives the heading counters the excerpt starts from. With h1: 118 the first numbered heading is chapter CXIX; without it the five chapters are numbered I to V. numberingTemplate: '{1:I}' in the chapter level sets those numbers in roman numerals, and the design prints them with {number}. :::numbering{startAt=212}, after the cover’s page break, numbers the plate 212 and the text pages 213 to 217 (numbering).
The whole recipe
// ═══ Postext Cookbook · Nº 065 · Pocket classic: short chapters that run on ═══════════ // https://postext.dev/en/cookbook/short-chapters-run-on // Code: MIT · Text: Machado de Assis, Dom Casmurro, 1899 (PD, Gutenberg #55752) · Art: in code // Fonts: Tinos (Apache 2.0), Abril Fatface, League Spartan (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the viewer's title; the sample is Portuguese const RECIPE = 'short-chapters-run-on'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#24202a', // the text: a violet near-black paper: '#f6f1e6', // the pocket book's paper plum: '#4f2a49', // the series colour: cover bands, the plate, the chapter numerals saffron: '#d9a03c', // the second ink: rules and drawings, never text on paper (2.1:1) muted: '#6b616e', // the running heads and the colophon (5.2:1 on paper) }; // Design slots read the hex, not the id, in 1.4.1 (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' } })), // The default bold and italic colours link to main-color, set here to the ink, not blue. { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; const [TEXT, DISPLAY, LABEL] = ['Tinos', 'Abril Fatface', 'League Spartan']; const TRIM = { width: 110, height: 178 }; // mm: a pocket book const [TOP, INNER, OUTER] = [15, 14, 12]; // mm; mirrored, so the inner margin is at the spine const [BODY, LEAD, LINES] = [9.5, 12.6, 33]; // pt, pt, and the lines of a full page const PT = 25.4 / 72; // mm per point const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const caps = (size, track) => ({ fontFamily: LABEL, fontWeight: 600, fontSize: pt(size), letterSpacing: pt(track), textTransform: 'uppercase' }); // #region answer: chapters that run on, each under a centred head drawn in the column // Machado's chapters run a page or two, so none opens a page. The level's break is off // (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1 // default would open each chapter on a recto) and a chapter starts two lines under the last. const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL), lineHeight: 1, color: col('plum'), align: 'center', placement: { ...at('container', 'top'), size: { width: 'fill' } } }, { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'), align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…' placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2 { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'), placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } }, ] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX… fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead }; // The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph // at the foot of a page, and headings.keepWithNext takes the head along when the paragraph // moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a // last line shorter than about 20 characters as a fault. Column balancing adds lines above // a head so that the page ends on line 33. // #endregion // #region text: a pocket page of 33 lines, in Portuguese const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), referenceColor: col('ink'), // for a :ref added later: main-color does // not reach it in 1.4.1, and it would print blue firstLineIndent: mm(5), // every paragraph indented, the first after a head too maxRuntTracking: 0 }; // 1.4.1 measures a runt fix's tracking but never paints it // (gotcha: runt-tracking-unpainted); the fix keeps its word spacing const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm left: mm(INNER), right: mm(OUTER), mirror: true } }; const LOCALE = 'pt'; // the Portuguese patterns, by their exact code (gotcha: hyphenation-locales) // #endregion // #region heads: the book's title over the verso, the chapter over the recto, folios at the foot const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity, pages: 'body', // never on the cover or the plate, which are opener pages ...caps(7.5, TRACK), color: col('muted'), align: 'center', placement: at('page', 'top', x + (TRACK / 2) * PT, HEAD_Y) }); // tracking, as in the answer const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity, pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) }); const header = { elements: [ head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it ] }; const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER), { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] }; // #endregion // #region cover: the cover and the plate, heading styles that fill a page each // span: 'page', in one column too, paints their art whole and keeps the \\ in the title // (gotcha: opener-clipped-at-top). A :::pagebreak follows each in the text (gotcha: // cover-pagebreak): 1.4.1 drops the cover's reserved room, since its foot band runs past the // column (gotcha: opener-taller-than-column), and the plate reserves room down to its caption // only, since pictures do not count (gotcha: opener-image-no-reserve). const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) }); const cover = { id: 'cover', numbered: false, span: 'page', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') }, placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } }, { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'), align: 'center', placement: at('page', 'top', 1.2 * PT, 44) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44), lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap', placement: onPage(72, { width: 'fill' }) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5), color: col('saffron'), placement: onPage(111, { width: mm(12) }) }, { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') }, placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } }, { kind: 'image', id: 'roundel', resourceId: 'roundel', placement: onPage(133, { width: mm(20) }) }, { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8), color: col('saffron'), align: 'center', placement: at('page', 'top', 0.9 * PT, 159) }, ] } } }; // The plate faces the first page of text: the morning sea off Flamengo, captioned with the // line of chapter CXXIII it illustrates, which the heading carries in its quote attribute. const plate = { id: 'plate', numbered: false, span: 'page', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'sea', resourceId: 'sea', placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } }, { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'), align: 'center', placement: at('page', 'top', 0.8 * PT, 146) }, { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true, fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap', placement: onPage(152, { width: mm(78) }) }, ] } } }; const resources = [ { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'roundel.svg', width: 60, height: 60 }, altText: 'The series mark: a casuarina tree in a ring.' }, { id: 'sea', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'sea.svg', width: TRIM.width, height: TRIM.height }, altText: 'A heavy morning sea under the Sugarloaf, with two canoes rowing out.' }, ]; // #endregion // The colophon floats to the foot of the last page, beside the series roundel. const colophon = { id: 'colophon', placement: 'bottom', backgroundEnabled: false, padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginBottom: pt(0), icon: { kind: 'resource', resourceId: 'roundel', size: mm(10) }, titleStyle: { gap: mm(3) }, body: { fontFamily: TEXT, fontSize: pt(7.6), lineHeight: pt(LEAD * 0.8), color: col('muted'), italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0) } }; const config = () => ({ // a factory: the engine caches resolved configs per object locale: LOCALE, colorPalette, page, layout: { layoutType: 'single' }, bodyText, // The heading's own line is hidden under its design but still measured, in this face. headings: { fontFamily: LABEL, fontWeight: 600, levels: [chapters] }, headingStyles: [cover, plate], calloutStyles: [colophon], header, footer, }); // #region art: the series roundel and the plate, drawn in code in the book's two inks let seed = 1871; // Mulberry32, seeded: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const n = (v) => v.toFixed(2); const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" ` + `height="${h}mm" viewBox="0 0 ${w} ${h}">${body}</svg>`; const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${n(width)}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; // The casuarina of Bento's garden (chapter II): a leaning trunk, and branches that arch out // on alternate sides and let their needles hang. function roundelSvg() { const { plum, saffron } = palette; const out = [`<circle cx="30" cy="30" r="29" fill="${saffron}"/>`, line('M30 30m-25.5 0a25.5 25.5 0 1 0 51 0a25.5 25.5 0 1 0 -51 0', plum, 0.9), line('M28.6 50Q30.6 33 30.2 10.5', plum, 1.6), line('M18.5 50.2H41.5', plum, 1.3)]; for (let k = 0; k < 9; k++) { const side = k % 2 ? 1 : -1; const y0 = 12.5 + k * 3.7; const reach = (3 + k * 1.55) * (0.8 + rand() * 0.35); const x1 = 30.2 + side * reach; const y1 = y0 + 1.2 + rand() * 1.4; out.push(line(`M30.2 ${n(y0)}Q${n(30.2 + side * reach * 0.5)} ${n(y0 - 2.2)} ${n(x1)} ${n(y1)}`, plum, 0.95)); const strands = 3 + Math.round(reach / 1.6); for (let j = 1; j <= strands; j++) { // needles hang from the arch const u = j / (strands + 0.5); const x = 30.2 + side * reach * u; const y = y0 + (y1 - y0) * u ** 2 - 2.2 * 2 * u * (1 - u) + 0.3; out.push(line(`M${n(x)} ${n(y)}q${n(side * 0.4)} ${n(2)} ${n(side * 0.1)} ` + `${n(3 + rand() * 2.4)}`, plum, 0.7)); } } return svg(60, 60, out.join('')); } // The plate: the Sugarloaf and Urca in the haze, the sun low beside them, and the swell in // rows that deepen towards the reader; the nearest one is dark enough to carry the caption. function seaSvg() { const { plum, saffron, paper } = palette; const [W, H, SKY, FOOT] = [TRIM.width, TRIM.height, 76, 136]; // mm: horizon, nearest swell const out = []; for (let i = 0; i < 6; i++) { // the sky in flat bands, warmer towards the horizon out.push(`<rect y="${n(i * 13)}" width="${W}" height="${n(SKY - i * 13)}" ` + `fill="${mix(paper, saffron, 0.12 + i * 0.1)}"/>`); } out.push(`<circle cx="36" cy="${SKY - 8}" r="10" fill="${saffron}"/>`); // behind the hills const hills = `M-2 ${SKY} L6 ${SKY - 4} Q13 ${SKY - 9} 21 ${SKY - 5} L28 ${SKY - 3} ` + `Q40 ${SKY - 8} 50 ${SKY - 4} Q57 ${SKY - 15} 64 ${SKY - 12} Q68 ${SKY - 11} 70 ${SKY - 7} ` + `L73 ${SKY - 9} Q76 ${SKY - 41} 84 ${SKY - 40} Q92 ${SKY - 37} 94 ${SKY - 8} ` + `L104 ${SKY - 3} L112 ${SKY} Z`; // Urca, then the Sugarloaf out.push(`<path d="${hills}" fill="${mix(plum, saffron, 0.3)}"/>`); out.push(`<rect y="${SKY}" width="${W}" height="${H - SKY}" fill="${plum}"/>`); for (let k = 0; k < 5; k++) { // the sun on the water, in broken strokes const half = 7 - k * 1.2; out.push(line(`M${n(36 - half + rand() * 2)} ${n(SKY + 1 + k * 1.6)}h${n(half * 1.6)}`, saffron, 0.8 - k * 0.1)); } // Swell: each row is a filled wave front; later rows overlap the earlier ones. const rows = 14; for (let row = 0; row < rows; row++) { const t = row / (rows - 1); const base = SKY + 3 + (FOOT - SKY - 3) * t ** 1.35; const amp = 0.4 + t * 3.2; const length = 9 + t * 34; const phase = rand() * length; const pts = []; for (let x = -4; x <= W + 4; x += 1.5) { const y = base - amp * Math.sin(((x + phase) / length) * Math.PI * 2) - amp * 0.35 * Math.sin(((x + phase) / (length * 0.47)) * Math.PI * 2); pts.push(`${n(x)} ${n(y)}`); } const last = row === rows - 1; // the nearest swell, dark enough to carry the caption const tone = last ? mix(plum, '#000000', 0.25) : mix(plum, row % 2 ? '#000000' : paper, row % 2 ? 0.04 + t * 0.12 : 0.1 - t * 0.07); out.push(`<path d="M${pts.join(' L')} L${W + 4} ${H} L-4 ${H} Z" fill="${tone}"/>`); if (row % 2 === 0) { // broken foam along every other crest out.push(line(`M${pts.join(' L')}`, mix(plum, paper, 0.45 - t * 0.15), 0.25 + t * 0.3, ` stroke-dasharray="${n(3 + t * 9)} ${n(5 + t * 12)}" opacity="0.8"`)); } } for (const [x, y, s] of [[22, SKY + 11, 0.8], [61, SKY + 19, 1.15]]) { // the canoes const dark = mix(plum, '#000000', 0.55); out.push(`<path d="M${n(x)} ${n(y)}q${n(5 * s)} ${n(2.2 * s)} ${n(10 * s)} 0` + `q${n(-5 * s)} ${n(0.8 * s)} ${n(-10 * s)} 0Z" fill="${dark}"/>`, `<circle cx="${n(x + 4.2 * s)}" cy="${n(y - 2.3 * s)}" r="${n(0.75 * s)}" fill="${dark}"/>`, line(`M${n(x + 4.2 * s)} ${n(y - 1.6 * s)}l${n(0.5 * s)} ${n(1.7 * s)}`, dark, 0.9 * s), line(`M${n(x + 1.5 * s)} ${n(y - 1.2 * s)}l${n(5.5 * s)} ${n(3.4 * s)}`, dark, 0.35 * s)); } return svg(W, H, out.join('')); } // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── // Dom Casmurro in Portuguese, in the first edition's spelling: the cover, the plate, then // pages 213 to 217 (:::numbering in the text), from the end of chapter CXVIII. const markdown = String.raw`---Markdown sample · 63 lines · content.en.md
title: "Dom Casmurro" author: "Machado de Assis" --- # Dom \\ Casmurro {style="cover"} :::pagebreak :::numbering{startAt=212} # Capítulo CXXIII {style="plate" quote="«…grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã.»"} :::pagebreak O retrato de Escobar, que eu tinha alli, ao pé do de minha mãe, falou-me como se fosse a propria pessoa. Combati sinceramente os impulsos que trazia do Flamengo; rejeitei a figura da mulher do meu amigo, e chamei-me desleal. Demais, quem me affirmava que houvesse alguma intenção daquella especie no gesto da despedida e nos anteriores? Tudo podia ligar-se ao interesse da nossa viagem. Sancha e Capitú eram tão amigas que seria um prazer mais para ellas irem juntas. Quando houvesse alguma intenção sexual, quem me provaria que não era mais que uma sensação fulgurante, destinada a morrer com a noite e o somno? Ha remorsos que não nascem de outro peccado, nem tem maior duração. Agarrei-me a esta hypothese que se conciliava com a mão de Sancha, que eu sentia de memoria dentro da minha mão, quente e demorada, apertada e apertando… Sinceramente, eu achava-me mal entre um amigo e a attracção. A timidez póde ser que fosse outra causa daquella crise; não é só o ceu que dá as nossas virtudes, a timidez tambem, não contando o acaso, mas o acaso é um méro accidente; a melhor origem dellas é o ceu. Entretanto, como a timidez vem do ceu, que nos dá a compleição, a virtude, filha della é, genealogicamente, o mesmo sangue celestial. Assim reflectiria, se pudesse; mas a principio vaguei á tôa. Paixão não era nem inclinação. Capricho seria ou quê? Ao fim de vinte minutos era nada, inteiramente nada. O retrato de Escobar pareceu falar-me; vi-lhe a altitude franca e simples, sacudi a cabeça e fui deitar-me. # Não faça isso, querida A leitora, que é minha amiga e abriu este livro com o fim de descançar da cavatina de hontem para a valsa de hoje, quer fechal-o ás pressas, ao ver que beiramos um abysmo. Não faça isso, querida; eu mudo de rumo. # Os autos Na manhã seguinte accordei livre das abominações da vespera; chamei-lhes allucinações, tomei café, percorri os jornaes e fui estudar uns autos. Capitú e prima Justina sairam para a missa das nove, na Lapa. A figura de Sancha desappareceu inteiramente no meio das allegações da parte adversa, que eu ia lendo nos autos, allegações falsas, inadmissiveis, sem apoio na lei nem nas praxes. Vi que era facil ganhar a demanda; consultei Dalloz, Pereira e Souza… Uma só vez olhei para o retrato de Escobar. Era uma bella photographia tirada um anno antes. Estava de pé, sobrecasaca abotoada, a mão esquerda no dorso de uma cadeira, a direita mettida ao peito, o olhar ao longe para a esquerda do espectador. Tinha garbo e naturalidade. A moldura que lhe mandei pôr não encobria a dedicatoria, escripta embaixo, não nas costas do cartão: «Ao meu querido Bentinho o seu querido Escobar 20-4-70.» Estas palavras fortaleceram-me os pensamentos daquella manhã, e espancaram de todo as recordações da vespera. Naquelle tempo a minha vista era boa; eu podia lel-as do logar em que estava. Tornei aos autos. # A catastrophe No melhor delles, ouvi passos precipitados na escada, a campainha soou, soaram palmas, golpes na cancella, vozes, acudiram todos, acudi eu mesmo. Era um escravo da casa de Sancha que me chamava: —Para ir lá… sinhô nadando, sinhô morrendo. Não disse mais nada, ou eu não lhe ouvi o resto. Vesti-me, deixei recado a Capitú e corri ao Flamengo. Em caminho, fui adivinhando a verdade. Escobar metteu-se a nadar, como usava fazer, arriscou-se um pouco mais fóra que de costume, apesar do mar bravio, foi enrolado e morreu. As canoas que acudiram mal puderam trazer-lhe o cadaver. # O enterro A viuva… Poupo-vos as lagrimas da viuva, as minhas, as da outra gente. Sai de lá cerca de onze horas; Capitú e prima Justina esperavam-me, uma com o parecer abatido e estupido, outra enfastiada apenas. —Vão fazer companhia a pobre Sanchinha; eu vou cuidar do enterro. Assim fizemos. Quiz que o enterro fosse pomposo, e a affluencia dos amigos foi numerosa. Praia, ruas, praça da Gloria, tudo eram carros, muitos delles particulares. A casa, não sendo grande, não podiam lá caber todos; muitos estavam na praia, falando do desastre, apontando o logar em que Escobar fallecèra, ouvindo referir a chegada do morto. José Dias ouviu tambem falar dos negocios do finado, divergindo alguns na avaliação dos bens, mas havendo accordo em que o passivo devia ser pequeno. Elogiavam as qualidades de Escobar. Um ou outro discutia o recente gabinete Rio Branco; estavamos em Março de 1871. Nunca me esqueceu o mez nem o anno. Como eu houvesse resolvido falar no cemiterio, escrevi algumas linhas e mostrei-as em casa a José Dias, que as achou realmente dignas do morto e de mim. Pediu-me o papel, recitou lentamente o discurso, pesando as palavras, e confirmou a primeira opinião; no Flamengo espalhou a noticia. Alguns conhecidos vieram interrogar-me: —Então, vamos ouvil-o? —Quatro palavras. Poucas mais seriam. Tinha-as escripto com receio de que a emoção me impedisse de improvisar. No tilbury em que andei uma ou duas horas, não fizera mais que recordar o tempo do seminario, as relações de Escobar, as nossas sympathias, a nossa amizade, começada, continuada e nunca interrompida, até que um lance da fortuna fez separar para sempre duas creaturas que promettiam ficar por muito tempo unidas. De quando em quando enxugava os olhos. O cocheiro aventurou duas ou tres perguntas sobre a minha situação moral; não me arrancando nada, continuou o seu officio. Chegando a casa, deitei aquellas emoções ao papel; tal seria o discurso. # Olhos de ressaca Emfim, chegou a hora da encommendação e da partida. Sancha quiz despedir-se do marido, e o desespero daquelle lance consternou a todos. Muitos homens choravam tambem, as mulheres todas. Só Capitú, amparando a viuva, parecia vencer-se a si mesma. Consolava a outra, queria arrancal-a dalli. A confusão era geral. No meio della, Capitú olhou alguns instantes para o cadaver tão fixa, tão apaixonadamente fixa, que não admira lhe saltassem algumas lagrimas poucas e caladas… As minhas cessaram logo. Fiquei a ver as della; Capitú enxugou-as depressa, olhando a furto para a gente que estava na sala. Redobrou de caricias para a amiga, e quiz leval-a; mas o cadaver parece que a retinha tambem. Momento houve em que os olhos de Capitú fitaram o defuncto, quaes os da viuva, sem o pranto nem palavras desta, mas grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã. :::callout{type="colophon"} Do fim do capítulo CXVIII ao fim do CXXIII de *Dom Casmurro* (1899), com a ortografia da edição Garnier transcrita pelo Project Gutenberg (n.º 55752); corrigiu-se um erro de transcrição no capítulo CXXIII. Ilustração e marca da coleção desenhadas para esta edição. Composto em Tinos (Apache 2.0), Abril Fatface e League Spartan (SIL OFL). :::`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Tinos: ['400', '400i'], // text, colophon; italic: the plate's quote, the colophon's title 'Abril Fatface': ['400'], // chapter numerals and the cover title 'League Spartan': ['600'], // chapter titles, running heads, folios, the cover's capitals }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('roundel.svg', roundelSvg()); await loadSvg('sea.svg', seaSvg()); // #region excerpt: these pages continue a book: chapter CXVIII is under way, the next is CXIX const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // #endregion const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Dom Casmurro, a pocket edition', es: 'Dom Casmurro, edición 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 new page
With a break before each chapter the five pages of text become seven: page 214 holds only CXIX, its head and four lines, and page 213, where no chapter starts, takes the plate’s title, CAPÍTULO CXXIII, as its running head.
- breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
+ breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(2 * LEAD),
+ advancedDesign: chapterHead };#Put the chapter number over the recto
{chapterNumber} prints the numeral of the same chapter the title comes from: CXIX · NÃO FAÇA ISSO, QUERIDA.
- head('recto-chapter', '{chapterTitle}', 'odd', SHIFT),
+ head('recto-chapter', '{chapterNumber} · {chapterTitle}', 'odd', SHIFT),Pitfalls
Pitfall
avoidWidows guards the foot of a column, avoidOrphans its head
Postext names the two lone lines its own way: avoidWidows (widowMinLines, widowPenalty) keeps a paragraph's first line from standing alone at the foot of a column, and avoidOrphans (orphanMinLines, orphanPenalty) keeps its last line from standing alone at the head of the next. Many style manuals give the two names the other way round, so pick the setting by where it acts. Both are on by default and work as penalties: the layout weighs each one against the empty lines that obeying it would leave. Widows, orphans and runts →
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
{number}/{chapterNumber} print the H1 number; {numberRoman} is parts-only
{number} and {chapterNumber} print the heading's formatted number, but {numberRoman}, {numberDecimal} and the other numeric variants are filled only on part pages. Format a chapter number in its numberingTemplate ({1:I}) or pass it as an attribute. Numbered headings →
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
A runt fix can tighten tracking that is never painted
In postext 1.4.1, when a paragraph ends on a runt, the layout sets it one line shorter: first with tighter word spacing, then with up to maxRuntTracking thousandths of an em of negative tracking. The canvas and PDF renderers paint tracking only above zero, so a tracked paragraph prints untracked: its justified lines lose the difference from their word spaces and look crushed, and its last line can run past the measure and be clipped at the column edge. Set bodyText.maxRuntTracking: 0, which keeps the word-spacing fix, and reword any runt that comes back. Widows, orphans and runts →
Pitfall
Put :::pagebreak after a full-page cover
In a multi-column layout a full-page opener such as a cover lets the next block start in column 2 of the same page, on top of the art. A :::pagebreak right after the cover heading ends the page and leaves no blank one. Covers, title pages and colophons →
Pitfall
An opener taller than its column loses its whole reservation
In postext 1.4.1, when the height an advanced-design heading reserves (its minHeight, or its lowest design element) is taller than the column it opens, the heading keeps only the height of its own title line, with no warning, and the text runs over the design. A full-page plate needs a column as tall as the page: give its heading style zero margins, then set minHeight to the page's height. Designed openers →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
An opener kept in its column is cut off at the top of the text block
In postext 1.4.1 an advanced-design heading that stays in its column is clipped at the column's top edge: a box or a picture anchored to the page or the bleed paints into the side margins but not into the top margin, and no warning says so. Give such a heading span: 'page', even in a single-column book: its design is then painted as the page's opener band, whole. Designed openers →
Pitfall
:::numbering takes effect at the next page
:::numbering changes the count from the next page that starts, not the current one. Put it right after a :::pagebreak (parity odd before chapter one) so the new numbering begins where you mean. Roman front matter →
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
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
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 →
- Raising
widowMinLinesto 3 in 1.4.1 lets CXIX and CXXI keep three lines under them at the foot, and their four-line paragraphs then split three and one: a single line opens pages 214 and 215, whateverorphanPenaltyis. - If CXVIII grows by a few lines, CXIX has room for only one line under its head.
keepWithNextthen carries the head over to page 214 and page 213 ends four or five lines short; withkeepWithNext: falsethe head closes page 213 with no text under it. - The plate is an H1, so
{chapterTitle}falls back on it: a page of text where no chapter has started since the plate, like page 213 once CXIX moves on, prints CAPÍTULO CXXIII as its running head. - Turn
avoidRuntsoff and the last line of the book holds manhã. alone instead of nadador da manhã. - Without the
:::pagebreakafter the cover, and with no plate, chapter CXVIII starts 19.5 mm from the top of the cover, under its 3 mm heading line. Without the one after the plate, eleven lines of text print over the sea once the caption moves up to 94 mm. - No line on these pages breaks at an enclitic’s hyphen (Vesti-me, metteu-se). Set Emfim, the first word of CXXIII, in italics and 1.4.1 sends that paragraph through the line breaker for text with inline marks, which ends a line on vencer- and starts the next with se a si mesma, without the hyphen that Portuguese spelling repeats at the head of the line.
- These page breaks hold only for this text at 9.5 on 12.6 pt on an 84 mm measure. After you change the text, the size or the measure, look again at the foot of every page and the space above every head.
Credits
- Recipe
- Ignacio Ferro
- Text
- Dom Casmurro (1899), from the end of chapter CXVIII to the end of chapter CXXIII, in the spelling of the Garnier edition, with one transcription slip corrected (homens, chapter CXXIII) · Machado de Assis · public domain
- The colophon, and the series name Coleção Casuarina (invented for this recipe) · Postext Cookbook · original
- Images
- The plate of the sea off Flamengo under the Sugarloaf, and the casuarina roundel, drawn in code in the book’s palette · Ignacio Ferro · MIT
- Fonts
- Tinos (Apache 2.0) · Abril Fatface (SIL OFL 1.1) · League Spartan (SIL OFL 1.1)


