# A book page on a baseline grid

> A mirrored two-column page whose text block is exactly 44 leads tall, so the body text sits on one grid across the gutter and the spine.

- HTML version: https://postext.dev/en/cookbook/baseline-grid-book-page
- Recipe Nº 013 · Page & grid · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Any genre
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [9](https://postext.dev/cookbook/baseline-grid-book-page/en/p01.webp?v=c84fb2ef), [10](https://postext.dev/cookbook/baseline-grid-book-page/en/p02.webp?v=c84fb2ef), [11](https://postext.dev/cookbook/baseline-grid-book-page/en/p03.webp?v=c84fb2ef)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/baseline-grid-book-page.md)

## What you'll build

Pages 9 to 11 of *The Ruled Page*, a collection on bookmaking, hold “The Secret Canon”, an essay on the proportions medieval scribes gave their pages. Its margins follow the canon it describes, 2:3:4:6 and mirrored, on a 210 × 280 mm trim around two columns of Vollkorn. A baseline grid is drawn over the pages in pale red, and the body text of both columns and both facing pages stands on it. The Playfair Display title fills the first column of the opener and the drawing of the canon heads the second. Section heads sit half a line off the grid, and the text under them goes back onto it. A workshop note in smaller type runs on a 10 pt leading of its own. On page 11 the essay ends under a page-wide figure in two columns cut to the same length, and the colophon closes the second.

**This recipe answers:**

- How do I set up a two-column book page and keep every line on one grid across the columns and the spread?
- How do I lock text to a baseline grid so lines align across columns and facing pages?
- How do I get flush column bottoms and a balanced last page (vertical justification)?
- How do I avoid widows, orphans and one-word last lines (runts), and keep a heading with its text?

## The short answer

One grid for the whole spread: trim, mirrored margins, two columns, leading.

```js
// script.js, lines 33–54
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
```

## Ingredients

**Teaches**

- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins): Four independent margins; with mirror on, left becomes the inner (spine) margin and swaps on every verso, so pages pair as spreads.
- [Baseline grid](https://postext.dev/en/docs/configuration.md#baseline-grid): The body leading is the grid every line sits on, so lines align across columns and facing pages; drawing the grid over the pages only shows it.

**Also uses**

- [Column balancing](https://postext.dev/en/docs/configuration.md#column-balancing)
- [Leaving the grid on purpose](https://postext.dev/en/docs/architecture.md#grid-breaking-elements)
- [Figure placement](https://postext.dev/en/docs/document-format.md#placement)
- [Widows, orphans and runts](https://postext.dev/en/docs/configuration.md#orphans-widows-runts-and-keep-together-rules)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Trim size](https://postext.dev/en/docs/configuration.md#page-size-presets)
- [One or two columns](https://postext.dev/en/docs/configuration.md#layout-types)
- [Column rule](https://postext.dev/en/docs/configuration.md#column-rule)
- [Units and dimensions](https://postext.dev/en/docs/configuration.md#dimensions)
- [Body type](https://postext.dev/en/docs/configuration.md#body-text)
- [Bullet lists and checklists](https://postext.dev/en/docs/configuration.md#unordered-lists)
- [Numbered lists](https://postext.dev/en/docs/configuration.md#ordered-lists)
- [Heading levels](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form)
- [Figure and Table in your language](https://postext.dev/en/docs/configuration.md#resource-types)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`captionStyle`](https://postext.dev/en/docs/configuration.md#caption-style), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`orderedLists`](https://postext.dev/en/docs/configuration.md#ordered-lists), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`unorderedLists`](https://postext.dev/en/docs/configuration.md#unordered-lists)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`defaultResourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

- Vollkorn (OFL-1.1), Playfair Display (OFL-1.1), Vollkorn SC (OFL-1.1)

## Method

### 1 · Let heads leave the grid, never lists

```js
// script.js, lines 104–117
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
```

The space above a heading is kept exactly as set, so 1.5 leads put the heading between two grid lines, and the text under it is snapped to the next line ([baseline grid](/en/docs/configuration#baseline-grid)). A list is not snapped before its first item, so the short answer sets list margins to zero (any whole number of leads would also work); with the default 1.5 em, the three items on page 10 would sit 1.3 pt below the lines of the column beside them. The title’s two lines of 46 pt take four leads each and its bottom margin one more, so the essay starts on line 10.

![Page 10: Lines to stand on. A verso with the outer margin on the left. The workshop note in small type falls between the grid lines; the heading below it sits half a line off the grid, and the text under the heading is back on the lines.](https://postext.dev/cookbook/baseline-grid-book-page/en/p02.webp?v=c84fb2ef)

*Page 10: the list in the second column keeps to the grid of the paragraphs beside it; the workshop note falls between the grid lines, and the heading under it sits half a line off them, with its text back on the grid.*

### 2 · Leave the grid on purpose, and come back

```js
// script.js, lines 120–125
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
```

The note is a `:::paragraphs` container set 7.8 on 10 pt, off the grid. After its last paragraph the position is rounded up to the next grid line, so the heading that follows sits where it would after body text ([the `:::paragraphs` container](/en/docs/configuration#the-paragraphs-container)). The colophon uses the same container at 7.5 on 10 pt: its four lines take 40 pt, 0.2 pt short of three leads, so the essay’s last column ends level with the one beside it.

### 3 · Place the figures where the grid can absorb them

```js
// script.js, lines 285–298
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
```

A float never lands above the paragraph that cites it ([placement](/en/docs/document-format#placement)). The canon, cited in the first column, can still take the head of the second on page 9; the page-wide comparison, cited on page 10, waits for page 11. Each band is rounded up to whole leads, so the text under a drawing starts on a grid line, level with the column beside it and with the facing page. Size a drawing to that rounding: at 61.8 mm the canon’s band, caption included, takes 17 leads; at 62.5 mm it would take 18 and leave almost a line of white under the caption.

![Page 11. Figure 1.2. This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 mm and two columns of 44 lines. A recto with the inner margin on the left: the page-wide comparison drawing heads the page, and the essay ends under it in two level columns, the colophon closing the second.](https://postext.dev/cookbook/baseline-grid-book-page/en/p03.webp?v=c84fb2ef)

*Page 11: the text under the page-wide figure starts on a grid line, and the essay ends in two level columns, the colophon closing the second.*

### 4 · Let balancing square the columns

```js
// script.js, lines 88–91
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };
```

Every full column on pages 9 and 10 ends on line 44, because the text block is exactly 44 leads and the copy was fitted to it. Keep-with-next and the widow, orphan and runt rules are on by default. When one of them ends a column a line short, balancing puts the missing space above an earlier heading ([column balancing](/en/docs/configuration#column-balancing)), where it shows as a gap, so fit the copy until balancing has nothing to fill. On the last page it divides the remaining lines so that both columns end on the same line.

### 5 · Mirror the margins, and number from page 9

```js
// script.js, lines 58–84
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
```

The short answer computes the foot margin: the 280 mm trim less the 24 mm head margin and a text block of exactly `LINES` leads (44 × 13.4 pt = 208 mm) leaves 48 mm, six units, as the 2:3:4:6 progression requires. With `mirror: true` the 16 mm set as `left` is the inner margin, on the left of a recto and on the right of a verso ([mirrored margins](/en/docs/configuration#mirrored-margins)), so heads anchored `OUTER` millimetres from the page edge end where the text block does, on the fore-edge side; `pages: 'body'` keeps them off the opener. `startAt: 9` prints folios 9 to 11, and `pageIndexOffset: 8` counts the eight pages before them, so mirroring, the heads’ parity and `breakBefore: 'odd'` follow the bound book. With an offset of 7 the first page would count as a verso, and the odd-page break would put a blank page before the essay.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/baseline-grid-book-page

### script.js

```js
// ═══ Postext Cookbook · Nº 013 · A book page on a baseline grid ═════════════════════
// https://postext.dev/en/cookbook/baseline-grid-book-page
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0)
// Fonts: Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// An essay on the canon of page proportions, on a page that shows its geometry and grid.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'baseline-grid-book-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// A scribe's colours: ink, one rubric red and the pale red of a ruled page.
const palette = {
  ink: '#211d1a', // text: a warm near-black
  rubric: '#b3261e', // the one accent: title, numbers, folios, the drawings' lines
  grid: '#efc6bd', // the baseline grid, as pale as a manuscript's ruling
  rule: '#d5cbbb', // the column rule and the drawings' hairlines
  muted: '#716860', // running heads, the workshop note, the colophon (5.1:1 on paper)
  tint: '#ede4d3', // the ground of the drawings
  paper: '#fbf8f1',
};
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' } })),
  // Defaults this config does not restate link to main-color: point it at the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];

// #region answer: one grid for the whole spread: trim, mirrored margins, two columns, leading
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
// #endregion

// #region heads: the book's title on versos, the essay's on rectos, folios from page 9
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
// #endregion

// #region balance: full columns end flush, and the last page ends level
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: defaultResourceTypes(LANG), // Spanish captions (gotcha: resource-types-locale)
  colorPalette,
  page,
  layout,
  bodyText: { ...body, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('rubric'), referenceBold: false,
    firstLineIndent: mm(4), indentAfterHeading: false }, // hyphenation and widow rules are on
  // #region flow: heads that leave the grid and come back to it; lists that never leave it
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
  // #endregion
  // #region note: small type leaves the grid on purpose, and the flow snaps back after it
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  // #endregion
  captionStyle: { fontSize: pt(8), labelColor: col('rubric'), descriptionItalic: true },
  header,
  footer,
});

// #region art: the two drawings, in the page's palette (lines only: no text, no filters)
const BLOCK_W = TRIM.width - 6 * UNIT; // mm: the text block, 162 mm
const COLUMN_W = (BLOCK_W - GUTTER) / 2; // mm: one column, 78 mm
const svgFile = (w, h, markup) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"`
  + ` width="${w * 10}" height="${h * 10}">`
  + `<rect width="${w}" height="${h}" fill="${palette.tint}"/>${markup}</svg>`;
const ln = (x1, y1, x2, y2, stroke, w = 0.4) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"`
  + ` stroke="${stroke}" stroke-width="${w}" stroke-linecap="round"/>`;
const leaf = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"`
  + ` fill="${palette.paper}" stroke="${palette.rule}" stroke-width="0.3"/>`;
const ruling = (x, y, w, h, n) => Array.from({ length: n }, (_, i) => // a ruled text block
  ln(x, y + (h * (i + 0.8)) / n, x + w, y + (h * (i + 0.8)) / n, palette.rule, 0.25)).join('');
const dot = (x, y) => `<circle cx="${x}" cy="${y}" r="0.8" fill="${palette.rubric}"/>`;

// Figure 1: the canon constructed on an open spread of 2:3 pages (sizes in mm).
function canonSvg(w, h) {
  const W = (w - 8) / 2, H = 1.5 * W, X = 4, Y = 3.5; // two 2:3 pages, the ninths under them
  const at = (fx, fy) => [X + fx * W, Y + fy * H]; // a point in page widths and heights
  const line = (a, b) => ln(...at(...a), ...at(...b), palette.rubric);
  let s = leaf(X, Y, W, H) + leaf(X + W, Y, W, H);
  s += ruling(...at(2 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // verso text block
  s += ruling(...at(10 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // recto text block
  s += `<circle cx="${X + W / 2}" cy="${Y + (4 * H) / 9}" r="${W / 2}" fill="none"`
    + ` stroke="${palette.muted}" stroke-width="0.35"/>`; // a page wide, a block tall
  s += ln(X + W, Y, X + W, Y + H, palette.muted, 0.5); // the spine
  s += line([0, 1], [2, 0]) + line([0, 0], [2, 1]); // 1 · the spread's diagonals
  s += line([1, 0], [0, 1]) + line([1, 0], [2, 1]); // 2 · each page's diagonal
  s += line([2 / 3, 1 / 3], [2 / 3, 0]) + line([4 / 3, 1 / 3], [4 / 3, 0]); // 3 · the verticals
  s += line([2 / 3, 0], [4 / 3, 1 / 3]) + line([4 / 3, 0], [2 / 3, 1 / 3]); // 4 · across the spine
  for (let i = 0; i <= 18; i++) { // the ninths, ticked under the spread
    const [x, y] = at(i / 9, 1);
    s += ln(x, y + 1.2, x, y + (i % 9 ? 2.8 : 4), palette.muted, 0.3);
  }
  for (const c of [[2 / 9, 1 / 9], [8 / 9, 1 / 9], [2 / 9, 7 / 9], [10 / 9, 1 / 9], [16 / 9, 1 / 9],
    [16 / 9, 7 / 9]]) s += dot(...at(...c)); // 5 · the corners the lines fix
  return svgFile(w, h, s);
}

// Figure 2: this book's page twice, at a quarter of its size.
function pagesSvg(w, h) {
  const k = (h - 8) / TRIM.height, W = TRIM.width * k, H = TRIM.height * k, Y = 4, GAP = 20;
  const X1 = (w - 2 * W - GAP) / 2, X2 = X1 + W + GAP;
  let s = leaf(X1, Y, W, H) + leaf(X2, Y, W, H);
  for (let i = 1; i < 9; i++) { // the canon's ninths
    s += ln(X1 + (i * W) / 9, Y, X1 + (i * W) / 9, Y + H, palette.grid, 0.3);
    s += ln(X1, Y + (i * H) / 9, X1 + W, Y + (i * H) / 9, palette.grid, 0.3);
  }
  s += ruling(X1 + W / 9, Y + H / 9, (6 * W) / 9, (6 * H) / 9, 32);
  s += `<rect x="${X1 + W / 9}" y="${Y + H / 9}" width="${(6 * W) / 9}" height="${(6 * H) / 9}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  const colW = COLUMN_W * k, top = Y + 3 * UNIT * k, tall = LINES * LEAD * MM_PER_PT * k;
  const left = X2 + 2 * UNIT * k;
  for (const x of [left, left + colW + GUTTER * k]) s += ruling(x, top, colW, tall, LINES);
  s += `<rect x="${left}" y="${top}" width="${BLOCK_W * k}" height="${tall}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  s += ln(X1, Y, X1, Y + H, palette.muted, 0.6) + ln(X2, Y, X2, Y + H, palette.muted, 0.6);
  return svgFile(w, h, s);
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Ruled Page"
subtitle: "Essays on the Making of Books"
---

# The Secret Canon

Before a medieval scribe wrote a word, the page had already been measured. The sheets of a quire were pricked along their edges with an awl and ruled from prick to prick with a dry point or a lead point, so that every line of writing had a line to stand on and every leaf of the book had the same frame. In many manuscripts the ruling can still be seen, faint as a watermark. It records two decisions the scribes never explained: how large the written area should be, and where on the leaf it should sit. The drawing in :ref{id="canon"} gives the answer that three people, working apart in the twentieth century, measured back out of the old books themselves.

In 1946 J. A. van de Graaf published, in a Dutch journal, a construction that finds the text block of a page with a straightedge alone, without measuring anything. A year later the Argentine typographer Raúl Rosarivo argued in *Divina proporción tipográfica* that Gutenberg, Peter Schöffer and Nicolas Jenson had divided their pages into ninths. And Jan Tschichold, who in his youth had championed asymmetric layout, spent his later years showing why the old proportions were so hard to improve on. Their results agree closely. The method is now known as the secret canon because nobody wrote it down, yet it can be measured in manuscript after manuscript and in the first printed books.

## Drawing the canon

All the construction needs is an open spread of two pages and a straightedge:

1. Draw the two diagonals of the spread.
2. On each page, draw the diagonal that runs from the head of the spine to the foot of the fore-edge.
3. Where it crosses a diagonal of the spread, draw a vertical line up to the head of the page.
4. Join its top to the crossing on the facing page.
5. The new line cuts the page diagonal at the inner top corner of the text block. The outer top corner lies on the spread diagonal, the outer bottom corner on the page diagonal, and the rectangle closes itself.

That rectangle is the scribes’ text block. The inner margin is a ninth of the page’s width and the outer margin two ninths; the head margin is a ninth of its height and the foot two ninths. On a page of two by three the four margins run 2:3:4:6, inner, head, outer, foot. The text block has the page’s proportions and is as tall as the page is wide, as the circle shows.

## Villard’s figure

Medieval draughtsmen could also divide a line without a ruler. The thirteenth-century portfolio of Villard de Honnecourt, thirty-three leaves of drawings now in the Bibliothèque nationale de France, contains a figure that divides a line into equal parts with a straightedge alone: a few diagonals across a rectangle, and each crossing marks off a third, a quarter or a fifth of its side, as far as the draughtsman cares to go. Tschichold showed that the same figure finds the ninths of a page. The leaves of the portfolio, as they survive, measure about 235 by 155 millimetres, close to two by three.

Rosarivo came to the ninths from the other end. Working with compass and ruler on books printed in the fifteenth century, he concluded that their type areas had been found by dividing the page into nine parts each way, the text taking six of them, and he called the 2:3 proportion of those pages the secret number. Experts at the Gutenberg Museum in Mainz examined his findings, which the *Gutenberg-Jahrbuch* later published again.

:::paragraphs{style="note"}
**Workshop note.** On a page of 160 by 240 millimetres the canon gives margins of 17.8 millimetres at the spine, 26.7 at the head, 35.6 at the fore-edge and 53.3 at the foot, and a text block of 106.7 by 160 millimetres, as tall as the page is wide. Divide the width and the height into nine and count: one part inside, one above, two outside and two below.
:::

## Lines to stand on

The ruling fixed the lines as well as the frame. Until early in the thirteenth century most scribes wrote their first line on top of the first ruled line; within a generation they had begun to hang it below that line instead, a change regular enough for palaeographers to date manuscripts by it. N. R. Ker, who described it in 1960, placed it around 1230. The ruling drew the columns, too: pairs of vertical lines bounded each column of a two-column book, and the hairline between the columns of this page is the last trace of them.

The printers kept to the discipline. Gutenberg’s Bible is set in two columns of forty lines on its first pages, forty-one on page ten and forty-two on nearly every page after it, which is why bibliographers call it the 42-line Bible. The lines were set closer together inside the same type area, so the frame held more text and the whole Bible needed less paper.

## What a working page keeps

The canon was made for large books meant to last, with room in their margins for a reader’s notes. Few books can afford it now. On this page of 210 by 280 millimetres it would ask for a text block of 140 by 187, and more than half of the paper would stay blank. The page you are reading keeps three things from the canon instead:

- the progression of its margins, still 2:3:4:6, now in steps of eight millimetres: 16 at the spine, 24 at the head, 32 at the fore-edge and 48 at the foot;
- the spread as the unit of design: two inner margins together are as wide as one outer margin, so the white around each text block reads evenly across the open book;
- the ruling itself, now a baseline grid of 13.4 points that the text of both columns stands on, on this page and on the page facing it.

It gives up the ninths. Its text block, 162 by 208 millimetres, is far larger than the canon’s, and it is divided into two columns of 78 millimetres with a gutter of six between them. Set across the whole block, a line of this type would run to more than a hundred characters, too long for the eye to find its way back to the start of the next; a column holds about fifty, and exactly 44 lines. The two pages are drawn side by side in :ref{id="pages"}.

A page that counts its lines can keep the feet of its columns level, too: every full column in this essay ends on the forty-fourth line. When the rules that keep a heading with its text, or keep a paragraph from leaving a single line behind, would end a column a line short, the missing line goes in as a little more space above a heading higher up, and the column still reaches the last line. On the last page of an essay the columns are cut to the same length instead, and the text ends in a level band.

A baseline grid fixes where every line will stand before a word is set, as the awl and the dry point did on parchment. The lists in this essay add no space of their own, so their items fall on the same lines as the paragraphs in the column beside them. A heading may sit between two lines of the grid, with a line and a half above it and half a line below, but the text under it comes straight back to the grid. The workshop note, in smaller type, runs on a closer leading of its own and returns to the grid where it ends. A figure is treated the same way: its band, caption included, is rounded up to whole lines (seventeen for :ref{id="canon"}), so the text below a drawing at the head of a page lines up with the text on the page facing it.

The grid on these pages is drawn in pale red so that the reader can check the text against it. Open the book at any spread and each line of the left page continues across the spine into a line of the right, as the scribes’ ruling ran from prick to prick across both halves of the opened sheet.

:::paragraphs{style="colophon"}
Set in Vollkorn, 9.8 on 13.4 points,

with Playfair Display and Vollkorn SC

(SIL Open Font License 1.1)

Text and drawings: Postext Cookbook, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook
// Captions and alt text in both sample languages: [caption, altText].
const CAPTIONS = {
  canon: {
    en: ['The canon on an open spread of 2:3 pages. The diagonals fix the corners of both text '
      + 'blocks; the circle, as wide as a page, is exactly as tall as its block.',
    'Two facing pages crossed by red construction lines, a ruled text block where they cross '
      + 'on each page, and a circle as wide as the left page.'],
    es: ['El canon sobre un pliego abierto de páginas 2:3. Las diagonales fijan las esquinas de '
      + 'las dos cajas; el círculo, tan ancho como la página, es tan alto como su caja.',
    'Dos páginas enfrentadas cruzadas por líneas rojas de construcción, una caja pautada donde '
      + 'se cortan en cada página y un círculo tan ancho como la página izquierda.'],
  },
  pages: {
    en: ['This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a '
      + 'text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 '
      + 'mm and two columns of 44 lines.',
    'Two identical pages: the left one divided into a nine-by-nine grid around its text block, '
      + 'the right one holding two ruled columns.'],
    es: ['La página de 210 × 280 mm de este libro, dos veces. A la izquierda, dividida en '
      + 'novenos por el canon, con una caja de 140 × 187 mm. A la derecha, tal como la compone '
      + 'el libro: márgenes de 16, 24, 32 y 48 mm y dos columnas de 44 líneas.',
    'Dos páginas iguales: la izquierda, dividida en una cuadrícula de nueve por nueve alrededor '
      + 'de su caja; la derecha, con dos columnas pautadas.'],
  },
};

// #region figures: one figure heads the next column, the other the next page
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { // text, display and label faces (Vollkorn SC ships no italic)
  Vollkorn: ['400', '400i', '700'], 'Playfair Display': ['700', '700i'],
  'Vollkorn SC': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('canon.svg', canonSvg(...CANON));
await loadSvg('pages.svg', pagesSvg(...PAGES));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'A book page on a baseline grid',
  es: 'Una página de libro sobre una rejilla base' }) });

// ─── 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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Hide the grid for the printer

`enabled` only controls the lines drawn over the finished pages; the layout keeps to the 13.4 pt grid either way, so no line moves.

```diff
-  baselineGrid: { enabled: true, color: col('grid') },
+  baselineGrid: { enabled: false },
```

### See what balancing does

With balancing off, page 11 runs its last eight lines and the colophon down the first column and leaves the second empty.

```diff
-const balancing = { stretchAfterFloats: false };
+const balancing = { enabled: false };
```

## Pitfalls

- **A 'top' float never lands on its citing page.** A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page.
- **A top float can push the last column down on a closing page.** In postext 1.4.1, when a chapter or story ends on a page that opens with a page-wide top float and its lines split unevenly between the columns, stretchAfterFloats adds a blank line under the float in the shorter column instead of letting it end short, so the two columns no longer start on the same line. Set headings.balancing.stretchAfterFloats to false, or fit the copy to an even number of lines.
- **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.
- **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.
- **Only 8 locales hyphenate, by exact code.** Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English.
- **Localise Figure/Table with defaultResourceTypes(locale).** The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes.
- **Quote every frontmatter value.** YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984".
- **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.

- A heading level with `span: 'page'` and no `advancedDesign` is meant to cross both columns, but postext 1.4.1 measures it at the width of one: “The Secret Canon” prints on one line across the page but reserves the height of two, with the column rule drawn through the band. Here the title stays in the first column.
- The rule that a `top` float never lands on its citing page holds for page-wide floats. A column `top` float can land on its citing page, at the head of a later column, as Figure 1.1 does on page 9.
- Page sizes, margins and the gutter take absolute units (mm, pt, cm, in). An `em` there has no type size to measure against, and the build stops with “dimensionToPx: baseFontSizePx is required for unit "em"”.
- Vollkorn’s word space is narrow, 0.2 em, so in a column of about 50 characters some justified lines come out loose or tight. The sample was fitted line by line against the capture’s loose-line report; check it again after any change to the text or the measure.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The essay “The Secret Canon” and its Spanish version “El canon secreto”, written for this recipe: Postext Cookbook, CC-BY-4.0
- Images: The two drawings, made in code in the page's palette: Postext Cookbook, CC-BY-4.0
- Type: Vollkorn (OFL-1.1), Playfair Display (OFL-1.1), Vollkorn SC (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 016 · Justified Spanish in a pocket novel](https://postext.dev/en/cookbook/spanish-pocket-novel.md): The opening of Marianela’s chapter I as a pocket edition: Spanish hyphenation, word spaces under 1.7×, no runts, a raised initial and Figura 1 on the map. · Level 2 (Intermediate) · Fiction, drama & literary prose
- [Nº 005 · Running heads by parity in a book of essays](https://postext.dev/en/cookbook/running-heads-by-parity.md): Header elements filtered by parity and page role: book title on versos, essay title cut short on rectos, folio tabs in the margin, a drop folio on openers. · Level 2 (Intermediate) · Fiction, drama & literary prose
- [Nº 018 · Section heads seven levels deep](https://postext.dev/en/cookbook/section-heads-field-manual.md): Sections numbered 1.1 to 1.12 in an amber pill that widens with its number, four more levels beneath them, and a seventh made with a heading style. · Level 2 (Intermediate) · Manuals, guides & reference
