What you'll build
The opening pages of a travel feature in Field Notes, a small magazine. Page 1 is a recto opener: an indigo band over the top 58% of the page, a drawing of salt pans and dunes, the title in 54 pt Young Serif, and two justified columns below that start the text with a bold lead-in. Page 2, overleaf, continues under a running head in letter-spaced capitals and ends on an end mark and a colophon. The design is set by one config factory and a palette of six colours. The title comes from the first-level heading, and the standfirst, author and date come from the frontmatter of the Markdown string, so the same pen sets any article whose title fits in two lines; only the drawing belongs to this one.
This recipe answers
- How do I stop headings, bold words and bullets from coming out blue?
- How do I set up a book page: trim size, mirrored inner and outer margins, two columns and a gutter?
- How do I turn a Markdown string into typeset pages and draw one on a canvas?
The short answer
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
colorPalette: colorPalette(),
page: { // mirror: left is the inner margin and right the outer one; versos swap them
width: mm(PAGE.width), height: mm(PAGE.height),
margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
right: mm(MARGIN.outer), mirror: true },
},
// 'double' is the default, stated so that the whole page setup reads in one place
layout: { layoutType: 'double', gutterWidth: mm(6) },
bodyText: { // justified, hyphenated and broken by paragraph: all on by default
fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
firstLineIndent: mm(4), indentAfterHeading: false,
// Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
// tightened by at most 4 thousandths of an em, so the grey of the text stays even.
minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
},
headings: {
fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
levels: [
// span: 'page' opens the H1 on a new page, across both columns. The restated break
// (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
advancedDesign: opener() },
// A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
// default) rounds up to 3, so the text below lands back on the grid.
{ level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
marginBottom: pt(0) },
],
},
unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
marginTop: pt(0), marginBottom: pt(0) },
calloutStyles: [colophon()],
header: header(),
footer: footer(),
});
One config factory in place of the default skin: page, type, colour, slots
Ingredients
- Features
- Document metadataSemantic colour paletteRunning heads and foliosPages on a canvasDesigned openersFull-width chapter bandPictures in page designsAnchoring design elementsHeads by page roleFonts before layoutTrim sizeMirrored marginsOne or two columnsBody typeBold, italic and their coloursColour swatchesHyphenation and document languageBullet lists and checklistsCallout boxesLine breaks in titles
- Also uses
- Figures and tables as resources
- Type
- Newsreader, Young Serif, Inter Tight (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Every face, before the first build
const FONTS = {
Newsreader: ['400', '400i', '700'], // text
'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
Postext measures text with the fonts the browser has already loaded and caches the widths, so a build that runs before the fonts arrive keeps its wrong line breaks. The kit loads these files from Fontsource, the same files a PDF would embed. buildWithFonts then looks through the faces the pages use. If a block or a design element is set in a face that is not loaded, it prints a console warning, loads the face and builds again; when Fontsource does not ship that face, the pen stops with an error (see Pitfalls). The bold and italic variants of a text face load without a warning, and only when the family has them.
#2 · Name the colours, then point at the names
const palette = {
ink: '#1b1e23', // text: a cool near-black, never #000
band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads
sand: '#e9dcc0', // the title, the byline and the dunes
salt: '#f7f4ee', // the standfirst, the salt pans and the cairn
rule: '#d6d3cc', // the hairline over the colophon
muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the band and the folios too.
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 defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
// point it at the accent. Header, footer and opener defaults do not follow it, so they
// are restated below.
{ id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
Every colour in the config is col('id'), a palette id with its hex beside it. bodyText.boldColor, italicColor, referenceColor and unorderedLists.color name ink, and headings.color names the accent. The engine's defaults for headings, bold, italic and bullets link to main-color, and this palette points that entry at the accent, so any of them you leave out comes out indigo instead of blue. Postext 1.4.1 paints header, footer and opener elements, and referenceColor, from the hex and ignores the id, so the design objects are factories: config() calls them on every build, and col() copies the current hex out of palette each time. The end mark is an inline swatch that names band, so a retint reaches it as well. The default running head does not read the palette and stays blue until you replace it (step 5).
#3 · A picture drawn in code
function landscape(w, h) { // in mm: the page width by the depth of the band
let seed = 11; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
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 f = (n) => n.toFixed(1);
const fill = (id, opacity) => `fill="${palette[id]}" fill-opacity="${opacity}"`;
const gy = h - 36; // the horizon: the flats take the bottom 36 mm of the band
const k = w / 170; // the flats are laid out across a 170 mm width, then scaled to the page
// A low dune: a smooth, jittered ridge from (x0, y0) up to (w, y1), filled down to the foot.
const dune = (x0, y0, y1, jitter, opacity) => {
const p = Array.from({ length: 9 }, (_, i) => [x0 + ((w - x0) * i) / 8,
y0 + ((y1 - y0) * i) / 8 - (i && i < 8 ? rand() * jitter : 0)]);
const ridge = p.slice(1, -1).map(([x, y], i) =>
`Q${f(x)} ${f(y)} ${f((x + p[i + 2][0]) / 2)} ${f((y + p[i + 2][1]) / 2)}`).join('');
return `<path d="M${f(x0)} ${f(y0)}${ridge}L${f(w)} ${f(y1)}V${h + 8}H${f(x0)}Z" `
+ `${fill('sand', opacity)}/>`;
};
// The article's forty pans in perspective, whiter towards the front, each a little askew.
const at = (u, v) => `${f(k * (36 - 32 * v + (56 + 50 * v) * u))},${f(gy + 2 + 34 * v ** 1.4)}`;
let pans = '';
for (let row = 0; row < 5; row++) {
for (let c = 0; c < 8; c++) {
const j = () => 0.05 * rand();
const [u0, u1] = [(c + 0.06 + j()) / 8, (c + 0.94 - j()) / 8];
const [v0, v1] = [(row + 0.1 + j()) / 5, (row + 0.9 - j()) / 5];
const s = 0.012 * (rand() - 0.5); // a slight twist
const white = Math.min(1, 0.6 + (0.36 * (row * 8 + c)) / 39 + 0.04 * rand());
pans += `<polygon points="${at(u0 + s, v0)} ${at(u1 + s, v0)} ${at(u1 - s, v1)} `
+ `${at(u0 - s, v1)}" ${fill('salt', white.toFixed(2))}/>`;
}
}
// The dunes at Sorra: a long windward slope, a sharp crest, a shaded slip face falling away
// to the right, and a cairn of white stones on the crest.
const [cx, cy] = [0.8 * w, 0.6 * h];
const up = `M${f(0.44 * w)} ${gy + 2}C${f(0.58 * w)} ${gy - 3} ${f(cx - 18)} ${f(cy + 3)} `
+ `${f(cx)} ${f(cy)}`;
const foot = `L${f(0.56 * w)} ${h}Q${f(0.48 * w)} ${gy + 12} ${f(0.44 * w)} ${gy + 2}Z`;
const brink = `C${f(cx + 2)} ${f(cy + 10)} ${f(cx + 7)} ${h - 10} ${f(cx + 14)} ${h}`;
const whole = `${up}C${f(cx + 6)} ${f(cy + 3)} ${f(cx + 20)} ${f(cy + 14)} ${w} ${f(cy + 22)}`
+ `V${h}${foot}`;
const sorra = `<path d="${whole}" fill="${palette.band}"/>` // opaque: hides the pans behind
+ `<path d="${whole}" ${fill('sand', 0.74)}/><path d="${up}${brink}${foot}" `
+ `${fill('sand', 0.88)}/>`;
let cairn = '';
let y = cy + 0.6;
for (const [sw, sh] of [[6.4, 2.2], [5, 2], [3.8, 1.8], [2.6, 1.5]]) {
cairn += `<ellipse cx="${f(cx + 0.8 * (rand() - 0.5))}" cy="${f(y - sh / 2)}" `
+ `rx="${sw / 2}" ry="${sh / 2}" ${fill('salt', 1)}/>`;
y -= sh * 0.82;
}
// The carriers' trail: across the pans, then up the windward slope to the cairn.
const trail = `M${f(0.13 * w)} ${h}C${f(0.22 * w)} ${h - 14} ${f(0.38 * w)} ${gy + 14} `
+ `${f(0.48 * w)} ${gy + 5}C${f(0.6 * w)} ${gy} ${f(cx - 18)} ${f(cy + 6)} `
+ `${f(cx - 1)} ${f(cy + 0.5)}`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" `
+ `viewBox="0 0 ${w} ${h}">${dune(0, gy, gy - 3, 5, 0.22)}`
+ `<rect y="${gy + 1}" width="${w}" height="35" ${fill('sand', 0.1)}/>${pans}${sorra}`
+ `${dune(0.66 * w, h + 4, gy + 18, 3, 1)}<path d="${trail}" fill="none" `
+ `stroke="${palette.ink}" stroke-opacity="0.55" stroke-width="0.7" `
+ `stroke-dasharray="1.4 1.2"/>${cairn}</svg>`;
}
The salt pans, the dunes and the cairn are an SVG string built from the same palette and a seeded random generator, so every run draws the same landscape and the capture changes only when the code does. It is drawn for a frame of PAGE.width × BAND millimetres and the resource declares the same frame at 10 px per mm, so the picture fills the band exactly and a deeper band gets a taller drawing. The opener shows it through an image element that names the resource by id.
#4 · The frontmatter writes the opener
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// under the horizon (BAND − 36 mm), onto the pale pans: keep titles short or deepen BAND
const DECK_W = 104; // mm: the standfirst stops short of the dune's crest (0.8 × PAGE.width)
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
enabled: true,
minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
slot: {
elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('band') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
// The drawing is PAGE.width × BAND (see resources): at full width it fills the band.
{ kind: 'image', id: 'art', resourceId: 'landscape',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
{ kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
offset: { x: mm(MARGIN.inner), y: mm(20) } } },
// {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
// text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
placement: below('kicker', 3, TITLE_W) },
{ kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
overflow: 'wrap', placement: below('title', 5, DECK_W) },
{ kind: 'text', id: 'byline', ...label, color: col('sand'),
content: t({ en: 'By {author} · {publishDate}',
es: 'Por {author} · {publishDate}' }),
placement: below('deck', 4.5) },
],
},
});
The first-level heading spans both columns, and its opener is a slot of elements: an indigo box to the bleed, the drawing, and four texts chained one below the other. A longer title pushes the standfirst and the byline down instead of overlapping them, but past two lines the byline lands on the pale salt pans. {titleText} is the text of the heading; {subtitle}, {author} and {publishDate} are frontmatter fields. The \\ in the heading breaks the title where you put it (line breaks in titles). minHeight counts from the top margin, so the reserved height ends 5 mm below the band.
#5 · Running heads instead of the blue default
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
elements: [ // folios on the outer margin's edge; each label hangs off its folio
head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
],
});
const footer = () => ({
elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
...label, color: col('band'),
placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
Leave header out and the engine's default comes back: Open Sans over a full-width rule, on every page. Here each folio is anchored to the physical page and filtered by parity, so it always sits at the outer edge. Its label is anchored to the folio, GAP millimetres away with right-of or left-of, and a folio of three or four digits pushes the label along instead of running into it. pages: 'body' keeps all four elements off the opener. The footer holds a single drop folio for the opener, and since footer is set, body pages lose the default centred folio.
#6 · Build with a fresh config, then paint
await loadFonts(FONTS, markdown);
await loadSvg('landscape.svg', landscape(PAGE.width, BAND));
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources }, config()), markdown);
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
title: t({ en: 'From a Markdown string to a designed page',
es: 'De una cadena Markdown a una página diseñada' }),
});
config() is a function because the engine caches each resolved config by object identity, so an edited setting takes effect only when every build gets a new object. showPages lays the pages out as they sit in a bound book, page 1 alone on the right, and paints each one with renderPageToCanvas.
The whole recipe
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════ // https://postext.dev/en/cookbook/first-page-from-markdown // Code: MIT · Text: original (CC BY 4.0) · Drawing: generated in code (CC BY 4.0) // Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // // This pen sets a Markdown string with a frontmatter block on two magazine pages and paints // them on canvases. The whole design is in config(); swap config() for {} in the build // below to see the engine's defaults. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'first-page-from-markdown'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Every setting that shapes the pages is in this part, from PAGE down to config(). const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on // #region palette: six named colours; every colour in the config links to one of them const palette = { ink: '#1b1e23', // text: a cool near-black, never #000 band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads sand: '#e9dcc0', // the title, the byline and the dunes salt: '#f7f4ee', // the standfirst, the salt pans and the cairn rule: '#d6d3cc', // the hairline over the colophon muted: '#66686e', // running heads and the colophon }; // Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers, // footers and openers from the hex (gotcha: palette-skips-designs). The design objects // below are factories that config() calls, so col() copies the hex out of `palette` on // every build, and a retint reaches the band and the folios too. 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 defaults of the text styles (headings, bold, italic, bullets) link to 'main-color': // point it at the accent. Header, footer and opener defaults do not follow it, so they // are restated below. { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } }, ]; // #endregion // #region art: salt pans and the dunes at Sorra, drawn in code for a page × band frame function landscape(w, h) { // in mm: the page width by the depth of the band let seed = 11; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe 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 f = (n) => n.toFixed(1); const fill = (id, opacity) => `fill="${palette[id]}" fill-opacity="${opacity}"`; const gy = h - 36; // the horizon: the flats take the bottom 36 mm of the band const k = w / 170; // the flats are laid out across a 170 mm width, then scaled to the page // A low dune: a smooth, jittered ridge from (x0, y0) up to (w, y1), filled down to the foot. const dune = (x0, y0, y1, jitter, opacity) => { const p = Array.from({ length: 9 }, (_, i) => [x0 + ((w - x0) * i) / 8, y0 + ((y1 - y0) * i) / 8 - (i && i < 8 ? rand() * jitter : 0)]); const ridge = p.slice(1, -1).map(([x, y], i) => `Q${f(x)} ${f(y)} ${f((x + p[i + 2][0]) / 2)} ${f((y + p[i + 2][1]) / 2)}`).join(''); return `<path d="M${f(x0)} ${f(y0)}${ridge}L${f(w)} ${f(y1)}V${h + 8}H${f(x0)}Z" ` + `${fill('sand', opacity)}/>`; }; // The article's forty pans in perspective, whiter towards the front, each a little askew. const at = (u, v) => `${f(k * (36 - 32 * v + (56 + 50 * v) * u))},${f(gy + 2 + 34 * v ** 1.4)}`; let pans = ''; for (let row = 0; row < 5; row++) { for (let c = 0; c < 8; c++) { const j = () => 0.05 * rand(); const [u0, u1] = [(c + 0.06 + j()) / 8, (c + 0.94 - j()) / 8]; const [v0, v1] = [(row + 0.1 + j()) / 5, (row + 0.9 - j()) / 5]; const s = 0.012 * (rand() - 0.5); // a slight twist const white = Math.min(1, 0.6 + (0.36 * (row * 8 + c)) / 39 + 0.04 * rand()); pans += `<polygon points="${at(u0 + s, v0)} ${at(u1 + s, v0)} ${at(u1 - s, v1)} ` + `${at(u0 - s, v1)}" ${fill('salt', white.toFixed(2))}/>`; } } // The dunes at Sorra: a long windward slope, a sharp crest, a shaded slip face falling away // to the right, and a cairn of white stones on the crest. const [cx, cy] = [0.8 * w, 0.6 * h]; const up = `M${f(0.44 * w)} ${gy + 2}C${f(0.58 * w)} ${gy - 3} ${f(cx - 18)} ${f(cy + 3)} ` + `${f(cx)} ${f(cy)}`; const foot = `L${f(0.56 * w)} ${h}Q${f(0.48 * w)} ${gy + 12} ${f(0.44 * w)} ${gy + 2}Z`; const brink = `C${f(cx + 2)} ${f(cy + 10)} ${f(cx + 7)} ${h - 10} ${f(cx + 14)} ${h}`; const whole = `${up}C${f(cx + 6)} ${f(cy + 3)} ${f(cx + 20)} ${f(cy + 14)} ${w} ${f(cy + 22)}` + `V${h}${foot}`; const sorra = `<path d="${whole}" fill="${palette.band}"/>` // opaque: hides the pans behind + `<path d="${whole}" ${fill('sand', 0.74)}/><path d="${up}${brink}${foot}" ` + `${fill('sand', 0.88)}/>`; let cairn = ''; let y = cy + 0.6; for (const [sw, sh] of [[6.4, 2.2], [5, 2], [3.8, 1.8], [2.6, 1.5]]) { cairn += `<ellipse cx="${f(cx + 0.8 * (rand() - 0.5))}" cy="${f(y - sh / 2)}" ` + `rx="${sw / 2}" ry="${sh / 2}" ${fill('salt', 1)}/>`; y -= sh * 0.82; } // The carriers' trail: across the pans, then up the windward slope to the cairn. const trail = `M${f(0.13 * w)} ${h}C${f(0.22 * w)} ${h - 14} ${f(0.38 * w)} ${gy + 14} ` + `${f(0.48 * w)} ${gy + 5}C${f(0.6 * w)} ${gy} ${f(cx - 18)} ${f(cy + 6)} ` + `${f(cx - 1)} ${f(cy + 0.5)}`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}">${dune(0, gy, gy - 3, 5, 0.22)}` + `<rect y="${gy + 1}" width="${w}" height="35" ${fill('sand', 0.1)}/>${pans}${sorra}` + `${dune(0.66 * w, h + 4, gy + 18, 3, 1)}<path d="${trail}" fill="none" ` + `stroke="${palette.ink}" stroke-opacity="0.55" stroke-width="0.7" ` + `stroke-dasharray="1.4 1.2"/>${cairn}</svg>`; } // #endregion // #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it const BAND = 140; // mm from the top edge: the band holds the top 58% of the page const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline // under the horizon (BAND − 36 mm), onto the pale pans: keep titles short or deepen BAND const DECK_W = 104; // mm: the standfirst stops short of the dune's crest (0.8 × PAGE.width) const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' }); const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase' }; const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); const opener = () => ({ enabled: true, minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, // The drawing is PAGE.width × BAND (see resources): at full width it fills the band. { kind: 'image', id: 'art', resourceId: 'landscape', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'), placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left offset: { x: mm(MARGIN.inner), y: mm(20) } } }, // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple). { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif', fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left', overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default) placement: below('kicker', 3, TITLE_W) }, { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader', fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left', overflow: 'wrap', placement: below('title', 5, DECK_W) }, { kind: 'text', id: 'byline', ...label, color: col('sand'), content: t({ en: 'By {author} · {publishDate}', es: 'Por {author} · {publishDate}' }), placement: below('deck', 4.5) }, ], }, }); // #endregion // #region running-heads: folio and title on body pages, a drop folio under the opener const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot const GAP = 3; // mm between a folio and its label, however many digits the folio has const head = (id, content, parity, placement, color = col('muted')) => ({ kind: 'text', id, content, parity, pages: 'body', ...label, color, placement, }); const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }); const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } }); const header = () => ({ elements: [ // folios on the outer margin's edge; each label hangs off its folio head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')), head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)), head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')), head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)), ], }); const footer = () => ({ elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', ...label, color: col('band'), placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }], }); // #endregion // The colophon: small sans under a 0.5 pt hairline, with no box around it. const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD), stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') }, padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) }, body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }); // #region answer: one config factory in place of the default skin: page, type, colour, slots const config = () => ({ // a new object per build (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette: colorPalette(), page: { // mirror: left is the inner margin and right the outer one; versos swap them width: mm(PAGE.width), height: mm(PAGE.height), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true }, }, // 'double' is the default, stated so that the whole page setup reads in one place layout: { layoutType: 'double', gutterWidth: mm(6) }, bodyText: { // justified, hyphenated and broken by paragraph: all on by default fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name) fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts // tightened by at most 4 thousandths of an em, so the grey of the text stays even. minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4, }, headings: { fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight levels: [ // span: 'page' opens the H1 on a new page, across both columns. The restated break // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener() }, // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by // default) rounds up to 3, so the text below lands back on the grid. { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ], }, unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–', marginTop: pt(0), marginBottom: pt(0) }, calloutStyles: [colophon()], header: header(), footer: footer(), }); // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── // content.<lang>.md, inlined by the Cookbook: every frontmatter value is quoted, since an ISO // date or a number would print empty (gotcha: quote-frontmatter). The end mark is an inline // swatch that names `band`, so it follows the palette too. const markdown = String.raw`---Markdown sample · 46 lines · content.en.md
title: "The Salt Road" subtitle: "Four days on foot along the carriers’ trail, from the salt pans of Arvela to the market at Castrel." author: "Lena Varga" publishDate: "May 2026" --- # The Salt \\ Road **The carriers set out from the dyke at Arvela.** At low water the flats below it run out for three kilometres, a grey plain scored by tidal channels, and from the dyke you see the salt pans before the sea: forty shallow squares of brine laid out like a chessboard, each one a shade whiter than the one before it. For six centuries the salt of Arvela left the coast on people’s backs. They loaded up at dawn, thirty kilos to a wicker basket, and walked inland on a trail that nobody ever paved: across the flats, over the dunes of Sorra, through the pine country and up to the old market town of Castrel, four days on foot. The railway ended the trade in 1911, but walkers have kept the trail open since. I walked it this April with Tomás Reis, whose grandmother carried salt when she was a girl, and who has spent twenty summers marking the route with cairns of white stones. “Watch the ground for *salt*,” he told me that first morning. “Where it was spilled, nothing grows.” On the second day, in the dunes, the path disappears for hours, and then a pale seam shows in the sand, a few centimetres wide, as if someone had drawn it in chalk. It is salt that fell from the baskets over six centuries, a crust that the wind uncovers every spring. Tomás walks beside it and never steps on it. We slept that night in a hollow of the dunes that the carriers called the Kitchen, because the wind never gets into it. Tomás lit no fire. He ate bread and dried fish and talked about his grandmother, who could taste a single grain and tell you whether it came from Arvela or from the pans of the south, and who never once, he says, lost the trail in fog. ## What came back The baskets went back to the coast full. A carrier’s ledger from 1887, kept in the parish archive at Sorra, lists the return loads of its first spring: - **Grain**, mostly rye and barley, from the upland farms above Castrel. - **Pine resin** and tar, to caulk the fishing boats at Arvela each winter. - **Wool and hides** from the May fair at Castrel. - **Nails and fish-hooks** from the smithy at Orsa. Until the railway came, a basket of salt bought a basket of grain. A good carrier made eleven round trips a year, eighty-eight days on the trail, and in between raked salt in the pans. The ledger is written in three hands. The first is a careful copperplate that records every load to the half kilo; the second, from 1893 on, writes nothing but numbers; the third, a child’s hand, turns up in the winters and spells the town Castrell. The last entry in the ledger, halfway down a page in the autumn of 1910, is a load of barley that was never marked as delivered, and nobody in Sorra knows why. Tomás thinks the carrier went to lay track for the railway, which paid in coin. ## Into the pines The third day is the longest. Beyond Orsa the trail climbs for eight hours through pine woods that smell of resin, past the ruined relay houses where the carriers slept on straw and paid for their beds in salt. Every house has a cairn by its door, topped with a white stone that Tomás put there. The best-kept house stands at the Nine Wells, an hour below the pass. Its lintel is cut with rows of short notches, one for each night a carrier slept under it, and in places they are so close that they have worn into one groove. Tomás tried to count them one winter and gave up at four thousand. On the fourth afternoon the trail comes out of the pines onto a bare ridge, and Castrel appears below: a grey town built around a square that is still called the Salt Market, although nobody has sold salt there for a hundred years. Like the carriers, Tomás leaves a handful of salt on the stone rim of the fountain before he drinks. Then he turns round and starts back towards the sea. The carriers never slept in Castrel, he says, because the inns there charged in coin and a carrier was paid in grain. :swatch{color="band"} :::callout{type="colophon"} A work of fiction: Arvela, Sorra, Orsa and Castrel are imaginary places. Set in Newsreader, Young Serif and Inter Tight (SIL Open Font License) · Text and drawing: original, CC BY 4.0. :::`; // The drawing is a resource, drawn for a PAGE.width × BAND mm frame at 10 px per mm: the // opener's image element points at it by id. const resources = [ { id: 'landscape', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'landscape.svg', width: PAGE.width * 10, height: BAND * 10 } }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // #region fonts: every face the design uses, loaded first (gotcha: fonts-first) const FONTS = { Newsreader: ['400', '400i', '700'], // text 'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400 'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon }; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: fonts, the drawing, one buildDocument call with a fresh config, then paint await loadFonts(FONTS, markdown); await loadSvg('landscape.svg', landscape(PAGE.width, BAND)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources }, config()), markdown); // showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }). showPages(doc, { title: t({ en: 'From a Markdown string to a designed page', es: 'De una cadena Markdown a una página diseñada' }), }); // #endregionKit · 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
#See what the config replaces
Build with an empty config and the same Markdown comes out on a 17 × 24 cm page in 8 pt EB Garamond, with blue Open Sans headings and a blue running head over a rule; the kit loads those faces for you, with a console warning.
- () => buildDocument({ markdown, resources }, config()), markdown);
+ () => buildDocument({ markdown, resources }, {}), markdown);#Retint the whole page
Change the accent and the next run paints the band, the folios, the subheads and the end mark in the new colour, because col() copies the new hex beside the id each time config() runs and the landscape function reads palette each time it draws.
- band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads
+ band: '#3f5b3a', // the one accent (a pine green): the band, the folios, the subheads#Paint one page on your own canvas
renderPageToCanvas sizes the canvas to the page in pixels at the document's dpi (300 by default) times scale, so scale: 0.5 paints about 150 dpi, and CSS decides how large it looks on screen.
-showPages(doc, {
- title: t({ en: 'From a Markdown string to a designed page',
- es: 'De una cadena Markdown a una página diseñada' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });Pitfalls
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
Quote every frontmatter value
YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984". Document metadata →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Pitfall
fontFamily is one family name, never a CSS stack
A stack such as 'Lora, serif' is read as one family that does not exist, so text silently measures with a fallback and canvas, HTML and PDF disagree. Name a single family. Fonts before layout →
Pitfall
A config is cached by identity: build a fresh object
The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config(). Pages on a canvas →
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
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 design text's lineHeight is a multiple, never a dimension
In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →
- Young Serif ships a single weight. Headings default to bold, so
headings.fontWeight: 400is required here: without it the kit looks for a bold Young Serif that Fontsource does not have, and the pen stops with an error.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Newsreader (SIL OFL 1.1) · Young Serif (SIL OFL 1.1) · Inter Tight (SIL OFL 1.1)


