What you'll build
The autumn card of Les Tanneurs, an imaginary Paris bistro: one sheet, 230 × 310 mm, printed on both sides. On the front, a striped awning hangs over the name, set in Limelight. Each course head stands between two brass rules that run out to the margins, with four dishes below it. A dish is its French name in bold with the garnish, an English line in italic beneath it and the price in bold, flush right. A dark green V disc marks the vegetarian dishes and an outlined SG pill the gluten-free ones. On the back, the wine list sits under a shelf of bottles, with the glass and bottle prices in columns headed 12 cl and 75 cl. Postext has no tab stops, so every price column belongs to a table with no rules and no caption.
This recipe answers
- How do I make a table with header rows, merged cells, column widths and per-cell alignment?
- How do I make inline chips: keyboard keys, tags, word banks for exercises?
- How do I set unnumbered artwork: ornaments, vignettes, logos?
- How do I add images and tables from code (resources) instead of Markdown ![]()?
The short answer
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
.map(([, dish, translation, price]) => [
{ content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
// Both cells start at the top of the row, so the price shares the dish's first baseline.
{ content: `**${price.content}**`, align: 'right' },
]);
// columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}
Dish on the left, price flush right: a two-column table with no rules
Ingredients
- Features
- Tables from dataTable styleAnchoring design elementsInline chipsParagraph stylesDesigned openersText, rules and boxes in page designsCustom resource typesFigures exactly hereFigures and tables as resourcesPaper colourHeading levelsSemantic colour palettePage and column breaksPages on a canvas
- Type
- Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1)
- Assets
- The awning and the shelf of bottles, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
Method
#1 · A table where the tab stops would be
The code is in the short answer above. Each course is a two-column table. The dish, a line break and the translation in italics share the first cell, and the price stands flush right in the second, on the dish's first baseline, because both cells start at the top of their row. columnWidths takes weights, so [6, 1] gives the prices 22.6 mm of the 158 mm measure. rules: 'none' and headerBackgroundEnabled: false remove the grid and the grey head row that the default table style draws. cellPadding: pt(LEAD / 4) makes each dish two and a half lines of the 14.5 pt leading, so a course of four dishes ends on its tenth grid line. At the default padding, 0.375 em, each course ends 0.9 mm below that line, every head after it drops a line and the card runs to four pages.
#2 · One resource type, in place and never numbered
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
...body });
The built-in figure and table types print a caption line and float each resource to the first free slot after its first reference. The menu type has no caption prefix and its pieces have no caption, so nothing prints under a course. Give it captionPrefix: 'Menu' and a numbered line prints under every piece, starting with “Menu 1.” under the awning, which takes the card to four pages. defaultPlacement sets each table and drawing at its ::resource line (Block embed). Without it the three course heads run together at the top of the front and the tables float below them, desserts first. The awning and the bottles are SVG resources of the same type, drawn in code and registered with the kit's loadSvg.
#3 · Merge the region rows
function wineList(tsv) {
let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
// One cell across the table, centred on the card like the course heads; a label centred
// in the first column alone would sit 22.6 mm left of them. The label face is the header's.
m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
// mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
});
return piece('wines', 'table', { table: { model: m } });
}
In 1.4.1 parseTSV marks no row as a header, so wineList sets headerRowCount: 1 by hand. Appellation · cépage, 12 cl and 75 cl then print over their columns in the header face, Josefin Sans. A line with a single field names a region. Its cell becomes a centred header cell, and mergeCells spreads it across the three columns and marks the two cells it covers hiddenBy (Building table models). Centred in the first column alone, BLANCS would stand 22.6 mm left of the axis that La Cave and Le Comptoir are centred on. A blank line in the TSV becomes a row of empty cells, one and a half lines tall, and that row is the gap above Blancs and Rouges.
#4 · Hang a rule on each side of the course title
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
placement: { anchor: { to: '#title', edge },
size: { width: 'fill' }, // to the column's edge
offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
textTransform: 'uppercase', color: col('wine'),
placement: { anchor: { to: 'container', edge: 'top' } } },
rule('left-of', -4), rule('right-of', 4),
] } };
A course head is a design in the column. The {titleText} element has no width, so its box is as wide as its capitals, and the 'top' anchor centres it on the column. Each rule hangs off one edge of the title with 'left-of' or 'right-of' and fills to the column's edge (Element placement), so the rules beside PLATS are 66.7 mm long and those beside LE COMPTOIR 56.2 mm, each 4 mm from the letters. The title's lineHeight is a multiple, 0.96, which makes its box 14.4 pt and keeps it inside one 14.5 pt grid line. At 1 the box is 15 pt, each head takes two lines and the card runs to four pages.
#5 · Badges as chips, small print as paragraph styles
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
// V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
// outline, 0.5 pt of main-color, which would ring the green in wine red.
{ id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
background: col('bottle'), color: col('paper') },
{ id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
{ id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
{ id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
marginTop: pt(LEAD) },
];
A chip draws a box round a run of text inside the line (Chip styles), so :chip[V]{style="veg"} typed after the garnish puts the disc on the dish's French line, in a table cell as in a paragraph. The chip styles set bold: true because a chip takes the weight of the text around it, 400 after a garnish, and FONTS loads Josefin Sans in 700 only. The notes under the desserts and the colophon on the back are :::paragraphs blocks at 9 and 7.5 pt, and both styles carry a marginTop of one line (Paragraph styles), because a table set 'here' leaves no space below itself. Without that margin the notes start on the line after the cheese plate's English line, as if they belonged to the dish.
#6 · Centre the card on one axis
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
levels: [ // Limelight ships one weight, 400, and no italic
// Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
// a break before an H1 would part the name from the awning and La Cave from its bottles.
{ level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
{ level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
] };
headings.textAlign and bodyText.textAlign put everything that is not a table on the card's centre line. lineHeight: pt(3 * LEAD) gives the 48 pt name three grid lines. Without it the name takes the default leading and four lines, the second line of notes moves onto a page of its own and the card runs to four pages. In 1.4.1 any headings object drops the H1 break, and level 1 states breakBefore: { enabled: false } all the same, because the name and La Cave belong on the page of the drawing above them. Restore the default, { enabled: true, parity: 'always-odd' }, and each drawing stands alone on a page, seven pages in all. The config sets no locale, because 1.4.1 hyphenates justified body text only and nothing on this card is justified.
The whole recipe
// ═══ Postext Cookbook · Nº 052 · Bistro menu: prices aligned without tab stops ═══════ // https://postext.dev/en/cookbook/bistro-menu // Code: MIT · Text: original, in French (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // The autumn menu of an imaginary Paris bistro: two sides of one card, every price column a // table with its rules switched off. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV, mergeCells } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'bistro-menu'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1f2a24', paper: '#f6efdf', // green-black text on cream card wine: '#6d1f2c', brass: '#a9823a', // the name, heads and labels; rules and the drawings' metal straw: '#e8d4a8', bottle: '#2f4235', sage: '#8a9a78', // awning stripes and labels; glass muted: '#6b6457' }; // the colophon const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color' (#295aa3, a blue); this palette makes it the wine red. const colorPalette = Object.entries({ ...palette, 'main-color': palette.wine }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Noticia Text', 'Limelight', 'Josefin Sans']; const [BODY, LEAD] = [10.5, 14.5]; // pt: the text, and the leading every table row keeps // #region answer: dish on the left, price flush right: a two-column table with no rules // Postext has no tab stops, so each course is a table. The document's tableStyle turns off // every rule and fill, so nothing on the page shows that a table is there. The wine list's head // row and merged region rows are in #region wines. const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'), headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'), headerBackgroundEnabled: false }; // header cells: the wine list's labels // The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price. function course(id, tsv) { const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id) .map(([, dish, translation, price]) => [ { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines // Both cells start at the top of the row, so the price shares the dish's first baseline. { content: `**${price.content}**`, align: 'right' }, ]); // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm. return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } }); } // #endregion // #region type: one resource type for the whole card: set where it stands, never numbered // No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement // 'here' sets each piece at its ::resource line; such a table never splits, so a course that // outgrows the page moves to the next one whole (gotcha: here-table-no-split). const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here' } }]; const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0, ...body }); // #endregion // #region wines: region rows merged across the three columns, prices under their labels function wineList(tsv) { let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] }; m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right', content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes' m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region if (r === 0 || !first.content || rest.some((cell) => cell.content)) return; // One cell across the table, centred on the card like the course heads; a label centred // in the first column alone would sit 22.6 mm left of them. The label face is the header's. m.rows[r][0] = { ...first, isHeader: true, align: 'center' }; // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby). m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } }); }); return piece('wines', 'table', { table: { model: m } }); } // #endregion // #region heads: a course head: its title centred, a brass rule anchored to each side of it // The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each // rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it // to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR. const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'), thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing placement: { anchor: { to: '#title', edge }, size: { width: 'fill' }, // to the column's edge offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals const courseHead = { enabled: true, slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15), lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line textTransform: 'uppercase', color: col('wine'), placement: { anchor: { to: 'container', edge: 'top' } } }, rule('left-of', -4), rule('right-of', 4), ] } }; // #endregion // #region badges: dietary badges as chips, explained in small type under the desserts // A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip // takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded. const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700 borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height const chipStyles = [ // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default // outline, 0.5 pt of main-color, which would ring the green in wine red. { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0), background: col('bottle'), color: col('paper') }, { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false, borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') }, ]; // The notes under the desserts and the colophon on the back: smaller, on the card's axis. // marginTop gives the line of air that a table set 'here' does not leave below itself. const paragraphStyles = [ { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) }, { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'), marginTop: pt(LEAD) }, ]; // #endregion // #region centred: one axis for the card: the name, the course heads and the notes centred // bodyText (in config) centres the lines under the name and the notes; only the tables keep a // left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config // sets no locale: French patterns would change nothing on this card. const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center', levels: [ // Limelight ships one weight, 400, and no italic // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since // a break before an H1 would part the name from the awning and La Cave from its bottles. { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false }, marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own ] }; // #endregion // #region art: a striped awning over the name, a shelf of bottles over the wine list function awning() { // 158 × 20 mm; the canopy narrows 6 % toward the wall const [W, N, ROD, DROP, HEM, INSET] = [1660, 19, 12, 104, 46, 50]; const s = W / N, top = (i) => INSET + i * (W - 2 * INSET) / N, bottom = (i) => i * s; const shade = { [palette.wine]: mix(palette.wine, palette.ink, 0.25), // the valance: each [palette.straw]: mix(palette.straw, palette.brass, 0.4) }; // stripe a shade darker let shapes = ''; for (let i = 0; i < N; i++) { const fill = i % 2 ? palette.straw : palette.wine, y = ROD + DROP; shapes += `<path d="M${top(i)} ${ROD}H${top(i + 1)}L${bottom(i + 1)} ${y}H${bottom(i)}Z" ` + `fill="${fill}"/><path d="M${bottom(i)} ${y}h${s}v${HEM}a${s / 2} ${s / 2} 0 0 1 ` + `${-s} 0Z" fill="${shade[fill]}"/>`; } shapes += `<rect x="${INSET - 16}" y="0" width="${W - 2 * INSET + 32}" height="${ROD}" rx="6" ` + `fill="${palette.brass}"/><rect x="0" y="${ROD + DROP - 3}" width="${W}" height="6" ` + `fill="${palette.brass}"/>`; // the rod on the wall, a brass bead along the front edge return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 212" width="${W}" ` + `height="212">${shapes}</svg>`; } function mix(a, b, t) { // a blend of two palette colours, t of the way from a to b const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const [x, y] = [rgb(a), rgb(b)]; return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0')) .join('')}`; } function bottles() { // 158 × 29 mm: glasses, bottles and a carafe on a brass shelf const [W, H, BASE] = [1660, 300, 286]; const glass = (x) => // a tulip glass, a third full `<path d="M${x - 34} ${BASE - 170}C${x - 36} ${BASE - 110} ${x - 20} ${BASE - 88} ${x} ` + `${BASE - 86}C${x + 20} ${BASE - 88} ${x + 36} ${BASE - 110} ${x + 34} ${BASE - 170}Z" ` + `fill="none" stroke="${palette.bottle}" stroke-width="4"/>` + `<path d="M${x - 33} ${BASE - 132}C${x - 30} ${BASE - 104} ${x - 16} ${BASE - 91} ${x} ` + `${BASE - 90}C${x + 16} ${BASE - 91} ${x + 30} ${BASE - 104} ${x + 33} ${BASE - 132}Z" ` + `fill="${palette.wine}"/>` + `<rect x="${x - 2.5}" y="${BASE - 88}" width="5" height="82" fill="${palette.bottle}"/>` + `<ellipse cx="${x}" cy="${BASE - 5}" rx="30" ry="5" fill="${palette.bottle}"/>`; const bottle = (x, h, w, shoulder, body, foil) => { // straight or sloping shoulders const neck = 13, top = BASE - h, sh = BASE - h * 0.62; return `<path d="M${x - w} ${BASE}V${sh}C${x - w} ${sh - shoulder} ` + `${x - neck} ${sh - shoulder} ${x - neck} ${sh - shoulder * 1.6}` + `V${top + 6}Q${x - neck} ${top} ${x - neck + 6} ${top}` + `H${x + neck - 6}Q${x + neck} ${top} ${x + neck} ${top + 6}V${sh - shoulder * 1.6}` + `C${x + neck} ${sh - shoulder} ${x + w} ${sh - shoulder} ${x + w} ${sh}V${BASE}Z" ` + `fill="${body}"/>` + `<rect x="${x - neck - 1}" y="${top}" width="${2 * neck + 2}" height="${h * 0.16}" ` + `rx="4" fill="${foil}"/>` + `<rect x="${x - w + 8}" y="${BASE - h * 0.44}" width="${2 * w - 16}" height="${h * 0.26}" ` + `fill="${palette.straw}"/>` + `<rect x="${x - w + 8}" y="${BASE - h * 0.3}" width="${2 * w - 16}" height="6" ` + `fill="${foil}"/>`; }; const carafe = (x) => `<path d="M${x - 16} ${BASE - 200}H${x + 16}V${BASE - 150}` + `C${x + 70} ${BASE - 120} ${x + 76} ${BASE - 20} ${x + 44} ${BASE}H${x - 44}` + `C${x - 76} ${BASE - 20} ${x - 70} ${BASE - 120} ${x - 16} ${BASE - 150}Z" fill="none" ` + `stroke="${palette.bottle}" stroke-width="4"/>` + `<path d="M${x - 64} ${BASE - 74}C${x - 70} ${BASE - 30} ${x - 58} ${BASE - 8} ${x - 42} ` + `${BASE - 4}H${x + 42}C${x + 58} ${BASE - 8} ${x + 70} ${BASE - 30} ${x + 64} ${BASE - 74}Z" ` + `fill="${palette.wine}"/>`; const C = W / 2; const art = glass(C - 330) + bottle(C - 225, 250, 38, 12, palette.bottle, palette.wine) + bottle(C - 125, 262, 42, 34, palette.sage, palette.brass) + carafe(C) + bottle(C + 125, 256, 44, 36, palette.bottle, palette.brass) + bottle(C + 225, 250, 38, 12, palette.bottle, palette.wine) + glass(C + 330) + `<rect x="${C - 420}" y="${BASE}" width="840" height="6" fill="${palette.brass}"/>`; return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" ` + `height="${H}">${art}</svg>`; } // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) colorPalette, tableStyle, resourceTypes, page: { sizePreset: 'custom', width: mm(230), height: mm(310), dpi: 150, backgroundColor: col('paper'), // one card, printed both sides: margins are not mirrored margins: { top: mm(22), bottom: mm(20), left: mm(36), right: mm(36) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to main-color, the wine red textAlign: 'center', firstLineIndent: mm(0) }, // the lines under the name and the notes headings, // the card's axis (#region centred) chipStyles, paragraphStyles, // the badges and the small print (#region badges) header: { elements: [] }, footer: { elements: [] }, // a menu has no running heads or folios }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 46 lines · content.en.md
title: "Les Tanneurs" --- ::resource{id="awning"} # Les Tanneurs *Bistrot parisien depuis 1931* Carte d’automne 2026 · *autumn menu* ## Entrées ::resource{id="entrees"} ## Plats ::resource{id="plats"} ## Desserts ::resource{id="desserts"} :::paragraphs{style="notes"} :chip[V]{style="veg"} Plat végétarien · *vegetarian* · :chip[SG]{style="gf"} Sans gluten · *gluten-free* Bœuf d’origine France · *French beef* · Prix nets, service compris · *service included* ::: :::pagebreak ::resource{id="bottles"} # La Cave Vins au verre et à la bouteille · *wine by the glass and by the bottle* ::resource{id="wines"} ## Le Comptoir ::resource{id="counter"} :::paragraphs{style="colophon"} Set in Limelight, Noticia Text and Josefin Sans (SIL OFL) · Les Tanneurs is an imaginary bistro · Text and drawings CC BY 4.0 :::`; // the two sides of the card, in French const dishes = String.raw`entrees **Œuf mayonnaise**, cornichons de la maison :chip[V]{style="veg"} :chip[SG]{style="gf"} Egg mayonnaise, house pickles 7Markdown sample · 15 lines · content.carte.en.md
entrees **Velouté de potimarron**, crème crue, noisettes torréfiées :chip[V]{style="veg"} :chip[SG]{style="gf"} Red kuri squash soup, raw cream, toasted hazelnuts 9 entrees **Terrine de campagne** au poivre vert, pain grillé Country terrine with green peppercorns, toast 11 entrees **Poireaux vinaigrette**, œuf mimosa :chip[V]{style="veg"} :chip[SG]{style="gf"} Leeks in vinaigrette, egg mimosa 10 plats **Blanquette de veau** à l’ancienne, riz pilaf Veal blanquette the old way, pilaf rice 24 plats **Paleron de bœuf** braisé au vin rouge, carottes fondantes Beef chuck braised in red wine, slow-cooked carrots 26 plats **Filet de lieu jaune**, beurre blanc, poireaux fondus :chip[SG]{style="gf"} Pollack fillet, beurre blanc, softened leeks 27 plats **Risotto aux cèpes**, mascarpone et sauge :chip[V]{style="veg"} :chip[SG]{style="gf"} Cep risotto, mascarpone and sage 22 desserts **Tarte fine aux pommes**, crème fraîche Thin apple tart, crème fraîche 10 desserts **Mousse au chocolat noir** Dark chocolate mousse 9 desserts **Île flottante**, pralines roses Floating island, pink pralines 9 desserts **Trois fromages affinés**, confiture de cerises noires Three matured cheeses, black cherry jam 12 counter **Kir** au vin blanc et cassis White wine with blackcurrant liqueur 6 counter **Pastis**, carafe d’eau fraîche Pastis with a jug of cold water 5 counter **Bière à la pression**, 25 cl Draught beer, 25 cl 5 counter **Café**, noisette ou allongé Espresso, macchiato or lungo 3`; // TSV: course · dish · translation · price const wines = String.raw`Appellation · cépage 12 cl 75 clMarkdown sample · 16 lines · content.cave.en.md
BULLES **Crémant de Loire** brut · *chenin blanc* 9 44 BLANCS **Muscadet Sèvre-et-Maine** sur lie 2023 · *melon de Bourgogne* 7 32 **Mâcon-Villages** 2022 · *chardonnay* 9 40 **Sancerre** 2023 · *sauvignon blanc* 11 52 **Chablis** 2022 · *chardonnay* 12 58 ROUGES **Côtes-du-Rhône** 2022 · *grenache, syrah* 7 32 **Saumur-Champigny** 2022 · *cabernet franc* 9 42 **Morgon** 2022 · *gamay* 10 46 **Bordeaux supérieur** 2020 · *merlot, cabernet sauvignon* 8 38 **Saint-Joseph** 2021 · *syrah* 12 56 **Saint-Émilion grand cru** 2018 · *merlot, cabernet franc* — 78`; // TSV: head row, regions, wines; one file for both editions const resources = [ piece('awning', 'svg', { svg: { fileId: 'awning.svg', width: 1660, height: 212 }, altText: 'A striped wine-red and straw awning on a brass rod.' }), piece('bottles', 'svg', { svg: { fileId: 'bottles.svg', width: 1660, height: 300 }, altText: 'Two glasses of red wine, four bottles and a carafe on a brass shelf.' }), course('entrees', dishes), course('plats', dishes), course('desserts', dishes), wineList(wines), course('counter', dishes), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Noticia Text': ['400', '400i', '700'], Limelight: ['400'], 'Josefin Sans': ['700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = markdown + dishes + wines; await loadFonts(FONTS, allText); await loadSvg('awning.svg', awning()); await loadSvg('bottles.svg', bottles()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: t({ en: 'Les Tanneurs: autumn menu', es: 'Les Tanneurs: carta de otoño' }) });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
#Stop the rules at a fixed length
A fixed width in place of 'fill' keeps each rule 20 mm long and 4 mm from the title, so the pair sits closer to the centre beside PLATS than beside LE COMPTOIR.
- size: { width: 'fill' }, // to the column's edge
+ size: { width: mm(20) }, // 20 mm, wherever the title ends#Rule the rows like a ledger
rules: 'horizontal' draws a 0.5 pt brass hairline over and under every row. Rules take no room, so both sides keep their layout, and the blank rows above Blancs and Rouges get a pair of hairlines of their own.
-const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
+const tableStyle = { rules: 'horizontal', borderColor: col('brass'),
+ borderWidth: pt(0.5), cellPadding: pt(LEAD / 4),Pitfalls
Pitfall
A 'here' table never splits
Only floated tables split across columns and pages; a table placed 'here' moves whole. Let a long table float, or keep inline tables short. Tables across pages →
Pitfall
An inline figure gets space above it but not below
In postext 1.4.1 a figure that ::resource sets at position 'here' gets one grid line of space above it, but below it only what is left over when the next line snaps to the baseline grid: anywhere from a whole line to almost nothing, so the next paragraph can start right under the caption. Follow the ::resource line with :::space{lines=1}; like any :::space, it is dropped at the top of a column. Figures exactly here →
Pitfall
A 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
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
Ragged text is never hyphenated
Hyphenation applies to justified text only; ragged-right text breaks between words, so a narrow ragged column gets a deep rag. Justify the passage or widen the measure. Hyphenation and document language →
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 →
Give every rule element a thickness. In 1.4.1 a rule without one paints nothing, although the configuration reference gives 0.5 pt as the default. Delete it from rule() and both rules of every course head disappear.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Images
- The awning and the shelf of bottles, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- Fonts
- Limelight (SIL OFL 1.1) · Noticia Text (SIL OFL 1.1) · Josefin Sans (SIL OFL 1.1)


