Skip to main content
Recipe number 36

Cookbook · Chapter 8 · Tables

Mail-order catalogue with pictures in cells

A seed catalogue whose price list is a table read from TSV, with a seed packet drawn in each variety's cell and the list split across a spread.

pp. 2–3 of 4

  • Trim 190 × 250 mm
  • 2 columns, 7 mm gutter
  • Gelasio 9.5/13
  • Alfa Slab One
  • Cabin Condensed
  • 4 pages
  • Level
  • Postext 1.4.1
  • Laid out in 9 ms
  • 229 lines of code

What you'll build

The spring price list of Brindlewood Seed Co., an invented seed farm, as a four-page booklet on buff paper. The cover sets Spring Seeds in Alfa Slab One over a sunflower and a truss of tomatoes, with the letter to growers in two columns below. Pages 2 and 3 carry the list. Each variety has its seed packet drawn at the top of its cell and its name under it, and the kinds of vegetable run down a merged first column. Prices sit flush right in a condensed label face, and red and green chips mark new and organic seed. The list breaks after the frying pepper and picks up on the facing page under the same red band, marked (continued). The back page is the order sheet, two rounded grids with empty lines to write on.

This recipe answers

  • How do I put pictures or icons inside table cells?
  • How do I split a long table across pages with a repeated header and a "continued" marker?
  • How do I style several tables differently (fills, zebra cells, rounded frames) in one document?
  • How do I make a table with header rows, merged cells, column widths and per-cell alignment?

The short answer

script.js · lines 43–78in full code
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [20, 40, 63, 17, 17] }; // mm
  for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
  for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
  for (let r = 1; r < m.rows.length; r++) {
    // The drawing is a resource of its own; the cell sets it at the top, centred, with the
    // variety's name under it, and the row grows to hold both.
    const variety = m.rows[r][1].content;
    m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
    m = setCellContent(m, at(r, 1), `**${variety}**`);
    m = setAlignment(m, at(r, 1), 'center');
    for (const c of [3, 4]) { // prices in the label face: lining figures, flush right, and a
      // bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash)
      m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
      m = setAlignment(m, at(r, c), 'right');
    }
    if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
    let end = r;
    while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
    // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
    // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
    m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
    m = setAlignment(m, at(r, 0), 'center', 'middle');
    m = setCellBackground(m, at(r, 0), col('cream'));
  }
  return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column. Only a float splits (gotcha: here-table-no-split).
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
  caption: '**Vegetable Seeds**', placement: { span: 'page' },
  note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
  table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });

A price list from TSV: a packet in each variety's cell, kinds merged down

Ingredients

Type
Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1)
Assets
  • The cover's sunflower and tomatoes and the nine seed packets, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

Method

#1 · Add what the TSV leaves out

The code is the short answer above. The TSV holds only words and prices. setCellImage puts each variety's packet, a resource named after the variety, at the top of its cell at 55% of the inner width, so the name sits under a 20.3 × 27.1 mm drawing and every row grows to 36 mm. mergeCells runs each kind down the first column and marks the cells it covers hiddenBy. A table's body cells share one face, Gelasio here, so each price is a chip with no fill, border or side padding, set in bold Cabin Condensed at 1.12 em for its lining figures.

#2 · Cite the list and split it across the spread

script.js · lines 82–98in full code
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
  headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
  // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
  // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
  overflow: 'split', continuedSuffix: '(continued)',
  continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
  borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
  bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
  position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
  gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };

The letter on page 1 cites the list with :ref{id="vegetables" text="the price list"}, and text prints those words in place of a number. A cited table floats to the first free slot after the citation, here the top of page 2. The list does not fit there, so the engine cuts it between rows after Jimmy Nardello, never inside a merged kind. Page 3 repeats the head row and the caption band with the suffix; the marker goes under the first part and the note under the last (Tables taller than the page). parseTSV marks no cell as a header, so headerRowCount: 1 is set by hand to paint the first row as the head row and repeat it on page 3. The suffix and the marker are italic and take the caption's face, so the band stays in Gelasio; Cabin Condensed has no italic.

#3 · A resource for every packet

script.js · lines 481–491in full code
// Nothing cites the drawings, so none is numbered or placed: the cover design and the cells
// draw them by id, and their typeId is never looked up.
const picture = (id, [w, h], altText) => ({ id, typeId: 'figure', kind: 'svg', altText,
  svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE }, createdAt: 0, updatedAt: 0 });
const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content);
const resources = [vegetableList(list), shipTo, orderForm,
  picture('cover-art', [ART.w, ART.h], 'A sunflower and a truss of tomatoes in a ploughed field.'),
  ...varieties.map((name) => picture(slug(name), PACK, `Seed packet of ${name}.`))];
await loadFonts(FONTS, markdown + list);
await loadSvg('cover-art.svg', coverArt()); // the kit's loadSvg calls registerResourceImage
for (const name of varieties) await loadSvg(`${slug(name)}.svg`, packet(PACKETS[slug(name)]));

