What you'll build
Nine pages of Afoot, a pocket anthology of three essays on walking, by Hazlitt (1822), Thoreau (1862) and Stevenson (1876). A dusk landscape drawn in code fills the cover. The contents give each essay a rust numeral, a title in Gloock, spaced leaders to its page and the author’s name in heather italic underneath. Each essay opens with its numeral and title, a byline in spaced capitals and a dateline with the magazine and year of its first printing. Versos carry the author’s name, rectos the essay’s title. Every essay’s heading carries its author, year and source as attributes, and the opener, the running heads and the contents print them from there, so a fourth essay needs no change to the configuration.
This recipe answers
- How do I add a table of contents that updates itself (leaders, page numbers, authors, part rows)?
- How do I add an author line, a standfirst or a lead with a drop cap to an opener?
- How do I set running heads: book title on the left page, chapter title on the right, page number outside?
The short answer
// Each essay's heading carries its credits, and every {attr.…} below reads them:
// # Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"}
// (a value cannot hold { or }, and one with " goes in single quotes; gotcha: attr-values)
// 1 · The opener: inside a heading's design, {attr.author} is that heading's attribute.
const byline = [
text('byline', '{attr.author}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
color: col('heather') }, below('title', 5)),
text('dateline', '{attr.source}, {attr.year}', { fontFamily: 'Spectral', italic: true,
fontSize: pt(9.5), color: col('muted'), align: 'left' }, below('byline', 1.2)),
];
// 2 · The verso running head (head() is in the running-heads region): in the page header,
// {attr.author} is the attribute of the essay the page belongs to.
const versoAuthor = head('verso-author', '{attr.author}', 'even',
at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y));
// 3 · The contents: toc.subtitle prints the attribute it names as a line under each title.
// 'author' and italic are the defaults; attr is written out so it can become 'source'.
const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10),
color: col('heather') };
// Hooked up below: byline → the opener, versoAuthor → header, authorLine → toc.subtitle.
// A heading without author="…" gets an empty byline and running head and no line in the
// contents, and the build gives no warning: check every heading.
One set of heading attributes, read in three places
Ingredients
- Features
- Table of contentsHeading attributesRunning heads and foliosDesigned openersNumbered headingsHeading stylesUnnumbered chaptersCovers, title pages and colophonsPictures in page designsAnchoring design elementsHeads by page roleMirrored marginsParagraph stylesPinned boxes and badgesCallout boxesExplicit vertical spaceDocument metadataSemantic colour paletteFigures and tables as resourcesPages on a canvas
- Type
- Spectral, Gloock, Hanken Grotesk (SIL OFL 1.1)
- Assets
- The cover: hills at dusk, drawn in code (Ignacio Ferro, CC BY 4.0)
Method
#1 · Number the essays and sink their text
const opener = { enabled: true,
// The hairline ends 7 mm above the foot of this reserve, so the text starts on the same grid
// line under every opener whose title fits on one line (SINK = 14 holds a two-line title).
minHeight: pt(SINK * LEAD),
slot: { elements: [
// {number} prints numberingTemplate '{1:I}': I, II, III. {numberRoman} would print
// nothing here: it is filled on part pages only (gotcha: heading-number-placeholders).
text('number', '{number}', { ...gloock, fontSize: pt(34), lineHeight: 1, color: col('rust') },
at('container', 'top-left', 0, 8)),
text('title', '{titleText}', { ...gloock, fontSize: pt(26), lineHeight: 1.08,
color: col('ink') }, below('number', 3, { width: 'fill' })),
...byline,
{ kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'),
placement: below('dateline', 6) }, // a horizontal rule runs to the column's edge
] } };
const essays = { level: 1, numberingTemplate: '{1:I}', advancedDesign: opener,
marginBottom: pt(0), // the heading's default margin would add to minHeight
// Restated (gotcha: headings-drop-h1-break); the cover and contents styles inherit it too.
breakBefore: { enabled: true, parity: 'any' } }; // 'any': each piece opens on the next page
The level’s numberingTemplate: '{1:I}' numbers the essays I, II and III, and {number} prints the numeral above the title (span and advanced design). minHeight reserves 12 lines of 14 pt and the hairline ends 7 mm above that, so under a one-line title the text starts 79 mm from the top edge on every opener. A second line of 26 pt title adds 9.9 mm and pushes the text one grid line down; with SINK = 14, one-line and two-line titles start their text on the same line. marginBottom: pt(0) keeps the heading’s default margin from adding to the reserve.
#2 · Let the contents fill themselves in
const ENTRY = 15; // pt: the essay titles in the contents
const MIDDLE = 0.3125; // em: how far Chrome's textBaseline 'middle' sits above Gloock's baseline
const contents = { // passed to the config as `toc`
// 1.4.1 centres an entry's number 0.3 × the entry size above its baseline (gotcha:
// toc-number-baseline): at 0.3 × 15 ÷ 0.3125 = 14.4 pt a Gloock numeral stands on it.
levels: [{ level: 1, fontFamily: 'Gloock', fontSize: pt(ENTRY), color: col('ink'),
numberFontSize: pt((0.3 * ENTRY) / MIDDLE), numberFontWeight: 400, // Gloock has one weight
numberColor: col('rust'), numberWidth: mm(8), numberGap: mm(3), marginBottom: pt(LEAD) }],
pageNumber: { fontFamily: 'Hanken Grotesk', fontSize: pt(8.5), fontWeight: 600,
color: col('muted'), width: mm(6) }, // the leader dots take this face and colour too
leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
subtitle: authorLine,
};
:::toc in the Markdown prints one entry per listed heading: number, title, leaders, page label and, through subtitle, the author. buildDocument lays the book out again until the page labels stop changing, so a renamed or lengthened essay updates the contents (table of contents). Postext 1.4.1 puts the middle of an entry’s number 0.3 × the entry size above the baseline, and Chrome puts Gloock’s middle 0.3125 em above its baseline, so a numeral of 0.3 × 15 ÷ 0.3125 = 14.4 pt stands on the 15 pt title’s baseline; a 12 pt one rides 0.3 mm high. A book with parts also gets a row for each :::part, drawn by toc.parts.design, as in Parts in colour.
#3 · Author on the verso, essay on the recto
// Each head: the label face, the pages of its parity, never an opener (pages: 'body'), where
// the byline names the author. A function declaration, so the answer above can call it.
function head(id, content, parity, placement, look = {}) {
return { ...text(id, content, { ...label, fontSize: pt(7.5), letterSpacing: pt(1.3),
color: col('muted'), ...look }, placement), parity, pages: 'body' };
}
const folio = { color: col('ink') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
versoAuthor,
head('recto-title', '{chapterTitle}', 'odd',
at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { align: 'right' }),
head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y),
{ ...folio, align: 'right' }),
] };
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', // an opener's folio
at('container', 'top', 0, 9), { ...folio, align: 'center' }), pages: 'opener' }] };
Each head is anchored to the page, 11 mm from the top edge, and parity picks the side: the folio lines up with the outer edge of the text and the name or title sits 7 mm further in. The pages: 'body' in head() keeps the heads off the openers, where the byline already names the author, and the footer’s pages: 'opener' prints a drop folio there instead (text elements). {chapterTitle} prints the essay’s title without its numeral.
#4 · Keep the cover and the contents out of the count
// Unnumbered, so the first essay is I; unlisted; and with no running heads or folio. They
// inherit the level's page break, 'any' (gotcha: style-inherits-break).
const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } };
const cover = { enabled: true, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: 'cover',
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
text('title', '{titleText}', { ...gloock, fontSize: pt(80), lineHeight: 1, color: col('ink') },
at('page', 'top-left', MARGIN.inner, 22)), // page 1 is a recto: the inner margin is left
text('subtitle', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(14),
color: col('ink'), align: 'left' }, below('title', 1)),
text('authors', '{attr.authors}', { ...label, fontSize: pt(8.5), letterSpacing: pt(2),
color: col('heather') }, below('subtitle', 5)),
text('imprint', '{attr.imprint}', { ...label, fontSize: pt(7.5), letterSpacing: pt(1.8),
color: col('paper') }, at('page', 'bottom-left', MARGIN.inner, -12)),
] } };
// span: 'page' in a one-column book: a design kept in the column is clipped at the column's top
// and bottom edges, which would leave bands of paper above and below the dusk.
const coverStyle = { id: 'cover', ...bare, span: 'page', advancedDesign: cover };
const contentsOpener = { enabled: true, slot: { elements: [ // {title}, {subtitle}: frontmatter
text('kicker', '{title} · {subtitle}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
color: col('heather') }, at('container', 'top-left', 0, 8)),
text('title', '{titleText}', { ...gloock, fontSize: pt(26), color: col('ink') },
below('kicker', 2.5)),
] } };
With numbered: false the cover and the contents do not advance the counter, so Hazlitt is I, and toc: false keeps them off the list (heading styles). Both styles inherit the level’s breakBefore, parity 'any', which is why the contents follows the cover on page 2 without a :::pagebreak. The cover style spans the page in a one-column book because a design kept in the column is clipped at the column’s top and bottom edges, which would leave 20 mm of paper above the landscape.
#5 · Write every colour out with its palette link
const palette = {
ink: '#241f26', // text: a plum-tinted near-black
paper: '#f7f2e8', // the page
heather: '#6b4468', // the bylines and the authors in the contents
rust: '#a4502a', // the essay numbers, and the cover's sun
rule: '#d5cabd', // hairlines
muted: '#6d6570', // running heads, datelines, page numbers in the contents
};
// A design element paints the hex written beside its paletteId (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 engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
{ id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
In 1.4.1 colorPalette reaches the text styles but not the design elements, so col() writes each colour’s hex beside its paletteId: the bylines, the running heads and the cover paint that hex. Pointing main-color at the ink covers every default the configuration does not restate, which would otherwise print in the engine’s blue, #295AA3.
The whole recipe
// ═══ Postext Cookbook · Nº 030 · Anthology with bylines ═══════════════════════════════ // https://postext.dev/en/cookbook/anthology-with-bylines // Code: MIT · Text: Hazlitt, Thoreau, Stevenson (public domain) · Cover: generated (CC BY 4.0) // Fonts: Spectral, Gloock, Hanken Grotesk (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 sample document ('en' | 'es') const RECIPE = 'anthology-with-bylines'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: heather for the credits, rust for the numbers, plum ink on warm paper const palette = { ink: '#241f26', // text: a plum-tinted near-black paper: '#f7f2e8', // the page heather: '#6b4468', // the bylines and the authors in the contents rust: '#a4502a', // the essay numbers, and the cover's sun rule: '#d5cabd', // hairlines muted: '#6d6570', // running heads, datelines, page numbers in the contents }; // A design element paints the hex written beside its paletteId (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 engine's defaults link to 'main-color': point it at the ink, so nothing prints blue. { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; // #endregion const TRIM = { width: 135, height: 180 }; // a pocket book, 3 : 4 const MARGIN = { top: 20, bottom: 22, inner: 19, outer: 15 }; // mirrored const LEAD = 14; // body leading in pt: the baseline grid const SINK = 12; // grid lines an essay opener reserves above its first line of text const HEAD = { y: 11, gap: 7 }; // running heads: mm from the top edge, folio to words const label = { fontFamily: 'Hanken Grotesk', fontWeight: 600, textTransform: 'uppercase', align: 'left' }; // design text is centred by default // One weight, no italic; 'wrap' breaks a long title (gotcha: overflow-ellipsis-default). const gloock = { fontFamily: 'Gloock', overflow: 'wrap', align: 'left' }; const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const below = (id, y, size) => ({ ...at(`#${id}`, 'below', 0, y), ...(size && { size }) }); const text = (id, content, look, placement) => ({ kind: 'text', id, content, ...look, placement }); // #region answer: one set of heading attributes, read in three places // Each essay's heading carries its credits, and every {attr.…} below reads them: // # Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"} // (a value cannot hold { or }, and one with " goes in single quotes; gotcha: attr-values) // 1 · The opener: inside a heading's design, {attr.author} is that heading's attribute. const byline = [ text('byline', '{attr.author}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('heather') }, below('title', 5)), text('dateline', '{attr.source}, {attr.year}', { fontFamily: 'Spectral', italic: true, fontSize: pt(9.5), color: col('muted'), align: 'left' }, below('byline', 1.2)), ]; // 2 · The verso running head (head() is in the running-heads region): in the page header, // {attr.author} is the attribute of the essay the page belongs to. const versoAuthor = head('verso-author', '{attr.author}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)); // 3 · The contents: toc.subtitle prints the attribute it names as a line under each title. // 'author' and italic are the defaults; attr is written out so it can become 'source'. const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10), color: col('heather') }; // Hooked up below: byline → the opener, versoAuthor → header, authorLine → toc.subtitle. // A heading without author="…" gets an empty byline and running head and no line in the // contents, and the build gives no warning: check every heading. // #endregion // #region opener: the essay's number and title, then the byline, over a sunk first line const opener = { enabled: true, // The hairline ends 7 mm above the foot of this reserve, so the text starts on the same grid // line under every opener whose title fits on one line (SINK = 14 holds a two-line title). minHeight: pt(SINK * LEAD), slot: { elements: [ // {number} prints numberingTemplate '{1:I}': I, II, III. {numberRoman} would print // nothing here: it is filled on part pages only (gotcha: heading-number-placeholders). text('number', '{number}', { ...gloock, fontSize: pt(34), lineHeight: 1, color: col('rust') }, at('container', 'top-left', 0, 8)), text('title', '{titleText}', { ...gloock, fontSize: pt(26), lineHeight: 1.08, color: col('ink') }, below('number', 3, { width: 'fill' })), ...byline, { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'), placement: below('dateline', 6) }, // a horizontal rule runs to the column's edge ] } }; const essays = { level: 1, numberingTemplate: '{1:I}', advancedDesign: opener, marginBottom: pt(0), // the heading's default margin would add to minHeight // Restated (gotcha: headings-drop-h1-break); the cover and contents styles inherit it too. breakBefore: { enabled: true, parity: 'any' } }; // 'any': each piece opens on the next page // #endregion // #region contents: the essays' numbers, titles, leaders and page labels, from the headings const ENTRY = 15; // pt: the essay titles in the contents const MIDDLE = 0.3125; // em: how far Chrome's textBaseline 'middle' sits above Gloock's baseline const contents = { // passed to the config as `toc` // 1.4.1 centres an entry's number 0.3 × the entry size above its baseline (gotcha: // toc-number-baseline): at 0.3 × 15 ÷ 0.3125 = 14.4 pt a Gloock numeral stands on it. levels: [{ level: 1, fontFamily: 'Gloock', fontSize: pt(ENTRY), color: col('ink'), numberFontSize: pt((0.3 * ENTRY) / MIDDLE), numberFontWeight: 400, // Gloock has one weight numberColor: col('rust'), numberWidth: mm(8), numberGap: mm(3), marginBottom: pt(LEAD) }], pageNumber: { fontFamily: 'Hanken Grotesk', fontSize: pt(8.5), fontWeight: 600, color: col('muted'), width: mm(6) }, // the leader dots take this face and colour too leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up subtitle: authorLine, }; // #endregion // #region running-heads: author on the verso, essay title on the recto, folios outside // Each head: the label face, the pages of its parity, never an opener (pages: 'body'), where // the byline names the author. A function declaration, so the answer above can call it. function head(id, content, parity, placement, look = {}) { return { ...text(id, content, { ...label, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('muted'), ...look }, placement), parity, pages: 'body' }; } const folio = { color: col('ink') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio), versoAuthor, head('recto-title', '{chapterTitle}', 'odd', at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { align: 'right' }), head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), { ...folio, align: 'right' }), ] }; const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', // an opener's folio at('container', 'top', 0, 9), { ...folio, align: 'center' }), pages: 'opener' }] }; // #endregion // #region front: the cover and the contents page, two headings kept out of the count // Unnumbered, so the first essay is I; unlisted; and with no running heads or folio. They // inherit the level's page break, 'any' (gotcha: style-inherits-break). const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } }; const cover = { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } }, text('title', '{titleText}', { ...gloock, fontSize: pt(80), lineHeight: 1, color: col('ink') }, at('page', 'top-left', MARGIN.inner, 22)), // page 1 is a recto: the inner margin is left text('subtitle', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(14), color: col('ink'), align: 'left' }, below('title', 1)), text('authors', '{attr.authors}', { ...label, fontSize: pt(8.5), letterSpacing: pt(2), color: col('heather') }, below('subtitle', 5)), text('imprint', '{attr.imprint}', { ...label, fontSize: pt(7.5), letterSpacing: pt(1.8), color: col('paper') }, at('page', 'bottom-left', MARGIN.inner, -12)), ] } }; // span: 'page' in a one-column book: a design kept in the column is clipped at the column's top // and bottom edges, which would leave bands of paper above and below the dusk. const coverStyle = { id: 'cover', ...bare, span: 'page', advancedDesign: cover }; const contentsOpener = { enabled: true, slot: { elements: [ // {title}, {subtitle}: frontmatter text('kicker', '{title} · {subtitle}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('heather') }, at('container', 'top-left', 0, 8)), text('title', '{titleText}', { ...gloock, fontSize: pt(26), color: col('ink') }, below('kicker', 2.5)), ] } }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Spectral', fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.7, maxWordSpacing: 1.6, // tighter than the 0.6–2 defaults maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted) // The hidden heading line is still measured, in this face; otherwise the build needs Open Sans. headings: { fontFamily: 'Gloock', fontWeight: 400, levels: [essays] }, headingStyles: [coverStyle, { id: 'contents', ...bare, advancedDesign: contentsOpener }], toc: contents, // Quoted verse, one paragraph per line (a paragraph keeps no line breaks, and 1.4.1 prints a // Markdown blockquote in a fixed #666666 grey); 'runon' resumes the sentence after it. paragraphStyles: [{ id: 'verse', firstLineIndent: mm(8), textAlign: 'left' }, { id: 'runon', firstLineIndent: pt(0) }, // In the note, under a :::space: a style's margins do not count inside a box (gotcha: // box-paragraph-margins). It takes the note body's indent, 0. { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted') }], calloutStyles: [{ id: 'note', placement: 'fixed', backgroundEnabled: false, // the page foot stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') }, padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { ...label, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('heather') }, body: { fontSize: pt(9), lineHeight: pt(12.5), firstLineIndent: pt(0) } }], header, footer, }); // #region art: the cover, drawn in code and seeded: the same dusk on every run let seed = 1822; // Mulberry32, a tiny seeded PRNG: 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 SKY = '#f2d6ae'; // apricot dusk const RISE = 10; // mm the whole landscape is lifted, so the card's crop takes in more of it const W = TRIM.width; const H = TRIM.height; // A ridge line: a few slow waves with seeded phases, sampled every millimetre. function ridge(base, waves) { const phases = waves.map(() => rand() * Math.PI * 2); return (x) => base - RISE + waves.reduce((y, [amp, len], i) => y + amp * Math.sin(x / len + phases[i]), 0); } const fillUnder = (f, colour) => { let d = `M-1 ${n(f(-1))}`; for (let x = 0; x <= W + 1; x += 1) d += ` L${x} ${n(f(x))}`; return `<path d="${d} L${W + 1} ${H + 1} L-1 ${H + 1} Z" fill="${colour}"/>`; }; // The footpath: a ribbon from the foot of the page to a fold of the near hill, narrowing // with distance. s runs from 0 at the far end to 1 at the foot of the page. const [NEAR, FAR] = [[98, H + 2], [60, 128 - RISE]]; const pathAt = (s) => [ // x, y and width in mm: the bends and the width shrink with distance FAR[0] + (NEAR[0] - FAR[0]) * s + 13 * s * Math.sin(Math.PI * (1 - s) * 2.1), FAR[1] + (NEAR[1] - FAR[1]) * s ** 1.5, 0.5 + 15 * s ** 1.7]; function footpath(from = 0) { // the part nearer than `from` const left = []; const right = []; for (let i = 0; i <= 80; i++) { const s = from + (1 - from) * (i / 80); const [x, y, w] = pathAt(s); left.push(`${n(x - w / 2)} ${n(y)}`); right.unshift(`${n(x + w / 2)} ${n(y)}`); } const fill = mix(SKY, palette.paper, 0.35); return `<path d="M${left.join(' L')} L${right.join(' L')} Z" fill="${fill}"/>`; } function coverSvg() { const layers = [ // far to near: base line, [amplitude, wavelength] waves, colour [98, [[3, 14], [2, 6]], mix(palette.heather, SKY, 0.72)], [108, [[4, 18], [1.5, 7]], mix(palette.heather, SKY, 0.55)], [119, [[5, 22], [2, 9]], mix(palette.heather, SKY, 0.36)], [133, [[6, 26], [2, 11]], mix(palette.heather, palette.ink, 0.12)], [152, [[7, 30], [2.5, 12]], mix(palette.heather, palette.ink, 0.62)], ].map(([base, waves, colour]) => ({ f: ridge(base, waves), colour })); const sky = '<linearGradient id="dusk" x1="0" y1="0" x2="0" y2="1">' // paler at the ridge + `<stop offset="0" stop-color="${mix(SKY, palette.rust, 0.1)}"/>` + `<stop offset="0.55" stop-color="${mix(SKY, palette.paper, 0.5)}"/></linearGradient>` + `<rect width="${W}" height="${H}" fill="url(#dusk)"/>`; const sun = `<circle cx="101" cy="${96 - RISE}" r="13" fill="${mix(palette.rust, SKY, 0.12)}"/>`; const birds = [[113, 66, 1.6], [119, 62, 1.2], [108, 71, 1]].map(([x, y, w]) => '<path ' + `d="M${n(x - w)} ${n(y - 0.4)} Q${n(x - w / 2)} ${n(y - 1)} ${x} ${y} ` + `Q${n(x + w / 2)} ${n(y - 1)} ${n(x + w)} ${n(y - 0.4)}" fill="none" ` + `stroke="${palette.ink}" stroke-width="0.35" stroke-linecap="round"/>`).join(''); const [far1, far2, mid, near, fore] = layers; // Three trees on the middle ridge, and hedgerows across the near hill as rows of shrubs. const dark = mix(palette.heather, palette.ink, 0.45); const copse = [[106, 2.4, 3.2], [111.5, 1.8, 2.6], [116, 2.9, 3.6]].map(([x, r, trunk]) => { const foot = mid.f(x) + 0.6; return `<path d="M${x} ${n(foot)} V${n(foot - trunk)}" stroke="${dark}" stroke-width="0.7"/>` + `<ellipse cx="${x}" cy="${n(foot - trunk - r * 0.8)}" rx="${n(r * 0.85)}" ry="${n(r)}" ` + `fill="${dark}"/>`; }).join(''); let hedges = ''; for (const [dy, x0, x1, s] of [[5, -1, 52, 0.8], [11, 70, W + 1, 1], [17, -1, 40, 1.25]]) { for (let x = x0; x < x1; x += (2 + rand() * 0.8) * s) { // s: nearer rows, bigger shrubs if (rand() < 0.1) continue; // a gap in the hedge const y = near.f(x) + dy + Math.sin(x / 9) * 1.5 + rand() * 0.4 * s; hedges += `<circle cx="${n(x)}" cy="${n(y)}" r="${n((0.7 + rand() * 0.4) * s)}" ` + `fill="${mix(palette.heather, palette.ink, 0.6)}"/>`; } } // The near stretch of the path starts just behind the crest it comes over. let crest = 0; while (crest < 1 && pathAt(crest)[1] < fore.f(pathAt(crest)[0]) - 1) crest += 0.005; const body = sky + sun + birds + fillUnder(far1.f, far1.colour) + fillUnder(far2.f, far2.colour) + fillUnder(mid.f, mid.colour) + copse + fillUnder(near.f, near.colour) + hedges + footpath() + fillUnder(fore.f, fore.colour) + footpath(crest); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" ` + `viewBox="0 0 ${W} ${H}">${body}</svg>`; } // The cover's resource: the design's image element names it by id, and loadSvg() below // registers the drawing under its fileId. const resources = [{ id: 'cover', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'cover.svg', width: TRIM.width * 10, height: TRIM.height * 10 }, altText: 'Hills at dusk in five layers, from dusty rose to deep heather, a low rust sun, ' + 'three trees on a ridge, hedgerows across the near hill and a pale footpath winding up ' + 'from the foot of the page.' }]; // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 67 lines · content.en.md
title: "Afoot" subtitle: "Three essays on walking" --- # Afoot {style="cover" authors="Hazlitt · Thoreau · Stevenson" imprint="The Fieldpath Library"} # Contents {style="contents"} :::toc :::callout{type="note" title="A note on the texts"} Each of these essays was first printed in a magazine, two in London and the third in Boston. Hazlitt’s appeared in *The New Monthly Magazine* in January 1822. Thoreau worked his up from a lecture he first gave in 1851, and *The Atlantic Monthly* printed it in June 1862, a month after his death. Stevenson’s came out in *The Cornhill Magazine* in 1876, and in its second paragraph he quotes Hazlitt by name. Each essay starts at its first line and stops well short of its last, in its author’s own spelling; […] marks a cut within a paragraph. :::space{lines=1} :::paragraphs{style="colophon"} Set in Spectral, Gloock and Hanken Grotesk (SIL Open Font License). The essays are in the public domain, from Project Gutenberg eBooks #3020, #1022 and #386; this note and the cover are CC BY 4.0. ::: ::: # On Going a Journey {author="William Hazlitt" year="1822" source="The New Monthly Magazine"} One of the pleasantest things in the world is going a journey; but I like to go by myself. I can enjoy society in a room; but out of doors, nature is company enough for me. I am then never less alone than when alone. :::paragraphs{style="verse"} *The fields his study, nature was his book.* ::: I cannot see the wit of walking and talking at the same time. When I am in the country I wish to vegetate like the country. I am not for criticising hedge-rows and black cattle. I go out of town in order to forget the town and all that is in it. There are those who for this purpose go to watering-places, and carry the metropolis with them. I like more elbow-room and fewer encumbrances. I like solitude, when I give myself up to it, for the sake of solitude; nor do I ask for :::paragraphs{style="verse"} *A friend in my retreat,* *Whom I may whisper solitude is sweet.* ::: The soul of a journey is liberty, perfect liberty, to think, feel, do, just as one pleases. We go a journey chiefly to be free of all impediments and of all inconveniences; to leave ourselves behind much more to get rid of others. It is because I want a little breathing-space to muse on indifferent matters, where Contemplation :::paragraphs{style="verse"} *May plume her feathers and let grow her wings,* *That in the various bustle of resort* *Were all too ruffled, and sometimes impair’d,* ::: :::paragraphs{style="runon"} that I absent myself from the town for a while, without feeling at a loss the moment I am left by myself. Instead of a friend in a postchaise or in a Tilbury, to exchange good things with, and vary the same stale topics over again, for once let me have a truce with impertinence. Give me the clear blue sky over my head, and the green turf beneath my feet, a winding road before me, and a three hours’ march to dinner—and then to thinking! It is hard if I cannot start some game on these lone heaths. I laugh, I run, I leap, I sing for joy. […] ::: # Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"} I wish to speak a word for Nature, for absolute Freedom and Wildness, as contrasted with a freedom and culture merely civil,—to regard man as an inhabitant, or a part and parcel of Nature, rather than a member of society. I wish to make an extreme statement, if so I may make an emphatic one, for there are enough champions of civilization: the minister and the school committee and every one of you will take care of that. I have met with but one or two persons in the course of my life who understood the art of Walking, that is, of taking walks—who had a genius, so to speak, for sauntering, which word is beautifully derived “from idle people who roved about the country, in the Middle Ages, and asked charity, under pretense of going à la Sainte Terre,” to the Holy Land, till the children exclaimed, “There goes a Sainte-Terrer,” a Saunterer, a Holy-Lander. They who never go to the Holy Land in their walks, as they pretend, are indeed mere idlers and vagabonds; but they who do go there are saunterers in the good sense, such as I mean. Some, however, would derive the word from sans terre without land or a home, which, therefore, in the good sense, will mean, having no particular home, but equally at home everywhere. For this is the secret of successful sauntering. He who sits still in a house all the time may be the greatest vagrant of all; but the saunterer, in the good sense, is no more vagrant than the meandering river, which is all the while sedulously seeking the shortest course to the sea. But I prefer the first, which, indeed, is the most probable derivation. For every walk is a sort of crusade, preached by some Peter the Hermit in us, to go forth and reconquer this Holy Land from the hands of the Infidels. It is true, we are but faint-hearted crusaders, even the walkers, nowadays, who undertake no persevering, never-ending enterprises. Our expeditions are but tours, and come round again at evening to the old hearth-side from which we set out. Half the walk is but retracing our steps. We should go forth on the shortest walk, perchance, in the spirit of undying adventure, never to return,—prepared to send back our embalmed hearts only as relics to our desolate kingdoms. If you are ready to leave father and mother, and brother and sister, and wife and child and friends, and never see them again,—if you have paid your debts, and made your will, and settled all your affairs, and are a free man; then you are ready for a walk. To come down to my own experience, my companion and I, for I sometimes have a companion, take pleasure in fancying ourselves knights of a new, or rather an old, order—not Equestrians or Chevaliers, not Ritters or Riders, but Walkers, a still more ancient and honorable class, I trust. The chivalric and heroic spirit which once belonged to the Rider seems now to reside in, or perchance to have subsided into, the Walker—not the Knight, but Walker Errant. He is a sort of fourth estate, outside of Church and State and People. We have felt that we almost alone hereabouts practiced this noble art; though, to tell the truth, at least if their own assertions are to be received, most of my townsmen would fain walk sometimes, as I do, but they cannot. No wealth can buy the requisite leisure, freedom, and independence which are the capital in this profession. It comes only by the grace of God. It requires a direct dispensation from Heaven to become a walker. You must be born into the family of the Walkers. Ambulator nascitur, non fit. Some of my townsmen, it is true, can remember and have described to me some walks which they took ten years ago, in which they were so blessed as to lose themselves for half an hour in the woods; but I know very well that they have confined themselves to the highway ever since, whatever pretensions they may make to belong to this select class. No doubt they were elevated for a moment as by the reminiscence of a previous state of existence, when even they were foresters and outlaws. # Walking Tours {author="Robert Louis Stevenson" year="1876" source="The Cornhill Magazine"} It must not be imagined that a walking tour, as some would have us fancy, is merely a better or worse way of seeing the country. There are many ways of seeing landscape quite as good; and none more vivid, in spite of canting dilettantes, than from a railway train. But landscape on a walking tour is quite accessory. He who is indeed of the brotherhood does not voyage in quest of the picturesque, but of certain jolly humours—of the hope and spirit with which the march begins at morning, and the peace and spiritual repletion of the evening’s rest. He cannot tell whether he puts his knapsack on, or takes it off, with more delight. […] Now, to be properly enjoyed, a walking tour should be gone upon alone. If you go in a company, or even in pairs, it is no longer a walking tour in anything but name; it is something else and more in the nature of a picnic. A walking tour should be gone upon alone, because freedom is of the essence; because you should be able to stop and go on, and follow this way or that, as the freak takes you; and because you must have your own pace, and neither trot alongside a champion walker, nor mince in time with a girl. And then you must be open to all impressions and let your thoughts take colour from what you see. You should be as a pipe for any wind to play upon. “I cannot see the wit,” says Hazlitt, “of walking and talking at the same time. When I am in the country I wish to vegetate like the country,”—which is the gist of all that can be said upon the matter. There should be no cackle of voices at your elbow, to jar on the meditative silence of the morning. And so long as a man is reasoning he cannot surrender himself to that fine intoxication that comes of much motion in the open air, that begins in a sort of dazzle and sluggishness of the brain, and ends in a peace that passes comprehension.`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces (gotcha: fonts-first) Spectral: ['400', '400i'], Gloock: ['400'], 'Hanken Grotesk': ['600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('cover.svg', coverSvg()); await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Anthology with bylines', es: 'Antología con firmas de autor' }) });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
#List where each essay first appeared
toc.subtitle prints any attribute of the heading, so the same Markdown can list each essay’s first magazine under its title.
-const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10),
+const authorLine = { enabled: true, attr: 'source', fontFamily: 'Spectral', fontSize: pt(10),#Put the book’s title on the verso
Many books set their own title on the verso and the chapter’s on the recto; {title} reads it from the Markdown’s frontmatter and prints AFOOT.
-const versoAuthor = head('verso-author', '{attr.author}', 'even',
+const versoAuthor = head('verso-author', '{title}', 'even',#Open every essay on a recto
With parity 'odd', Stevenson moves to page 9 behind a blank page 8. The two front-matter styles then need an 'any' of their own, or the contents inherit 'odd' and open on page 3 behind a blank verso.
- breakBefore: { enabled: true, parity: 'any' } }; // 'any': each piece opens on the next page
+ breakBefore: { enabled: true, parity: 'odd' } };
-const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } };
+const bare = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
+ header: { elements: [] }, footer: { elements: [] } };Pitfalls
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 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
Contents numbers sit a little above the entry's baseline
In postext 1.4.1 :::toc paints each entry's number centred on the line instead of on the text's baseline, so the chapter numbers ride slightly high beside their titles, about 0.7 mm next to a 16 pt title, whatever face or size you give them. No toc option moves them yet, so look at the contents page at full size before you print. Table of contents →
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
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's margins do not count inside a box
In postext 1.4.1 a :::paragraphs container nested in a :::callout ignores its style's marginTop and marginBottom, so a small-print line set under a note's text sits right against it. Give the style a taller lineHeight, which puts air above its first line, or keep the line out of the box. Paragraph styles →
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
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 →
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 →
- Hazlitt quotes verse in the middle of his sentences. A paragraph keeps no line breaks, so each line of verse is a paragraph of its own in a
:::paragraphs{style="verse"}container, and the sentence that resumes after it goes in arunonstyle with no indent. A Markdown blockquote would print in a fixed#666666grey italic, with the body’s first-line indent and no side indent, and no setting changes that in 1.4.1. - An attribute that a heading lacks prints nothing and raises no warning. Without
author="…", an essay’s byline and verso head come out empty and its line under the title disappears from the contents. - The 14.4 pt of step 2 holds for Gloock only. For another face, find how far above the baseline Chrome puts its middle:
measureText('I').alphabeticBaselinewithtextBaseline = 'middle'returns it, negated, in pixels. Then setnumberFontSizeto 0.3 × the entry size divided by that distance in em (Hanken Grotesk’s is 0.2675 em).
Credits
- Recipe
- Ignacio Ferro
- Text
- “On Going a Journey” (1822), from its first line to “I sing for joy”, in Table Talk: Essays on Men and Manners · William Hazlitt · public domain
- “Walking” (1862), its first five paragraphs · Henry David Thoreau · public domain
- “Walking Tours” (1876), the opening of its first paragraph and the whole of its second, in Virginibus Puerisque · Robert Louis Stevenson · public domain
- The note on the texts and the colophon · Ignacio Ferro · CC BY 4.0
- Images
- The cover: hills at dusk, drawn in code · Ignacio Ferro · CC BY 4.0
- Fonts
- Spectral (SIL OFL 1.1) · Gloock (SIL OFL 1.1) · Hanken Grotesk (SIL OFL 1.1)


