# Photo essay with full-bleed plates

> Plates as heading styles built from a list, each filling its page to the trim; the storm plate runs across the gutter of a spread in two halves.

- HTML version: https://postext.dev/en/cookbook/photo-essay-full-bleed
- Recipe Nº 068 · Figures & images · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Photobooks
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/photo-essay-full-bleed/en/p01.webp?v=42ca79d1), [2](https://postext.dev/cookbook/photo-essay-full-bleed/en/p02.webp?v=42ca79d1), [3](https://postext.dev/cookbook/photo-essay-full-bleed/en/p03.webp?v=42ca79d1), [4](https://postext.dev/cookbook/photo-essay-full-bleed/en/p04.webp?v=42ca79d1), [5](https://postext.dev/cookbook/photo-essay-full-bleed/en/p05.webp?v=42ca79d1), [6](https://postext.dev/cookbook/photo-essay-full-bleed/en/p06.webp?v=42ca79d1), [7](https://postext.dev/cookbook/photo-essay-full-bleed/en/p07.webp?v=42ca79d1), [8](https://postext.dev/cookbook/photo-essay-full-bleed/en/p08.webp?v=42ca79d1), [9](https://postext.dev/cookbook/photo-essay-full-bleed/en/p09.webp?v=42ca79d1)
- Last updated: 2026-09-27
- Other languages: [es](https://postext.dev/es/cookbook/photo-essay-full-bleed.md)

## What you'll build

*Sierra*, a landscape photobook on a 280 × 210 mm page: one day in a granite range in six plates, from first light to the morning after the first snow. Each plate fills its page to the trim and carries only a small numeral and the hour in its lower left corner; the first also carries the title, SIERRA in 72-pt Syne, reversed out of the dawn sky. The storm runs across the gutter of pages 4 and 5 as one picture. Three short texts, set in a 100 mm column near the spine, face the plates that follow them. The storm’s text ends on a smaller dusk plate that floats under it, with its numeral in the caption. The last plate shows the cirque of the second from the same boulder, under snow.

**This recipe answers:**

- How do I give each plate of a photo essay a full-bleed page of its own, and run one across a spread?
- How do I insert deliberate blank pages, or start a section on a fresh spread?
- How do I hide running heads on openers and blank pages, or paint a blank verso in the part colour?
- How do I set unnumbered artwork: ornaments, vignettes, logos?

## The short answer

One heading style per plate, generated from a list.

```js
// script.js, lines 35–58
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'mediodia', art: 'mediodia' },
  { id: 'tormenta', art: 'tormenta', half: 'verso' }, // one picture across a spread:
  { id: 'tormenta-recto', art: 'tormenta', half: 'recto' }, // the left half, then the right
  { id: 'noche', art: 'noche' },
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // No margins: the plate's column is the page, and minHeight fills it, so what follows starts
  // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
  // taller than the column is dropped whole (gotcha: opener-taller-than-column).
  margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
  advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});
```

## Ingredients

**Teaches**

- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements): Logos, photos and ornaments placed in headers, footers, openers and part pages, sized with the aspect ratio kept.
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles): Named variants of a heading level, picked with {style="…"}: a different opener, typography, running heads or page setup per chapter.
- [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.

**Also uses**

- [Anchoring design elements](https://postext.dev/en/docs/configuration.md#element-placement)
- [Section geometry](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Chapters that open on a recto](https://postext.dev/en/docs/configuration.md#break-before)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Caption style](https://postext.dev/en/docs/configuration.md#caption-style)
- [Covers, title pages and colophons](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Hyphenation and document language](https://postext.dev/en/docs/justification.md#supported-locales)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [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)

**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), [`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), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration.md#page), [`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**

- Andada Pro (OFL-1.1), Syne (OFL-1.1), Syne Mono (OFL-1.1)

## Method

### 1 · A heading style for each plate, built from a list

The code is [the short answer](#the-short-answer) above. A heading style can name its own picture, so each plate is a style that `plate()` builds from one row of `PLATES`, and the Markdown sets the order of the essay, one line per plate: `# Storm {style="tormenta" n="III" hora="16:40"}`. `span: 'page'` starts every plate on a page of its own. Zero margins make the plate’s column as tall as the page, and `minHeight` fills that column, so whatever follows a plate starts on the next page. You need both settings. Without `minHeight`, the storm’s right half, which draws no text, keeps only its heading’s own 7.6 mm line, and the next text starts on that page, over the picture. With the text pages’ margins, the column is 158 mm deep, shorter than the 210 mm `minHeight`, so postext 1.4.1 drops the reservation altogether and all three texts print over the plates before them.

The storm’s two styles draw the same 560 mm picture: the verso half at the page’s left edge, the recto half 280 mm further left, so each page shows its own half and the canvas crops the rest at the trim. `parity: 'even'` keeps the two halves facing. Without it, taking the noon plate out of the Markdown would put the left half on page 3, a recto, and the right half overleaf on page 4; with it, page 3 stays blank and the storm still fills pages 4 and 5.

### 2 · The numeral and the hour come from the heading line

```js
// script.js, lines 62–73
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];
```

The numeral and the hour are heading attributes, so the content stays in the Markdown and one design serves every plate. The hour hangs `right-of` the numeral, which aligns their tops. A design text’s baseline sits 0.8 of its line box below the element’s top, and the line box is 1.2 times the type size by default, so `dropTo(13, 8)` lowers the hour by 0.8 × 1.2 × (13 − 8) pt and puts the 8-pt hour on the 13-pt numeral’s baseline, 193.4 mm from the top of the page. The hour opens with a middle dot, as plate IV’s caption does, because Syne’s I is a plain bar, and alone beside the hour it reads as a rule. The recto half of the storm draws no caption, and the snow plate sets its caption in ink, because its foreground is white.

### 3 · A title and a colophon on the plates themselves

```js
// script.js, lines 77–91
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));
```

The first plate is the cover. `{title}` and `{subtitle}` read the frontmatter, and `extra` in `PLATES` adds both elements to that plate’s design, in the colour of its caption. The colophon goes on the last plate the same way, in ink on the snow: two 7.5-pt elements, one per line, from the `colofon` and `tipos` attributes, flush right, the second on the numeral’s baseline. A single wrapped element would break wherever its width ran out; with one element per line, the break falls after the licence.

### 4 · Texts follow their plates without a break

```js
// script.js, lines 95–115
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };
```

The plate before a text fills its page, so the text starts the next page with no break of its own. A page whose first block is a heading with a break is an opener page, and the folio’s `pages: 'body'` leaves it out. With no break, the text pages keep their folio; the plates, opened by span headings, get none. postext 1.4.1 already drops the level-1 break once a `headings` object exists, so deleting `breakBefore: { enabled: false }` changes nothing today. The line matters once a release restores the default always-odd break: it keeps the texts from breaking, so their pages stay body pages with a folio. `FOOT` sets the folio on the baseline of the plates’ numerals, 193.4 mm from the top of the page, so a text’s folio and the numeral of the plate facing it sit on one line.

### 5 · Declare every plate, cite only the one that floats

```js
// script.js, lines 196–223
// Plate IV is the one plate that floats, so a counter of its type would number it 1. The type
// prints no number (no caption prefix, an empty template) and the caption carries the numeral.
// The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else.
const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }),
  shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // pixels per unit of a drawing; the page plates draw in mm
const picture =(id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX },
  altText: t(alt), ...extra });
const resources = [ // the five the heading styles draw, never cited, and plate IV
  picture('alba', [280, 210], { en: 'Eight ridges fading into a peach dawn haze.',
    es: 'Ocho crestas que se pierden en la bruma del alba.' }),
  picture('mediodia', [280, 210], { en: 'A granite cirque and its lake under a pale noon sky.',
    es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }),
  picture('tormenta', [560, 210], { en: 'A storm over the range: rain on the left, lightning '
    + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la '
    + 'izquierda; un rayo y un claro de sol a la derecha.' }),
  picture('noche', [280, 210], { en: 'Stars over dark ridges and two lit windows at a refuge.',
    es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }),
  picture('nieve', [280, 210], { en: 'The cirque the morning after, white with new snow.',
    es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }),
  // A 250 × 100 drawing: 2500 px, 423 mm at 150 dpi, that the float fits to the 100 mm measure.
  picture('atardecer', [250, 100], {
    en: 'A crest lit orange under strips of cloud in a violet sky.',
    es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' },
  { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }),
];
```

A design’s image element draws a declared resource, so all six pictures are in `resources`. Plate IV is the only one cited with `:ref`, and so the only one that floats. It takes the first free slot after the citation, on the page of its text, one line under the last paragraph. A numbered type would caption it *Plate 1*, since it is the only resource of its type that floats. `lamina` has an empty `numberingTemplate` instead, and the caption itself begins with IV. In the sentence, `:ref`’s `text` names the plate, because a bare `:ref` prints only the word *plate*.

## 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/photo-essay-full-bleed

### script.js

```js
// ═══ Postext Cookbook · Nº 068 · Photo essay with full-bleed plates ═══════════════
// https://postext.dev/en/cookbook/photo-essay-full-bleed
// Code: MIT · Text: original (CC BY 4.0) · Plates: drawn in code (CC BY 4.0)
// Fonts: Andada Pro, Syne, Syne Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Sierra, a landscape photobook: one day in a mountain range in six plates, each on a page of
// its own and one across the gutter of a spread, with three short texts between them.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'photo-essay-full-bleed';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the paper between the plates, the ink and one rust for the labels
const palette = {
  ink: '#1c1f27', // text: the blue-black of the night plate
  paper: '#f3f0ea', // every page's ground: a pale stone, seen only between the plates
  accent: '#94462e', // the times over each text: the dusk plate's rust, dark enough for 8 pt
  muted: '#5f646d', // the running foot and the caption of the small plate
  white: '#fbfaf7', // type set on the plates
};
// Design elements read the hex, not the palette, in 1.4.1 (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' } }));
// #endregion
const PAGE = { width: 280, height: 210 }; // a landscape photobook, in mm
const MARGIN = { top: 28, bottom: 24, inner: 40, outer: 140 }; // text pages: a 100 mm measure
const LEAD = 15; // body leading in pt
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });

// #region answer: one heading style per plate, generated from a list
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'mediodia', art: 'mediodia' },
  { id: 'tormenta', art: 'tormenta', half: 'verso' }, // one picture across a spread:
  { id: 'tormenta-recto', art: 'tormenta', half: 'recto' }, // the left half, then the right
  { id: 'noche', art: 'noche' },
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // No margins: the plate's column is the page, and minHeight fills it, so what follows starts
  // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
  // taller than the column is dropped whole (gotcha: opener-taller-than-column).
  margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
  advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});
// #endregion

// #region caption: the plate's numeral and hour, lower left, from the heading's attributes
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];
// #endregion

// #region cover: the book's title reversed out of the dawn sky of the first plate
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));
// #endregion

// #region texts: a text between plates opens on the next page with its hours above it
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  // The English sample is British English, set with the US patterns: 1.4.1 ships no en-gb.
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  resourceTypes: [lamina],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150,
    backgroundColor: col('paper'), // the ground of every page; the plates cover it
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Andada Pro', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to blue
    referenceColor: col('ink'), referenceBold: false, // 'lámina IV' reads as a word of the text
    firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
    minWordSpacing: 0.7, maxWordSpacing: 1.6, // a narrower range than the defaults, 0.6 to 2
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  // A heading's own line is measured even where its design paints the title: set it in a face
  // the page loads, or the kit fetches Open Sans for it.
  headings: { fontFamily: 'Syne', levels: [
    // A text follows its plate with no forced break (gotcha: headings-drop-h1-break): the
    // plate fills its page, so the text still starts a page, and that page stays a body page,
    // with its running foot.
    { level: 1, breakBefore: { enabled: false }, advancedDesign: textOpener },
  ] },
  headingStyles: PLATES.map(plate),
  captionStyle: { fontFamily: 'Syne Mono', fontSize: pt(7.5), color: col('muted') },
  header: { elements: [] },
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Sierra"
subtitle: "An essay in six lights"
---

# Dawn {style="alba" n="I" hora="08:12"}

# The climb {hora="06:40–14:06 · 1770–2180 m"}

We left the car park at 6:40 with head torches, on the paved track that climbs between the broom. At that hour the granite still holds the night’s cold, and our boots sound louder than they should. From the first bend we can see the lights of the village below, eleven of them, counted twice; from the second we can’t.

At 8:12 the sun touches the highest point of the crest. It is a pink edge that lasts as long as it takes to get the camera out; then the light works its way down the gullies, slowly, and takes almost an hour to reach the path. The meadow grass is white with frost and crunches under our boots. An ibex watches us from a boulder and does not move, so we are the ones who go round.

We stop at the pass to eat. There is a spring that is not on the map, an iron spout driven into the rock, and the water is cold enough to make our teeth ache. Andrés notes the time and the altitude of every photograph in a notebook; at one o’clock the thermometer hanging from his rucksack reads eleven degrees in the sun.

Solar noon falls after two here, on summer time. By then the shadows have gone under the stones, and the lake, seen from the pass, looks like a sheet of tin someone has left behind between the walls.

# Noon {style="mediodia" n="II" hora="14:06"}

# Storm {style="tormenta" n="III" hora="16:40"}

# Storm {style="tormenta-recto"}

# Eleven seconds {hora="15:00–19:50 · 2180–1950 m"}

At three o’clock the air grows heavy. A cloud that at first we take for another mountain is building in the west. We pack in a hurry. The first thunder comes eleven seconds after the flash: nearly four kilometres away, Andrés reckons, because he always counts. The next one comes after four.

We take shelter under the overhang of a boulder as big as a house. First small hail, then rain; the wall opposite fades behind the curtain and comes back darker, washed. Nobody speaks for a good hour.

Around half past seven the storm drifts away down the valley and leaves the sky broken into strips. For a moment the sun comes out below the cloud, low in the west, and turns the whole crest a colour that lasts less than three minutes (:ref{id="atardecer" text="plate IV"}). We go down to the refuge soaked through, beside the stream, which is running full.

# Night {style="noche" n="V" hora="22:10"}

# First snow {hora="22:10–09:00 · 1950 m"}

At ten the sky is clear, and the refuge window cannot hold all its stars. At midnight somebody opens the door and says it is snowing. We go out in our socks. The snow falls straight down, with no wind, in large flakes that are slow to melt on our sleeves, and the torch lights only a cone of white points coming towards us.

In the morning the thermometer at the window reads two below. There is a hand’s depth of snow at the door and a rim of thin ice along the lake. The ridges that were grey yesterday are white with black streaks, the ribs where the snow will not stick, and the sky is the same colour as the ground.

Andrés takes the last photograph at 8:31, from the same boulder as at noon yesterday. It takes him some time to find it, because the stones he used as markers are now identical white mounds. In the end he places it by matching the crest to yesterday’s picture on the camera screen.

It is the first snow of the autumn, two weeks early, the warden says. We set off at nine. The paved track is under the snow, so we go down from one cairn to the next.

# Snow {style="nieve" n="VI" hora="08:31" colofon="Sierra. An essay in six lights · Plates drawn in code · Text and plates: CC BY 4.0" tipos="Set in Andada Pro, Syne and Syne Mono (SIL OFL)"}
`; // content.<lang>.md, inlined by the Cookbook

// #region plates: every picture is a resource; only plate IV is cited, so only it floats
// Plate IV is the one plate that floats, so a counter of its type would number it 1. The type
// prints no number (no caption prefix, an empty template) and the caption carries the numeral.
// The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else.
const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }),
  shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // pixels per unit of a drawing; the page plates draw in mm
const picture =(id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX },
  altText: t(alt), ...extra });
const resources = [ // the five the heading styles draw, never cited, and plate IV
  picture('alba', [280, 210], { en: 'Eight ridges fading into a peach dawn haze.',
    es: 'Ocho crestas que se pierden en la bruma del alba.' }),
  picture('mediodia', [280, 210], { en: 'A granite cirque and its lake under a pale noon sky.',
    es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }),
  picture('tormenta', [560, 210], { en: 'A storm over the range: rain on the left, lightning '
    + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la '
    + 'izquierda; un rayo y un claro de sol a la derecha.' }),
  picture('noche', [280, 210], { en: 'Stars over dark ridges and two lit windows at a refuge.',
    es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }),
  picture('nieve', [280, 210], { en: 'The cirque the morning after, white with new snow.',
    es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }),
  // A 250 × 100 drawing: 2500 px, 423 mm at 150 dpi, that the float fits to the 100 mm measure.
  picture('atardecer', [250, 100], {
    en: 'A crest lit orange under strips of cloud in a violet sky.',
    es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' },
  { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }),
];
// #endregion

// #region art: six plates drawn in code: seeded ridges, flat fills and gradients
// No filters, masks or markers (gotcha: svg-no-marker-filters): the haze is ridge after ridge,
// each a shade darker than the one behind it, and the glows are radial gradients.
function mulberry32(seed) {
  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 n1 = (v) => Math.round(v * 10) / 10;
const pathOf = (pts) => pts.map(([x, y], i) => `${i ? 'L' : 'M'}${n1(x)} ${n1(y)}`).join('');
const paint = (hex, a = 1) => `fill="${hex}"${a < 1 ? ` fill-opacity="${a}"` : ''}`;
// Midpoint displacement: n + 1 heights, each octave `decay` as rough as the one before.
function roughness(rand, n, amp, decay = 0.6) {
  const a = new Array(n + 1).fill(0);
  for (let step = n; step > 1; step /= 2, amp *= decay) {
    for (let i = 0; i < n; i += step) {
      a[i + step / 2] = (a[i] + a[i + step]) / 2 + (rand() - 0.5) * amp;
    }
  }
  return a;
}
// A ridge line: the highest of its peaks [x, height, half-width, curve] over a base, plus rock.
function ridge(rand, w, base, peaks, rough) {
  const n = 512;
  const nz = roughness(rand, n, rough);
  return nz.map((dy, i) => {
    const x = -12 + ((w + 24) * i) / n;
    const lift = Math.max(0, ...peaks.map(([cx, h, hw, p = 1.5]) =>
      (Math.abs(x - cx) < hw ? h * (1 - Math.abs(x - cx) / hw) ** p : 0)));
    return [x, base - lift + dy];
  });
}
// One range of mountains in `lit`. With `shade`, each named peak gets a facet turned from the
// light: from the summit along the crest to the saddle, then down to a foot under the saddle.
// Each peak brings two or three lesser summits of its own, so a crest is never a triangle.
function range(rand, { w, h, base, peaks, rough = 3, lit, shade, light = -1, streak }) {
  const all = peaks.flatMap(([cx, ph, hw, p]) => [[cx, ph, hw, p], ...Array.from(
    { length: 2 + Math.floor(rand() * 2) }, () => [cx + (rand() - 0.5) * hw * 1.2,
      ph * (0.55 + rand() * 0.3), hw * (0.25 + rand() * 0.25), 1.2])]);
  const line = ridge(rand, w, base, all, rough);
  const idx = (x) => Math.max(0, Math.min(512, Math.round(((x + 12) / (w + 24)) * 512)));
  let out = `<path d="${pathOf(line)}L${w + 12} ${h + 2}L-12 ${h + 2}Z" ${paint(lit)}/>`;
  if (!shade) return out;
  const side = -light; // the shaded face looks away from the light
  for (const [cx, ph] of peaks) {
    let i = idx(cx);
    while (i > 0 && i < 512 && line[i][1] > line[i + side][1]) i += side; // up to the summit
    const top = i;
    while (i + side >= 0 && i + side <= 512 && line[i + side][1] >= line[i][1] - 0.4) i += side;
    const face = side > 0 ? line.slice(top, i + 1) : line.slice(i, top + 1).reverse();
    const [tx, ty] = face[0];
    const [sx, sy] = face.at(-1);
    const foot = [tx + (sx - tx) * (0.3 + rand() * 0.2), sy + (sy - ty) * (0.6 + rand() * 0.5) + 6];
    const spur = [0.8, 0.6, 0.4, 0.2].map((t) => [tx + (foot[0] - tx) * t + (rand() - 0.5) * 1.4,
      ty + (foot[1] - ty) * t]);
    const gully = [0.25, 0.5, 0.75].map((t) => [sx + (foot[0] - sx) * t + (rand() - 0.5) * 1.2,
      sy + (foot[1] - sy) * t]);
    const under = ([x, y]) => [x, Math.max(y, line[idx(x)][1] + 0.2)]; // never above the crest
    out += `<path d="${pathOf([...face, ...[...gully, foot, ...spur].map(under)])}Z" `
      + `${paint(shade)}/>`;
    // Gullies on the shaded face, parallel to the arête. `false` paints none but draws the same
    // numbers, so plates II and VI keep one geometry.
    if (streak !== undefined) {
      const [fx, fy] = foot;
      for (let k = 0; k < 4; k++) {
        const along = 0.2 + rand() * 0.7; // where it leaves the crest, from summit to saddle
        const [cx0, cy0] = face[Math.floor(along * (face.length - 1))];
        // Parallel to the arête, a gully leaves the face 1 - along of the way to the foot.
        const room = 0.85 * (1 - along);
        const t0 = Math.min(0.12 + rand() * 0.3, room / 2); // where it starts below the crest
        const t1 = Math.min(t0 + 0.12 + rand() * 0.25, room); // and where it fades out
        const [x0, y0] = [cx0 + (fx - tx) * t0, cy0 + (fy - ty) * t0];
        const [x1, y1] = [cx0 + (fx - tx) * t1, cy0 + (fy - ty) * t1];
        const wd = 0.25 + rand() * 0.4;
        if (streak) {
          out += `<path d="M${n1(x0 - wd)} ${n1(y0)}L${n1(x0 + wd)} ${n1(y0)}`
            + `L${n1(x1)} ${n1(y1)}Z" ${paint(streak)}/>`;
        }
      }
    }
  }
  return out;
}
// A hazy sequence of `n` ranges, far to near, their colour stepping from `far` to `near`. With
// `warm` ({ id, hex, from }), each range turns towards `hex` east of `from` (a fraction of the
// width), less so the nearer it is: a low sun lighting the far ridges through a gap.
function hazy(rand, { w, h, n, top, bottom, far, near, rough = 5, height = 26, warm }) {
  const channel = (hex, k) => parseInt(hex.slice(k, k + 2), 16);
  const blend = (a, b, t) => `#${[1, 3, 5].map((k) => Math.round(channel(a, k) * (1 - t)
    + channel(b, k) * t).toString(16).padStart(2, '0')).join('')}`;
  let out = '';
  for (let k = 0; k < n; k++) {
    const t = k / (n - 1);
    const peaks = Array.from({ length: 3 + Math.floor(rand() * 3) }, () =>
      [rand() * w, height * (0.4 + rand() * 0.8) * (1 + t * 0.6), 30 + rand() * 60, 1 + rand()]);
    let lit = blend(far, near, t ** 1.3);
    if (warm) {
      const id = `${warm.id}${k}`;
      out += `<defs><linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="0" y1="0" `
        + `x2="${w}" y2="0"><stop offset="${warm.from}" stop-color="${lit}"/><stop offset="0.94" `
        + `stop-color="${blend(lit, warm.hex, 0.75 * (1 - t) ** 1.5)}"/></linearGradient></defs>`;
      lit = `url(#${id})`;
    }
    out += range(rand, { w, h, base: top + (bottom - top) * t, peaks, rough: rough * (1 + t),
      lit });
  }
  return out;
}
const sky = (id, w, h, stops) => `<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">`
  + stops.map(([o, c]) => `<stop offset="${o}" stop-color="${c}"/>`).join('')
  + `</linearGradient></defs><rect width="${w}" height="${h}" fill="url(#${id})"/>`;
// A bank of cloud hanging from the top edge: rounded lumps along a base that runs from y0 on
// the left to y1 on the right.
function ceiling(rand, w, y0, y1, hex, lump = 10) {
  const lumps = [];
  for (let x = -20; x < w + 20; x += 10 + rand() * 16) {
    const r = 11 + rand() * 14; // wide lumps: two narrow ones meet in a sharp cusp
    lumps.push([x, Math.min(r * 0.7, lump * (0.4 + rand() * 0.8)), r]);
  }
  const nz = roughness(rand, 256, 1.5);
  const edge = nz.map((dy, i) => {
    const x = -8 + ((w + 16) * i) / 256;
    const hang = Math.max(0, ...lumps.map(([cx, lh, r]) =>
      (Math.abs(x - cx) < r ? lh * Math.sqrt(1 - ((x - cx) / r) ** 2) : 0)));
    return [x, y0 + ((y1 - y0) * (x + 8)) / (w + 16) + hang + dy];
  });
  return `<path d="M-8 -8${pathOf(edge).replace('M', 'L')}L${w + 8} -8Z" ${paint(hex)}/>`;
}
const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" `
  + `height="${h * PX}" viewBox="0 0 ${w} ${h}">${body}</svg>`;

// Granite boulders: rounded blocks on a flat foot, their crowns lit, or deep in snow.
function boulders(rand, list, body, crown, depth) {
  return list.map(([x, y, r]) => {
    const pts = Array.from({ length: 11 }, (_, k) => {
      const a = Math.PI + (Math.PI * k) / 10; // the upper half, left to right
      const q = r * (0.85 + rand() * 0.3);
      return [x + Math.cos(a) * q * 1.5, y + Math.sin(a) * q];
    });
    const cap = pts.slice(1, 10).map(([px, py]) => [px, py - 0.4]);
    const low = cap.map(([px, py]) => [px, py + r * depth + rand() * r * 0.1]).reverse();
    return `<path d="${pathOf(pts)}Z" ${paint(body)}/>`
      + `<path d="${pathOf([...cap, ...low])}Z" ${paint(crown)}/>`;
  }).join('');
}
const stars = (rand, w, top, bottom, count, hex) => Array.from({ length: count }, () => {
  const x = rand() * w;
  const y = top + (rand() ** 1.6) * (bottom - top); // thinner towards the ridge
  return `<circle cx="${n1(x)}" cy="${n1(y)}" r="${n1(0.12 + rand() ** 3 * 0.55)}" `
    + `${paint(hex, 0.35 + rand() * 0.65)}/>`;
}).join('');
// Choughs: a gull-wing stroke each, drawn as a closed shape.
const birds = (list, hex) => list.map(([x, y, s]) => `<path d="M${x - s} ${y - s * 0.3}`
  + `Q${x - s * 0.4} ${y - s * 0.55} ${x} ${y}`
  + `Q${x + s * 0.4} ${y - s * 0.55} ${x + s} ${y - s * 0.3}`
  + `Q${x + s * 0.4} ${y - s * 0.3} ${x} ${y + s * 0.18}`
  + `Q${x - s * 0.4} ${y - s * 0.3} ${x - s} ${y - s * 0.3}Z" ${paint(hex)}/>`).join('');
// A bolt: a jagged stroke with one fork, over a wider, fainter stroke.
function bolt(rand, x, y0, y1, hex) {
  const pts = [[x, y0]];
  for (let y = y0; y < y1;) {
    y += 3 + rand() * 5;
    pts.push([pts.at(-1)[0] + (rand() - 0.45) * 6, Math.min(y, y1)]);
  }
  const fork = [pts[4]];
  for (let k = 0; k < 5; k++) {
    fork.push([fork.at(-1)[0] + 2 + rand() * 3, fork.at(-1)[1] + 3 + rand() * 3]);
  }
  return [[6, 0.14], [2.8, 0.3], [1.1, 1]].map(([sw, a]) => [pts, fork].map((p) =>
    `<path d="${pathOf(p)}" fill="none" stroke="${hex}" stroke-opacity="${a}" `
    + `stroke-width="${sw * (p === fork ? 0.6 : 1)}" stroke-linejoin="round" `
    + 'stroke-linecap="round"/>').join('')).join('');
}
// Rain in `n` streaks, thinning out over its last `fade` mm to the east, where the shower ends.
const rain = (rand, n, x0, x1, y0, y1, hex, a, fade) => Array.from({ length: n }, () => {
  const x = x0 + rand() * (x1 - x0);
  const y = y0 + rand() * 10;
  const thin = Math.min(1, (x1 - x) / fade) ** 1.5;
  return `<path d="M${n1(x)} ${n1(y)}l${n1(-(y1 - y) * 0.18)} ${n1(y1 - y)}" stroke="${hex}" `
    + `stroke-opacity="${(a * thin * (0.4 + rand() * 0.6)).toFixed(2)}" stroke-width="0.25"/>`;
}).join('');
// A still lake: the water, a bright band under the far shore where the wall is mirrored,
// and a few streaks of wind on the surface.
function lake(rand, y, h, w, water, shine) {
  let out = `<rect x="-2" y="${y}" width="${w + 4}" height="${h}" ${paint(water)}/>`
    + `<rect x="-2" y="${y}" width="${w + 4}" height="${n1(h * 0.28)}" ${paint(shine, 0.55)}/>`;
  for (let k = 0; k < 7; k++) {
    const x = rand() * w;
    out += `<rect x="${n1(x)}" y="${n1(y + h * (0.35 + rand() * 0.55))}" `
      + `width="${n1(8 + rand() * 30)}" height="0.35" ${paint(shine, 0.7)}/>`;
  }
  return out;
}

// A soft light: a radial gradient that fades to nothing, in place of a blur filter.
const glow = (id, cx, cy, rx, ry, hex, a) => `<defs><radialGradient id="${id}">`
  + `<stop offset="0" stop-color="${hex}" stop-opacity="${a}"/>`
  + `<stop offset="1" stop-color="${hex}" stop-opacity="0"/></radialGradient></defs>`
  + `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="url(#${id})"/>`;

// The corners darkened a little, as a lens does.
const vignette = (id, w, h, a) => `<defs><radialGradient id="${id}" cx="0.5" cy="0.45" `
  + 'r="0.75"><stop offset="0.55" stop-color="#0b0d14" stop-opacity="0"/>'
  + `<stop offset="1" stop-color="#0b0d14" stop-opacity="${a}"/></radialGradient></defs>`
  + `<rect width="${w}" height="${h}" fill="url(#${id})"/>`;

// II and VI: one cirque seen from one boulder, at noon and the morning after the first snow.
// The same seed draws the same ridges; only the colours change.
function cirque(k, w = 280, h = 210) {
  const rand = mulberry32(13);
  const crest = [[34, 40, 50], [98, 58, 60], [150, 46, 40], [214, 62, 62], [262, 38, 40]];
  const [sky0, sky1, sky2] = k.sky;
  return svgDoc(w, h, sky('s', w, h, [[0, sky0], [0.45, sky1], [0.62, sky2]])
    + hazy(rand, { w, h, n: 2, top: 104, bottom: 112, far: k.far[0], near: k.far[1], height: 30 })
    + range(rand, { w, h, base: 122, rough: 6, light: -1, peaks: crest,
      lit: k.crest[0], shade: k.crest[1], streak: k.crest[2] })
    + range(rand, { w, h, base: 146, rough: 6, light: -1, peaks: [[20, 30, 60], [252, 34, 60]],
      lit: k.slope[0], shade: k.slope[1], streak: k.slope[2] })
    + lake(rand, 146, 28, w, k.lake[0], k.lake[1])
    + range(rand, { w, h, base: 180, rough: 6, light: -1, peaks: [[-10, 30, 90], [292, 44, 110]],
      lit: k.near[0], shade: k.near[1] })
    + range(rand, { w, h, base: 214, rough: 8, light: -1, peaks: [[70, 26, 110], [236, 30, 90]],
      lit: k.ground[0], shade: k.ground[1] })
    + boulders(rand, [[64, 204, 10], [120, 209, 5], [152, 205, 8], [182, 207, 5]], ...k.rock)
    + vignette('v', w, h, k.vignette));
}
const NOON = { sky: ['#8fabc6', '#cfdbe6', '#edf1f4'], far: ['#d3dce5', '#c2ccd6'],
  crest: ['#b3bdc8', '#8795a6', false], slope: ['#7f8fa0', '#66778b', false], // bare rock
  lake: ['#7f97ad', '#c3d1dd'], near: ['#56687b', '#46566a'], ground: ['#343f4d', '#2a333f'],
  rock: ['#6f6c69', '#bdb7ad', 0.28], vignette: 0.3 };
const SNOW = { sky: ['#aeb8c3', '#dce2e8', '#eef2f5'], far: ['#e3e8ed', '#d6dde4'],
  crest: ['#f8fafc', '#c3cdd8', '#6f7985'], slope: ['#e8edf2', '#b6c2cf', '#7d8692'],
  lake: ['#a3b2c0', '#e3e9ee'], near: ['#f1f4f7', '#ccd6e0'], ground: ['#f5f7f9', '#dfe5eb'],
  rock: ['#4b515a', '#f7f9fb', 0.62], vignette: 0.18 };

const ART = {
  alba(w = 280, h = 210) { // I: first light; eight ranges in the haze, the title in the sky
    const rand = mulberry32(3);
    return svgDoc(w, h, sky('s', w, h, [[0, '#1f2640'], [0.34, '#4e4f6e'], [0.56, '#a97f8a'],
      [0.7, '#e3a07f'], [0.8, '#f4cda4']])
      + glow('g', 206, 128, 70, 34, '#fbe0bb', 0.7)
      + birds([[64, 104, 1.5], [71, 99, 1.1], [77, 106, 1]], '#3b3550')
      + hazy(rand, { w, h, n: 8, top: 132, bottom: 212, far: '#dcae9f', near: '#262739',
        height: 22 })
      + vignette('v', w, h, 0.35));
  },
  mediodia: () => cirque(NOON), // II: flat light over the cirque and its lake
  tormenta(w = 560, h = 210) { // III: the storm over two pages; far right, the sun breaks through
    const rand = mulberry32(21);
    const clouds = ceiling(rand, w, 92, 56, '#6a6878', 8) // the far bank, lit from the gap
      + ceiling(rand, w, 80, 32, '#4a4c5d', 10)
      + ceiling(rand, w, 64, 6, '#33353f', 12)
      + ceiling(rand, w, 40, -30, '#20222b', 14); // the storm, overhead
    return svgDoc(w, h, sky('s', w, h, [[0, '#1f222c'], [0.45, '#474b5e'], [0.68, '#7a7486'],
      [0.8, '#a99a8c']])
      + glow('g', 520, 118, 120, 46, '#f3d6a4', 0.75)
      + clouds
      + rain(rand, 420, 0, 320, 88, 176, '#aeb6c6', 0.55, 120) // the last streaks cross the gutter
      + hazy(rand, { w, h, n: 4, top: 136, bottom: 160, far: '#8a8290', near: '#5b5d6e',
        height: 30, warm: { id: 'sun', hex: '#e8b98c', from: 0.6 } })
      + bolt(rand, 374, 84, 160, palette.white)
      + hazy(rand, { w, h, n: 3, top: 172, bottom: 214, far: '#3e4252', near: '#15171d',
        height: 26 })
      + vignette('v', w, h, 0.4));
  },
  atardecer(w = 250, h = 100) { // IV: the crest lit by the sun behind us, the east sky violet
    const rand = mulberry32(5);
    let strips = '';
    const bands = [[-10, 130, 20, 6], [70, 262, 32, 5], [-10, 96, 42, 4], [160, 252, 12, 3.4]];
    for (const [x0, x1, y, t] of bands) { // strips of cloud, lit from below
      const mid = (x0 + x1) / 2;
      const d = `M${x0} ${y}Q${mid} ${y - t} ${x1} ${y}Q${mid} ${y + t * 0.7} ${x0} ${y}Z`;
      strips += `<path d="${d}" ${paint('#f5b27a')}/><path d="${d}" transform="translate(0 -0.8)" `
        + `${paint('#54445f')}/>`;
    }
    return svgDoc(w, h, sky('s', w, h, [[0, '#262440'], [0.4, '#4b4367'], [0.66, '#8a6889'],
      [0.8, '#c08d99']])
      + strips
      + range(rand, { w, h, base: 72, rough: 5, lit: '#f29a62', shade: palette.accent, light: -1,
        peaks: [[70, 26, 40], [150, 36, 46], [215, 24, 36]] })
      + hazy(rand, { w, h, n: 3, top: 80, bottom: 102, far: '#6d4a64', near: '#1f1827',
        height: 12 })
      + vignette('v', w, h, 0.35));
  },
  noche(w = 280, h = 210) { // V: clear after the storm; two windows lit at the refuge
    const rand = mulberry32(12);
    const refuge = `<path d="M184 170h14v-6l-7-4.4l-7 4.4Z" ${paint('#0b0d15')}/>`
      + `<rect x="187.4" y="165.4" width="2.2" height="2" ${paint('#f2b45c')}/>`
      + `<rect x="192.2" y="165.4" width="2.2" height="2" ${paint('#f2b45c', 0.75)}/>`
      + glow('l', 190.6, 166.4, 9, 6, '#f2b45c', 0.35);
    return svgDoc(w, h, sky('s', w, h, [[0, '#070912'], [0.5, '#141a31'], [0.8, '#263050']])
      + glow('m', 150, 60, 170, 40, '#7d8bb8', 0.12)
      + stars(rand, w, 0, 150, 520, palette.white)
      + range(rand, { w, h, base: 142, rough: 6, lit: '#28304c', shade: '#1c2238', light: -1,
        peaks: [[60, 40, 60], [150, 56, 56], [236, 44, 60]] })
      + hazy(rand, { w, h, n: 2, top: 158, bottom: 168, far: '#20263b', near: palette.ink,
        height: 18 })
      + refuge
      + hazy(rand, { w, h, n: 2, top: 188, bottom: 214, far: '#0e111c', near: '#07080d',
        height: 18 })
      + vignette('v', w, h, 0.4));
  },
  nieve: () => cirque(SNOW), // VI: the same view the morning after; ink type goes on it
};
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  'Andada Pro': ['400'], Syne: ['700', '800'], 'Syne Mono': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all(Object.entries(ART).map(([id, draw]) => loadSvg(`${id}.svg`, draw())));
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Sierra: a photo essay in landscape',
  es: 'Sierra: un ensayo fotográfico apaisado' }) });

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