A cell image names a resource by id, so each packet is an SVG resource of its own, and the kit's loadSvg registers its drawing under the fileId with registerResourceImage. Nothing cites the packets, so none is numbered or placed outside its cell. An SVG drawn as an image cannot use the page's web fonts, so the packets carry no lettering and the name is set as cell text.

#4 · A second table style for the order sheet

script.js · lines 245–264in full code
function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height
  let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) =>
    widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) };
  for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) });
  m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff
    if (r === 0 || !cell.content || cell.hiddenBy) return;
    m = setCellBackground(m, at(r, c), col('paper'));
    if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels
  }));
  return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' },
    table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 };
}
const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'],
  ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]);
const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet
const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces',
  'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'],
['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]),
'We guarantee every packet to grow. If a variety fails to come up in your garden, write to '
  + 'us before August 1 and we will send a new packet or refund what you paid for it.');

The two grids use the named style form, with grid rules, a 2.5 mm radius on the outer frame and a cream fill to write on. The price list keeps the document's tableStyle (Named table styles). An empty cell needs no placeholder, because its row still takes a line, 7.3 mm here. Each total merges four columns into one label set flush right. The order sheet's heading style switches page 4 to a single column, so both grids run the full 157 mm measure.

#5 · Keep the letter under the drawing

script.js · lines 115–141in full code
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover drawing, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the drawing and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
  // A style's header replaces the document's on every page of its section, so it carries
  // the running heads too. The frame is drawn there: in the design it would count as
  // reserved height down to the page's foot, more than the column holds, and 1.4.1 then
  // drops the reservation and sets the letter over the drawing (gotcha:
  // opener-reserves-anchored).
  header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener',
    style: { borderColor: col('basil'), borderWidth: pt(1.2) },
    placement: on('page', 'top-left', FRAME, FRAME,
      { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }] },
  // The drawing is an image, and images do not count towards the height an opener
  // reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it.
  advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
    text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
    text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
      fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
    text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
      box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
        left: mm(4), right: mm(4) } } }),
    { kind: 'image', id: 'art', resourceId: 'cover-art',
      placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
  ] } },
};

The cover drawing is an image element, and an image adds nothing to the height an opener reserves. Without minHeight the letter would start 55 mm from the top of the page, over the sunflower. SINK adds 10 mm of air under the drawing and rounds the depth below the top margin up to whole 13 pt lines, 34 of them, so both columns of the letter start on the grid, 177.9 mm down the page.

The whole recipe

// ═══ Postext Cookbook · Nº 036 · Mail-order catalogue with pictures in cells ═══════
// https://postext.dev/en/cookbook/seed-catalogue
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A fictional seed farm's spring list: a table read from TSV, a packet drawn in each row.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells, setAlignment, setCellBackground, setCellContent, setCellImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'seed-catalogue';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#2d2620', paper: '#f0e2c4', // brown-black text on buff
  cream: '#fcf7ea', tomato: '#c23b22', // cells to write in; the accent: bands, titles, NEW
  basil: '#2f6b3b', sun: '#e8b53a', // rules, ORGANIC, the form's head; the cover's banner
  rule: '#c7ae86', muted: '#6d5f50', // hairlines; running heads and notes
};
// 1.4.1 designs read the hex, not paletteId: col() sets both (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

const TRIM = { w: 190, h: 250 }; // mm: a stapled mail-order booklet
const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored: 157 mm wide
const LEAD = 13; // pt: the body leading, the grid the headings keep to
const TEXT = 'Gelasio', DISPLAY = 'Alfa Slab One', LABEL = 'Cabin Condensed';
const PACKET = 0.55; // the packet drawing's share of its cell's inner width
const caps = (size, fontWeight = 600) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight,
  letterSpacing: pt(size * 0.18), textTransform: 'uppercase' });
const on = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default)
const chip = (id, size, ink, box) => ({ id, fontFamily: LABEL, bold: true, fontSize: em(size),
  color: col(ink), borderRadius: pt(1.2), ...box });
const rule = (id, below, gap, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('basil'),
  placement: { ...on(`#${below}`, 'below', 0, gap), size: { width: 'fill' } } });

// #region answer: a price list from TSV: a packet in each variety's cell, kinds merged down
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [20, 40, 63, 17, 17] }; // mm
  for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
  for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
  for (let r = 1; r < m.rows.length; r++) {
    // The drawing is a resource of its own; the cell sets it at the top, centred, with the
    // variety's name under it, and the row grows to hold both.
    const variety = m.rows[r][1].content;
    m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
    m = setCellContent(m, at(r, 1), `**${variety}**`);
    m = setAlignment(m, at(r, 1), 'center');
    for (const c of [3, 4]) { // prices in the label face: lining figures, flush right, and a
      // bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash)
      m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
      m = setAlignment(m, at(r, c), 'right');
    }
    if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
    let end = r;
    while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
    // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
    // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
    m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
    m = setAlignment(m, at(r, 0), 'center', 'middle');
    m = setCellBackground(m, at(r, 0), col('cream'));
  }
  return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column. Only a float splits (gotcha: here-table-no-split).
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
  caption: '**Vegetable Seeds**', placement: { span: 'page' },
  note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
  table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });
