What you'll build
Birds of the Estuary is a pocket field guide in two parts, one per habitat. The mudflats print in mud brown and the reedbeds in reed green, and each colour is written once, in the :::part that opens its habitat. Each divider is a full-bleed field with a 150 pt Roman numeral and the habitat's species listed in cream. The back of the leaf is painted to match, with the part's tab in reverse at the fore-edge. On the species pages the thumb tab, the folio, the kicker, the rule under the Latin name, the bold field marks, the bullets and the plate label take the part's colour. The contents open under a strip of the estuary and give each part a band in its colour, with its divider's page number. The drawings carry the two hexes in their SVG code, because a part's palette does not reach pictures.
This recipe answers
- How do I divide a book into parts or sections, each with its own colour and divider page?
- How do I hide running heads on openers and blank pages, or paint a blank verso in the part colour?
- How do I add a table of contents that updates itself (leaders, page numbers, authors, part rows)?
- How do I colour key terms (bold or italic) in the body or inside boxes?
The short answer
// In the Markdown: :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"}
// Every colour below that is linked to 'band' takes #8c5e24 until the next part.
const parts = { // passed to the config as `parts`
// A divider opens on a recto by default. The break after it goes to the next recto too, so
// the back of the leaf stays blank and versoDesign paints it (gotcha: verso-design-breakafter).
breakAfter: { parity: 'odd' },
margins: { top: mm(125) }, // the fence's text starts low, under the title
design: { elements: [ // the divider: its container is the whole trim
box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
text('part', t({ en: 'Part', es: 'Parte' }), { ...label, fontSize: pt(10),
letterSpacing: pt(2.4), color: col('paper') },
at('top-left', MARGIN.inner, MARGIN.top)), // a recto: the inner margin is on the left
// Roman for the parts, Arabic for the species. {numberRoman} re-formats number="I" (or
// "1"), on part pages only (gotcha: heading-number-placeholders).
text('numeral', '{numberRoman}', { ...display, fontSize: pt(150), lineHeight: 0.9,
color: col('paper') }, below('part', 0)),
// The \\ in the title breaks the line here; the contents and the heads get one line.
text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98,
color: col('paper') }, below('numeral', 2, { width: mm(MEASURE) })),
rule('rule', col('paper'), 1, below('title', 7, { width: mm(14) })),
] },
// The back of the leaf: the same band, edge to edge, the part's tab in reverse at the
// fore-edge (a verso's is on the left) and its name at the foot.
versoDesign: { elements: [
box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
text('tab', '{partNumber}', { ...label, fontSize: pt(9), color: col('band'), align: 'center',
box: { backgroundColor: col('paper') } }, { ...at('top-left', 0, TAB.y), size: TAB.size }),
text('name', '{partTitle}', { ...label, fontSize: pt(9), letterSpacing: pt(2.4),
color: col('paper') }, at('bottom-left', MARGIN.outer, -MARGIN.bottom)),
] },
// The fence's list of species, in the paper colour on the band.
bodyStyle: { fontSize: pt(11), color: col('paper'), textAlign: 'left', numberColor: col('paper'),
orderedLists: { fontFamily: 'Barlow Condensed', separator: '', gap: mm(4) } },
};
A divider, its painted verso and its list, all in the part's own 'band'
Ingredients
- Features
- Colours per partPart divider pagesTable of contentsSemantic colour paletteBold, italic and their coloursBleed bands and thumb tabsHeads by page roleRunning heads and foliosDesigned openersHeading attributesNumbered headingsHeading stylesUnnumbered chaptersLine breaks in titlesCovers, title pages and colophonsPictures in page designsCustom resource typesFigures exactly herePinned boxes and badgesPage and column breaks
- Also uses
- Callout boxesPaper colourParagraph stylesFigures and tables as resourcesExplicit vertical space
- Type
- Alegreya, Zilla Slab, Barlow Condensed (SIL OFL 1.1)
- Assets
- The cover, the contents strip and the four plates, drawn in code (Ignacio Ferro, CC BY 4.0)
Method
#1 · One palette entry for the parts to override
const palette = {
ink: '#1f2624', // text: a green-tinted near-black
band: '#3c4b4f', // the house slate, before any part; each :::part brings its own
paper: '#f6f3ea', // the page, and the type set on a band
rule: '#d5d1c4', // hairlines
muted: '#61675f', // running heads, Latin names, the colophon
};
// The paletteId is the link a part's palette="band=#…" follows; the hex is written out too,
// as the palette alone would not reach design elements (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' } },
];
A part overrides palette entries by id, so everything that should change with the habitat links to one entry, band. Its own value, a slate, prints only outside the parts (the cover's kicker and the title of the note under the contents), and no other colour in the config has the same hex. col() writes the hex beside the id, because in Postext 1.4.1 the document palette never reaches design elements; a part's override does, through the id.
#2 · A divider, its verso and its list
The code for this step is the short answer above. A :::part opens a divider page on a recto, and parts.design lays it out over the whole trim. The Markdown between its fences starts 125 mm from the top edge, under the title: a few lines on the habitat in the habitat paragraph style, then its list of species in bodyStyle. versoDesign paints the back of the leaf (the field, and the tab in reverse) only when that page is blank, and breakAfter: { parity: 'odd' } keeps it blank by sending the first species on to the next recto. {numberRoman} prints the part's number="I" as a Roman numeral ("1" would print the same I), while the species are numbered 1 to 4 in Arabic figures. The \\ in the title breaks the line on the divider only; the contents and the running heads set the title on one line.
#3 · The text's accents follow the part
const bodyText = {
fontFamily: 'Alegreya', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('band'), // the field marks: **Bill**, **Voice.**
italicColor: col('ink'), referenceColor: col('ink'),
firstLineIndent: mm(4), indentAfterHeading: false,
// Tighter than the 0.6–2 defaults. runtMinCharacters counts word spaces, not letters:
// 45 are about 20 letters of Alegreya (the default 20, about 9); a shorter last line is a runt.
minWordSpacing: 0.75, maxWordSpacing: 1.7, runtMinCharacters: 45,
// A runt is fixed with word spacing only: the default also tightens the tracking, which
// 1.4.1 measures but never paints (gotcha: runt-tracking-unpainted).
maxRuntTracking: 0,
};
const unorderedLists = { bulletChar: '▪', color: col('band'), // the field marks' bullets
marginTop: pt(0), marginBottom: pt(0) };
// The plates are numbered with the species, and their label is in the part's colour.
const resourceTypes = [{ id: 'plate', numberingTemplate: '{n}', resetOn: 'never',
counterFormat: 'decimal', ...t({
en: { name: 'Plate', namePlural: 'Plates', shortLabel: 'Pl.', captionPrefix: 'Plate' },
es: { name: 'Lámina', namePlural: 'Láminas', shortLabel: 'Lám.', captionPrefix: 'Lámina' },
}) }];
const captionStyle = { fontSize: pt(8.5), labelColor: col('band'), descriptionItalic: true,
gap: mm(1.8) };
From one :::part to the next, every text colour equal to band's own value takes the part's value (the :::part container). Here those are the bold field marks, the bullets and the plate labels. Body text and italics stay in ink. The plates are a resource type of their own, labelled Plate and never reset, so they count 1 to 4 in step with the species.
#4 · The species opener
const species = { enabled: true, slot: { elements: [
text('kicker', '{number} · {attr.status}', { ...label, fontSize: pt(8.5),
letterSpacing: pt(1.7), color: col('band') }, at('top-left', 0, 1, 'container')),
// 'cm' is a unit symbol: it keeps its lower case, so the size is not set in capitals.
text('size', '{attr.size}', { ...label, textTransform: 'none', fontSize: pt(8.5),
letterSpacing: pt(0.5), color: col('muted') }, at('top-right', 0, 1, 'container')),
text('name', '{titleText}', { ...display, fontSize: pt(22), lineHeight: 1.05,
color: col('ink') }, below('kicker', 1.5, { width: 'fill' })),
text('latin', '{attr.latin}', { fontFamily: 'Alegreya', italic: true, fontSize: pt(11.5),
color: col('ink') }, below('name', 0.8)),
rule('rule', col('band'), 0.75, below('latin', 2, { width: mm(MEASURE) })),
] } };
The species opener prints three of the heading's attributes: status in the kicker after the number, size at the right of the kicker and latin under the name. The kicker and the rule under the Latin name link to band, so one design serves both habitats. The heading sets no page break of its own: each species starts after a :::pagebreak, or after the break that follows the part, so its page counts as a body page and prints the running heads. The size is set without the label's capitals, because cm is a unit symbol.
#5 · A band per part in the contents
const contents = {
levels: [{ level: 1, fontFamily: 'Zilla Slab', fontSize: pt(12), fontWeight: 600,
numberFontFamily: 'Barlow Condensed', numberFontWeight: 600, numberColor: col('muted'),
numberWidth: mm(5), numberGap: mm(3), marginTop: pt(4) }],
pageNumber: { fontFamily: 'Barlow Condensed', fontSize: pt(10), fontWeight: 600, width: mm(7) },
leader: { char: '. ' },
subtitle: { enabled: true, attr: 'latin', fontFamily: 'Alegreya', fontSize: pt(9.5),
color: col('muted') }, // the Latin name, in italic by default
// A part row lays out this design with the part's number, title, page and palette.
parts: { height: mm(8.5), marginTop: pt(LEAD), design: { elements: [
box('row', col('band'), { ...at('top-left', 0, 0, 'container'), ...fill }),
text('part', t({ en: 'Part {number}', es: 'Parte {number}' }), { ...label, fontSize: pt(8),
letterSpacing: pt(1.6), color: col('paper') }, at('left', 3, 0, 'container')),
text('title', '{titleText}', { ...display, fontSize: pt(12), color: col('paper') },
at('left', 20, 0, 'container')),
text('page', '{pageNumber}', { ...label, fontSize: pt(10), color: col('paper'),
align: 'right' }, at('right', -2.5, 0, 'container')),
] } },
};
:::toc gives every :::part a row and lays toc.parts.design out in it with that part's number, title, divider page and palette (table of contents), so the same box prints brown for the mudflats and green for the reedbeds. Each species row under a band shows its number, a dotted leader and its page, with the Latin name from the latin attribute on the line below.
#6 · The part in the running heads and the tab
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label,
fontSize: pt(8), letterSpacing: pt(1.4), color: col('muted'), overflow: 'clip', ...style },
placement), parity, pages: 'body' }); // dividers are 'part' pages, their versos 'blank'
const folio = { fontSize: pt(9), fontWeight: 700, color: col('band') };
const tab = (parity, edge) => head(`tab-${parity}`, '{partNumber}', parity,
{ ...at(edge, 0, TAB.y), size: TAB.size },
{ fontSize: pt(9), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } });
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio),
head('verso-title', BOOK, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
head('recto-title', '{partTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)),
head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio),
tab('even', 'top-left'), tab('odd', 'top-right'), // on the fore-edge, left on a verso
] };
With pages: 'body' the heads print on body pages only. The dividers are part pages and their painted versos blank pages; the cover and the contents each open with a page-wide heading, which makes them openers. The recto head prints {partTitle} on one line, and the tab at the fore-edge prints {partNumber} on a box whose background links to band.
The whole recipe
// ═══ Postext Cookbook · Nº 019 · Parts in colour from one attribute ═══════════════════════ // https://postext.dev/en/cookbook/parts-in-colour // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Alegreya, Zilla Slab, Barlow Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A pocket field guide to two habitats. Each :::part names its own 'band' colour, and every // colour linked to 'band' takes it: the divider and its verso, the tab, the field marks. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'parts-in-colour'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: 'band' is the entry the parts override; the others keep their value const palette = { ink: '#1f2624', // text: a green-tinted near-black band: '#3c4b4f', // the house slate, before any part; each :::part brings its own paper: '#f6f3ea', // the page, and the type set on a band rule: '#d5d1c4', // hairlines muted: '#61675f', // running heads, Latin names, the colophon }; // The paletteId is the link a part's palette="band=#…" follows; the hex is written out too, // as the palette alone would not reach design elements (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: 150, height: 200 }; // a pocket guide const MARGIN = { top: 22, bottom: 20, inner: 19, outer: 21 }; // mirrored; room for a thumb const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 110 mm, about 70 characters const LEAD = 14.5; // body leading in pt: the baseline grid const PLATE = { width: MEASURE, height: 55 }; // each species' plate, at the text width (mm) const STRIP = 44; // the contents' picture strip, from the top edge (mm) const HEAD = { y: 12, gap: 8 }; // running heads from the top edge; folio to title (mm) const TAB = { y: 26, size: { width: mm(8), height: mm(24) } }; // the thumb tab, at the fore-edge const TITLE_DROP = 6; // mm from the foot of the contents strip to the title's box const BOOK = t({ en: 'Birds of the Estuary', es: 'Aves del estuario' }); const label = { fontFamily: 'Barlow Condensed', fontWeight: 600, textTransform: 'uppercase' }; const display = { fontFamily: 'Zilla Slab', fontWeight: 700 }; const at = (edge, x, y, to = 'page') => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const below = (id, y, size) => ({ ...at('below', 0, y, `#${id}`), ...(size && { size }) }); const fill = { size: { width: 'fill', height: 'fill' } }; const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap', align: 'left', ...style, placement }); const box = (id, color, placement) => ({ kind: 'box', id, style: { backgroundColor: color }, placement }); const rule = (id, color, w, placement) => ({ kind: 'rule', id, color, thickness: pt(w), placement }); // #region answer: a divider, its painted verso and its list, all in the part's own 'band' // In the Markdown: :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"} // Every colour below that is linked to 'band' takes #8c5e24 until the next part. const parts = { // passed to the config as `parts` // A divider opens on a recto by default. The break after it goes to the next recto too, so // the back of the leaf stays blank and versoDesign paints it (gotcha: verso-design-breakafter). breakAfter: { parity: 'odd' }, margins: { top: mm(125) }, // the fence's text starts low, under the title design: { elements: [ // the divider: its container is the whole trim box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }), text('part', t({ en: 'Part', es: 'Parte' }), { ...label, fontSize: pt(10), letterSpacing: pt(2.4), color: col('paper') }, at('top-left', MARGIN.inner, MARGIN.top)), // a recto: the inner margin is on the left // Roman for the parts, Arabic for the species. {numberRoman} re-formats number="I" (or // "1"), on part pages only (gotcha: heading-number-placeholders). text('numeral', '{numberRoman}', { ...display, fontSize: pt(150), lineHeight: 0.9, color: col('paper') }, below('part', 0)), // The \\ in the title breaks the line here; the contents and the heads get one line. text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98, color: col('paper') }, below('numeral', 2, { width: mm(MEASURE) })), rule('rule', col('paper'), 1, below('title', 7, { width: mm(14) })), ] }, // The back of the leaf: the same band, edge to edge, the part's tab in reverse at the // fore-edge (a verso's is on the left) and its name at the foot. versoDesign: { elements: [ box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }), text('tab', '{partNumber}', { ...label, fontSize: pt(9), color: col('band'), align: 'center', box: { backgroundColor: col('paper') } }, { ...at('top-left', 0, TAB.y), size: TAB.size }), text('name', '{partTitle}', { ...label, fontSize: pt(9), letterSpacing: pt(2.4), color: col('paper') }, at('bottom-left', MARGIN.outer, -MARGIN.bottom)), ] }, // The fence's list of species, in the paper colour on the band. bodyStyle: { fontSize: pt(11), color: col('paper'), textAlign: 'left', numberColor: col('paper'), orderedLists: { fontFamily: 'Barlow Condensed', separator: '', gap: mm(4) } }, }; // #endregion // #region flow: the accents of the text, linked to 'band' so that each part retints them const bodyText = { fontFamily: 'Alegreya', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('band'), // the field marks: **Bill**, **Voice.** italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // Tighter than the 0.6–2 defaults. runtMinCharacters counts word spaces, not letters: // 45 are about 20 letters of Alegreya (the default 20, about 9); a shorter last line is a runt. minWordSpacing: 0.75, maxWordSpacing: 1.7, runtMinCharacters: 45, // A runt is fixed with word spacing only: the default also tightens the tracking, which // 1.4.1 measures but never paints (gotcha: runt-tracking-unpainted). maxRuntTracking: 0, }; const unorderedLists = { bulletChar: '▪', color: col('band'), // the field marks' bullets marginTop: pt(0), marginBottom: pt(0) }; // The plates are numbered with the species, and their label is in the part's colour. const resourceTypes = [{ id: 'plate', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...t({ en: { name: 'Plate', namePlural: 'Plates', shortLabel: 'Pl.', captionPrefix: 'Plate' }, es: { name: 'Lámina', namePlural: 'Láminas', shortLabel: 'Lám.', captionPrefix: 'Lámina' }, }) }]; const captionStyle = { fontSize: pt(8.5), labelColor: col('band'), descriptionItalic: true, gap: mm(1.8) }; // #endregion // #region species: each entry opens with its number and status, name, Latin and a 'band' rule const species = { enabled: true, slot: { elements: [ text('kicker', '{number} · {attr.status}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7), color: col('band') }, at('top-left', 0, 1, 'container')), // 'cm' is a unit symbol: it keeps its lower case, so the size is not set in capitals. text('size', '{attr.size}', { ...label, textTransform: 'none', fontSize: pt(8.5), letterSpacing: pt(0.5), color: col('muted') }, at('top-right', 0, 1, 'container')), text('name', '{titleText}', { ...display, fontSize: pt(22), lineHeight: 1.05, color: col('ink') }, below('kicker', 1.5, { width: 'fill' })), text('latin', '{attr.latin}', { fontFamily: 'Alegreya', italic: true, fontSize: pt(11.5), color: col('ink') }, below('name', 0.8)), rule('rule', col('band'), 0.75, below('latin', 2, { width: mm(MEASURE) })), ] } }; // #endregion // #region contents: a band per part in that part's colour, then its species with leaders const contents = { levels: [{ level: 1, fontFamily: 'Zilla Slab', fontSize: pt(12), fontWeight: 600, numberFontFamily: 'Barlow Condensed', numberFontWeight: 600, numberColor: col('muted'), numberWidth: mm(5), numberGap: mm(3), marginTop: pt(4) }], pageNumber: { fontFamily: 'Barlow Condensed', fontSize: pt(10), fontWeight: 600, width: mm(7) }, leader: { char: '. ' }, subtitle: { enabled: true, attr: 'latin', fontFamily: 'Alegreya', fontSize: pt(9.5), color: col('muted') }, // the Latin name, in italic by default // A part row lays out this design with the part's number, title, page and palette. parts: { height: mm(8.5), marginTop: pt(LEAD), design: { elements: [ box('row', col('band'), { ...at('top-left', 0, 0, 'container'), ...fill }), text('part', t({ en: 'Part {number}', es: 'Parte {number}' }), { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('paper') }, at('left', 3, 0, 'container')), text('title', '{titleText}', { ...display, fontSize: pt(12), color: col('paper') }, at('left', 20, 0, 'container')), text('page', '{pageNumber}', { ...label, fontSize: pt(10), color: col('paper'), align: 'right' }, at('right', -2.5, 0, 'container')), ] } }, }; // #endregion // #region running-heads: on body pages only, never on a divider or its verso; a 'band' tab const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label, fontSize: pt(8), letterSpacing: pt(1.4), color: col('muted'), overflow: 'clip', ...style }, placement), parity, pages: 'body' }); // dividers are 'part' pages, their versos 'blank' const folio = { fontSize: pt(9), fontWeight: 700, color: col('band') }; const tab = (parity, edge) => head(`tab-${parity}`, '{partNumber}', parity, { ...at(edge, 0, TAB.y), size: TAB.size }, { fontSize: pt(9), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } }); const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio), head('verso-title', BOOK, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)), head('recto-title', '{partTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)), head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio), tab('even', 'top-left'), tab('odd', 'top-right'), // on the fore-edge, left on a verso ] }; // #endregion // The cover and the contents are headings with no number and no contents entry. They get // no running heads either: a page-wide heading makes its page an 'opener', not 'body'. const unlisted = { numbered: false, toc: false, span: 'page' }; const cover = { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { ...at('top-left', 0, 0, 'bleed'), size: { width: 'fill' } } }, text('kicker', '{subtitle}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8), color: col('band') }, at('top-left', MARGIN.inner, MARGIN.top)), // slate: no part yet text('title', '{titleText}', { ...display, fontSize: pt(50), lineHeight: 0.95, color: col('ink') }, below('kicker', 3, { width: mm(120) })), ] } }; // The contents open under a strip of the estuary: mud and waders, then the reeds. const contentsOpener = { enabled: true, minHeight: mm(STRIP), slot: { elements: [ { kind: 'image', id: 'strip', resourceId: 'strip', placement: { ...at('top-left', 0, 0, 'bleed'), size: { width: 'fill' } } }, text('title', '{titleText}', { ...display, fontSize: pt(26), color: col('ink') }, at('top-left', 0, STRIP - MARGIN.top + TITLE_DROP, 'container')), ] } }; const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // hyphenation, by exact code (gotcha: hyphenation-locales) 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 } }, layout: { layoutType: 'single' }, bodyText, unorderedLists, resourceTypes, captionStyle, headings: { ...display, levels: [ // breakBefore stated: the documented H1 page break would make each species page an // 'opener', with no running heads (gotcha: headings-drop-h1-break). :::pagebreak instead. { level: 1, fontSize: pt(22), breakBefore: { enabled: false }, numberingTemplate: '{1}', marginBottom: pt(0), advancedDesign: species }, ] }, headingStyles: [ { id: 'cover', ...unlisted, advancedDesign: cover }, { id: 'contents', ...unlisted, advancedDesign: contentsOpener }, ], toc: contents, parts, paragraphStyles: [ // The habitat's few lines on a divider: no indent, in the paper colour. { id: 'habitat', fontSize: pt(11), color: col('paper'), textAlign: 'left', firstLineIndent: pt(0), marginBottom: pt(LEAD / 2) }, // In the box its margins do not count; the leading adds air (gotcha: box-paragraph-margins). { id: 'colophon', fontFamily: 'Barlow Condensed', fontSize: pt(8), lineHeight: pt(15), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0) }, ], // The note under the contents is pinned to the foot of the text block. calloutStyles: [{ id: 'about', placement: 'fixed', backgroundEnabled: false, 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(8), letterSpacing: pt(1.6), color: col('band') }, body: { fontSize: pt(9.5), lineHeight: pt(13), firstLineIndent: pt(0), textAlign: 'left' } }], header, footer: { elements: [] }, // the default footer would centre a folio in Open Sans }); // #region art: the cover, the strip and the plates, drawn in code from a fixed seed // The habitats' colours, as each :::part writes them. An SVG keeps the colours written in it, // so the drawings are painted in their part's colours here, not by the palette. const HABITAT = { mud: '#8c5e24', reed: '#51702f' }; let seed = 2026; // 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 pts = (...values) => values.map(n).join(' '); // path coordinates 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 tint of a towards b const paint = (c, o = 1) => `fill="${c}"${o < 1 ? ` fill-opacity="${o}"` : ''}`; const stroke = (c, w, o = 1) => `fill="none" stroke="${c}" stroke-width="${w}" ` + `stroke-linecap="round" stroke-linejoin="round"${o < 1 ? ` stroke-opacity="${o}"` : ''}`; const sheet = ({ width, height }, body) => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${width * 10}" height="${height * 10}" viewBox="0 0 ${width} ${height}">${body}</svg>`; const place = (x, y, k, body) => `<g transform="translate(${n(x)} ${n(y)}) scale(${k})">` + `${body}</g>`; const PLUME = '#8d7565'; // reed plumes: a purple-brown const WATER = '#71878d'; // the tide's edge on the cover: a grey estuary blue // The birds face left in a 100-unit box, feet at y = 68. Waders keep their outline apart, // for the reflection on the wet mud. const CURLEW = { body: 'M21.5 17 C26 20.5 30.5 25 31.5 31 C30 39 37 47 49 48.5 C60 50 71 46 80 40.5 L90.5 35.5 ' + 'C86 33 81 31 76 30 C68 25 58 22.5 49 22.5 C42.5 22 39 19.5 35.5 14.5 C33.5 10 31 7.5 27 7.5 ' + 'C23 7.5 20.3 10 20.5 13.3 C20.6 15 20.8 16.3 21.5 17 Z', bill: 'M20.8 11.2 C12 12.4 5 18 0.9 30.4 C0.7 31 1.3 31.1 1.5 30.6 C6 21.2 13 16.8 21.2 15.2 Z', legs: 'M47 46 L43.8 68 M54 46 L58.5 68', draw() { const [brown, dark, pale, legs] = ['#8a7556', '#5b4a35', '#dcd0b6', '#76868d']; let g = `<path d="M54 46 L56 56.5 L58.5 68 M58.5 68 l3.2 0.2 M58.5 68 l-2.3 0.3" ` + `${stroke(legs, 1.6)}/><path d="M47 46 L45.8 56.5 L43.8 68 M43.8 68 l-3.5 0.2 ` + `M43.8 68 l2.5 0.4" ${stroke(legs, 1.7)}/><path d="${this.body}" ${paint(brown)}/>` + `<path d="M31.8 33 C31.5 41 39 47 49.5 48 C58 48.8 66 46.5 73 42.8 C62 44 51 42.5 43 38.5 ` + `C38 36 34 34.5 31.8 33 Z" ${paint(pale)}/><path d="M42 26.5 C52 23.5 66 25 76 30 ` + `C82 32 88 34 92 36.2 C84 38.5 76 39.4 68 39.4 C58 39.4 48 36 42 30.5 Z" ${paint(dark)}/>`; for (let i = 0; i < 5; i++) { // pale edges of the folded wing g += `<path d="M${51 + i * 7.2} ${n(30.2 + i * 1.25)} q5 2.4 10.5 2.5" ` + `${stroke(pale, 0.75, 0.75)}/>`; } for (let i = 0; i < 46; i++) { // streaks on the neck and breast, inside the outline const y = 12 + rand() * 24; const front = y < 18 ? 21.5 + (y - 12) * 0.2 : 22.7 + (y - 18) * 0.55; const back = y < 22 ? 30 + (y - 12) * 0.8 : 40; g += `<path d="M${n(front + 1.2 + rand() * (back - front - 2.4))} ${n(y)} l0.25 1.3" ` + `${stroke(dark, 0.55, 0.75)}/>`; } return `${g}<path d="${this.bill}" ${paint('#352c26')}/><path d="M21.6 9.8 ` + `C23.2 8.4 26.2 8.2 28.6 9.3" ${stroke(pale, 0.9, 0.85)}/>` + `<circle cx="24.6" cy="11.5" r="1.05" ${paint(palette.ink)}/>`; }, }; const REDSHANK = { body: 'M20 22 C23 27 26 30 28 35 C27 43 36 51 50 51.5 C61 52 71 47 79 42 L90 36 C86 33.5 82 32 ' + '76 31 C67 26 58 24.5 48 25 C41 24.5 36 22 33 17 C31 13.5 28.5 12 25 12 C20.5 12 18 15 18.3 ' + '18.4 C18.5 20 19 21.2 20 22 Z', bill: 'M19 16.8 L3.5 20.6 L3.3 21.3 L19.2 21 Z', legs: 'M50 49 L46.5 68 M56 49 L60.5 68', draw() { const [back, dark, white, legs] = ['#86796a', '#5f5446', '#f4f1e8', '#dd5530']; let g = `<path d="M56 49 L58.5 58.5 L60.5 68 M60.5 68 l3 0.2 M60.5 68 l-2.4 0.3" ` + `${stroke(legs, 1.9)}/><path d="M50 49 L48.5 58.5 L46.5 68 M46.5 68 l-3.4 0.2 ` + `M46.5 68 l2.4 0.4" ${stroke(legs, 2)}/><path d="${this.body}" ${paint(back)}/>` + `<path d="M27.8 37 C28 44 36 50.5 50 51 C60 51.4 68 48.5 75 44.5 C63 46 52 45 43 41.5 ` + `C37 39.5 31 38.5 27.8 37 Z" ${paint(white)}/><path d="M42 29 C52 26 65 27.5 75 31.5 ` + `C81 33.5 87 35 91.5 37 C84 39.5 76 40.5 68 40.5 C58 40.5 48 37.5 42 32.5 Z" ` + `${paint(dark)}/>`; for (let i = 0; i < 26; i++) { // pale spots on the wing const x = 48 + rand() * 38; const y = 31.5 + rand() * 6 + (x - 48) * 0.05; if (y > 29 + (x - 42) * 0.2 && y < 39) { g += `<circle cx="${n(x)}" cy="${n(y)}" r="0.45" ${paint(white, 0.7)}/>`; } } for (let i = 0; i < 30; i++) { // streaks on the breast const y = 24 + rand() * 13; g += `<path d="M${n(23 + (y - 22) * 0.35 + rand() * 9)} ${n(y)} l0.2 1" ` + `${stroke(dark, 0.55, 0.7)}/>`; } return `${g}<path d="${this.bill}" ${paint('#2e2622')}/>` // the bill, red at the base + `<path d="M19 16.8 L11 18.8 L11 21.1 L19.2 21 Z" ${paint('#c9452b')}/>` + `<circle cx="23.2" cy="17.2" r="1.9" ${paint(white)}/>` + `<circle cx="23.2" cy="17.2" r="1.05" ${paint(palette.ink)}/>`; }, }; // A male clinging to a stem that stands at x = 46.2 of his box. const REEDLING = { draw() { const [tawny, grey, cream, wing] = ['#c68a50', '#9aa8b2', '#f2ede2', '#a8733f']; const dark = palette.ink; return `<path d="M24 31 C24 25.5 28 22.5 32 23 C36.5 23.5 38.5 27 38.5 31 C43 34 46 39 46 46 ` + `C46 51 44.5 55 43.5 58.5 L55.2 89.6 C56 92.6 52.2 94.6 50.6 92.2 L37.8 62 C32 61 27 57 ` + `25.5 51 C24 46 24.5 41 26.5 37.5 C25 35.5 24 33.5 24 31 Z" ${paint(tawny)}/>` + `<path d="M24 31 C24 25.5 28 22.5 32 23 C36.5 23.5 38.5 27 38.5 31 C36 33.5 31 35 27 35.5 ` + `C25.2 34.5 24 33 24 31 Z" ${paint(grey)}/><path d="M25 35.5 C27 36 28.5 38 29.5 41 ` + `C28.5 44 27.8 46.5 27.4 49 C25.3 45 24.6 40 25 35.5 Z" ${paint(cream)}/>` + `<path d="M26.3 30.8 C28.6 31.8 30.6 35 30.9 40.8 C29.3 40 27.8 37.2 27 35.2 ` + `C26.4 33.8 26 32.2 26.3 30.8 Z" ${paint(dark)}/>` // the moustache + `<path d="M37 35.5 C42.5 38 45.2 43.5 45.2 49.5 C44.4 53.5 42.5 56 40.3 57.2 ` + `C38.2 51 37 44 37 35.5 Z" ${paint(wing)}/><path d="M39.5 42 C41 47 41.8 51.5 41.5 56" ` + `${stroke(dark, 1.3)}/><path d="M38 43.5 C39.3 48 39.8 52 39.5 56.3" ${stroke(cream, 0.8)}/>` + `<path d="M36.8 58.8 C38.6 60.8 41 61.6 43 60.6 L41.8 57.8 Z" ${paint(dark)}/>` + `<path d="M44.2 60 L54.2 90" ${stroke(cream, 0.55, 0.8)}/>` + `<path d="M41 62 L51.5 90.5" ${stroke(wing, 0.5)}/>` + `<path d="M24.4 28.4 L19.8 29.8 L24.5 31.1 Z" ${paint('#e2a43c')}/>` + `<circle cx="27.8" cy="28.9" r="1.3" ${paint('#e7b53e')}/>` + `<circle cx="27.8" cy="28.9" r="0.62" ${paint(dark)}/>` + `<path d="M42.5 50.5 L47.5 51.2 M43 56.5 L47.6 57" ${stroke('#2c2724', 0.9)}/>`; } }; // Singing, one foot on each of two stems at x = 37 and 55 of its box. const WARBLER = { draw() { const [brown, buff, throat, dark] = ['#9a7a55', '#e5d4b2', '#f3ecdc', '#4a3d31']; return `<path d="M38 50 L37 58 M35 58.4 L39 57.6 M52 49 L55 58 M53 58.4 L57 57.6" ` + `${stroke('#8c7c6c', 1.3)}/><path d="M14 37 C15.5 33 19.5 31 24.5 31.2 C29 31.4 32 33.5 ` + `34 36.5 C42 36.3 52 37.5 60 40 L71 42.2 C73.5 42.8 74 46.2 71.6 46.8 L60 46.5 C54 50 46 52 ` + `38 51 C30 50 24 46.5 22 42.5 C19 41.5 15.5 40 14 37 Z" ${paint(brown)}/>` + `<path d="M22 42.5 C26 43.5 32 45 40 45.5 C48 46 55 45.5 60.5 44.6 C55 49.2 46 51.8 38 51 ` + `C30 50 24 46.5 22 42.5 Z" ${paint(buff)}/><path d="M17 38.7 C19.5 39 22 40.5 23 42.8 ` + `C20 42 17.5 40.8 17 38.7 Z" ${paint(throat)}/><path d="M35 38 C44 37.5 53 38.8 60 41.2 ` + `C54 42.6 44 42.8 36 41.5 Z" ${paint('#86683f')}/>` + `<path d="M18 34.3 C21 33.6 24 34 26 35" ${stroke(throat, 0.7, 0.85)}/>` + `<path d="M14.8 34.6 L8 33.2 L14.4 36.2 Z" ${paint(dark)}/>` // the bill, open + `<path d="M14.4 37.6 L8.4 37.4 L14.6 36.4 Z" ${paint('#b8906a')}/>` + `<circle cx="20.6" cy="35.4" r="1.05" ${paint(palette.ink)}/>`; } }; // A reed from (x, foot) up to height h, leaning by `lean`, with leaves and perhaps a plume. function reed(x, foot, h, lean, colour, width, leaves, plume) { const [tx, ty] = [x + lean, foot - h]; let g = `<path d="M${pts(x, foot)} Q${pts(x + lean * 0.2, foot - h * 0.6, tx, ty)}" ` + `${stroke(colour, width)}/>`; for (let i = 0; i < leaves; i++) { const k = 0.25 + (i / leaves) * 0.6 + rand() * 0.08; const [dir, len] = [rand() < 0.5 ? -1 : 1, 7 + rand() * 9]; const [out, back] = [pts(dir * len * 0.5, -len * 0.35, dir * len, len * 0.25), pts(-dir * len * 0.45, -len * 0.28, -dir * len, -len * 0.2)]; g += `<path d="M${pts(x + lean * k * k, foot - h * k)} q${out} q${back}Z" ${paint(colour)}/>`; } for (let i = 0; plume && i < 9; i++) { // a feathery plume, drooping to one side const curl = pts(2.5 + i / 8, 1.5 + i / 4, 3 + i / 4, 5 + i * 0.375); g += `<path d="M${pts(tx, ty + i * 0.875)} q${curl}" ${stroke(plume, 0.9, 0.9)}/>`; } return g; } // Far birds in flight, a shallow 'm' each. const flock = (x, y, count, spread, colour) => Array.from({ length: count }, () => { const [fx, fy, w] = [x + rand() * spread, y + rand() * spread * 0.3, 1.2 + rand() * 0.8]; return `<path d="M${pts(fx - w, fy - 0.4)} Q${pts(fx - w / 2, fy - 0.9, fx, fy)} ` + `Q${pts(fx + w / 2, fy - 0.9, fx + w, fy - 0.4)}" ${stroke(colour, 0.35)}/>`; }).join(''); // Shining channels across wet mud: [y, from x, to x, opacity]. const channels = (rows) => rows.map(([y, x0, x1, o]) => `<path d="M${pts(x0, y)} ` + `Q${pts((x0 + x1) / 2, y - 1, x1, y)} Q${pts((x0 + x1) / 2, y + 1.3, x0, y)} Z" ` + `${paint(palette.paper, o)}/>`).join(''); // A wader standing at (x, ground), scale k, over its reflection on the wet mud. const wader = (bird, x, ground, k) => `<g transform="translate(${n(x)} ${n(ground + 34 * k)}) ` + `scale(${k} ${-k / 2})" opacity="0.1"><path d="${bird.body}" ${paint(palette.ink)}/>` + `<path d="${bird.bill}" ${paint(palette.ink)}/>` + `<path d="${bird.legs}" ${stroke(palette.ink, 1.7)}/></g>` + place(x, ground - 68 * k, k, bird.draw()); // The mudflat plates: a far shore, the mud and its channels, the bird; a creek for the redshank. function mudflat(bird, x, k, creek) { const wash = (t) => mix(HABITAT.mud, palette.paper, t); const { width: w, height: h } = PLATE; const [top, ground] = [h - 29, h - 12]; // the far shore, and where the bird stands let g = `<rect width="${w}" height="${h}" ${paint(wash(0.9))}/><path d="M0 ${top - 1} ` + `C14 ${top - 3} 26 ${top - 2} 38 ${top - 3.4} C50 ${top - 4.4} 58 ${top - 1.6} ` + `72 ${top - 1.5} L${w} ${top - 1.2} V${top + 2} H0 Z" ${paint(wash(0.72))}/>` + `<rect y="${top}" width="${w}" height="${h - top}" ${paint(wash(0.8))}/>` + channels([[top + 3.5, -2, 64, 0.6], [top + 7, 58, w + 2, 0.5], [h - 7, -2, 36, 0.5], [h - 4, 70, w + 2, 0.45]]); for (let i = 0; i < 36; i++) { // ripples, longer towards the viewer const y = top + 4 + rand() * (h - top - 5); const len = 1.6 + (y - top) * 0.14; g += `<path d="M${n(rand() * w)} ${n(y)} q${n(len / 2)} -0.45 ${n(len)} 0" ` + `${stroke(wash(0.62), 0.25 + (y - top) * 0.008, 0.8)}/>`; } if (creek) { // behind the bird: the saltmarsh on the far bank, then the creek const y = (v) => n(top + v * 0.8); const marsh = mix(HABITAT.reed, palette.paper, 0.72); g += `<path d="M0 ${y(3)} C18 ${y(1.5)} 36 ${y(3.5)} 58 ${y(2)} C80 ${y(0.8)} 98 ${y(2.8)} ` + `${w} ${y(1.8)} V${y(12)} H0 Z" ${paint(marsh)}/>`; for (let i = 0; i < 90; i++) { // tufts of grass along its top const gx = rand() * w; const gy = Number(y(2.2 + Math.sin(gx / 9) * 0.8 + rand() * 1.5)); g += `<path d="M${pts(gx, gy)} l-0.9 -1.8 M${pts(gx, gy)} l0.1 -2.4 M${pts(gx, gy)} l1 -1.6" ` + `${stroke(mix(HABITAT.reed, palette.paper, 0.4), 0.3)}/>`; } g += `<path d="M${w} ${y(9)} C90 ${y(8)} 80 ${y(13)} 64 ${y(13.5)} C44 ${y(14)} 24 ${y(10)} ` + `0 ${y(11)} V${y(16)} C24 ${y(15)} 44 ${y(19)} 66 ${y(18)} C82 ${y(17.5)} 92 ${y(13)} ` + `${w} ${y(13.5)} Z" ${paint(wash(0.62))}/>`; } else g += flock(78, 4, 5, 18, wash(0.35)); return sheet(PLATE, g + wader(bird, x, ground, k)); } // The reedbed plates: pale reeds far off, plumed ones nearer, and the stems the bird holds. function reedbed(bird, x, y, k, stems) { const wash = (t) => mix(HABITAT.reed, palette.paper, t); const { width: w, height: h } = PLATE; let g = `<rect width="${w}" height="${h}" ${paint(wash(0.9))}/>`; for (let i = 0; i < 24; i++) { g += reed(rand() * w, h + 2, 30 + rand() * 22, -2 + rand() * 4, wash(0.78), 0.5, 2, rand() < 0.5 && mix(PLUME, palette.paper, 0.6)); } for (let i = 0; i < 11; i++) { const rx = rand() * w; if (stems.every((s) => Math.abs(s - rx) >= 6)) { g += reed(rx, h + 2, 38 + rand() * 18, -3 + rand() * 6, wash(0.55), 0.6, 3, PLUME); } } for (const s of stems) g += reed(s, h + 2, h + 4, 0, wash(0.3), 0.9, 2); return sheet(PLATE, g + place(x, y, k, bird.draw())); } // The cover: sky for the title, the far shore, the mud with a curlew, the reeds in front. function coverArt() { const { width: w, height: h } = TRIM; const wash = (c, t) => mix(c, palette.paper, t); const [shore, mud, ground] = [98, 102, 152]; let g = `<rect width="${w}" height="${h}" ${paint(palette.paper)}/>` + `<path d="M0 ${shore} C20 ${shore - 3} 34 ${shore - 2} 52 ${shore - 5} C70 ${shore - 8} ` + `86 ${shore - 3} 104 ${shore - 2.5} C120 ${shore - 2} 136 ${shore - 4} ${w} ${shore - 3} ` + `V${mud} H0 Z" ${paint(wash(HABITAT.mud, 0.6))}/>` + `<rect y="${mud}" width="${w}" height="${h - mud}" ${paint(wash(HABITAT.mud, 0.66))}/>` + `<path d="M0 ${mud} H${w} V${mud + 2.5} C100 ${mud + 4.5} 50 ${mud + 1.5} 0 ${mud + 3.5} Z" ` + `${paint(WATER)}/>` + channels([[mud + 9, -2, 80, 0.5], [mud + 16, 50, w + 2, 0.45], [ground + 8, -2, 60, 0.45], [ground + 17, 40, 120, 0.4]]) + flock(92, 80, 7, 26, wash(HABITAT.mud, 0.3)) + wader(CURLEW, 10, ground, 0.92); for (let i = 0; i < 64; i++) { // reeds low along the foot, rising towards the fore-edge const rx = rand() * (w + 8); const tall = Math.max(0, rx - 96) * 1.6; g += reed(rx, h + 2, 14 + rand() * 10 + tall, -3 + rand() * 6, i % 3 ? HABITAT.reed : mix(HABITAT.reed, palette.ink, 0.35), 0.8, tall > 20 ? 3 : 2, tall > 20 && rand() < 0.6 && PLUME); } return sheet(TRIM, g); } // The contents' strip: the mud and its waders on the left, the reedbed on the right. function stripArt() { const size = { width: TRIM.width, height: STRIP }; const wash = (c, t) => mix(c, palette.paper, t); const [shore, ground] = [22, 38]; const w = size.width; let g = `<rect width="${w}" height="${STRIP}" ${paint(wash(HABITAT.mud, 0.9))}/>` + `<path d="M0 ${shore} C24 ${shore - 3} 40 ${shore - 1} 64 ${shore - 4} C84 ${shore - 6} ` + `110 ${shore - 2} ${w} ${shore - 3} V${shore + 2} H0 Z" ${paint(wash(HABITAT.mud, 0.7))}/>` + `<rect y="${shore + 1.5}" width="${w}" height="${STRIP}" ${paint(wash(HABITAT.mud, 0.78))}/>` + channels([[shore + 5, -2, 70, 0.6], [shore + 13, 10, 90, 0.5], [STRIP - 3, -2, 60, 0.45]]) + flock(40, 8, 6, 22, wash(HABITAT.mud, 0.35)) + wader(CURLEW, 14, ground, 0.27) + wader(REDSHANK, 50, ground + 2.5, 0.21); for (let i = 0; i < 46; i++) { // the reedbed takes over towards the fore-edge const rx = 78 + rand() * 80; const tall = (rx - 78) * 0.4; g += reed(rx, STRIP + 2, 7 + tall + rand() * 7, -2 + rand() * 4, i % 2 ? HABITAT.reed : wash(HABITAT.reed, 0.4), 0.6, 2, tall > 10 && rand() < 0.5 && PLUME); } return sheet(size, g); } const drawings = () => ({ cover: coverArt(), strip: stripArt(), curlew: mudflat(CURLEW, 26, 0.6, false), redshank: mudflat(REDSHANK, 32, 0.56, true), reedling: reedbed(REEDLING, 36, -3, 0.5, [36 + 46.2 * 0.5]), warbler: reedbed(WARBLER, 32, -6, 0.66, [32 + 37 * 0.66, 32 + 55 * 0.66]), }); // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 103 lines · content.en.md
title: "Birds of the Estuary" subtitle: "A pocket guide to the mudflats and the reedbeds" --- # Birds of \\ the Estuary {style="cover"} :::pagebreak # Contents {style="contents"} :::toc :::callout{type="about" title="How to use this guide"} The birds are grouped by habitat: the open mud of the lower estuary, and the reedbeds along its upper reaches. Each habitat has a colour, printed on its tab at the edge of the page, on the field marks in its species texts and on its row in the list above. Sizes are total length, from bill tip to tail tip. :::paragraphs{style="colophon"} Set in Alegreya, Zilla Slab and Barlow Condensed (SIL OFL) · Text and drawings: original, CC BY 4.0. ::: ::: :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"} :::paragraphs{style="habitat"} Twice a day the tide drains out of the estuary and leaves a mile of shining mud. It looks empty, but every square metre hides thousands of worms, snails and shrimps, and waders fly in from half of Europe to feed on them. ::: 1. Eurasian Curlew 2. Common Redshank ::: # Eurasian Curlew {latin="Numenius arquata" size="50–60 cm" status="Winter visitor"} ::resource{id="curlew"} :::space{lines=1} Our largest wader, and the easiest to name: no other bird on the mud carries so long and so curved a bill. Curlews work the soft mud at low water, walking slowly and probing to the hilt for worms and small crabs. - **Bill** very long and down-curved, longest in the female. - **Plumage** grey-brown, finely streaked; legs grey-blue. - **In flight** a white wedge runs up the back from the tail. **Voice.** A rising, far-carrying *cur-lee*, the sound of an estuary in winter; in spring, a bubbling song on the moors where it breeds. **Where and when.** On the mud from August to March, roosting in flocks on the saltmarsh at high tide. The curlew is declining across Europe and listed as Near Threatened: give a roost a wide berth. :::pagebreak # Common Redshank {latin="Tringa totanus" size="27–29 cm" status="Resident"} ::resource{id="redshank"} :::space{lines=1} The noisiest bird on the marsh. A redshank sees you first and tells everything within half a mile, bobbing nervously and piping as it flies off; it has long been called the warden of the marshes. - **Legs** bright orange-red; the bill red at the base, dark at the tip. - **Plumage** plain grey-brown in winter, mottled browner in summer. - **In flight** a broad white trailing edge to the wing, and white up the back. **Voice.** A ringing *tew-hu-hu*, often the first sound you hear on the marsh, and a frantic *teuk-teuk-teuk* when it is alarmed. **Where and when.** All year round on the creeks and the tideline, picking shrimps and small snails from the mud. It nests in tussocks of saltmarsh grass, often in loose colonies. :::part{number="II" title="The \\ Reedbeds" palette="band=#51702f"} :::paragraphs{style="habitat"} Where the river meets the tide, the common reed grows in beds taller than a person, green in summer and gold in winter. Few birds live in them, and most of those stay hidden among the stems. ::: 3. Bearded Reedling 4. Eurasian Reed Warbler ::: # Bearded Reedling {latin="Panurus biarmicus" size="14.5–17 cm" status="Resident"} ::resource{id="reedling"} :::space{lines=1} A small, long-tailed bird that seldom leaves the reeds, and you will usually hear it first: a pinging call from deep in the reedbed, then a party of birds whirring low over the plumes on short, rounded wings. - **Male** head blue-grey, with a drooping black moustache (and no beard). - **Body** warm tawny; the long, graduated tail is half the bird. - **Female** brown-headed and without the moustache. **Voice.** A metallic *ping*, like a tiny bell, repeated as the party moves. **Where and when.** All year in large reedbeds. It eats insects in summer and reed seeds in winter, and swallows grit in autumn to grind them. Once classed with the tits (Linnaeus named it *Parus biarmicus*), it now has a family of its own, the Panuridae. :::pagebreak # Eurasian Reed Warbler {latin="Acrocephalus scirpaceus" size="13 cm" status="Summer visitor"} ::resource{id="warbler"} :::space{lines=1} A plain brown warbler, heard far more often than seen. Its song runs on from inside the reeds all through a summer day: a slow, rhythmic chatter, *jit-jit-jit, churr-churr*, with phrases borrowed from other birds. - **Upperparts** unstreaked warm brown; underparts buff, throat whitish. - **Head** a flat forehead and a strong, pointed bill. - **Movement** sidles up the stems, often gripping two at once. **Where and when.** A summer visitor from late April to September, wintering in Africa south of the Sahara. Its nest is a deep cup woven round three or four reed stems, and it is one of the cuckoo’s commonest hosts.`; // content.<lang>.md, inlined by the Cookbook // The plates' captions, and the alt text of every drawing. const CAPTIONS = t({ en: { cover: 'A curlew on the mud, with reeds in front.', strip: 'A curlew and a redshank on the mud, with the reedbed beyond.', curlew: 'Adult at low water. The female’s bill is the longer.', redshank: 'Adult by a saltmarsh creek, on the red legs that give it its name.', reedling: 'Male on a reed stem. The female has a plain brown head.', warbler: 'Singing from the reeds, one foot on each stem.', }, es: { cover: 'Un zarapito en el fango, con carrizos delante.', strip: 'Un zarapito y un archibebe en el fango, y el carrizal al fondo.', curlew: 'Adulto en bajamar. La hembra tiene el pico más largo.', redshank: 'Adulto junto a un caño de la marisma, sobre las patas rojas que lo delatan.', reedling: 'Macho en un tallo de carrizo. La hembra tiene la cabeza parda.', warbler: 'Cantando en el carrizal, con una pata en cada tallo.', } }); // Every drawing is an SVG resource, sized in mm at 10 px per mm (as sheet() draws them). const drawing = (id, { width, height }, more) => ({ id, typeId: 'plate', kind: 'svg', createdAt: 0, updatedAt: 0, altText: CAPTIONS[id], svg: { fileId: `${id}.svg`, width: width * 10, height: height * 10 }, ...more }); // Plates stand where ::resource{id="…"} is, a :::space after (gotcha: here-figure-no-space-after). const resources = [drawing('cover', TRIM), drawing('strip', { width: TRIM.width, height: STRIP }), ...['curlew', 'redshank', 'reedling', 'warbler'].map((id) => drawing(id, PLATE, { caption: CAPTIONS[id], placement: { position: 'here' } }))]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { Alegreya: ['400', '400i', '700'], 'Zilla Slab': ['600', '700'], // text, display 'Barlow Condensed': ['400', '600', '700'] }; // and labels // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); for (const [id, svg] of Object.entries(drawings())) await loadSvg(`${id}.svg`, svg); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: BOOK });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
#Add a third habitat
A fence before the first saltmarsh species adds a third colour, with its divider, painted verso and contents row, and the config stays as it is. Drop the note under the contents as well, because the third band and its species need the space.
+:::part{number="III" title="The \\ Saltmarsh" palette="band=#6b4a7a"}
+:::
+
+# Common Shelduck {latin="Tadorna tadorna" size="58–67 cm" status="Resident"}#Let the species face the divider
Without the break to the next recto, the first species starts on the back of the divider, so no blank verso is left for versoDesign to paint.
- breakAfter: { parity: 'odd' },Pitfalls
Pitfall
parts.versoDesign needs parts.breakAfter parity 'odd'
The back of a part divider is painted only when parts.breakAfter { enabled: true, parity: 'odd' } leaves that verso blank. Part divider pages →
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
Page 1 is a recto: plan pages with physical numbers
Page 1 is a right-hand page and page 2 the first verso, so plan spreads with physical page numbers: an opener on an even page faces the odd page after it. Page and column breaks →
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
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
An inline figure gets space above it but not below
In postext 1.4.1 a figure that ::resource sets at position 'here' gets one grid line of space above it, but below it only what is left over when the next line snaps to the baseline grid: anywhere from a whole line to almost nothing, so the next paragraph can start right under the caption. Follow the ::resource line with :::space{lines=1}; like any :::space, it is dropped at the top of a column. Figures exactly here →
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
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 →
- A part recolours the text by comparing values: any text colour equal to
band's own value switches with the part, even one linked to another entry or to none. Keep that hex forbandalone. - The tab prints
{partNumber}, which is empty outside a part, so a body page before the first:::partgets an empty slate tab. Keep every body page inside a part. - Pictures and inline swatches keep the colours written in them. The plates here take their habitat's colours from
HABITATin the code, so a new colour in a:::parthas to go there too.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Images
- The cover, the contents strip and the four plates, drawn in code · Ignacio Ferro · CC BY 4.0
- Fonts
- Alegreya (SIL OFL 1.1) · Zilla Slab (SIL OFL 1.1) · Barlow Condensed (SIL OFL 1.1)