### Float plates opposite their entries

For plates that float to the recto facing a catalogue entry, with the figure’s width set from its proportions, see [Catalogue entries facing their plates](https://postext.dev/en/cookbook/catalogue-facing-plates.md).

### Open an article on a bled photograph

For a photograph bled across the head of an article, with the headline and standfirst over it, see [the magazine feature](https://postext.dev/en/cookbook/magazine-feature-opener.md).

## Pitfalls

- **An opener's images never count towards the height it reserves.** In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin.
- **An opener taller than its column loses its whole reservation.** In postext 1.4.1, when the height an advanced-design heading reserves (its minHeight, or its lowest design element) is taller than the column it opens, the heading keeps only the height of its own title line, with no warning, and the text runs over the design. A full-page plate needs a column as tall as the page: give its heading style zero margins, then set minHeight to the page's height.
- **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.
- **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.
- **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.
- **No <marker> or filters in SVG art (raster fallback).** An SVG figure stays vector in the PDF only without <marker>, filters and masks; otherwise it falls back to a raster, and deeply nested filters can blank it in Chrome. Draw arrowheads as paths.
- **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.
- **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.
- **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.

- Never cite a plate that a design draws. Cited, it also floats as a figure: a `:ref` to the noon plate in the first text puts a second copy of it on page 3, and the storm moves two pages on, after a blank page.
- The dusk plate sits one line under its text, not at the foot of the page. On the last page before a plate, postext 1.4.1 lifts a float that lies below the text up to it, and `position: 'bottom'` changes nothing there.
- Each text is fitted to one page in both editions. When the first runs on to page 3, the noon plate moves to page 4 and the storm to pages 6 and 7, after a blank page 5.
- The storm’s right half is a second `# Storm` heading. The page prints no title for it, but a `:::toc` built from the headings lists the storm twice, on pages 4 and 5.
- The plates stop at the trim, which is the edge of the canvas. With `page.cutLines` on, the canvas grows around the trim, and the bleed around each plate shows the paper colour. For print, anchor the plates to `'bleed'` and make each one larger by the bleed on every side.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The three texts, the captions and the colophon, in Spanish and English, and the six plates, drawn in code: Postext Cookbook, CC-BY-4.0
- Type: Andada Pro (OFL-1.1), Syne (OFL-1.1), Syne Mono (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [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º 034 · Catalogue entries facing their plates](https://postext.dev/en/cookbook/catalogue-facing-plates.md): Each entry opens a verso and cites its plate in the commentary’s first sentence; the plate floats to the facing recto, 221 mm tall at the scan’s proportions. · Level 3 (Advanced) · Catalogues
- [Nº 004 · Magazine feature: photo opener to end mark](https://postext.dev/en/cookbook/magazine-feature-opener.md): One advancedDesign opener sets a bleed photo and the heading's kicker, headline, standfirst and byline; two floated boxes and a chip end mark follow. · Level 3 (Advanced) · Magazines & zines