// #endregion

// #region split: the list's style says what a continued part prints; the forms have their own
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
  headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
  // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
  // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
  overflow: 'split', continuedSuffix: '(continued)',
  continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
  borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
  bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
  position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
  gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };
// #endregion

const run = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', ...caps(7.5), color: col('muted'), placement: on('page', edge, x, 12.5),
  ...extra });
const folio = { fontWeight: 700, color: col('ink') };
const header = { elements: [ // folios outside, the booklet's name on the verso, its issue opposite
  run('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio),
  run('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 6),
  run('recto-title', '{subtitle}', 'odd', 'top-right', -(MARGIN.outer + 6)),
  run('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, folio),
] };
const footer = { elements: [{ ...run('drop-folio', '{pageNumber}', 'all', 'bottom', 0, folio),
  pages: 'opener', placement: on('page', 'bottom', 0, -11) }] };

// #region cover: the frontmatter and the heading set the type; the drawing fills the middle
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover drawing, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the drawing and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
  // A style's header replaces the document's on every page of its section, so it carries
  // the running heads too. The frame is drawn there: in the design it would count as
  // reserved height down to the page's foot, more than the column holds, and 1.4.1 then
  // drops the reservation and sets the letter over the drawing (gotcha:
  // opener-reserves-anchored).
  header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener',
    style: { borderColor: col('basil'), borderWidth: pt(1.2) },
    placement: on('page', 'top-left', FRAME, FRAME,
      { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }] },
  // The drawing is an image, and images do not count towards the height an opener
  // reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it.
  advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
    text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
    text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
      fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
    text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
      box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
        left: mm(4), right: mm(4) } } }),
    { kind: 'image', id: 'art', resourceId: 'cover-art',
      placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
  ] } },
};
// #endregion

// #region heads: slab-serif section heads over a basil double rule, and the order sheet
const h2 = { level: 2, marginTop: pt(0), marginBottom: pt(0), advancedDesign: { // both open a
  enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [ // column: no space above
    text('title', '{titleText}', on('container', 'top-left', 0, 0), { fontFamily: DISPLAY,
      fontSize: pt(14), lineHeight: 1.1, color: col('tomato') }),
    rule('thick', 'title', 1.4, 1.2), rule('thin', 'thick', 0.7, 0.4),
  ] } } };
