What you'll build
Chapter 1 of a trail crew's field manual, Drainage, on three B5 pages in two columns: IBM Plex Serif for the text, IBM Plex Sans Condensed for the heads and section numbers, IBM Plex Mono for labels, folios and subsection numbers. The opener sets the chapter number in a large amber pill over a trail's elevation profile, where amber dots mark twelve sites flagged for new water bars. Smaller pills number sections 1.1 to 1.12 and widen at 1.10. Heads 1.2.1, 1.5.1 and 1.5.2 are tracked capitals under a green rule. Levels 4 to 6 go unnumbered and change face instead (serif italic, green condensed bold, mono capitals), and a seventh level in grey italic names the tools. The safety rules open on green run-in terms. An unnumbered checklist, marked by a hollow square, closes the chapter with lists numbered 1., a) and i. and two task boxes.
This recipe answers
- How do I number headings (1, 1.1, 1.1.1) and style each level differently?
- How do I build a number badge beside the title that widens when the number grows (9 → 10)?
- How do I handle more than six heading levels?
- How do I customise lists: bullets per level, (a)/(i) numbering, task checkboxes, spacing that stays on the grid?
The short answer
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it (gotcha: overflow-ellipsis-default).
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
Section numbers 1.1 … 1.12 in an amber pill that widens with the number
Ingredients
- Features
- Numbered headingsText, rules and boxes in page designsAnchoring design elementsHeading levelsHeading stylesUnnumbered chaptersDesigned openersPictures in page designsHeading attributesParagraph stylesBold, italic and their coloursBullet lists and checklistsNumbered listsBaseline gridWidows, orphans and runtsRunning heads and foliosHeads by page roleMirrored marginsSemantic colour paletteFigures and tables as resources
- Also uses
- Full-width chapter band
- Type
- IBM Plex Serif, IBM Plex Sans Condensed, IBM Plex Mono (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Put the number in a pill that grows with it
The code is in the short answer above. numberingTemplate: '{1}.{2}' joins the chapter and section counters into 1.1 to 1.12 (per-level overrides). A level with an advanced design no longer prints the number before its title, so the design places {number} itself. Here it sits in a text element with a filled, rounded box and no width, so the pill is as wide as the number plus its padding: 10.1 mm for 1.9 and 12.7 mm for 1.10. 'right-of' hangs the title on the pill's right edge and aligns its lines left, which is why the long title of 1.5 wraps beside the number (element placement). The title also needs overflow: 'wrap', because by default design text that does not fit is cut with an ellipsis. The pill is 3 pt shorter than two grid lines, and every section head starts on a grid line, so those 3 pt are the gap between the pill and the text under it, whether the head opens a column or follows a paragraph.

#2 · Rule and track the third level
// Headings have no letterSpacing of their own; design text has, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
{ kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
] } } };
In 1.4.1 a heading level has no letterSpacing, but design text does, so level 3 is drawn by a design as well: a 0.75 pt green rule, the number in IBM Plex Mono and the tracked title beside it. The third counter of '{1}.{2}.{3}' restarts under every section, so 1.2.1 and 1.5.1 both end in 1. DROP lowers only the rule. The number is placed LEAD - DROP under it, which keeps number and title a grid line below the top of the head for any DROP. With the rule nearer the number than the paragraph above, it reads as part of the head.
#3 · Step down through levels 4 to 6
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
levels: [
// Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
numberingTemplate: '{1}', advancedDesign: opener },
h2, h3,
// No template below level 3, so no number: each level changes face, colour or case.
{ level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
{ level: 5, fontSize: pt(9.4), color: col('band') },
{ level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
] };
A level without a numberingTemplate prints no number, so from level 4 down the heads differ in face, colour or case instead: an italic serif at level 4, the display face in green at 5 and semibold mono capitals at 6. The line height and margins set on headings itself reach every level and put each head on the grid, with a blank line above it and none below. Any headings object drops the default page break before a chapter, so level 1 restates it.
#4 · Make a seventh level and an unnumbered section with styles
const headingStyles = [
// Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped):
// '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey.
{ id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
textTransform: 'none', color: col('muted') },
// numbered: false: no number, and the H2 counter does not move. An empty {number} would
// still paint the amber pill, so the style draws a hollow square in its place.
{ id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
size: { width: pt(PILL_H), height: pt(PILL_H) } } },
sectionTitle('box'),
] } } },
];
const paragraphStyles = [
// Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
{ id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
{ id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
Markdown stops at ######, and a heading drops its bold and italic marks, so ###### *Rock bar* would print as one more level 6. The heading style 'level7' sets the tool names in a grey condensed italic of weight 500, lighter than the 600 of level 6, and textTransform: 'none' switches off the capitals they would otherwise inherit. They read a step below the mono label above them, though at 8.4 pt they are larger than its 7.8 pt (heading styles). numbered: false leaves the checklist out of the count, so a section after it would still be 1.13. An empty {number} would still paint the amber pill, so the style brings its own design, with a hollow square in the pill's place. The green run-in terms of the safety rules come from the boldColor of a paragraph style.
#5 · Change the list markers with depth
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies).
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
marginTop: pt(0), marginBottom: pt(0), levels: [
{ level: 2, numberFormat: 'lower-alpha', separator: ')' },
{ level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
Each depth takes its own marker, colour and separator from levels: the checklist counts 1., a) and i. (ordered per-level overrides) and the bullets fade from green to sage (unordered per-level overrides). Level 1 keeps the default format, 'arabic'; 'decimal', the word CSS uses, would print “undefined”. The two - [ ] items that close the checklist print taskCheckboxChar, ☐ by default, in place of the bullet, in the green of the first-level bullets (task list extensions). With zero margins above and below, every list stays on the baseline grid.
#6 · Open the chapter on the trail's profile
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight
// reaches past the profile: the text starts on the first grid line CLEAR mm or more under it.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
{ kind: 'image', id: 'profile', resourceId: 'profile',
placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
{ kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
placement: at('container', 'top-left') },
{ kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
placement: at('#num', 'right-of', mm(4)) },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
// Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
{ kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
mm(DEPTH - LEGEND)) },
] } };
The profile is an image element of the opener, anchored to the page and as wide as it. A page-wide figure floated to the top and cited on page 1 would have opened page 2 instead (image elements). In an opener a picture reserves no height, so minHeight starts the text on the first grid line 6 mm or more below it. The legend on the green is design text, because an SVG drawn as an image cannot use the page's fonts. The large pill reuses the section pill's face and fill, and {number} prints the chapter's own number, from numberingTemplate: '{1}'.
The whole recipe
// ═══ Postext Cookbook · Nº 018 · Section heads seven levels deep ═════════════════ // https://postext.dev/en/cookbook/section-heads-field-manual // Code: MIT · Text: original (CC BY 4.0) · Picture: drawn in code (MIT) // Fonts: IBM Plex Serif, Sans Condensed, Mono (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 = 'section-heads-field-manual'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // forest green for structure, a signal amber for numbers ink: '#1d2320', // text: a green-black band: '#2f6b3f', // the accent: rules, run-in terms, bullets, numbers, folios (6.4:1) signal: '#e0a526', // the number pills, with ink on them (7.3:1) sage: '#7a9e80', // the second bullet and the profile's upper contours tint: '#e9f0e6', // the opener's sky; the legend on the green (5.5:1) muted: '#5f6a62', // running heads, level 7, roman list numbers, the colophon (5.6:1) }; // The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // Defaults this config does not restate link to 'main-color', so it points at the accent. const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const TRIM = { width: 176, height: 250 }; // mm: ISO B5, a common size for field manuals const [TOP, INNER, OUTER] = [22, 16, 14]; // mm: margins; the running heads align to OUTER const LEAD = 13.2; // pt: the body leading, the pitch of the baseline grid const LINES = 44; // grid lines in the text block, so every full column ends on one baseline const [DISPLAY, LABEL] = ['IBM Plex Sans Condensed', 'IBM Plex Mono']; // with the serif text const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x, y } }); // #region answer: section numbers 1.1 … 1.12 in an amber pill that widens with the number const H2 = 13.5; // pt: the number and the title share one size and one line height, const LH = 1.2; // so, under the same top padding, they share one baseline // Every section head starts on a grid line, so the 3 pt the pill falls short of two lines // is the gap the grid snap leaves between the pill and the text under it. const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH }; const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'), box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number placement: at('container', 'top-left') }; // plus its padding // 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long // title wraps beside the number, never under it (gotcha: overflow-ellipsis-default). const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face, color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } }, placement: at(`#${from}`, 'right-of', mm(2.2)) }); // The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels. const h2 = { level: 2, numberingTemplate: '{1}.{2}', advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } }; // #endregion // #region ruled: level 3, a green rule over the number and a tracked capital title // Headings have no letterSpacing of their own; design text has, so this head is a design. const small = { fontSize: pt(8.4), lineHeight: LH }; const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2 advancedDesign: { enabled: true, slot: { elements: [ { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'), placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } }, { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small, color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600, ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'), overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) }, ] } } }; // #endregion // #region opener: the chapter number in the section pill, scaled up, over the trail's profile const DEPTH = 96; // mm: the profile's foot, measured from the top of the page const CLEAR = 6; // mm: the least room between the profile's foot and the text under it const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') }; // A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight // reaches past the profile: the text starts on the first grid line CLEAR mm or more under it. const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [ { kind: 'image', id: 'profile', resourceId: 'profile', placement: { ...at('page', 'top-left'), size: { width: 'fill' } } }, { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'), borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } }, placement: at('container', 'top-left') }, { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } }, placement: at('#num', 'right-of', mm(4)) }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true, fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap', placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } }, // Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts). { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500, fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER), mm(DEPTH - LEGEND)) }, ] } }; // #endregion // #region levels: numbers down to 1.1.1, then italic, bold and label faces for 4 to 6 const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid, lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below levels: [ // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, numberingTemplate: '{1}', advancedDesign: opener }, h2, h3, // No template below level 3, so no number: each level changes face, colour or case. { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) }, { level: 5, fontSize: pt(9.4), color: col('band') }, { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' }, ] }; // #endregion // #region styles: a seventh level and an unnumbered section as heading styles; run-in terms const headingStyles = [ // Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped): // '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey. { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4), textTransform: 'none', color: col('muted') }, // numbered: false: no number, and the H2 counter does not move. An empty {number} would // still paint the amber pill, so the style draws a hollow square in its place. { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8), borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'), size: { width: pt(PILL_H), height: pt(PILL_H) } } }, sectionTitle('box'), ] } } }, ]; const paragraphStyles = [ // Run-in heads: the bold term opening each rule prints in the accent, not in body ink. { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) }, { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ]; // #endregion // #region lists: bullets that fade with depth; numbers 1. then a) then i.; task boxes // Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'. const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'), levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1 // Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies). const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6), marginTop: pt(0), marginBottom: pt(0), levels: [ { level: 2, numberFormat: 'lower-alpha', separator: ')' }, { level: 3, numberFormat: 'lower-roman', color: col('muted') }] }; // #endregion // Running heads, HEAD mm from the trim: folio and book on versos, chapter and folio on rectos. const HEAD = 12; // mm; an opener keeps only a drop folio, HEAD mm above its foot const FOLIO_GAP = 9; // mm from a folio to the title beside it const runHead = { fontFamily: DISPLAY, fontWeight: 600, fontSize: pt(7.8), letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted') }; const folio = { ...runHead, fontFamily: LABEL, color: col('band') }; const head = (id, content, parity, edge, x, style = runHead) => ({ kind: 'text', id, content, parity, pages: 'body', ...style, placement: at('page', edge, mm(x), mm(HEAD)) }); const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(6) }, bodyText: { fontFamily: 'IBM Plex Serif', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.85, maxWordSpacing: 1.4, // a narrow band: an even grey, line to line maxRuntTracking: 0 }, // runt fixes tighten spaces only (gotcha: runt-tracking-unpainted) headings, headingStyles, paragraphStyles, unorderedLists, orderedLists, header: { elements: [head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('v-book', '{title}', 'even', 'top-left', OUTER + FOLIO_GAP), head('r-chapter', t({ en: 'Chapter {chapterNumber} · {chapterTitle}', es: 'Capítulo {chapterNumber} · {chapterTitle}' }), 'odd', 'top-right', -(OUTER + FOLIO_GAP)), head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }, footer: { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', ...folio, placement: at('page', 'bottom', mm(0), mm(-HEAD)) }] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 132 lines · content.en.md
title: "Trail Crew Field Manual" subtitle: "Maintenance with hand tools" author: "Postext Cookbook" --- # Drainage {lead="Where and how to build the drains of a trail, from an outsloped tread to a stone culvert." profile="Lookout Ridge Trail, km 0 to 4.2 · twelve sites flagged for new water bars"} In one season, boots pack a new trail until its tread sheds rain like a metal roof, and the rain runs down it, picking up speed and soil. This chapter shows how to turn that water off the trail before it cuts a rut. ## Why water is the enemy The faster water runs, the more soil it carries away, and it runs faster the steeper the grade and the longer the run. A sheet of water that barely moves on a flat tread turns into a cutting stream on a long, steep pitch. Once a rut forms, hikers walk beside it, the tread widens and each storm digs the rut deeper. Drainage breaks the run into short pieces, so the water never gets going. ## Reading the ground Walk the section during a storm if you can, or straight after one. Water will show you where it wants to go. Look for these signs: - silt fans below a steep pitch - puddles that hikers step around, wearing a new path beside them - a rut down the middle of the tread - shallower than a boot sole: reshape it - deeper: it needs a water bar - roots and rocks standing proud of the tread ### Flag before you dig Mark every site with flagging tape before the crew arrives, and record its station in the log: its distance from the trailhead, the grade and the structure you propose. When the section is walked and flagged, a crew leader can plan the day in minutes. ## Outslope first The cheapest drain is a tread that tilts. Shape it to fall by about 5 per cent toward the downhill edge, 3 cm across a tread 60 cm wide, so that water crosses it in a thin sheet instead of running down its length. Rake off the berm of loose soil that builds up along the outer edge, since a berm turns the tread back into a gutter. Check the tilt with a short level across the tread; an outslope too slight to see still sheds water, and a steeper one only turns ankles. ## Grade dips In a grade dip, the grade reverses for a short way: the trail drops, rises again for a few metres, then resumes its climb, and water leaves at the low point. Built into new trail, dips are almost invisible to hikers and need little upkeep. On an old trail you can often carve one with a grub hoe where the grade eases. ## Water bars: turning water off steep tread Where the grade is too steep for a dip, a water bar turns the flow across the tread. It is a line of rock or timber set into the tread at an angle, its top a little above the surface, with an armoured outlet at its lower end. ### Laying out a bar Skew the bar 30 to 45 degrees off the square, so the water keeps enough speed to carry its silt away; a bar laid straight across the tread fills with sediment after the first storm. Space the bars more closely as the grade steepens: on loose soil at 10 per cent, one every 25 or 30 metres, and closer still on a steeper pitch. #### Choosing the spot Place the bar where the water can leave with ease, in a natural hollow on the downhill side. Never let it drain onto a switchback or over a steep drop, where the outflow would cut into the slope below. ### Building a rock bar Dig a trench across the tread at the angle you chose, two thirds as deep as your tallest rock is high. Set the rocks on edge and shoulder to shoulder, with at least two thirds of each one buried, and key the upper end 30 cm into the bank so that water cannot run around it. #### The trench Keep the trench walls vertical and its floor on firm mineral soil. Throw the spoil well downhill, clear of the tread, and keep the best for backfill. On loose soil, widen the trench and line its downhill side with smaller stones, so that the bar rests on something firm. ##### Tools for the trench A grub hoe and a shovel open it, and two steel bars set the rocks. ###### Steel bars Each is about 1.5 m long; the heavier weighs as much as a loaded daypack. Lay them down when not in use, never upright against a tree. ###### Rock bar {style="level7"} The heavy one: a lever to pry rocks loose and walk them into place. Keep your fingers clear of the pivot rock and lift with your legs. ###### Tamping bar {style="level7"} The lighter bar, with a flat tamping foot at one end. Backfill in layers no thicker than a hand and tamp each one hard: the first flow carries off loose fill behind a bar. ## Knicks On flat or rolling tread where puddles gather, a knick drains water with no structure at all. It is a shallow half-moon about 3 metres long, shaved into the tread so its outer edge sits a hand’s depth below the rest. ## Check steps Where the trail climbs a gully and the water cannot be turned aside, slow it down instead. Check steps are low risers of stone or timber set across the tread, each one holding back a level bed of soil, and the water loses speed at every landing. A rise of 15 to 20 cm makes an easy step with a pack on. Key every step well into the banks. ## Lead-off ditches Water turned off the trail must go somewhere else. A lead-off ditch carries it from a bar or a dip to ground where it can spread out harmlessly. Dig it at least as wide as the outlet, give it an even fall, and end it where the plants are thick enough to catch the silt. ## Culverts Where a spring or a small stream crosses the trail, carry its water under the tread. An open culvert, two lines of flat rocks with a gap between them, is easy to clean. A culvert roofed with stone slabs makes a smoother tread but needs its inlet cleared after every storm. ## Armouring outlets Wherever water leaves the trail, it can start a gully of its own. Line the outlet of every bar, dip and culvert with a fan of stones the size of a fist, set into the soil, and carry the armour on until the flow meets plants or bedrock. ## Tool safety The crew leader checks every tool at the trailhead, and these four rules hold all day: :::paragraphs{style="rules"} **Carry.** Edged tools travel by your side, blade down and in its guard, on the downhill side of the trail, and never on a shoulder. **Spacing.** Keep two tool lengths between workers, and call out before every swing. **Rock work.** Move rocks with a bar and gravity, not with your back. Nobody stands downhill of a rock that is moving. **Protection.** A hard hat, gloves, eye protection and stiff-soled boots for the whole crew. ::: ## Recording your work Log each structure you build or clean, with its station, type, material and condition. After a season, the log shows which drains fail first: redesign those rather than repair them. ## Checklist {style="checklist"} After every big storm, walk the section with a hoe and a rock bar and work through this list: 1. Water bars 1. Clear sediment from the channel. 2. Check the outlet armour. 1. Reset stones that have moved. 2. Extend it to where plants begin. 2. Dips and knicks 1. Restore the outslope. 2. Clear the lead-off ditches. 3. Culverts 1. Clear the inlet and the outlet. 2. Rebuild any headwall that has settled. - [ ] Flag any damage too big to fix today. - [ ] Log every repair. :::paragraphs{style="colophon"} Text: CC BY 4.0, written for the Postext Cookbook · Set in IBM Plex Serif, IBM Plex Sans Condensed and IBM Plex Mono (SIL OFL) :::`; // content.<lang>.md, inlined by the Cookbook // The profile is a resource that no :ref cites: only the opener's image element draws it. const resources = [{ id: 'profile', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'profile.svg', width: TRIM.width * 10, height: DEPTH * 10 }, altText: t({ en: 'A 376 m climb in 4.2 km; amber dots mark twelve sites flagged for water bars.', es: 'Subida de 376 m en 4,2 km; puntos ámbar en doce sitios balizados para desviadores.' }) }]; // #region art: the trail's elevation profile, drawn in code with a seeded PRNG function profileSvg() { // Survey points, distance (km) and elevation (m): an easy valley, then the climb. const KM = 4.2; const pts = [[0, 1180], [0.8, 1190], [1.5, 1204], [2.1, 1226], [2.6, 1262], [3.0, 1330], [3.35, 1412], [3.7, 1486], [4.0, 1535], [4.2, 1556]]; const Y0 = DEPTH - 12; // mm: where 1180 m sits in the picture const K = 50 / 376; // mm of picture per metre of climb const elev = (d) => { // smoothstep between survey points: monotone, no overshoot const next = pts.findIndex(([x]) => x > d); const i = next < 0 ? pts.length - 2 : Math.max(0, next - 1); const [[x0, e0], [x1, e1]] = [pts[i], pts[i + 1]]; const u = Math.min(1, (d - x0) / (x1 - x0)); return e0 + (e1 - e0) * u * u * (3 - 2 * u); }; let seed = 18; // Mulberry32: the same wobble on every run const rand = () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const N = 220; const crest = Array.from({ length: N + 1 }, (_, i) => [(TRIM.width * i) / N, Y0 - (elev((KM * i) / N) - 1180) * K + (rand() - 0.5) * 0.5]); const xy = (list) => list.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join('L'); // Contour bands every 50 m, from the band green in the valley to sage on the ridge: each // band is the profile clipped between two contours. const mix = (a, b, u) => '#' + [1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - u) + parseInt(b.slice(i, i + 2), 16) * u).toString(16).padStart(2, '0')).join(''); const bands = Array.from({ length: 8 }, (_, k) => { const floor = Y0 - k * 50 * K; const top = crest.map(([x, y]) => [x, Math.min(floor, Math.max(y, floor - 50 * K))]); return `<path d="M0 ${floor}L${xy(top)}L${TRIM.width} ${floor}Z" ` + `fill="${mix(palette.band, palette.sage, k / 7)}"/>`; }).join(''); // The twelve flagged sites, placed one per 32 m of climb: they crowd where it steepens. const dots = Array.from({ length: 12 }, (_, k) => { const target = 1180 + 32 * (k + 0.5); let [lo, hi] = [0, KM]; for (let it = 0; it < 40; it++) { const mid = (lo + hi) / 2; if (elev(mid) < target) lo = mid; else hi = mid; } return `<circle cx="${((TRIM.width * lo) / KM).toFixed(2)}" ` + `cy="${(Y0 - (target - 1180) * K).toFixed(2)}" r="1.9" fill="${palette.signal}" ` + `stroke="${palette.ink}" stroke-width="0.35"/>`; }).join(''); return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM.width * 10}" ` + `height="${DEPTH * 10}" viewBox="0 0 ${TRIM.width} ${DEPTH}">` + `<rect width="${TRIM.width}" height="${DEPTH}" fill="${palette.tint}"/>` + `<path d="M0 ${DEPTH}L${xy(crest)}L${TRIM.width} ${DEPTH}Z" fill="${palette.band}"/>` + `${bands}<path d="M${xy(crest)}" fill="none" stroke="${palette.ink}" ` + `stroke-width="0.7" stroke-linejoin="round"/>${dots}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'IBM Plex Serif': ['400', '400i', '700'], 'IBM Plex Sans Condensed': ['500i', '600', '700'], 'IBM Plex Mono': ['400', '500', '600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('profile.svg', profileSvg()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Section heads seven levels deep', es: 'Títulos de sección hasta siete niveles' }) });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
#Drop the chapter number from the pills
Take the chapter's counter out of the template and the pills read 1 to 12, widening at 10; level 3 prints 1.5.1 until its own template drops {1} too.
-const h2 = { level: 2, numberingTemplate: '{1}.{2}',
+const h2 = { level: 2, numberingTemplate: '{2}',#Give every pill the same width
A fixed width, that of 1.10, centres each number in an equal pill and lines the titles up in one column.
- placement: at('container', 'top-left') }; // plus its padding
+ placement: { ...at('container', 'top-left'), size: { width: mm(12.7) } } };Pitfalls
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 drops its bold and italic marks
In postext 1.4.1 a heading line loses its inline marks: ###### *Rock bar* prints Rock bar in the plain level-6 face, without the asterisks and without italic. A seventh level, or a word set apart inside a title, needs a heading style ({style="…"}) or an advanced design instead. Heading styles →
Pitfall
Design text overflow defaults to 'ellipsis-end'
A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines. Text, rules and boxes in page designs →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
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
A 'top' float never lands on its citing page
A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page. Figure placement →
Pitfall
Text inside an SVG <img> cannot use web fonts
An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →
Pitfall
Lists say 'arabic', resources 'roman-upper', pages 'upper-roman'
Each numbering setting spells its formats differently: lists take numberFormat 'arabic' ('decimal' prints "undefined"), resource types take counterFormat 'roman-upper', pages and :::numbering take 'upper-roman'. Numbered lists →
Pitfall
Most warnings exist only in the Sandbox
Unknown ids, styles and directives, missing fonts and loose lines are checked by the Sandbox, not the engine: a pen only gets doc.warnings and parseMarkdownWithIssues. An unknown style silently falls back and an unknown directive prints as text, so check your ids. Warnings and diagnostics →
Pitfall
A no-break space still breaks the line
In postext 1.4.1 the line breaker treats U+00A0 as an ordinary space, so 0.08 %, 2.006 s or Section 2 can split across two lines. Close the pair up (0.08%) or reword the sentence. Escapes and literal characters →
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
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
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
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 →
Sandbox check · headingHierarchy
Heading hierarchy jump
Why. A heading skips a level, such as an H1 followed directly by an H3.
Fix. Use the next level down, or restyle the level you meant instead of skipping one. Docs →
- The seventh level is a level-6 heading with the style
level7, so no head in the sample drops more than one level below the head before it: The trench, Tools for the trench, STEEL BARS and Rock bar run 4, 5, 6, 6. The Sandbox warning “Heading hierarchy jump” reports exactly such a skip, so it never appears on this chapter. - A lead-in line that ends in a colon stays with its list only if the first item fits in the room left: in 1.4.1 the rule checks for one line, so a two-line first item that meets a single free line moves to the next column alone and strands the colon. Section 1.2 keeps its first item to one line in both editions.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- IBM Plex Serif (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)


