What you'll build
Two facing pages from Salt & Olive Oil, an invented Spanish home cookbook, on a 190 × 250 mm trim, one recipe to a page. A drawing made in code bleeds across the top 104 mm: a tortilla with a slice pulled out on a saffron table, a bowl of gazpacho on a sage one. Over it sit a kicker, the title in 42 pt Young Serif, pills for time, difficulty and season, and a note in Caveat handwriting that points at the dish. Below the italic headnote, a white card carries a tomato-red servings tab on its top-right corner. The ingredients run down its left column with olive dashes and tag chips; under the tortilla's, line drawings of a bowl, a pan and a plate head a checklist. The method fills the right column, each step led by a 26 pt old-style figure.
This recipe answers
- How do I put two columns inside a box (a text column beside a figure column)?
- How do I add a numbered tab ("BOX 1-1"), a corner icon, or a margin icon with a rule?
- How do I customise lists: bullets per level, (a)/(i) numbering, task checkboxes, spacing that stays on the grid?
- How do I make inline chips: keyboard keys, tags, word banks for exercises?
- How do I add an author line, a standfirst or a lead with a drop cap to an opener?
The short answer
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two (gotcha: callout-columns).
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
// No space of its own above: the tab starts on the first grid line under the opener.
columnGap: mm(7), marginTop: mm(0),
// label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
// beside it and a rule from the far corner that makes the tab part of the card.
label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].
A white card with a servings tab, two columns inside it
Ingredients
- Features
- Columns inside a boxNumbered box tabsNumbered listsNested boxesBox icons and corner badgesInline chipsBullet lists and checklistsCallout boxesDesigned openersFull-width chapter bandHeading stylesHeading attributesText, rules and boxes in page designsPictures in page designsLine breaks in titlesPaper colourRunning heads and foliosSemantic colour paletteParagraph stylesFigures and tables as resources
- Type
- Young Serif, Figtree, Caveat (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Give each column a box of its own
With loose lists, a columns group in a box cuts where its columns level best, between blocks or between the lines of one, so the gazpacho's first step splits across the gutter: its first line closes the ingredient column and the rest opens the method column. A nested box, as in the short answer above, is one block, and the group never cuts inside it. A picture set in one needs a ::resource with placement 'here', since any other placement makes it a float, which leaves the box and is placed like any cited figure. The card style's label turns the fence's label="SERVES 4" into the tab: an offset equal to its height stands it on the card's top edge, and the rule runs from the far corner to the fork and knife (callout styles).
#2 · Centre the step numbers on two lines
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
numberFontSize: pt(STEP), color: col('tomato'),
separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
numberVerticalOffset: pt(DROP) };
A list number is painted centred a little above the item's first baseline, so numberFontSize alone would set the 26 pt figures level with the first line; numberVerticalOffset lowers them 2.5 mm, to the middle of the band from the first line's capitals to the second line's baseline. Young Serif has old-style figures: 3, 4 and 5 descend below the baseline that 1 and 2 sit on. With separatorColor set, the separator is painted as a run of its own, and the default full stop prints olive beside a red figure (ordered lists).
#3 · Head the checklist with a strip of drawings
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
align: 'center' }, // the title centred on the strip
titleStyle: { ...column.titleStyle, color: col('olive') } };
The prep box holds only its title and its icon, so it works as a header: an icon width 3.75 times its size stretches the icon's square into an 18.75 × 5 mm strip, and the three drawings fill it left of the title (callout styles). An icon takes a column of its own beside everything in its box, so the tasks go after the box, where they line up with the ingredients' dashes instead of starting 21 mm in. - [ ] items print the default task box, and unorderedLists.marginTop: 0 lets them follow the header at its marginBottom, the 2.4 mm gap the column titles keep (unordered lists).
#4 · Make the tags chips
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];
:chip[Vegetarian]{style="tag"} puts a word in a box that never breaks across lines. A borderRadius larger than half the box's height is clamped to that half, which turns both ends into semicircles; with borderWidth: 0 the pill is drawn in the tint alone. The box is 11.4 pt tall, under the card's 12.8 pt leading, so a second line of tags would not touch the first (chip styles).
#5 · One design, one drawing per recipe
const BAND = 104; // mm: the drawing's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
{ to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style per recipe: the same design, each with its own drawing.
const opener = (art) => ({ id: art, advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: art,
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: mm(PAGE.w), height: mm(BAND) } } },
text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
{ fontWeight: 600, align: 'right' }), // on the page, like the arrow drawn in the picture
pill('time'), pill('level', 'time'), pill('season', 'level'),
text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
{ lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
// The drawing reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
// so the card starts below the lead without a minHeight.
text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
{ italic: true, lineHeight: 1.45 }),
] } } });
# Tortilla \\ de patatas {style="tortilla" kicker="…" lead="…"} picks a heading style and fills its slots from the attributes, the italic headnote among them (heading attributes). An image element names a fixed resource id, which is why opener(art) builds one style per drawing. Each pill is anchored right-of the one before it, the title above the first pill and the kicker above the title, so a longer title pushes the kicker up while the pills keep their line. The drawing reserves no height in 1.4.1, but the headnote under it does, so the card starts below the headnote without a minHeight. The note is anchored to the page, like the drawing, and ends 2 mm short of the drawn arrow on both pages, although the mirrored margins move the text block 2 mm to the right on the recto.
#6 · Register the drawings as resources
// The opener's image element and both icons name a resource id; the resource names the file
// the canvas paints (loadSvg registers it). No :ref cites them, so none is numbered.
const ART = { // markup, width and height in mm, and the alt text
tortilla: [tortillaArt(), PAGE.w, BAND, t({ en: 'A potato omelette with a slice pulled out, '
+ 'on a blue-rimmed plate and a red-checked napkin', es: 'Una tortilla de patatas con una '
+ 'porción separada, en un plato de borde azul sobre una servilleta de cuadros rojos' })],
gazpacho: [gazpachoArt(), PAGE.w, BAND, t({ en: 'A bowl of gazpacho with diced vegetables and '
+ 'a spoon, on a blue-checked napkin beside two tomatoes', es: 'Un cuenco de gazpacho con '
+ 'dados de verdura y una cuchara, sobre una servilleta de cuadros azules junto a dos '
+ 'tomates' })],
cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })],
kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate',
es: 'Un bol con dos huevos, una sartén y un plato' })] };
const resources = Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure',
kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10,
height: h * 10 } })); // 10 px a millimetre: the sizes only set the aspect ratio here
await Promise.all(Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg)));
The opener's image element, the tab's icon and the checklist header's icon each name a resource, and the canvas paints the file loadSvg registered for it. No :ref cites these resources, so none becomes a numbered figure. An SVG drawn as an image cannot use the page's web fonts, so the drawings carry no words: the handwritten notes are design text set over them.
The whole recipe
// ═══ Postext Cookbook · Nº 033 · Recipe card: ingredients beside the method ═══════ // https://postext.dev/en/cookbook/recipe-card // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Young Serif, Figtree, Caveat (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'recipe-card'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#2b2118', muted: '#76634e', // text; folios and the colophon paper: '#f7eddb', card: '#fffdf8', // the cream page; the white recipe card on it tomato: '#bf3d29', olive: '#6b7a3a', tint: '#f6e3c1' }; // numbers and tab; dashes; tags // The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the tomato, so nothing prints blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.tomato }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, HAND] = ['Figtree', 'Young Serif', 'Caveat']; const PAGE = { w: 190, h: 250, top: 22, inner: 18, outer: 16 }; // mm, mirrored margins const [BODY, LEAD] = [9.4, 12.8]; // pt: the text of the cards and the notes under them // #region answer: a white card with a servings tab, two columns inside it // In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group // of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method. // The group levels its columns by cutting between blocks or lines, so loose lists would run // the method on under the ingredients. A nested box is one block that never splits, so the // only cut left is between the two (gotcha: callout-columns). const TAB = 5.6; // mm: the tab's height, and how far it rises above the card const card = { id: 'card', background: col('card'), padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) }, // No space of its own above: the tab starts on the first grid line under the opener. columnGap: mm(7), marginTop: mm(0), // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram // beside it and a rule from the far corner that makes the tab part of the card. label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6), icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } }; // The two columns: frameless boxes whose only device is a tracked title. const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; const column = { id: 'column', backgroundEnabled: false, padding: NONE, lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } }; // config() plugs them in: calloutStyles: [card, column, prep]. // #endregion // #region prep: a checklist under a header with a strip of three pictograms // :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that // holds only its title and icon. The icon is one picture of three drawings; a width KIT times // its size makes its box a strip, and the picture is fitted into width × size, left of the // title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]' // items follow the box, where they print the default task box, '☐'. const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height const prep = { id: 'prep', backgroundEnabled: false, padding: NONE, marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT), align: 'center' }, // the title centred on the strip titleStyle: { ...column.titleStyle, color: col('olive') } }; // #endregion // #region steps: big step numbers in the display face, an olive full stop after each // Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em // x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's // first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young // Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two // lines, from the cap height of the first to the baseline of the second. const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only numberFontSize: pt(STEP), color: col('tomato'), separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run numberVerticalOffset: pt(DROP) }; // #endregion // #region chips: tags for diet and occasion, as pills in the text const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'), borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }]; // #endregion // #region opener: the drawing bled across the head, the title and pills set on it const BAND = 104; // mm: the drawing's foot; the pills sit 13 mm above it, the lead 6 mm below const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left', overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) }; // Pills are text boxes chained right-of each other; each prints one heading attribute. const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor: { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), { fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3), padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } }); // One heading style per recipe: the same design, each with its own drawing. const opener = (art) => ({ id: art, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: art, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(BAND) } } }, text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } }, { fontWeight: 600, align: 'right' }), // on the page, like the arrow drawn in the picture pill('time'), pill('level', 'time'), pill('season', 'level'), text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' }, offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple) text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' }, offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked), // The drawing reserves no height (gotcha: opener-image-no-reserve); the lead under it does, // so the card starts below the lead without a minHeight. text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }), { italic: true, lineHeight: 1.45 }), ] } } }); // #endregion // Folios at the foot of the outer corner: the book on versos, the recipe on rectos. const foot = (id, content, parity, edge, x, look) => text(id, content, TEXT, 7.6, { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-12) } }, { color: col('muted'), parity, align: edge.endsWith('left') ? 'left' : 'right', ...look }); const folio = { fontFamily: DISPLAY, fontSize: pt(10), color: col('tomato') }; const footer = { elements: [ foot('verso-folio', '{pageNumber}', 'even', 'bottom-left', PAGE.outer, folio), foot('verso-book', '{title}', 'even', 'bottom-left', PAGE.outer + 9, tracked), foot('recto-dish', '{chapterTitle}', 'odd', 'bottom-right', -(PAGE.outer + 9), tracked), foot('recto-folio', '{pageNumber}', 'odd', 'bottom-right', -PAGE.outer, folio), ] }; const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) colorPalette, chipStyles, orderedLists, footer, header: { elements: [] }, page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(PAGE.top), bottom: mm(20), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, // left is the inner margin layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: mm(0) }, // ragged and flush: the notes under the cards // A designed heading's own text is hidden but still measured: in Young Serif 400, the only // weight it ships, not in the default Open Sans 700 that FONTS does not load. headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // 'any': each recipe opens the next page, whichever side it is on. span: 'page' paints the // opener outside the column's clip: kept in the column, the drawing is cut at the top margin. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } }, ] }, headingStyles: [opener('tortilla'), opener('gazpacho')], // Olive dashes for the whole document: an olive lists.color on the column style would turn // the step numbers olive too (gotcha: box-list-colour-numbers). unorderedLists: { bulletChar: '–', color: col('olive'), marginTop: mm(0), // under the checklist's header, the header's marginBottom alone marginBottom: pt(LEAD / 2) }, // half a line above the tags calloutStyles: [card, column, prep], paragraphStyles: [{ id: 'colophon', fontSize: pt(7.4), lineHeight: pt(10), color: col('muted'), marginTop: pt(LEAD) }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 66 lines · content.en.md
title: "Salt & Olive Oil" --- # Tortilla \\ de patatas {style="tortilla" kicker="Spanish potato omelette" time="45 min" level="Intermediate" season="All year" note="onion, always!" lead="Our house version: potatoes poached slowly in plenty of oil, eggs barely set and ten minutes’ rest so the potato soaks up the egg. With onion, as my grandmother made it; without, if your family argues about it."} :::callout{type="card" label="SERVES 4"} :::columns{count=2} :::callout{type="column" title="Ingredients"} - **6** large free-range eggs - **800 g** frying potatoes - **1** medium onion - **300 ml** extra virgin olive oil - Fine salt :chip[Vegetarian]{style="tag"} :chip[Gluten-free]{style="tag"} :chip[Picnic]{style="tag"} :::callout{type="prep" title="Before you start"} ::: - [ ] Eggs out of the fridge - [ ] A bowl for eggs and potato - [ ] A plate wider than the pan ::: :::callout{type="column" title="Method"} 1. Peel and thinly slice the potatoes, cut the onion into fine strips and salt them. 2. Poach them in the oil over a medium heat, in a 24 cm pan, until tender but pale: about 20 minutes. 3. Drain, keeping the oil, and stir the hot potato into the eggs, beaten with salt. Leave for 10 minutes. 4. Heat a spoonful of the oil over a high heat, pour in the mixture and let it set for 2 minutes, loosening the edges. 5. Turn it over with a plate, let it set a minute more and serve it warm. ::: ::: ::: **Keep the oil.** Strained into a jar, it will fry the next tortilla. # Gazpacho \\ andaluz {style="gazpacho" kicker="Andalusian cold soup" time="25 min + 2 h chilling" level="Easy" season="June to September" note="serve it ice cold!" lead="In many Andalusian homes it is drunk from a glass, straight from the fridge. It needs truly ripe tomatoes, and oil poured in a thin stream while the blender runs, which leaves it creamy and orange."} :::callout{type="card" label="SERVES 6"} :::columns{count=2} :::callout{type="column" title="Ingredients"} - **1.5 kg** ripe plum tomatoes - **1** long green pepper - **1** cucumber - **1** garlic clove - **100 g** day-old bread, soaked in water - **120 ml** extra virgin olive oil - **3 tbsp** sherry vinegar - Salt and cold water :chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"} ::: :::callout{type="column" title="Method"} 1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic. 2. Put it all in the blender with the soaked bread, the vinegar and a teaspoon of salt; leave for 15 minutes. 3. Blend on full power for 2 minutes, then, with the motor running, add the oil in a thin stream until it emulsifies. 4. Press it through a fine sieve and thin it with cold water to taste. 5. Season with salt and vinegar and chill for at least 2 hours before serving. ::: ::: ::: **Make it ahead.** It keeps two days in a covered jug in the fridge; stir it before you pour. :::paragraphs{style="colophon"} *Salt & Olive Oil* · Set in Young Serif, Figtree and Caveat (SIL OFL) · Recipes and drawings: original, CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // #region art: two table-top drawings and the pictograms, in the palette's colours // No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts); // the handwritten note is a design element set in Caveat where the drawn arrow starts. const TABLE = { saffron: '#e9a23b', sage: '#b9c08a', blue: '#2f5d8a', china: '#fffaf0', gold: '#e9b659', crust: '#d4923a', brown: '#b8702a', soup: '#d6552f', oil: '#e8c547' }; const n = (v) => +v.toFixed(2); function mulberry32(seed) { // a seeded PRNG: the same drawing in every capture return () => { 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 svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const blob = (x, y, rx, ry, turn, fill, opacity) => `<ellipse cx="${n(x)}" cy="${n(y)}" ` + `rx="${n(rx)}" ry="${n(ry)}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})" fill="${fill}" ` + `fill-opacity="${n(opacity)}"/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`); const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${body}</g>`; const polar = (cx, cy, r, deg) => [cx + r * Math.cos(deg * Math.PI / 180), cy + r * Math.sin(deg * Math.PI / 180)]; // A napkin in checks: two sets of translucent stripes, darker where they cross. function gingham(size, check, color, opacity) { let out = `<rect x="${-size / 2}" y="${-size / 2}" width="${size}" height="${size}" ` + `fill="${TABLE.china}"/>`; for (let i = 0; i < size / check; i++) { const at = -size / 2 + i * check; out += `<rect x="${n(at)}" y="${-size / 2}" width="${check / 2}" height="${size}" ` + `fill="${color}" fill-opacity="${opacity}"/>` + `<rect x="${-size / 2}" y="${n(at)}" width="${size}" height="${check / 2}" ` + `fill="${color}" fill-opacity="${opacity}"/>`; } return out; } // A shadow, white china with a blue rim, a ring of cream dots and a fine inner line. function plate(r, rim) { let out = circle(1.6, 2.4, r, palette.ink, ' fill-opacity=".16"') + circle(0, 0, r, TABLE.china) + circle(0, 0, r - rim / 2, 'none', ` stroke="${TABLE.blue}" stroke-width="${rim}"`); for (let a = 0; a < 360; a += 10) { out += circle(...polar(0, 0, r - rim / 2, a), 0.55, TABLE.china); } return out + circle(0, 0, r - rim - 1.4, 'none', ` stroke="${TABLE.blue}" stroke-width=".45"`); } // A sector path: the omelette with a slice taken out, or the slice itself. const sector = (r, a0, a1) => { const [x0, y0] = polar(0, 0, r, a0); const [x1, y1] = polar(0, 0, r, a1); return `M0 0L${n(x0)} ${n(y0)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${n(x1)} ${n(y1)}Z`; }; function omelette(r, a0, a1, rand) { // a browned rim, a golden top mottled where it caught let out = path(sector(r, a0, a1), TABLE.crust) + path(sector(r - 1.8, a0, a1), TABLE.gold); // Spots in proportion to the sector, each kept clear of the rim and of both cut edges. const clear = (d, da) => da > 0 && d * Math.sin(Math.min(da, 90) * Math.PI / 180); // mm const inside = (d, a, rx) => d + rx < r - 2.4 && [a - a0, a1 - a].every((da) => clear(d, da) > rx + 0.4); const spot = (count, size, fill, opacity) => { for (let i = 0; i < Math.ceil(count * (a1 - a0) / 360); i++) { const rx = size * (0.5 + rand()); const [d, a] = [Math.sqrt(rand()) * r, a0 + rand() * (a1 - a0)]; const [ry, turn, alpha] = [rx * (0.4 + rand() * 0.4), rand() * 180, 0.6 + rand() * 0.6]; if (inside(d, a, rx)) out += blob(...polar(0, 0, d, a), rx, ry, turn, fill, opacity * alpha); } }; spot(24, 3.4, TABLE.crust, 0.4); // browned patches spot(12, 2.4, '#f5d98a', 0.35); // pale patches spot(48, 0.45, TABLE.brown, 0.5); // specks for (const a of [a0, a1]) { // the cut edges catch the light: a pale line along each const [x, y] = polar(0, 0, r - 1, a); out += stroke(`M0 0L${n(x)} ${n(y)}`, '#f5dc93', 0.9); } return out; } function arrow(d, tip, turn) { // a hand-drawn line and its head, as paths, never a <marker> return stroke(d, palette.ink, 0.55) + group(...tip, turn, stroke('M-2.4-1.3L0 0-2.4 1.5', palette.ink, 0.55)); } function sprig(x, y, turn, rand) { // an olive twig: paired grey-green leaves and two olives let out = stroke('M0 0C10-2 22-3 34-9', palette.olive, 0.7); for (let i = 0; i < 7; i++) { const t = 3 + i * 4.4; for (const side of [-1, 1]) { const leaf = 'M0 0C2-1.3 7-1.5 10 0C7 1.5 2 1.3 0 0Z'; out += group(t, -t * 0.18, side * (32 + rand() * 12) - 8, path(leaf, side > 0 ? palette.olive : '#8d9a5c')); } } out += circle(12, 4.6, 2.3, '#4a3a38') + circle(20, 3.4, 2.1, '#7d8a3a') + circle(11.3, 3.8, 0.6, TABLE.china, ' fill-opacity=".5"'); return group(x, y, turn, out); } const [PLATE_X, PLATE_Y] = [138, 45]; // mm: the plate or bowl, right of the title function tortillaArt() { const rand = mulberry32(23); let body = `<rect width="${PAGE.w}" height="${BAND}" fill="${TABLE.saffron}"/>`; body += group(152, 32, -11, gingham(108, 11, palette.tomato, 0.2)); const [CUT0, CUT1, PULL] = [-6, 30, 9]; // the slice in degrees; mm it is pulled out const shadow = (a0, a1) => group(0.9, 1.5, 0, path(sector(31, a0, a1), palette.ink, ' fill-opacity=".14"')); const slice = polar(0, 0, PULL, (CUT0 + CUT1) / 2); body += group(PLATE_X, PLATE_Y, 0, plate(43, 6.4) + shadow(CUT1, CUT0 + 360) + omelette(31, CUT1, CUT0 + 360, rand) + group(...slice, 0, shadow(CUT0, CUT1) + omelette(31, CUT0, CUT1, rand))); body += sprig(150, 95, -24, rand); body += arrow('M90 30C96 27 100 29 103 34', [103, 34], 62); // from the note towards the omelette return svgDoc(PAGE.w, BAND, body); } function tomato(x, y, r, turn) { // seen from above: a red disc, a green star, a highlight const star = [0, 72, 144, 216, 288].map((a) => group(0, 0, a, path('M0 0C1-1 3.2-1 4.2 0C3.2 1 1 1 0 0Z', palette.olive))).join(''); return group(x, y, turn, circle(0, 0, r, palette.tomato) + circle(-r * 0.35, -r * 0.35, r * 0.3, TABLE.china, ' fill-opacity=".25"') + star + circle(0, 0, 0.9, '#4f5c27')); } function gazpachoArt() { const rand = mulberry32(7); let body = `<rect width="${PAGE.w}" height="${BAND}" fill="${TABLE.sage}"/>`; body += group(156, 32, 9, gingham(108, 11, TABLE.blue, 0.22)); // A bowl from above: rim, pale inner wall, soup, drops of oil and a heap of diced vegetables. let bowl = plate(42, 5.8) + circle(0, 0, 32.5, '#efe5d2') + circle(0, 0, 29.5, TABLE.soup) + circle(0, 0, 29.5, 'none', ' stroke="#b8401f" stroke-width="1.2"') + path('M-24-12A26 26 0 0 1 4-26A28 28 0 0 0-24-12Z', '#e2703f'); // light on the surface for (const [x, y, rr] of [[-14, 4, 2.4], [-10, 12, 1.4], [-17, -6, 1.6], [-6, 17, 1.8], [-19, 3, 0.9], [2, 20, 1.1], [-12, -12, 1]]) { bowl += blob(x, y, rr, rr * 0.8, 20, TABLE.oil, 0.9); } const dice = ['#8fae4a', '#4f7a2a', '#f1d49a', '#b8321e', '#e3bf72']; for (let i = 0; i < 24; i++) { const [x, y] = polar(7, -5, Math.sqrt(rand()) * 11, rand() * 360); const size = 2 + rand() * 1.3; bowl += group(x, y, rand() * 90, `<rect x="${n(-size / 2)}" y="${n(-size / 2)}" ` + `width="${n(size)}" height="${n(size)}" rx=".5" fill="${dice[i % 5]}"/>`); } // A spoon resting in the soup, its handle over the rim towards the napkin. bowl += group(14, -12, -38, stroke('M6 0L40 0', palette.ink, 3.2, ' stroke-opacity=".14" transform="translate(1 1.5)"') + stroke('M6 0L40 0', '#cfcabf', 3) + blob(0, 0, 7, 4.6, 0, '#dedad0', 1) + blob(-0.8, -0.8, 4.6, 2.6, 0, TABLE.china, 0.55)); body += group(PLATE_X, PLATE_Y, 0, bowl); // Two tomatoes at the foot, shadows included, clear of the band's lower edge. const shade = (x, y, r) => circle(x + 1, y + 1.6, r, palette.ink, ' fill-opacity=".15"'); body += shade(180, 91, 9) + tomato(180, 91, 9, 12) + shade(164, 95, 6.5) + tomato(164, 95, 6.5, -30); body += arrow('M90 30C96 27 100 29 104 33', [104, 33], 55); // from the note to the bowl return svgDoc(PAGE.w, BAND, body); } const cutlery = svgDoc(6, 8, stroke('M1.6 .6V7.4M.6 .6V2.6C.6 3.4 2.6 3.4 2.6 2.6V.6', palette.tomato, 0.55) + stroke('M4.6 7.4V.6C5.8 1.4 5.8 3.6 4.6 4.4', palette.tomato, 0.55)); // The kit in line drawings: a bowl with two eggs, the frying pan and the plate that turns the // tortilla over, in a box KIT times as wide as it is tall. const egg = (x, turn) => `<ellipse cx="${x}" cy="2.5" rx="1.05" ry="1.35" ` + `transform="rotate(${turn} ${x} 2.5)" fill="none" stroke="${palette.olive}" ` + 'stroke-width=".6"/>'; const kit = svgDoc(8 * KIT, 8, egg(3, -12) + egg(5, 14) + stroke('M.6 3.9H7.4M1 3.9C1 6.4 2.4 7.4 4 7.4S7 6.4 7 3.9', palette.olive, 0.7) + stroke('M9.8 4.4H16.8M10.2 4.4L10.8 6.7C10.9 7.1 11.2 7.3 11.6 7.3H15C15.4 7.3 15.7 7.1 ' + '15.8 6.7L16.4 4.4', palette.olive, 0.7) + stroke('M16.8 5L20.2 4.1', palette.olive, 1.1) + circle(26.2, 4.2, 3.3, 'none', ` stroke="${palette.olive}" stroke-width=".7"`) + circle(26.2, 4.2, 2, 'none', ` stroke="${palette.olive}" stroke-width=".5"`)); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses. Layout measures with the browser's fonts, so the // kit loads them from Fontsource before the first build (gotcha: fonts-first). const FONTS = { Figtree: ['400', '400i', '600', '700'], 'Young Serif': ['400'], Caveat: ['600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: the drawings and the pictograms are resources, cited by id, never by :ref // The opener's image element and both icons name a resource id; the resource names the file // the canvas paints (loadSvg registers it). No :ref cites them, so none is numbered. const ART = { // markup, width and height in mm, and the alt text tortilla: [tortillaArt(), PAGE.w, BAND, t({ en: 'A potato omelette with a slice pulled out, ' + 'on a blue-rimmed plate and a red-checked napkin', es: 'Una tortilla de patatas con una ' + 'porción separada, en un plato de borde azul sobre una servilleta de cuadros rojos' })], gazpacho: [gazpachoArt(), PAGE.w, BAND, t({ en: 'A bowl of gazpacho with diced vegetables and ' + 'a spoon, on a blue-checked napkin beside two tomatoes', es: 'Un cuenco de gazpacho con ' + 'dados de verdura y una cuchara, sobre una servilleta de cuadros azules junto a dos ' + 'tomates' })], cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })], kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate', es: 'Un bol con dos huevos, una sartén y un plato' })] }; const resources = Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 } })); // 10 px a millimetre: the sizes only set the aspect ratio here await Promise.all(Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg))); // #endregion await loadFonts(FONTS, markdown); // The excerpt is pages 58 and 59 of the book: 57 pages come before it, so the tortilla opens // on a verso and the two recipes face each other. const doc = await buildWithFonts(() => buildDocument({ markdown, resources, continuation: { pageIndexOffset: 57, pageNumbering: { startAt: 58 } } }, config()), markdown); showPages(doc, { title: t({ en: 'Recipe card', es: 'Tarjeta de receta' }) });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
#Count blocks instead of boxing the columns
Without the column boxes, breaks names the child that opens the right column, and each list item, the tags line and the checklist's header count as one: the gazpacho needs breaks="10" (eight ingredients and the tags come first), the tortilla breaks="11". The column titles and the column style's space between steps disappear with them.
:::callout{type="card" label="SERVES 6"}
-:::columns{count=2}
-:::callout{type="column" title="Ingredients"}
+:::columns{count=2 breaks="10"}
- **1.5 kg** ripe plum tomatoes
@@ seven more ingredients and a blank line @@
:chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"}
-:::
-:::callout{type="column" title="Method"}
+
1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic.
@@ steps 2 to 5 @@
:::
-:::
:::#Stand the tab on the left corner
'top-left' mirrors the label: the tab moves to the left corner, the fork and knife to its right, and the rule runs in from the right.
background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
+ position: 'top-left', // 'top-right' by defaultPitfalls
Pitfall
:::columns works only inside a box and never splits
:::columns is ignored outside a callout, and a box that splits never cuts inside a columns group. A breaks attribute counts child blocks, with a nested box as one. Columns inside a box →
Pitfall
A list number is centred on its first line, not set on the baseline
In postext 1.4.1 an ordered list's number is painted with the canvas 'middle' baseline, 0.3 em of the text above the item's first baseline, so a number in another face or at a larger numberFontSize grows up and down from there instead of standing on the line: a display face rides high, and a big step number hangs across the first line. Place it with orderedLists.numberVerticalOffset, and check the result at full size. Numbered lists →
Pitfall
A box's lists.color also recolours its list numbers
In postext 1.4.1 a callout style's lists.color reaches the numbers of its ordered lists whenever it differs from unorderedLists.color, even when orderedLists sets a colour of its own: give a box olive bullets that way and its step numbers turn olive too. To colour a box's bullets alone, set unorderedLists.color for the document and leave lists.color out of the box style. Callout boxes →
Pitfall
Chips taller than the line pitch touch the next line
A chip whose box is taller than the line pitch touches the chips on the next line (the chipOverlap warning). Reduce its vertical padding, border or size, or open the leading. Inline chips →
Pitfall
Lists say 'arabic', resources 'roman-upper', pages 'upper-roman'
Each numbering setting spells its formats differently: lists take numberFormat 'arabic' ('decimal' prints "undefined"), resource types take counterFormat 'roman-upper', pages and :::numbering take 'upper-roman'. Numbered lists →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
A 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 →
Pitfall
Design text overflow defaults to 'ellipsis-end'
A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines. Text, rules and boxes in page designs →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
Text inside an SVG <img> cannot use web fonts
An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →
Pitfall
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
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 label has no
textTransformorletterSpacingin 1.4.1, so SERVES 4 and PARA 4 are typed in capitals in the fences. - The task box ☐ is in none of the three faces, so the browser draws it from a system font and its shape changes from one system to another.
- Keep a wide icon in the box's own column: at
position: 'corner', 1.4.1 offsets it by half its height, not half its width, so the 18.75 mm strip runs 16 mm past the box's right edge.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Young Serif (SIL OFL 1.1) · Figtree (SIL OFL 1.1) · Caveat (SIL OFL 1.1)