const order = { id: 'order', span: 'page', layout: { layoutType: 'single' }, // one wide column
  advancedDesign: { enabled: true, slot: { elements: [
    text('address', '{attr.address}', on('container', 'top-left', 0, 0),
      { ...caps(8), color: col('basil') }),
    text('title', '{titleText}', on('#address', 'below', 0, 1.5), { fontFamily: DISPLAY,
      fontSize: pt(34), lineHeight: 1, color: col('tomato') }),
    rule('thick', 'title', 2.2, 1.6), rule('thin', 'thick', 0.8, 0.5),
    text('how', '{attr.how}', { ...on('#thin', 'below', 0, 3), size: { width: mm(150) } },
      { fontFamily: TEXT, italic: true, fontSize: pt(10.5), lineHeight: 1.35, align: 'left',
        color: col('ink') }),
  ] } } };
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette,
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) }, // two 75 mm columns of letter text
  bodyText: { fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceBold: false, referenceItalic: true,
    firstLineIndent: mm(4), indentAfterHeading: false, // ~48 characters to a 75 mm column, so
    maxWordSpacing: 1.6 }, // a cap under the default 2; Knuth–Plass can exceed it, so reword to fit
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) }, h2] },
  headingStyles: [cover, order],
  paragraphStyles: [
    { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) },
    { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'),
      textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  chipStyles: [ // a price is a bare chip: the label face, bold, a little larger than the text
    chip('price', 1.12, 'ink', { backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) }),
    chip('new', 0.8, 'cream', { background: col('tomato'), borderWidth: pt(0) }),
    chip('organic', 0.8, 'basil', { backgroundEnabled: false, borderColor: col('basil'),
      borderWidth: pt(0.6) })],
  resourceTypes: [{ id: 'list', name: 'Price list', shortLabel: 'List', captionPrefix: '',
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no prefix: the
  // caption is the list's name alone, with no "Table 1".
  tableStyle, tableStyles, captionStyle, header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown sample · 35 lines · content.en.mdtitle: "Brindlewood Seed Co." subtitle: "Catalog No. 12 · Spring 2027" --- # Spring Seeds {style="cover"} ## To Our Growers This is our twelfth spring catalog, and it lists only the vegetables we grew out ourselves last summer at Brindlewood Farm, nine of them, each tested for germination in the packing shed this January. You will find each of them, with a drawing of its packet, in :ref{id="vegetables" text="the price list"} on the next two pages. Tomatoes open the list, as they have done since our first catalog in 2016. Two varieties are new to it this spring and carry a red :chip[NEW]{style="new"} tag, while four were grown on the certified organic field behind the barn and are marked :chip[ORGANIC]{style="organic"}. The rest came from farms we have bought seed from for years, and each one was grown out beside our own before we listed it. Order early. Last spring the Brandywine and the Jimmy Nardello were sold out by March. We mail seed three days a week from February 1 to May 31. The order sheet is on the back page; tear it out, or copy it by hand and keep the catalog whole. :::paragraphs{style="signature"} *Ada Brindle and Tom Okafor* Brindlewood Farm, Marrow Creek ::: ## From Our Trial Garden Every variety on these pages spent last summer in our trial rows. We set out twenty plants of each and score them for how quickly they come up, how long they bear and whether the fruit comes true to type. A tomato that throws odd fruit on two of its twenty plants is dropped from the list. Each lot was tested in January: a hundred seeds on damp paper in a warm room, counted after one week and again after two, and the figure is printed on the packet. Stored cool and dry in a sealed jar, tomato and squash seed stays good for four years, bean seed for three and pepper seed for two. Onion and parsnip seed keeps for only one year, which is why this list carries neither. # Order Sheet {style="order" address="Mail to Brindlewood Seed Co. · Box 12 · Marrow Creek" how="Write one line for each variety: its number, its name and how many packets or ounces you want. Add the postage, and mail the sheet with a check or money order."} ::resource{id="ship-to"} ::resource{id="order"} :::paragraphs{style="colophon"} Set in Gelasio, Alfa Slab One and Cabin Condensed (SIL OFL) · Text and drawings: CC BY 4.0 · A fictional seed house :::
`; // the letter, the trial notes and the order sheet const list = String.raw`KIND VARIETY DESCRIPTION PACKET OUNCE
Markdown sample · 9 lines · content.list.en.md**Heirloom tomatoes** Brandywine *No. 101 · 90 days.* The pink beefsteak a Philadelphia seed house first listed in 1889. Fruit of a pound or more on tall, potato-leaved vines, sweet but with enough bite to taste of tomato. Stake the vines well. :chip[ORGANIC]{style="organic"} 3.95 42.00 Cherokee Purple *No. 102 · 80 days.* Dusky rose fruit of 10 to 12 ounces with green shoulders and brick-red flesh, kept by a Tennessee family long before it reached seed lists in the 1990s. Rich and smoky; slice it thick. 3.95 42.00 Gold Medal *No. 103 · 85 days.* A yellow beefsteak streaked red inside and out, so every slice comes out marbled. Mild and low in acid. Give it the longest season you have. :chip[NEW]{style="new"} 4.25 46.00 Amish Paste *No. 104 · 80 days.* Heart-shaped fruit of 8 to 12 ounces, meaty enough for sauce and juicy enough to eat off the vine. A paste tomato holds less water and fewer seeds, so it cooks down faster. 3.95 38.00 **Frying pepper** Jimmy Nardello *No. 201 · 80 days.* A long, thin-walled Italian frying pepper, sweet and red when ripe; fry it whole in olive oil. Brought from Basilicata to Connecticut in 1887 by the Nardello family. :chip[ORGANIC]{style="organic"} 4.25 48.00 **Beans** Kentucky Wonder *No. 301 · 65 days.* Pole bean. Meaty green pods 7 to 9 inches long, stringless if you pick them young, and in American gardens since the 1870s. Give it a pole or a fence 6 feet tall. :chip[ORGANIC]{style="organic"} 3.50 4.75 Dragon Tongue *No. 302 · 60 days.* A bush bean from the Netherlands. Flat, pale yellow pods streaked violet, tender up to 7 inches long. Cooking turns them plain yellow. :chip[NEW]{style="new"} 3.75 5.25 **Squash** Black Beauty *No. 401 · 50 days.* Zucchini. Glossy green-black fruit on an open bush that is easy to pick over. Cut them at 6 to 8 inches, every other day in July, and the plants keep bearing. On seed lists since the 1920s. 3.50 9.50 Waltham Butternut *No. 402 · 100 days.* Winter squash of 4 to 5 pounds with sweet orange flesh. Its hard, solid stems resist the vine borer that kills other squash, and it keeps until March in a cool room. :chip[ORGANIC]{style="organic"} 3.75 11.00
`; // the price list: TSV, as a spreadsheet exports it // #region forms: the order sheet: empty rows to write in, labels on buff, totals merged function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) => widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) }; for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) }); m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff if (r === 0 || !cell.content || cell.hiddenBy) return; m = setCellBackground(m, at(r, c), col('paper')); if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels })); return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' }, table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 }; } const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'], ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]); const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces', 'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'], ['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]), 'We guarantee every packet to grow. If a variety fails to come up in your garden, write to ' + 'us before August 1 and we will send a new packet or refund what you paid for it.'); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Gelasio: ['400', '400i', '700'], // text, captions, notes and the list 'Alfa Slab One': ['400'], // display: the cover, section heads, the order sheet 'Cabin Condensed': ['400', '600', '700'], // labels: heads, prices, chips, the forms }; // #region art: packets and the cover in flat colour with an ink line, from a seeded PRNG const SCALE = 10; // px per unit of the drawings' boxes: the engine keeps only the ratio const PACK = [60, 80]; // a packet: 3 : 4 const f = (n) => +n.toFixed(2); const INK = `stroke="${palette.ink}" stroke-linejoin="round"`; const HUE = { leaf: '#5d8a2f', vein: '#3f6424', sky: '#dde6dc', soil: '#8e6c47', furrow: '#6f5234', purple: '#7a3845', gold: '#f0b43c', squash: '#d9a55a', dusk: '#e4d0a4', seed: '#4b2f18', zucchini: '#2e4a2a' }; function mulberry32(seed) { // the same speckles on every run: no Math.random() return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const svg = ([w, h], body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * SCALE}" ` + `height="${h * SCALE}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const g = (x, y, deg, body) => `<g transform="translate(${f(x)} ${f(y)}) rotate(${f(deg)})">` + `${body}</g>`; const shape = (d, fill, width) => `<path d="${d}" fill="${fill}" ${INK} ` + `stroke-width="${f(width)}"/>`; const stroke = (d, color, width) => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${f(width)}" stroke-linecap="round"/>`; function lobed(cx, cy, w, h, n, bulge) { // an ellipse whose edge swells between n points const at2 = (a, k = 1) => `${f(cx + Math.cos(a) * w * k)} ${f(cy + Math.sin(a) * h * k)}`; let d = `M${at2(-Math.PI / 2)}`; for (let i = 1; i <= n; i++) { const a = -Math.PI / 2 + (i / n) * 2 * Math.PI; d += `Q${at2(a - Math.PI / n, bulge)} ${at2(a)}`; } return `${d}Z`; } function outline(len, half, bend = 0) { // a closed shape round an axis that bends by `bend` const top = [], bottom = []; for (let i = 0; i <= 36; i++) { const t = i / 36, x = t * len, y = bend * t * t, h = half(t); top.push(`${f(x)} ${f(y - h)}`); bottom.unshift(`${f(x)} ${f(y + h)}`); } return `M${top.join('L')}L${bottom.join('L')}Z`; } function leaf(x, y, len, wid, deg, teeth = 0) { // from its stalk at (x, y); teeth serrate it const saw = (t) => (teeth ? 1 - 0.3 * ((t * teeth) % 1) : 1); // a serrated edge const half = (t) => wid * Math.sin(Math.PI * t) ** 0.8 * saw(t); return g(x, y, deg, shape(outline(len, half), HUE.leaf, wid * 0.1) + stroke(`M0 0H${f(len * 0.85)}`, HUE.vein, wid * 0.09)); } function sprig(x, y, len, deg, teeth = 3) { // a tomato leaf: leaflets in pairs, one at the tip const pair = (t, k) => [-1, 1].map((side) => leaf(t * len, 0, len * 0.3 * k, len * 0.1 * k, side * 58, teeth)).join(''); return g(x, y, deg, stroke(`M0 0H${f(len * 0.9)}`, HUE.vein, len * 0.03) + pair(0.28, 0.8) + pair(0.52, 0.95) + pair(0.74, 1) + leaf(len * 0.86, 0, len * 0.34, len * 0.12, 0, teeth)); } function squashLeaf(x, y, r) { // five rounded lobes and their veins const veins = [0, 1, 2, 3, 4].map((i) => { const a = -Math.PI / 2 + (i / 5) * 2 * Math.PI; const end = `${f(x + Math.cos(a) * r * 0.8)} ${f(y + Math.sin(a) * r * 0.7)}`; return stroke(`M${f(x)} ${f(y)}L${end}`, HUE.vein, r * 0.06); }).join(''); return shape(lobed(x, y, r, r * 0.86, 5, 1.32), HUE.leaf, r * 0.06) + veins; } function calyx(x, y, len) { // six sepals splayed over the shoulder, and the stalk const sepals = [168, 205, 250, 292, 335, 12].map((deg, i) => { const a = (deg * Math.PI) / 180, l = len * (i % 2 ? 0.8 : 1); const tx = x + Math.cos(a) * l, ty = y + Math.sin(a) * l * 0.55 + l * 0.12; const nx = -Math.sin(a) * l * 0.16, ny = Math.cos(a) * l * 0.16; const mx = (x + tx) / 2, my = (y + ty) / 2; return `M${f(x)} ${f(y)}Q${f(mx + nx)} ${f(my + ny)} ${f(tx)} ${f(ty)}` + `Q${f(mx - nx)} ${f(my - ny)} ${f(x)} ${f(y)}`; }); return shape(sepals.join(''), HUE.vein, len * 0.05) + stroke(`M${f(x)} ${f(y)}q${f(len * 0.1)} ${f(-len * 0.4)} ${f(len * 0.35)} ${f(-len * 0.6)}`, HUE.vein, len * 0.22); } function tomato(cx, cy, r, { fill = palette.tomato, rib = '#9e2c17', form = 'round', shoulder, streak } = {}) { const w = r * (form === 'beef' ? 1.22 : 1); const h = r * (form === 'beef' ? 0.86 : form === 'heart' ? 1.14 : 0.94); const body = form === 'heart' ? `M${f(cx)} ${f(cy - h * 0.92)}C${f(cx + w * 1.35)} ${f(cy - h * 1.12)} ${f(cx + w * 0.9)} ` + `${f(cy + h * 0.6)} ${f(cx)} ${f(cy + h)}C${f(cx - w * 0.9)} ${f(cy + h * 0.6)} ` + `${f(cx - w * 1.35)} ${f(cy - h * 1.12)} ${f(cx)} ${f(cy - h * 0.92)}Z` : lobed(cx, cy, w, h, form === 'beef' ? 7 : 6, form === 'beef' ? 1.09 : 1.04); let out = shape(body, fill, r * 0.07); if (shoulder) { out += `<path d="M${f(cx - w * 0.72)} ${f(cy - h * 0.5)}Q${f(cx)} ${f(cy - h * 1.05)} ` + `${f(cx + w * 0.72)} ${f(cy - h * 0.5)}Q${f(cx)} ${f(cy - h * 0.62)} ${f(cx - w * 0.72)} ` + `${f(cy - h * 0.5)}Z" fill="${shoulder}" opacity="0.8"/>`; } if (streak) { out += [-0.45, -0.1, 0.3, 0.6].map((k) => stroke(`M${f(cx + w * k)} ${f(cy + h * 0.8)}` + `Q${f(cx + w * k * 1.35)} ${f(cy + h * 0.1)} ${f(cx + w * k * 0.9)} ${f(cy - h * 0.45)}`, streak, r * 0.12)).join(''); } out += (form === 'beef' ? [-0.4, 0.4] : [-0.3, 0.3]).map((k) => stroke(`M${f(cx + w * k * 0.4)} ` + `${f(cy - h * 0.78)}Q${f(cx + w * k * 1.5)} ${f(cy)} ${f(cx + w * k)} ${f(cy + h * 0.8)}`, rib, r * 0.05)).join(''); out += `<ellipse cx="${f(cx - w * 0.45)}" cy="${f(cy - h * 0.25)}" rx="${f(w * 0.15)}" ` + `ry="${f(h * 0.08)}" transform="rotate(-40 ${f(cx - w * 0.45)} ${f(cy - h * 0.25)})" ` + 'fill="#ffffff" opacity="0.45"/>'; return out + calyx(cx, cy - h * (form === 'heart' ? 0.88 : 0.84), r * 0.55); } function pod(x, y, len, wid, deg, fill, streak) { // a bean hanging from (x, y), seeds swelling const half = (t) => (wid / 2) * Math.sin(Math.PI * Math.min(1, t * 1.04)) ** 0.3 * (1 + 0.1 * Math.cos(t * Math.PI * 10)); let body = shape(outline(len, half, len * 0.14), fill, wid * 0.13); if (streak) { // violet flecks run along the pod body += [[0.12, -0.18], [0.42, 0.15], [0.66, -0.12]].map(([t, k]) => stroke(`M${f(t * len)} ` + `${f(len * 0.14 * t * t + wid * k)}q${f(len * 0.1)} ${f(len * 0.03)} ${f(len * 0.2)} ` + `${f(len * 0.06)}`, streak, wid * 0.16)).join(''); } return g(x, y, deg, body + stroke(`M0 0h${f(-wid * 0.9)}`, HUE.vein, wid * 0.35)); } function beans(fill, streak, flat) { // a trifoliate leaf over three pods hanging from the vine const wid = flat ? 4.2 : 3; return stroke('M10 35Q20 28 30 31T50 31', HUE.vein, 1.1) + leaf(30, 31, 12, 5.2, -150) + leaf(30, 31, 12, 5.2, -30) + leaf(30, 31, 11, 5.5, -90) + pod(25, 33, 25, wid, 98, fill, streak) + pod(30, 33, 27, wid, 88, fill, streak) + pod(35, 33, 24, wid, 76, fill, streak); } function zucchini() { // a fruit lying under two leaves, its flower still on the end const rand = mulberry32(401); const dots = Array.from({ length: 30 }, () => `<circle cx="${f(-16 + rand() * 32)}" ` + `cy="${f(-3.5 + rand() * 7)}" r="0.45" fill="#6f8f4f"/>`).join(''); return squashLeaf(19, 38, 10) + squashLeaf(41, 37, 9) + g(28, 50, -10, `<rect x="-19" y="-5.5" width="38" height="11" rx="5.5" ` + `fill="${HUE.zucchini}" ${INK} stroke-width="0.6"/>${dots}` + stroke('M-14 -2.8H12', '#6d8a58', 1) + stroke('M-19 0h-3', HUE.vein, 2.4)) + shape(lobed(46, 46, 3.6, 3.6, 5, 1.6), palette.sun, 0.5) + '<circle cx="46" cy="46" r="1.3" fill="#d9831e"/>'; } function butternut() { // the bell of a winter squash, stem up const d = 'M26 24C26 35 20 37 20 47A10 9.5 0 0 0 40 47C40 37 34 35 34 24Q30 21 26 24Z'; return squashLeaf(15, 40, 8) + shape(d, HUE.squash, 0.7) + stroke('M28 26Q27 38 24 54M32 26Q33 38 36 54', '#b98a45', 0.5) + stroke('M30 23.5V19.5', '#6d5a2e', 2.2) + '<ellipse cx="30" cy="53.5" rx="2" ry="1.2" ' + 'fill="#b98a45"/>'; } function horns() { // two long frying peppers, their green caps together const half = (t) => 2.7 * (1 - t) ** 0.7 + 0.25; const horn = (x, y, deg) => g(x, y, deg, shape(outline(33, half, 6), palette.tomato, 0.6) + stroke('M3 -1.5Q16 -1.9 29 2', '#e8735a', 0.8) + shape('M-1.5 -2.4Q2 -2.8 2.5 0Q2 2.8 -1.5 2.4Z', HUE.vein, 0.4) + stroke('M-1.5 0h-3', HUE.vein, 1.6)); return leaf(29, 32, 12, 4.5, -60) + horn(18, 38, 8) + horn(22, 31, 26); } const TOMATO = { brandywine: { fill: '#d4574c', form: 'beef' }, // potato-leaved: smooth leaves 'cherokee-purple': { fill: HUE.purple, rib: '#4f2029', shoulder: '#6a7a38' }, 'gold-medal': { fill: HUE.gold, rib: '#c98a1f', form: 'beef', streak: palette.tomato }, 'amish-paste': { form: 'heart' } }; const PACKETS = { // each variety's packet: its band colour and what the window shows ...Object.fromEntries(Object.entries(TOMATO).map(([id, look]) => [id, { band: palette.tomato, art: sprig(26, 30, 15, -150, id === 'brandywine' ? 0 : 3) + sprig(34, 30, 15, -30, id === 'brandywine' ? 0 : 3) + tomato(30, 44, look.form === 'beef' ? 16 : 15, look) }])), 'jimmy-nardello': { band: palette.tomato, art: horns() }, 'kentucky-wonder': { band: palette.basil, art: beans('#6f9a36') }, 'dragon-tongue': { band: palette.basil, art: beans('#efd27a', HUE.purple, true) }, 'black-beauty': { band: palette.sun, art: zucchini() }, 'waltham-butternut': { band: palette.sun, art: butternut() }, }; // No lettering on the packet: an SVG drawn as an image cannot use the page's fonts (gotcha: // svg-no-webfonts), so the variety's name is set in the cell under it. function packet({ band, art }) { // cream paper, a coloured head and foot, a window on the crop return svg(PACK, `<rect x="1" y="1" width="58" height="78" rx="2" fill="${palette.cream}" ` + `${INK} stroke-width="1"/><rect x="4" y="4" width="52" height="9" fill="${band}"/>` + `<path d="M4 15.6H56" stroke="${band}" stroke-width="0.8"/><path d="M7 66V41A23 23 0 0 1 ` + `53 41V66Z" fill="${HUE.sky}" stroke="${band}" stroke-width="1.6"/><path d="M7.8 58Q30 ` + `53.5 52.2 58V65.2H7.8Z" fill="${HUE.soil}"/>${art}<rect x="4" y="69" width="52" ` + `height="7" fill="${band}"/>`); } function sunflower(cx, cy, r) { // two rings of petals round a disc of seeds in a spiral const rand = mulberry32(1931); const petals = (n, len, fill, turn) => Array.from({ length: n }, (_, i) => g(cx, cy, (360 * i) / n + turn, shape(`M${f(r * 0.5)} 0Q${f(r * 0.5 + len * 0.5)} ${f(-len * 0.22)} ` + `${f(r * 0.5 + len)} 0Q${f(r * 0.5 + len * 0.5)} ${f(len * 0.22)} ${f(r * 0.5)} 0Z`, fill, 0.35))).join(''); const seeds = Array.from({ length: 170 }, (_, i) => { // the golden angle, 137.5° const a = i * 2.39996, d = Math.sqrt(i) * r * 0.037 + rand() * 0.15; return `<circle cx="${f(cx + Math.cos(a) * d)}" cy="${f(cy + Math.sin(a) * d)}" r="0.42" ` + `fill="${i % 3 ? HUE.seed : '#7a5230'}"/>`; }).join(''); return petals(24, r * 0.72, '#d9982a', 7.5) + petals(24, r * 0.78, palette.sun, 0) + shape(lobed(cx, cy, r * 0.52, r * 0.52, 12, 1.01), HUE.seed, 0.5) + seeds; } function coverArt() { // a sunflower and a truss of tomatoes growing out of a ploughed field const [w, h] = [ART.w, ART.h]; const furrows = [0, 1, 2].map((i) => stroke(`M${28 + i * 6} ${h - 5 + i * 2.2}Q75 ` + `${h - 15 + i * 2.6} ${122 - i * 6} ${h - 5 + i * 2.2}`, HUE.furrow, 0.6)).join(''); return svg([w, h], `<circle cx="75" cy="50" r="47" fill="${HUE.dusk}"/>` + shape(`M6 ${h}Q75 ${h - 26} 144 ${h}Z`, HUE.soil, 0.6) + furrows + stroke(`M50 ${h - 10}Q44 66 51 40`, HUE.vein, 3.2) + leaf(47, 74, 24, 9, 200, 6) + leaf(49, 60, 22, 8, -25, 6) + leaf(48, 86, 18, 7, -15, 6) + sunflower(51, 37, 26) + stroke(`M111 ${h - 10}Q104 76 110 56T107 20`, HUE.vein, 2.6) + sprig(110, 86, 22, 200) + sprig(110, 72, 24, -22) + sprig(108, 42, 22, 205) + sprig(107, 28, 20, -40) + sprig(107, 21, 12, -95) + stroke('M110 52Q124 45 137 49M120 48.5V58M110 75Q104 73 101.5 77', HUE.vein, 1.2) + tomato(135, 57, 8) + tomato(100, 83, 6, { fill: '#9dbb52', rib: '#6f8f3a' }) + tomato(118, 70, 12, { form: 'beef', fill: '#d4574c' })); } // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: every drawing is a resource of its own, registered under its fileId // Nothing cites the drawings, so none is numbered or placed: the cover design and the cells // draw them by id, and their typeId is never looked up. const picture = (id, [w, h], altText) => ({ id, typeId: 'figure', kind: 'svg', altText, svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE }, createdAt: 0, updatedAt: 0 }); const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content); const resources = [vegetableList(list), shipTo, orderForm, picture('cover-art', [ART.w, ART.h], 'A sunflower and a truss of tomatoes in a ploughed field.'), ...varieties.map((name) => picture(slug(name), PACK, `Seed packet of ${name}.`))]; await loadFonts(FONTS, markdown + list); await loadSvg('cover-art.svg', coverArt()); // the kit's loadSvg calls registerResourceImage for (const name of varieties) await loadSvg(`${slug(name)}.svg`, packet(PACKETS[slug(name)])); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown + list); showPages(doc, { title: 'Brindlewood Seed Co. · Spring 2027' });
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

#Keep only what fits

With overflow: 'clip' the list stops after the five varieties that fit on page 2, with the note under them, and page 3 holds only the trial notes, in its first column.

-  overflow: 'split', continuedSuffix: '(continued)',
+  overflow: 'clip', continuedSuffix: '(continued)',

#Shrink the packets

At 40% the rows drop to 29 mm, but the two beans share a merged cell and need 57 mm together, more than the 44 mm left on page 2. The trial notes move up into that space, and page 3 holds only the rest of the list, with the page blank below 157 mm.

-const PACKET = 0.55; // the packet drawing's share of its cell's inner width
+const PACKET = 0.4; // the packet drawing's share of its cell's inner width

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

Merged cells need hiddenBy placeholders: use mergeCells

Cells are laid out by their position in the row array, so a merged cell needs placeholder cells marked hiddenBy where it spreads; leaving them out, as HTML does, shifts every later column. Build merges with mergeCells. Tables from data →

Pitfall

An opener reserves height down to its lowest page-anchored element

An advanced-design opener reserves the height of its lowest element, and page- or bleed-anchored elements below the heading count too, so decoration at the foot of the page pushes the text to the next page. Keep such decoration above the heading, move it to a header or footer slot, or set the reservation with minHeight. Designed openers →

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

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 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

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

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 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 →

Pitfall

A table cell keeps the backslash of \$

postext 1.4.1 does not read table cells for maths and does not unescape \$ in them: a cell written \$4.50 prints \$4.50, backslash included, while \* in the same cell prints a plain asterisk. A paragraph needs the escape, because a bare $ opens maths there. Write $ bare in cell content and \$ in paragraphs; a string used in both places prints wrong in one of them. Tables from data →

  • A resource nobody cites is never placed. Take the :ref out of the letter and the catalogue has no price list.
  • In 75 mm columns Knuth–Plass can leave a justified line whose spaces stretch past maxWordSpacing. The letter and the trial notes were reworded until the loosest line stretched its spaces 1.33 times their normal width. The notes are also fitted to 14 lines: with 13, the closing page's column balancing puts a blank grid line above From Our Trial Garden, and the heading starts one line lower than the text in the other column.

Credits

Text
Original prose, CC BY 4.0
Images
  • The cover's sunflower and tomatoes and the nine seed packets, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
Fonts
Gelasio (SIL OFL 1.1) · Alfa Slab One (SIL OFL 1.1) · Cabin Condensed (SIL OFL 1.1)