# Trade paperback: sunk openers and recto chapters

> The Awakening as a 140 × 216 mm trade paperback: a drawn cover and a colophon with no running heads, then chapters sunk on rectos under an italic numeral.

- HTML version: https://postext.dev/en/cookbook/trade-paperback-novel
- Recipe Nº 038 · Complete publications · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Fiction, drama & literary prose
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/trade-paperback-novel/en/p01.webp?v=cec3c40d), [2](https://postext.dev/cookbook/trade-paperback-novel/en/p02.webp?v=cec3c40d), [3](https://postext.dev/cookbook/trade-paperback-novel/en/p03.webp?v=cec3c40d), [4](https://postext.dev/cookbook/trade-paperback-novel/en/p04.webp?v=cec3c40d), [5](https://postext.dev/cookbook/trade-paperback-novel/en/p05.webp?v=cec3c40d), [6](https://postext.dev/cookbook/trade-paperback-novel/en/p06.webp?v=cec3c40d), [7](https://postext.dev/cookbook/trade-paperback-novel/en/p07.webp?v=cec3c40d), [8](https://postext.dev/cookbook/trade-paperback-novel/en/p08.webp?v=cec3c40d)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/trade-paperback-novel.md)

## What you'll build

Eight pages of a 140 × 216 mm trade paperback hold the first two chapters of Kate Chopin’s *The Awakening* (1899). The cover, drawn in code, shows the gulf from the Lebrun cottages on Grand Isle: a lugger on the horizon between the water-oaks, and the white sunshade coming up through the camomile. The colophon sits at the foot of its verso. Each chapter opens on a recto with its text 81 mm down, under a 48 pt italic numeral, an 18 mm rust-red hairline and the chapter’s first sentence in small capitals, broken by sense into two centred lines. Chapter I ends on page 5 with a camomile tailpiece, so page 6 stays blank, with no running head or folio, and chapter II opens on page 7. The running heads carry the author on versos and the title in italic on rectos.

**This recipe answers:**

- How do I start every chapter on a right-hand page, sunk a third of the way down, as a trade paperback does?
- How do I add an author line, a standfirst or a lead with a drop cap to an opener?
- How do I insert deliberate blank pages, or start a section on a fresh spread?
- How do I set running heads: book title on the left page, chapter title on the right, page number outside?
- How do I make a cover, a half title, a title page and a colophon?
- How do I add extra vertical space between two blocks, when blank lines do nothing?
- How do I set unnumbered artwork: ornaments, vignettes, logos?

## The short answer

A sunk opener: the numeral, a hairline, and the first sentence in small caps.

```js
// script.js, lines 48–80
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
```

## Ingredients

**Teaches**

- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design): A heading drawn as a free composition of text, rules, boxes and pictures, reserving the height it needs above the body.
- [Chapters that open on a recto](https://postext.dev/en/docs/configuration.md#break-before): A heading level starts a new page: the next one, the next recto or the next verso, with a blank page added when needed.
- [Running heads per section](https://postext.dev/en/docs/configuration.md#heading-styles): A heading style carries its own header and footer, so front matter, sections or dictionary letters get their own furniture.

**Also uses**

- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Covers, title pages and colophons](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements)
- [Section geometry](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Document metadata](https://postext.dev/en/docs/document-format.md#frontmatter)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Explicit vertical space](https://postext.dev/en/docs/document-format.md#space)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Figures exactly here](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Widows, orphans and runts](https://postext.dev/en/docs/configuration.md#orphans-widows-runts-and-keep-together-rules)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`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), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`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)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`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**

- Crimson Pro (OFL-1.1), Cormorant Garamond (OFL-1.1), Cormorant SC (OFL-1.1)

## Method

### 1 · The opener comes from the heading and its attribute

The code is [the short answer](#the-short-answer) above. Chopin’s chapters have no titles, so each heading holds only its numeral (`# I`), which `{titleText}` prints. `{attr.lead}` prints the heading’s `lead` attribute, which holds the chapter’s first sentence; the Markdown paragraph under the heading starts with the sentence after it ([heading attributes](/en/docs/document-format#heading-attributes)). Design text cannot be justified, so the lead is centred, and the `\n` in each attribute breaks it by sense, after *cage* and after *bright;*. That `\n` breaks the line only when `paragraphIndent` is above zero; at 0 the lead prints the two characters. The lead’s box starts nine grid lines below the top of the text block, and a `minHeight` of 12 lines leaves one blank line under it; without `minHeight` the text starts right under the lead ([span and advanced design](/en/docs/configuration#span-and-advanced-design)). `marginBottom: pt(0)` replaces the level’s default half-em margin, which would push the text a line lower.

`breakBefore` with parity `'odd'` sends every chapter to a recto. The config restates it because any `headings` object drops the level’s default break ([break before](/en/docs/configuration#break-before)). To force a recto where there is no heading, write `:::pagebreak{parity="odd"}` in the Markdown; in a layout of two or more columns, `:::columnbreak` moves the text to the next column instead ([`:::pagebreak`](/en/docs/document-format#pagebreak)).

### 2 · A trade page of whole lines

```js
// script.js, lines 34–44
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
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: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
```

The bottom margin is what the 216 mm trim leaves after the top margin and 35 lines of 14 pt, so every full page ends on the same line, as pages 3 and 4 do. With `mirror: true` the 20 mm inner margin falls on the spine side of rectos and versos alike ([mirrored margins](/en/docs/configuration#mirrored-margins)). The 104.5 mm measure holds about 69 characters of Crimson Pro at 10.5 pt. The body text keeps word spaces between 0.7 and 1.9 of normal; at the default minimum of 0.6, one line on these pages closes to 0.62. It also sets `maxRuntTracking: 0`. At the default, the paragraph that ends *nodding good-by to him.* is set a line shorter with negative tracking that the canvas does not paint, so its word spaces print crushed.

### 3 · Running heads that skip openers and blank pages

```js
// script.js, lines 84–104
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
```

`parity: 'even'` puts the author in Cormorant SC small capitals on versos, and `'odd'` puts the title in italic on rectos; the folios stand at the outer edge of the text block. `pages: 'body'` keeps all four elements off the openers and off page 6, the blank verso the parity break adds. With `pages: 'all'` that page would print *6* and *Kate Chopin*. The openers get a drop folio instead, a footer element that `pages: 'opener'` limits to them ([text elements](/en/docs/configuration#text-elements)).

### 4 · The cover is a section with no running heads

```js
// script.js, lines 108–133
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COVER_ART = { w: TRIM.width, h: 134 }; // mm: the drawing fills the width, 134 mm deep
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
```

The cover is a heading, `# The Awakening {style="cover"}`. Its style draws the art over the top 134 mm, the frontmatter’s title at 50 pt and the author in small capitals. The style also applies to the section the heading opens, pages 1 and 2, until chapter I. Its empty `header` and `footer` keep the drop folio off the cover and the running heads off the colophon. Its top margin puts the colophon on the last six grid lines of the text block, so the colophon’s last line is level with the last line of page 3 ([heading styles](/en/docs/configuration#heading-styles)). The style needs `span: 'page'` even in one column. A design kept in the column is clipped at the column’s top, here 165 mm down; without `span: 'page'` the art would not print and the title would lose its upper part.

![Page 2. The colophon, set small at the foot of the page, with no running head or folio.](https://postext.dev/cookbook/trade-paperback-novel/en/p02.webp?v=cec3c40d)

*Page 2: the colophon at the foot of the cover’s verso, with no running head or folio.*

### 5 · A tailpiece closes each chapter

```js
// script.js, lines 137–151
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
const resources = [
  svg('cover', 'cover.svg', COVER_ART.w, COVER_ART.h, 'The gulf seen between the trunks of '
    + 'water-oaks, a lugger on the horizon; a white sunshade comes up from the beach through '
    + 'the camomile.'),
  // ::resource places each ornament once, so each chapter end has its own id.
  ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    `A camomile flower between two sprigs closes chapter ${n}.`)),
];
```

The ornament type has no caption prefix, so the camomile flower prints without a label, 17% of the measure wide, where `::resource{id="tailpiece-1"}` stands ([resource types](/en/docs/configuration#resource-types)). An inline embed keeps one grid line above it, and the `:::space` before it adds a second between the chapter’s last line and the flower ([`:::space`](/en/docs/document-format#space)). Both tailpieces draw the same file, `fleuron.svg`, under two ids, because a resource is placed only once.

## 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/trade-paperback-novel

### script.js

```js
// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══
// https://postext.dev/en/cookbook/trade-paperback-novel
// Code: MIT · Text: Kate Chopin, The Awakening, 1899 (PD, Gutenberg #160) · Art: drawn in code
// Fonts: Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document (this recipe is English only)
const RECIPE = 'trade-paperback-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231d18', // the text: a warm near-black
  rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece
  muted: '#6e655b', // running heads, folios on openers, the colophon
  paper: '#fbf7ef', // a cream book paper
  sea: '#2f5d6b', shallows: '#7fa6a3', sand: '#e8d9b8', camomile: '#d8b04a', // the cover
};
// Each colour names its palette entry and carries its hex (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' } })),
  // The engine's defaults (headings, lists, callouts, captions) link to 'main-color', the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];
const TEXT = 'Crimson Pro'; // the text face
const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title
const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name
const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch
const line = (n) => pt(n * LEAD); // n grid lines

// #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
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: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto, folios outside
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
// #endregion

// #region cover: a section of its own: art, title, author, no heads, a colophon on its verso
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COVER_ART = { w: TRIM.width, h: 134 }; // mm: the drawing fills the width, 134 mm deep
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
// #endregion

// #region tailpiece: an ornament type that prints no caption, where ::resource stands
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
const resources = [
  svg('cover', 'cover.svg', COVER_ART.w, COVER_ART.h, 'The gulf seen between the trunks of '
    + 'water-oaks, a lugger on the horizon; a white sunshade comes up from the beach through '
    + 'the camomile.'),
  // ::resource places each ornament once, so each chapter end has its own id.
  ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    `A camomile flower between two sprigs closes chapter ${n}.`)),
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  resourceTypes: [ornament],
  page,
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  // The designs paint every heading; the level's own face is the numeral's (500 italic), so the
  // kit has no unused face to load.
  headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] },
  headingStyles: [cover],
  paragraphStyles: [colophon],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Awakening"
author: "Kate Chopin"
---

# The Awakening {style="cover"}

:::paragraphs{style="colophon"}
First published by Herbert S. Stone & Company, Chicago, in April 1899. Chapters I and II follow Project Gutenberg eBook #160, with Chopin’s spelling and punctuation.

Set in Crimson Pro, Cormorant Garamond and Cormorant SC (SIL Open Font License). The cover, drawn in code, shows the gulf from the Lebrun cottages on Grand Isle.

A Postext Cookbook edition. The text is in the public domain.
:::

# I {lead="A green and yellow parrot, which hung in a cage\noutside the door, kept repeating over and over:"}

“*Allez vous-en! Allez vous-en! Sapristi!* That’s all right!”

He could speak a little Spanish, and also a language which nobody understood, unless it was the mocking-bird that hung on the other side of the door, whistling his fluty notes out upon the breeze with maddening persistence.

Mr. Pontellier, unable to read his newspaper with any degree of comfort, arose with an expression and an exclamation of disgust.

He walked down the gallery and across the narrow “bridges” which connected the Lebrun cottages one with the other. He had been seated before the door of the main house. The parrot and the mocking-bird were the property of Madame Lebrun, and they had the right to make all the noise they wished. Mr. Pontellier had the privilege of quitting their society when they ceased to be entertaining.

He stopped before the door of his own cottage, which was the fourth one from the main building and next to the last. Seating himself in a wicker rocker which was there, he once more applied himself to the task of reading the newspaper. The day was Sunday; the paper was a day old. The Sunday papers had not yet reached Grand Isle. He was already acquainted with the market reports, and he glanced restlessly over the editorials and bits of news which he had not had time to read before quitting New Orleans the day before.

Mr. Pontellier wore eye-glasses. He was a man of forty, of medium height and rather slender build; he stooped a little. His hair was brown and straight, parted on one side. His beard was neatly and closely trimmed.

Once in a while he withdrew his glance from the newspaper and looked about him. There was more noise than ever over at the house. The main building was called “the house,” to distinguish it from the cottages. The chattering and whistling birds were still at it. Two young girls, the Farival twins, were playing a duet from “Zampa” upon the piano. Madame Lebrun was bustling in and out, giving orders in a high key to a yard-boy whenever she got inside the house, and directions in an equally high voice to a dining-room servant whenever she got outside. She was a fresh, pretty woman, clad always in white with elbow sleeves. Her starched skirts crinkled as she came and went. Farther down, before one of the cottages, a lady in black was walking demurely up and down, telling her beads. A good many persons of the *pension* had gone over to the *Chênière Caminada* in Beaudelet’s lugger to hear mass. Some young people were out under the water-oaks playing croquet. Mr. Pontellier’s two children were there—sturdy little fellows of four and five. A quadroon nurse followed them about with a faraway, meditative air.

Mr. Pontellier finally lit a cigar and began to smoke, letting the paper drag idly from his hand. He fixed his gaze upon a white sunshade that was advancing at snail’s pace from the beach. He could see it plainly between the gaunt trunks of the water-oaks and across the stretch of yellow camomile. The gulf looked far away, melting hazily into the blue of the horizon. The sunshade continued to approach slowly. Beneath its pink-lined shelter were his wife, Mrs. Pontellier, and young Robert Lebrun. When they reached the cottage, the two seated themselves with some appearance of fatigue upon the upper step of the porch, facing each other, each leaning against a supporting post.

“What folly! to bathe at such an hour in such heat!” exclaimed Mr. Pontellier. He himself had taken a plunge at daylight. That was why the morning seemed long to him.

“You are burnt beyond recognition,” he added, looking at his wife as one looks at a valuable piece of personal property which has suffered some damage. She held up her hands, strong, shapely hands, and surveyed them critically, drawing up her fawn sleeves above the wrists. Looking at them reminded her of her rings, which she had given to her husband before leaving for the beach. She silently reached out to him, and he, understanding, took the rings from his vest pocket and dropped them into her open palm. She slipped them upon her fingers; then clasping her knees, she looked across at Robert and began to laugh. The rings sparkled upon her fingers. He sent back an answering smile.

“What is it?” asked Pontellier, looking lazily and amused from one to the other. It was some utter nonsense; some adventure out there in the water, and they both tried to relate it at once. It did not seem half so amusing when told. They realized this, and so did Mr. Pontellier. He yawned and stretched himself. Then he got up, saying he had half a mind to go over to Klein’s hotel and play a game of billiards.

“Come go along, Lebrun,” he proposed to Robert. But Robert admitted quite frankly that he preferred to stay where he was and talk to Mrs. Pontellier.

“Well, send him about his business when he bores you, Edna,” instructed her husband as he prepared to leave.

“Here, take the umbrella,” she exclaimed, holding it out to him. He accepted the sunshade, and lifting it over his head descended the steps and walked away.

“Coming back to dinner?” his wife called after him. He halted a moment and shrugged his shoulders. He felt in his vest pocket; there was a ten-dollar bill there. He did not know; perhaps he would return for the early dinner and perhaps he would not. It all depended upon the company which he found over at Klein’s and the size of “the game.” He did not say this, but she understood it, and laughed, nodding good-by to him.

Both children wanted to follow their father when they saw him starting out. He kissed them and promised to bring them back bonbons and peanuts.

:::space

::resource{id="tailpiece-1"}

# II {lead="Mrs. Pontellier’s eyes were quick and bright;\nthey were a yellowish brown, about the color of her hair."}

She had a way of turning them swiftly upon an object and holding them there as if lost in some inward maze of contemplation or thought.

Her eyebrows were a shade darker than her hair. They were thick and almost horizontal, emphasizing the depth of her eyes. She was rather handsome than beautiful. Her face was captivating by reason of a certain frankness of expression and a contradictory subtle play of features. Her manner was engaging.

Robert rolled a cigarette. He smoked cigarettes because he could not afford cigars, he said. He had a cigar in his pocket which Mr. Pontellier had presented him with, and he was saving it for his after-dinner smoke.

This seemed quite proper and natural on his part. In coloring he was not unlike his companion. A clean-shaved face made the resemblance more pronounced than it would otherwise have been. There rested no shadow of care upon his open countenance. His eyes gathered in and reflected the light and languor of the summer day.

Mrs. Pontellier reached over for a palm-leaf fan that lay on the porch and began to fan herself, while Robert sent between his lips light puffs from his cigarette. They chatted incessantly: about the things around them; their amusing adventure out in the water—it had again assumed its entertaining aspect; about the wind, the trees, the people who had gone to the *Chênière;* about the children playing croquet under the oaks, and the Farival twins, who were now performing the overture to “The Poet and the Peasant.”

Robert talked a good deal about himself. He was very young, and did not know any better. Mrs. Pontellier talked a little about herself for the same reason. Each was interested in what the other said. Robert spoke of his intention to go to Mexico in the autumn, where fortune awaited him. He was always intending to go to Mexico, but some way never got there. Meanwhile he held on to his modest position in a mercantile house in New Orleans, where an equal familiarity with English, French and Spanish gave him no small value as a clerk and correspondent.

He was spending his summer vacation, as he always did, with his mother at Grand Isle. In former times, before Robert could remember, “the house” had been a summer luxury of the Lebruns. Now, flanked by its dozen or more cottages, which were always filled with exclusive visitors from the “*Quartier Français*,” it enabled Madame Lebrun to maintain the easy and comfortable existence which appeared to be her birthright.

Mrs. Pontellier talked about her father’s Mississippi plantation and her girlhood home in the old Kentucky blue-grass country. She was an American woman, with a small infusion of French which seemed to have been lost in dilution. She read a letter from her sister, who was away in the East, and who had engaged herself to be married. Robert was interested, and wanted to know what manner of girls the sisters were, what the father was like, and how long the mother had been dead.

When Mrs. Pontellier folded the letter it was time for her to dress for the early dinner.

“I see Léonce isn’t coming back,” she said, with a glance in the direction whence her husband had disappeared. Robert supposed he was not, as there were a good many New Orleans club men over at Klein’s.

When Mrs. Pontellier left him to enter her room, the young man descended the steps and strolled over toward the croquet players, where, during the half-hour before dinner, he amused himself with the little Pontellier children, who were very fond of him.

:::space

::resource{id="tailpiece-2"}
`; // content.en.md, inlined by the Cookbook

// #region art: the cover and the fleuron, drawn in millimetres in the page's palette
function mulberry32(seed) { // a seeded PRNG: the same drawing on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(
  parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k)
  .toString(16).padStart(2, '0')).join('')}`;
const n2 = (v) => +v.toFixed(2);
// A wavy band edge from x = 0 to W at height y, amplitude a: the top of a filled band.
function edge(rnd, W, y, a, steps = 14) {
  let d = `M0 ${n2(y)}`;
  for (let i = 1; i <= steps; i++) {
    const x = (W * i) / steps;
    d += `Q${n2(x - W / steps / 2)} ${n2(y + (rnd() - 0.5) * 2 * a)} `
      + `${n2(x)} ${n2(y + (rnd() - 0.5) * a)}`;
  }
  return d;
}
function drawCover(W, H) { // chapter I: the gulf, far and hazy, seen from the cottage porch
  const rnd = mulberry32(1899);
  const P = palette;
  const out = [];
  const shape = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`);
  const band = (y, a, color) => shape(`${edge(rnd, W, y, a)}L${W} ${H}L0 ${H}Z`, color);
  const bez = (a, b, c, d, t) => (1 - t) ** 3 * a + 3 * (1 - t) ** 2 * t * b
    + 3 * (1 - t) * t * t * c + t ** 3 * d;
  // The sky: pale blue overhead, warming into haze at the horizon; a white noon sun.
  const sky = mix(P.shallows, P.paper, 0.5);
  out.push(`<rect width="${W}" height="${H}" fill="${sky}"/>`);
  out.push(`<circle cx="47" cy="20" r="6" fill="${mix(P.sand, P.paper, 0.7)}"/>`);
  [[40, 0.7, 0.3], [52, 0.8, 0.55], [62, 0.6, 0.8]].forEach(([y, a, k]) =>
    band(y, a, mix(sky, mix(P.sand, P.paper, 0.45), k)));
  // The gulf: melting into the haze at the horizon, deep further out, green over the shallows.
  const HORIZON = 70;
  out.push(`<rect y="${HORIZON}" width="${W}" height="${H - HORIZON}" `
    + `fill="${mix(P.shallows, P.paper, 0.35)}"/>`);
  band(HORIZON + 4, 0.2, mix(P.sea, P.shallows, 0.35));
  band(HORIZON + 11, 0.5, P.sea);
  band(HORIZON + 18, 0.8, mix(P.sea, P.shallows, 0.55));
  for (let i = 0; i < 70; i++) { // glints on the water, longer in the foreground
    const y = HORIZON + 5 + rnd() * 17;
    const [x, len] = [rnd() * W, 1 + ((y - HORIZON) / 20) * 5 * (0.5 + rnd())];
    out.push(`<path d="M${n2(x)} ${n2(y)}h${n2(len)}" stroke="${mix(P.shallows, P.paper, 0.5)}" `
      + `stroke-width="${n2(0.25 + (y - HORIZON) / 60)}" stroke-linecap="round"/>`);
  }
  // Beaudelet's lugger on the horizon, on its way to the Chênière.
  shape(`M84 ${HORIZON + 0.6}h6.5l-1 1.3h-4.6z`, P.ink);
  shape(`M86.2 ${HORIZON + 0.4}L86.8 ${HORIZON - 7.2}L89.9 ${HORIZON - 5.4}`
    + `L89.2 ${HORIZON + 0.4}Z`, mix(P.rubric, P.sand, 0.3));
  // The surf, the beach and the stretch of yellow camomile.
  band(HORIZON + 24, 0.7, P.paper);
  band(HORIZON + 25.3, 0.9, P.sand);
  band(HORIZON + 31, 1.2, mix(P.camomile, P.sand, 0.35));
  band(HORIZON + 38, 1.6, mix(P.camomile, P.shallows, 0.2));
  band(HORIZON + 50, 2, mix(P.camomile, P.sea, 0.25));
  // The path up from the beach, widening as it comes near.
  const PATH_TOP = HORIZON + 30;
  const [left, right] = [[66, 68, 76, 70], [69.6, 74, 88, 96]]; // its edges, top to bottom
  shape(`M${left[0]} ${PATH_TOP}C${left[1]} 112 ${left[2]} 124 ${left[3]} ${H}H${right[3]}`
    + `C${right[2]} 124 ${right[1]} 112 ${right[0]} ${PATH_TOP}Z`, mix(P.sand, P.camomile, 0.2));
  const onPath = (x, y) => { const t = (y - PATH_TOP) / (H - PATH_TOP); // the edges' y runs evenly
    return t >= 0 && x > bez(...left, t) - 1 && x < bez(...right, t) + 1; };
  for (let i = 0; i < 560; i++) { // camomile heads, bigger and sparser towards the viewer
    const y = HORIZON + 32 + rnd() ** 0.8 * (H - HORIZON - 32);
    const x = rnd() * W;
    const r = 0.25 + ((y - HORIZON - 32) / (H - HORIZON)) * 1.4;
    if (onPath(x, y)) continue;
    out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(r)}" fill="${P.paper}"/>`
      + `<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(r * 0.45)}" fill="${P.camomile}"/>`);
  }
  // Edna and Robert under the white, pink-lined sunshade.
  const [sx, sy] = [70.5, HORIZON + 33];
  shape(`M${sx - 1.3} ${sy}v4.2h1.1v-4.2z`, P.paper);
  shape(`M${sx + 0.4} ${sy}v4.2h1.1v-4.2z`, mix(P.ink, P.sea, 0.4));
  shape(`M${sx - 3.2} ${sy - 0.2}Q${sx} ${sy - 3.6} ${sx + 3.2} ${sy - 0.2}Z`, P.paper);
  shape(`M${sx - 3.2} ${sy - 0.2}Q${sx} ${sy + 0.6} ${sx + 3.2} ${sy - 0.2}Z`,
    mix(P.rubric, P.paper, 0.55));
  // The gaunt trunks of the water-oaks and their limbs: tapered curves, wide at the base.
  const bark = mix(P.ink, P.sea, 0.35);
  const bend = ([x0, y0], [cx, cy], [x1, y1], t) => [
    (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1,
    (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1];
  const limb = (p0, c, p1, w0, w1) => {
    const [l, r] = [[], []]; // the limb's two outlines
    for (let i = 0; i <= 12; i++) {
      const t = i / 12;
      const [x, y] = bend(p0, c, p1, t);
      const [dx, dy] = [(1 - t) * (c[0] - p0[0]) + t * (p1[0] - c[0]),
        (1 - t) * (c[1] - p0[1]) + t * (p1[1] - c[1])];
      const k = (w0 + (w1 - w0) * t) / 2 / Math.hypot(dx, dy);
      l.push(`${n2(x - dy * k)} ${n2(y + dx * k)}`);
      r.unshift(`${n2(x + dy * k)} ${n2(y - dx * k)}`);
    }
    shape(`M${l.join('L')}L${r.join('L')}Z`, bark);
  };
  // Each oak: a trunk (foot, bend, top; widths) and limbs that leave it at t along its length.
  [[[108, H + 1], [106, 70], [110, -1], 1.8, 1, []], // a third oak, further back
    [[9, H + 1], [4, 70], [17, -1], 4.6, 2.2, [[0.63, [22, 30], [40, 9], 2.4, 0.7],
      [0.78, [5, 18], [-2, 12], 1.6, 0.6]]],
    [[125, H + 1], [131, 70], [117, -1], 4.2, 2, [[0.66, [110, 30], [97, 10], 2.2, 0.7]]],
  ].forEach(([foot, c, top, w0, w1, limbs]) => {
    limb(foot, c, top, w0, w1);
    limbs.forEach(([t, lc, tip, l0, l1]) => limb(bend(foot, c, top, t), lc, tip, l0, l1));
  });
  const crown = (cx, cy, rx, ry, n) => {
    for (let i = 0; i < n; i++) {
      const a = rnd() * Math.PI * 2;
      const d = Math.sqrt(rnd());
      const [x, y] = [cx + Math.cos(a) * rx * d, cy + Math.sin(a) * ry * d];
      const shade = rnd() < 0.3 ? mix(P.sea, P.shallows, 0.4) : mix(P.sea, P.ink, 0.45);
      out.push(`<ellipse cx="${n2(x)}" cy="${n2(y)}" rx="${n2(1.8 + rnd() * 2.4)}" `
        + `ry="${n2(1.2 + rnd() * 1.4)}" fill="${shade}"/>`);
    }
  };
  crown(12, 3, 36, 13, 170);
  crown(128, 4, 32, 14, 150);
  // Spanish moss, in clumps hanging from the limbs.
  [[24, 20], [31, 14], [36, 11], [4, 15], [104, 16], [110, 21], [99, 12], [14, 13], [126, 13]]
    .forEach(([x, y]) => {
      for (let j = 0; j < 6; j++) {
        const [dx, len] = [(rnd() - 0.5) * 3.2, 4 + rnd() * 9];
        out.push(`<path d="M${n2(x + dx)} ${n2(y)}c${n2(rnd() - 0.5)} ${n2(len / 3)} `
          + `${n2(rnd() - 0.5)} ${n2((2 * len) / 3)} ${n2((rnd() - 0.5) * 1.5)} ${n2(len)}" `
          + `stroke="${mix(P.shallows, P.sea, 0.2)}" stroke-width="0.5" fill="none" `
          + 'stroke-linecap="round"/>');
      }
    });
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" `
    + `viewBox="0 0 ${W} ${H}">${out.join('')}</svg>`;
}
function drawFleuron(color) { // a camomile head between two leafy sprigs
  const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" `
    + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join('');
  const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" `
    + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>`
    + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>`
    + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>`
    + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`;
  return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" '
    + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}`
    + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Crimson Pro': ['400', '400i'], // text, colophon, folios
  'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head
  'Cormorant SC': ['600'], // the leads, the author on the cover and the verso
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('cover.svg', drawCover(COVER_ART.w, COVER_ART.h));
await loadSvg('fleuron.svg', drawFleuron(palette.rubric));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Trade paperback: sunk openers and recto chapters' });

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

### Let chapter II follow on the next page

With parity `'any'`, chapter II opens on page 6, a verso, and the book ends on page 7; the drop folio is centred on the text block, not the page, so on that verso it still stands under the middle of the text.

```diff
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
```

### Mark a scene break inside a chapter

Set between two `:::space` lines, the same flower marks a scene break, and the paragraph after it starts flush because `indentAfterHeading` is off. Placed after *seemed long to him.*, the flower falls at the foot of page 4; the second space no longer fits there and is dropped. The break adds four lines, which push chapter I’s tailpiece alone onto page 6, under a running head, so copy-fit the chapter again after you add one.

```diff
 const resources = [
+  svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),
```

```md
That was why the morning seemed long to him.

:::space

::resource{id="break-1"}

:::space

“You are burnt beyond recognition,” he added, …
```

## Pitfalls

- **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.
- **Page 1 is a recto: plan pages with physical numbers.** Page 1 is a right-hand page and page 2 the first verso, so plan spreads with physical page numbers: an opener on an even page faces the odd page after it.
- **\n in an attribute breaks lines only with paragraphIndent > 0.** In a design text element, a \n written in an attribute value starts a new line only when paragraphIndent is above zero or a drop cap is set; otherwise the text stays on one line. Set paragraphIndent to a hair (0.01 pt), or use one attribute per line.
- **A design text's lineHeight is a multiple, never a dimension.** In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title.
- **Design text is never justified, so a drop-cap lead is ragged.** In postext 1.4.1 a design text element aligns left, centre or right and wraps word by word: there is no justified alignment, and hyphenate: true only splits a word too long for a whole line. The lines of a lead set beside a dropCap therefore end ragged next to justified body text. Keep the lead to the lines beside the initial and fit them by hand: with lines: 1 (a raised initial) the lead is one line, which the dropCap gap can fit flush; the paragraph goes on in the Markdown.
- **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.
- **A runt fix can tighten tracking that is never painted.** In postext 1.4.1, when a paragraph ends on a runt, the layout sets it one line shorter: first with tighter word spacing, then with up to maxRuntTracking thousandths of an em of negative tracking. The canvas and PDF renderers paint tracking only above zero, so a tracked paragraph prints untracked: its justified lines lose the difference from their word spaces and look crushed, and its last line can run past the measure and be clipped at the column edge. Set bodyText.maxRuntTracking: 0, which keeps the word-spacing fix, and reword any runt that comes back.
- **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.
- **::resource{id="…"} takes double quotes only.** A block embed is recognised only as ::resource{id="…"} with double quotes; any other form stays in the text as a visible line.
- **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.
- **Layout warning: Parity cascade** (`parityCascade`). Parity breaks stack up and produce more than two blank pages in a row. Fix: Review always-odd breaks and breakBefore parities; for short chapters, parity any usually avoids the blanks. ([Documentation](https://postext.dev/en/docs/configuration.md#break-before))

- With `'always-odd'` a blank separator page follows every section before any parity padding, so chapter I would move to page 5, after two blank pages, and the book would run to ten pages.
- These pages are copy-fitted. With an outer margin of 16 mm instead of 15.5, page 7 splits *after-din-ner* at a second hyphen and ends a line short, page 8 ends a paragraph on the scrap *spondent.*, and the second tailpiece falls onto a ninth page. Check the foot of every page after you change the measure, the type size or the sink.
- *Mr.* and *Mrs.* are tied to the name with a no-break space (U+00A0). In 1.4.1 it holds in plain paragraphs, but in a paragraph with italics it breaks like an ordinary space. The paragraph that ends *seemed long to him.* keeps an ordinary space, because tying the name there opens its first line to 2.2 times the normal word space.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The Awakening (1899), chapters I and II: Kate Chopin ([source](https://www.gutenberg.org/ebooks/160)), public domain
- Text: The colophon: Postext Cookbook, original
- Images: The cover’s view of the gulf and the camomile tailpiece, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Type: Crimson Pro (OFL-1.1), Cormorant Garamond (OFL-1.1), Cormorant SC (OFL-1.1)
- Code: MIT · Sample content: MIT

## 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º 017 · Five chapter openers in one book](https://postext.dev/en/cookbook/five-chapter-openers.md): One opener on level 1 and four heading styles named in the Markdown; each style swaps the accent colour, and some also change the margins, columns or folios. · Level 3 (Advanced) · Any genre
- [Nº 006 · Front matter in roman folios, then page 1](https://postext.dev/en/cookbook/front-matter-roman-to-arabic.md): The cover and prelims are unnumbered headings counted in lower-case roman; :::numbering restarts the count at 1 on the recto where the novel opens. · Level 3 (Advanced) · Fiction, drama & literary prose
