Skip to main content
Recipe number 23

Cookbook · Chapter 9 · Complete publications

Magazine cover and sectioned contents

A photo cover whose cover lines hang below the masthead, and contents generated with a coloured row per section. Each section is a part that opens no page.

  • Trim 230 × 300 mm
  • 2 columns, 6 mm gutter
  • Spectral 9.5/13.5
  • Bodoni Moda
  • Jost
  • 6 pages
  • Level
  • Postext 1.4.1
  • Laid out in 268 ms
  • 250 lines of code

What you'll build

Six pages from an issue of FALLOW, an imaginary quarterly of food and land, on a 230 × 300 mm page. A Tuscan valley bleeds off the cover under a 118 pt Bodoni masthead, and three cover lines sit on a paper panel, each kicker in its section's colour, with the price and barcode in the corner. Page 2 holds the generated contents: a coloured tab per section, with the story's title, page number and standfirst under it. The right-hand column carries the editor's letter, the contributors and the colophon. The sections that follow are terracotta, olive and wine. Every story opens with a picture bled across the head of the page and a panel in the section's colour rising into it, and its folio tabs, bullets and boxes take that colour. The colour is set by the :::part fence above each story, which opens no divider page.

This recipe answers

  • How do I make a cover, a half title, a title page and a colophon?
  • How do I add a table of contents that updates itself (leaders, page numbers, authors, part rows)?
  • How do I divide a book into parts or sections, each with its own colour and divider page?
  • How do I give every chapter its own photo, colour or opener variant?

The short answer

script.js · lines 39–74in full code
const MASTHEAD = TRIM.width - MARGIN.inner - MARGIN.outer; // the word spans the text block
const PANEL = 84; // mm: the cover lines' panel, a column of paper over the photograph
const SEAM = 0.3; // mm: each box overlaps the one above, so no hairline of photo shows
// A cover line: a kicker in its section's colour over a line in Bodoni, on paper boxes of one
// width. Chained with 'below' they read as one panel, and a longer line pushes the rest down.
const sheet = (top, bottom) => ({ backgroundColor: col('paper'),
  padding: { top: mm(top), right: mm(5), bottom: mm(bottom), left: mm(5) } });
const small = { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('ink') };
const coverLine = (n, colour, above = n === 1 ? '#masthead' : `#line${n - 1}`) => [
  text(`kicker${n}`, `{attr.kicker${n}}`, { ...small, color: col(colour),
    box: sheet(n === 1 ? 5 : 4, 1.5) },
  { ...at(above, 'below', 0, n === 1 ? 8 : -SEAM), size: { width: mm(PANEL) } }),
  text(`line${n}`, `{attr.line${n}}`, { ...bodoni, italic: true, fontSize: pt(15),
    lineHeight: 1.12, color: col('ink'), box: sheet(0, n === 3 ? 5 : 0) }, // line 3 ends the panel
  { ...at(`#kicker${n}`, 'below', 0, -SEAM), size: { width: mm(PANEL) } }),
];
const cover = { enabled: true, slot: { elements: [
  { kind: 'image', id: 'photo', resourceId: 'cover', // the JPEG is cropped to the trim, 23 : 30
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
  text('strap', '{subtitle}', small, at('page', 'top-left', MARGIN.inner, 12)), // a recto
  text('issue', '{attr.issue}', { ...small, align: 'right' },
    at('page', 'top-right', -MARGIN.outer, 12)),
  text('masthead', '{titleText}', { ...bodoni, fontWeight: 900, fontSize: pt(118),
    lineHeight: 0.86, // a multiple of the size (gotcha: design-lineheight-multiple)
    letterSpacing: pt(1), textTransform: 'uppercase', align: 'center', color: col('ink') },
  { ...at('#strap', 'below', 0, 6), size: { width: mm(MASTHEAD) } }),
  ...coverLine(1, 'olive'), ...coverLine(2, 'terracotta'), ...coverLine(3, 'wine'),
  // The price and a made-up barcode sit in the corner, anchored to the page, not the words.
  text('price', '{attr.price}', { ...small, fontSize: pt(7), box: sheet(1, 1.5) },
    { ...at('page', 'bottom-right', -MARGIN.outer, -12), size: { width: mm(32) } }),
  { kind: 'image', id: 'barcode', resourceId: 'barcode',
    placement: { ...at('#price', 'above', 0, SEAM), size: { width: mm(32) } } },
] } };
// hook-up: headingStyles gets { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
// footer: { elements: [] } }, with no folio; the Markdown's first line is
// # Fallow {style="cover" issue="…" price="…" kicker1="…" line1="…" … kicker3="…" line3="…"}

The cover, one design slot whose words hang from each other

Ingredients

Type
Spectral, Bodoni Moda, Jost (SIL OFL 1.1)
Assets
  • castellina-cover-1150.jpg
  • castellina-farm-1530.jpg
  • flasks-1920.jpg
  • harvest-1840.jpg
  • Castellina in Chianti (the cover and its copy in the contents) (Rowan Heuvel, CC0 1.0)
  • Castellina in Chianti, a detail (the feature's photograph) (Rowan Heuvel, CC0 1.0)
  • Plants in beakers (the Field notes opener) (chuttersnap, CC0 1.0)
  • Harvesting the wheat crop (the Kitchen opener) (meriç tuna, CC0 1.0)

Method

#1 · Hang the cover from its masthead

The code is the short answer above. Only the photograph, the two lines at the head and the price are anchored to the page. Everything else is anchored to another element (element placement): the masthead below the strap, the first kicker 8 mm below the masthead, then each line below its kicker and each kicker below the line before, so a longer cover line or a bigger masthead pushes down what sits under it instead of printing over it. Each kicker and each line carries its own paper box, 84 mm wide, and the six boxes stacked together make the panel, which grows with the words. Every box after the first overlaps the one above by 0.3 mm (SEAM), and the barcode overlaps the price the same way, because on the canvas two fills that only touch leave a hairline of the photograph between them. A magazine has no half title and no title page; the cover carries its name, and the contents page carries the issue number, the season and the colophon. For a book's front matter, see Front matter in roman folios, then page 1.

#2 · Make each section a part without a page

script.js · lines 78–87in full code
const parts = { page: false }; // a :::part now only sets the section's title and palette
const story = (id, picture) => ({ id, numbered: false, advancedDesign: opener(picture) });
const headingStyles = () => [
  { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
    footer: { elements: [] } }, // no folio on the cover (the header is empty everywhere)
  { id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener,
    footer: folioLine('{title}', '{publishDate}') }, // no section yet: the issue instead
  // One opener for every story; the style names its picture, the part its colour.
  story('grain', 'flasks'), story('rest', 'fields'), story('bread', 'harvest'),
];

With parts.page: false, a fence such as :::part{title="Features" palette="band=#5d6a2b"} opens no divider page and only sets the section's title and palette from the next block on (the :::part container). Each story's H1 takes a heading style with numbered: false, so the contents list it without a number. The style picks the opener's picture, and the part supplies the colour.

#3 · Link every section colour to one entry

script.js · lines 14–27in full code
const palette = {
  ink: '#1c1a16', // text: a warm near-black
  paper: '#fbf8f1', // the page, and type set on colour
  band: '#8b6a3e', // the house earth; each :::part fence brings its own 'band'
  muted: '#6b6358', // credits and the standfirsts in the contents
  olive: '#5d6a2b', terracotta: '#a3472a', wine: '#7a2c3a', // as in the :::part fences: edit both
};
// A part overrides by id; designs paint the hex 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' } })),
  // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];

A part overrides palette entries by id, so everything that changes with the section links to band: the opener's panel, the folio tab, the bullets and list numbers, the pull quote, and the fact box's stripe and title. In the text the override goes by value: any colour whose hex equals band's base hex (#8b6a3e) takes the section's, which is why no other entry uses that hex. Design elements work the other way round. Postext 1.4.1 paints the hex written beside their paletteId and ignores colorPalette, but a part's override reaches them through the paletteId, so col() writes both.

#4 · Let the contents group themselves

script.js · lines 91–124in full code
const mid = at('container', 'left', 0, -1); // the row's middle, 1 mm up: air above the title
const contents = { // passed to the config as `toc`
  levels: [{ level: 1, fontFamily: 'Bodoni Moda', fontSize: pt(17), fontWeight: 600,
    marginBottom: pt(2 * LEAD) }], // in ink, on the body's 13.5 pt leading
  pageNumber: { fontSize: pt(26), width: mm(14) }, // in the levels' Bodoni
  leader: { enabled: false }, subtitle: { enabled: true, attr: 'standfirst', // in italic
    fontFamily: 'Spectral', fontSize: pt(10), color: col('muted') },
  // A part row is a design as wide as the column; its 'band' takes the part's palette. No
  // {pageNumber}: a part with no divider page has no page (gotcha: toc-part-rows-no-label).
  parts: { height: pt(2 * LEAD), design: { elements: [ // two grid lines (1.4.1's default: 2 em)
    { kind: 'rule', id: 'line', thickness: pt(0.75), color: col('band'), // behind the tab
      placement: { ...mid, size: { width: 'fill' } } },
    text('tab', '{titleText}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
      color: col('paper'), box: { backgroundColor: col('band'),
        padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } } },
    mid),
  ] } },
};
// The colophon closes the contents page; the Markdown calls it with :::paragraphs.
const colophon = { id: 'colophon', fontFamily: 'Jost', fontSize: pt(7), lineHeight: pt(10),
  color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) };
const air = { padding: { bottom: mm(9) } }; // empty padding counts: the columns start lower
const contentsOpener = { enabled: true, slot: { elements: [
  { kind: 'rule', id: 'rule', thickness: pt(1), color: col('ink'),
    placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
  text('kicker', '{titleText}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8),
    color: col('band') }, at('#rule', 'below', 0, 5)),
  text('issue', '{attr.issue}', { ...bodoni, italic: true, fontSize: pt(44), lineHeight: 1.05,
    color: col('ink') }, at('#kicker', 'below', 0, 2)),
  text('strap', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11),
    color: col('muted'), align: 'left' }, at('#issue', 'below', 0, 1.5)),
  text('number', '{attr.number}', { ...bodoni, fontWeight: 900, fontSize: pt(160), lineHeight: 1,
    align: 'right', color: col('band'), box: air }, at('container', 'top-right', 0, 4)),
] } };

:::toc lists the stories and gives each :::part a row laid out from toc.parts.design with that part's palette (table of contents), so one row design prints three tabs in three colours. The rule and the tab both hang from mid, the row's middle raised 1 mm, which keeps the tab clear of the title below. With the leader off, nothing runs between a title and its page number (3, 4 and 6), and subtitle.attr prints the opener's standfirst attribute under the title in italic. The colophon that ends the right-hand column is a paragraph style, Jost 7 pt in muted, which the Markdown applies with :::paragraphs{style="colophon"}.

#5 · Give every story the same opener

script.js · lines 128–152in full code
const PHOTO = 150; // mm from the top edge to the foot of the picture
const FOOT = PHOTO - MARGIN.top; // the same foot from the container, which starts at the margin
const LIFT = 34; // mm: how far the panel rises into the picture
const WIDE = 136; // mm: the panel's width
const panel = (top, bottom) => ({ backgroundColor: col('band'),
  padding: { top: mm(top), right: mm(6), bottom: mm(bottom), left: mm(6) } });
const below = (id) => ({ ...at(`#${id}`, 'below', 0, -SEAM), size: { width: mm(WIDE) } });
const opener = (resourceId) => ({ enabled: true, slot: { elements: [
  { kind: 'image', id: 'picture', resourceId, // fitted, never cropped: each file is 230 : 150
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } },
  text('credit', '{attr.credit}', { ...small, fontSize: pt(6.5), color: col('muted'),
    letterSpacing: pt(0.6), align: 'right' }, at('container', 'top-right', 0, FOOT + 2)),
  text('section', '{partTitle}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7),
    color: col('paper'), box: panel(6, 2) }, { ...at('container', 'top-left', 0, FOOT - LIFT),
    size: { width: mm(WIDE) } }),
  text('title', '{titleText}', { ...bodoni, fontWeight: 700, fontSize: pt(32), lineHeight: 1.02,
    color: col('paper'), box: panel(0, 3) }, below('section')),
  text('standfirst', '{attr.standfirst}', { fontFamily: 'Spectral', italic: true,
    fontSize: pt(11.5), lineHeight: 1.35, overflow: 'wrap', align: 'left',
    color: col('paper'), box: panel(0, 6) }, below('title')),
  // The picture reserves nothing (gotcha: opener-image-no-reserve); the panel and the byline
  // reach below it, and the byline's empty padding keeps the story 5 mm under it.
  text('byline', '{attr.byline}', { ...small, fontSize: pt(7.5), letterSpacing: pt(1.3),
    box: { padding: { top: mm(4), bottom: mm(5) } } }, at('#standfirst', 'below')),
] } });

The picture bleeds off the top of the page and reaches 150 mm down it. A panel in band rises 34 mm into its foot. The panel is three text boxes 136 mm wide, each chained below the one before, holding the section's name from {partTitle}, the title and the standfirst. The picture reserves no height, so the story starts under the lowest element that does, the byline, whose empty 5 mm of bottom padding keeps the first line at least that far below it.

#6 · Name the section in the folio

script.js · lines 156–171in full code
const TAB = 6.5; // mm: the tab's side; the words beside it take its height and centre on it
const folioLine = (first, second) => ({ elements: [['even', 'left', 1], ['odd', 'right', -1]]
  .flatMap(([parity, side, s]) => {
    const inward = s > 0 ? 'right-of' : 'left-of'; // from the outer edge towards the gutter
    const beside = (id, content, style, to, gap) => text(id, content, { ...style, align: side },
      { ...at(to, inward, s * gap), size: { height: mm(TAB) } }); // text centres vertically
    return [
      text(`tab-${parity}`, '{pageNumber}', { ...label, fontSize: pt(8), color: col('paper'),
        align: 'center', box: { backgroundColor: col('band') } },
      { ...at('page', `bottom-${side}`, s * MARGIN.outer, -11),
        size: { width: mm(TAB), height: mm(TAB) } }),
      beside(`first-${parity}`, first, { ...small, color: col('band') }, `#tab-${parity}`, 3),
      beside(`second-${parity}`, second, { fontFamily: 'Spectral', italic: true,
        fontSize: pt(8), color: col('muted') }, `#first-${parity}`, 2.5),
    ].map((element) => ({ ...element, parity })); // no pages filter: openers get folios too
  }) });

The page number sits in a 6.5 mm square of band at the outer foot. The section's name is anchored beside it, towards the gutter, and the story's title beside the name; all three boxes are 6.5 mm tall with their text centred vertically, so they share one centre line. Magazines print the folio on an opening page too, so the footer has no pages filter. The cover's heading style empties the footer, and the contents' style puts the magazine's name and the issue's season ({title} and {publishDate}) where the section and the story go.

The whole recipe

// ═══ Postext Cookbook · Nº 023 · Magazine cover and sectioned contents ═══════════════
// https://postext.dev/en/cookbook/magazine-cover-and-contents
// Code: MIT · Text: original (CC BY 4.0) · Photos: R. Heuvel, chuttersnap, m. tuna (CC0)
// Fonts: Spectral, Bodoni Moda, Jost (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 ('en' | 'es')
const RECIPE = 'magazine-cover-and-contents';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one house colour, 'band', that every section overrides
const palette = {
  ink: '#1c1a16', // text: a warm near-black
  paper: '#fbf8f1', // the page, and type set on colour
  band: '#8b6a3e', // the house earth; each :::part fence brings its own 'band'
  muted: '#6b6358', // credits and the standfirsts in the contents
  olive: '#5d6a2b', terracotta: '#a3472a', wine: '#7a2c3a', // as in the :::part fences: edit both
};
// A part overrides by id; designs paint the hex 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' } })),
  // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 230, height: 300 }; // an independent-magazine trim, in mm
const MARGIN = { top: 22, bottom: 20, inner: 16, outer: 14 }; // mirrored
const LEAD = 13.5; // body leading in pt: the baseline grid
const caps = { fontFamily: 'Jost', fontWeight: 600, textTransform: 'uppercase' }; // the label face
const label = { ...caps, align: 'left' }; // design text is centred by default
const bodoni = { fontFamily: 'Bodoni Moda', overflow: 'wrap', align: 'left' };
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, placement, ...look });

// #region answer: the cover, one design slot whose words hang from each other
const MASTHEAD = TRIM.width - MARGIN.inner - MARGIN.outer; // the word spans the text block
const PANEL = 84; // mm: the cover lines' panel, a column of paper over the photograph
const SEAM = 0.3; // mm: each box overlaps the one above, so no hairline of photo shows
// A cover line: a kicker in its section's colour over a line in Bodoni, on paper boxes of one
// width. Chained with 'below' they read as one panel, and a longer line pushes the rest down.
const sheet = (top, bottom) => ({ backgroundColor: col('paper'),
  padding: { top: mm(top), right: mm(5), bottom: mm(bottom), left: mm(5) } });
const small = { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('ink') };
const coverLine = (n, colour, above = n === 1 ? '#masthead' : `#line${n - 1}`) => [
  text(`kicker${n}`, `{attr.kicker${n}}`, { ...small, color: col(colour),
    box: sheet(n === 1 ? 5 : 4, 1.5) },
  { ...at(above, 'below', 0, n === 1 ? 8 : -SEAM), size: { width: mm(PANEL) } }),
  text(`line${n}`, `{attr.line${n}}`, { ...bodoni, italic: true, fontSize: pt(15),
    lineHeight: 1.12, color: col('ink'), box: sheet(0, n === 3 ? 5 : 0) }, // line 3 ends the panel
  { ...at(`#kicker${n}`, 'below', 0, -SEAM), size: { width: mm(PANEL) } }),
];
const cover = { enabled: true, slot: { elements: [
  { kind: 'image', id: 'photo', resourceId: 'cover', // the JPEG is cropped to the trim, 23 : 30
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
  text('strap', '{subtitle}', small, at('page', 'top-left', MARGIN.inner, 12)), // a recto
  text('issue', '{attr.issue}', { ...small, align: 'right' },
    at('page', 'top-right', -MARGIN.outer, 12)),
  text('masthead', '{titleText}', { ...bodoni, fontWeight: 900, fontSize: pt(118),
    lineHeight: 0.86, // a multiple of the size (gotcha: design-lineheight-multiple)
    letterSpacing: pt(1), textTransform: 'uppercase', align: 'center', color: col('ink') },
  { ...at('#strap', 'below', 0, 6), size: { width: mm(MASTHEAD) } }),
  ...coverLine(1, 'olive'), ...coverLine(2, 'terracotta'), ...coverLine(3, 'wine'),
  // The price and a made-up barcode sit in the corner, anchored to the page, not the words.
  text('price', '{attr.price}', { ...small, fontSize: pt(7), box: sheet(1, 1.5) },
    { ...at('page', 'bottom-right', -MARGIN.outer, -12), size: { width: mm(32) } }),
  { kind: 'image', id: 'barcode', resourceId: 'barcode',
    placement: { ...at('#price', 'above', 0, SEAM), size: { width: mm(32) } } },
] } };
// hook-up: headingStyles gets { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
// footer: { elements: [] } }, with no folio; the Markdown's first line is
// # Fallow {style="cover" issue="…" price="…" kicker1="…" line1="…" … kicker3="…" line3="…"}
// #endregion

// #region sections: parts with no divider page, and a heading style for each kind of page
const parts = { page: false }; // a :::part now only sets the section's title and palette
const story = (id, picture) => ({ id, numbered: false, advancedDesign: opener(picture) });
const headingStyles = () => [
  { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
    footer: { elements: [] } }, // no folio on the cover (the header is empty everywhere)
  { id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener,
    footer: folioLine('{title}', '{publishDate}') }, // no section yet: the issue instead
  // One opener for every story; the style names its picture, the part its colour.
  story('grain', 'flasks'), story('rest', 'fields'), story('bread', 'harvest'),
];
// #endregion

// #region contents: generated from the headings, with a row in each section's colour
const mid = at('container', 'left', 0, -1); // the row's middle, 1 mm up: air above the title
const contents = { // passed to the config as `toc`
  levels: [{ level: 1, fontFamily: 'Bodoni Moda', fontSize: pt(17), fontWeight: 600,
    marginBottom: pt(2 * LEAD) }], // in ink, on the body's 13.5 pt leading
  pageNumber: { fontSize: pt(26), width: mm(14) }, // in the levels' Bodoni
  leader: { enabled: false }, subtitle: { enabled: true, attr: 'standfirst', // in italic
    fontFamily: 'Spectral', fontSize: pt(10), color: col('muted') },
  // A part row is a design as wide as the column; its 'band' takes the part's palette. No
  // {pageNumber}: a part with no divider page has no page (gotcha: toc-part-rows-no-label).
  parts: { height: pt(2 * LEAD), design: { elements: [ // two grid lines (1.4.1's default: 2 em)
    { kind: 'rule', id: 'line', thickness: pt(0.75), color: col('band'), // behind the tab
      placement: { ...mid, size: { width: 'fill' } } },
    text('tab', '{titleText}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
      color: col('paper'), box: { backgroundColor: col('band'),
        padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } } },
    mid),
  ] } },
};
// The colophon closes the contents page; the Markdown calls it with :::paragraphs.
const colophon = { id: 'colophon', fontFamily: 'Jost', fontSize: pt(7), lineHeight: pt(10),
  color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) };
const air = { padding: { bottom: mm(9) } }; // empty padding counts: the columns start lower
const contentsOpener = { enabled: true, slot: { elements: [
  { kind: 'rule', id: 'rule', thickness: pt(1), color: col('ink'),
    placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
  text('kicker', '{titleText}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8),
    color: col('band') }, at('#rule', 'below', 0, 5)),
  text('issue', '{attr.issue}', { ...bodoni, italic: true, fontSize: pt(44), lineHeight: 1.05,
    color: col('ink') }, at('#kicker', 'below', 0, 2)),
  text('strap', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11),
    color: col('muted'), align: 'left' }, at('#issue', 'below', 0, 1.5)),
  text('number', '{attr.number}', { ...bodoni, fontWeight: 900, fontSize: pt(160), lineHeight: 1,
    align: 'right', color: col('band'), box: air }, at('container', 'top-right', 0, 4)),
] } };
// #endregion

// #region opener: one story opener; its colour comes from the part, not from the design
const PHOTO = 150; // mm from the top edge to the foot of the picture
const FOOT = PHOTO - MARGIN.top; // the same foot from the container, which starts at the margin
const LIFT = 34; // mm: how far the panel rises into the picture
const WIDE = 136; // mm: the panel's width
const panel = (top, bottom) => ({ backgroundColor: col('band'),
  padding: { top: mm(top), right: mm(6), bottom: mm(bottom), left: mm(6) } });
const below = (id) => ({ ...at(`#${id}`, 'below', 0, -SEAM), size: { width: mm(WIDE) } });
const opener = (resourceId) => ({ enabled: true, slot: { elements: [
  { kind: 'image', id: 'picture', resourceId, // fitted, never cropped: each file is 230 : 150
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } },
  text('credit', '{attr.credit}', { ...small, fontSize: pt(6.5), color: col('muted'),
    letterSpacing: pt(0.6), align: 'right' }, at('container', 'top-right', 0, FOOT + 2)),
  text('section', '{partTitle}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7),
    color: col('paper'), box: panel(6, 2) }, { ...at('container', 'top-left', 0, FOOT - LIFT),
    size: { width: mm(WIDE) } }),
  text('title', '{titleText}', { ...bodoni, fontWeight: 700, fontSize: pt(32), lineHeight: 1.02,
    color: col('paper'), box: panel(0, 3) }, below('section')),
  text('standfirst', '{attr.standfirst}', { fontFamily: 'Spectral', italic: true,
    fontSize: pt(11.5), lineHeight: 1.35, overflow: 'wrap', align: 'left',
    color: col('paper'), box: panel(0, 6) }, below('title')),
  // The picture reserves nothing (gotcha: opener-image-no-reserve); the panel and the byline
  // reach below it, and the byline's empty padding keeps the story 5 mm under it.
  text('byline', '{attr.byline}', { ...small, fontSize: pt(7.5), letterSpacing: pt(1.3),
    box: { padding: { top: mm(4), bottom: mm(5) } } }, at('#standfirst', 'below')),
] } });
// #endregion

// #region folios: a folio tab in the section's colour, then the section and the story
const TAB = 6.5; // mm: the tab's side; the words beside it take its height and centre on it
const folioLine = (first, second) => ({ elements: [['even', 'left', 1], ['odd', 'right', -1]]
  .flatMap(([parity, side, s]) => {
    const inward = s > 0 ? 'right-of' : 'left-of'; // from the outer edge towards the gutter
    const beside = (id, content, style, to, gap) => text(id, content, { ...style, align: side },
      { ...at(to, inward, s * gap), size: { height: mm(TAB) } }); // text centres vertically
    return [
      text(`tab-${parity}`, '{pageNumber}', { ...label, fontSize: pt(8), color: col('paper'),
        align: 'center', box: { backgroundColor: col('band') } },
      { ...at('page', `bottom-${side}`, s * MARGIN.outer, -11),
        size: { width: mm(TAB), height: mm(TAB) } }),
      beside(`first-${parity}`, first, { ...small, color: col('band') }, `#tab-${parity}`, 3),
      beside(`second-${parity}`, second, { fontFamily: 'Spectral', italic: true,
        fontSize: pt(8), color: col('muted') }, `#first-${parity}`, 2.5),
    ].map((element) => ({ ...element, parity })); // no pages filter: openers get folios too
  }) });
// #endregion

const flush = { marginTop: pt(0), marginBottom: pt(0) }; // lists sit on the grid
const marker = { fontFamily: 'Jost', fontWeight: 600, color: col('band'), ...flush }; // list marks
const boxText = { textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) };
const boxStyle = { backgroundEnabled: false, ...flush, // one device: a stripe in 'band'
  stripe: { enabled: true, side: 'top', width: pt(2), color: col('band') },
  padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) } };
const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette, resourceTypes: [photoType],
  page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner
  layout: { gutterWidth: mm(6) }, // two columns, the default
  bodyText: { fontFamily: 'Spectral', fontSize: pt(9.5), lineHeight: pt(LEAD), // justified
    color: col('ink'), boldColor: col('band'), italicColor: col('ink'),
    referenceColor: col('ink'), firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.7, // tighter than the 0.6–2 defaults
    runtMinCharacters: 40, // counted in word spaces: last lines under about 20 letters cost
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  headings: { fontFamily: 'Bodoni Moda', marginBottom: pt(0), levels: [
    // Restated (gotcha: headings-drop-h1-break); 'any': a story opens on the very next page.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), fontWeight: 400, italic: true,
      color: col('band'), marginTop: pt(LEAD) },
  ] },
  headingStyles: headingStyles(), parts, toc: contents,
  unorderedLists: { ...marker, bulletChar: '–' }, orderedLists: marker, // in the label face
  calloutStyles: [{ id: 'quote', ...boxStyle, marginTop: pt(LEAD), // the floated box has none
    body: { fontFamily: 'Bodoni Moda', fontSize: pt(16), lineHeight: pt(1.5 * LEAD),
      italicColor: col('band'), ...boxText } },
  { id: 'facts', ...boxStyle, placement: 'top', // floats to the next column head
    titleStyle: { ...caps, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('band') },
    body: { fontFamily: 'Jost', fontSize: pt(8.5), lineHeight: pt(12), ...boxText } }],
  captionStyle: { fontFamily: 'Jost', fontSize: pt(7.5), gap: mm(2),
    note: { fontSize: pt(6.5), color: col('muted') } },
  paragraphStyles: [{ id: 'bios', firstLineIndent: pt(0), spaceBetween: pt(LEAD / 2) }, colophon],
  header: { elements: [] }, footer: folioLine('{partTitle}', '{chapterTitle}'), // at the foot
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const photoType = { id: 'photo', // no figure number: an empty caption prefix and template
  name: t({ en: 'Photograph', es: 'Fotografía' }), shortLabel: t({ en: 'photo', es: 'foto' }),
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' };
const picture = (id, fileId, [width, height], alt, extra = {}) => { // alt: { en, es }
  const kind = fileId.endsWith('.svg') ? 'svg' : 'bitmap';
  return { id, kind, typeId: 'photo', createdAt: 0, updatedAt: 0, altText: t(alt), ...extra,
    [kind]: { fileId, width, height, ...(kind === 'bitmap' && { format: 'jpeg' }) } };
};
const resources = [ // alt texts and captions in both languages; t() picks the edition's
  picture('cover', 'castellina-cover-1150.jpg', [1150, 1500], { en: 'Vines, cypresses and a '
    + 'farmhouse below hazy hills.', es: 'Viñas, cipreses y una casa bajo colinas brumosas.' }),
  picture('thumb', 'castellina-cover-1150.jpg', [1150, 1500], { en: 'The cover of this issue.',
    es: 'La portada de este número.' }, { placement: { position: 'here', width: 0.45 },
    caption: t({ en: 'On the cover: the hills below Castellina in Chianti, where our feature '
      + 'is set.', es: 'En la portada: las colinas de Castellina in Chianti, escenario del '
      + 'reportaje.' }),
    note: t({ en: 'Photograph: Rowan Heuvel, CC0', es: 'Fotografía: Rowan Heuvel, CC0' }) }),
  picture('flasks', 'flasks-1920.jpg', [1920, 1252], { en: 'Glass flasks holding green shoots '
    + 'on a shelf.', es: 'Matraces de vidrio con brotes verdes en una balda.' }),
  picture('fields', 'fields.svg', [TRIM.width * 10, PHOTO * 10], { en: 'Fields from above, one '
    + 'unsown and in flower.', es: 'Campos vistos desde arriba; uno, sin sembrar y en flor.' }),
  picture('harvest', 'harvest-1840.jpg', [1840, 1200], { en: 'Ripe wheat, a combine harvester '
    + 'blurred behind it.', es: 'Trigo maduro y, desenfocada detrás, una cosechadora.' }),
  picture('barcode', 'barcode.svg', [320, 140], { en: 'A barcode.', es: 'Un código de barras.' }),
  // 1530 px at 150 dpi would print 259 mm: it shrinks to the measure (gotcha: bitmap-print-size).
  // A top float opens the page after its ::resource line (gotcha: top-float-next-page).
  picture('vines', 'castellina-farm-1530.jpg', [1530, 900], { en: 'Rows of vines, cypresses and '
    + 'a farmhouse on a Tuscan slope.', es: 'Hileras de viñas, cipreses y una casa de campo en '
    + 'una ladera toscana.' }, { placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Vines and cypresses by a farmhouse near Castellina in Chianti, in summer.',
      es: 'Viñas y cipreses junto a una casa de campo de Castellina in Chianti, en verano.' }),
    note: t({ en: 'Photograph: Rowan Heuvel, CC0, via Wikimedia Commons',
      es: 'Fotografía: Rowan Heuvel, CC0, vía Wikimedia Commons' }) }),
];

const markdown = String.raw`---
Markdown sample · 132 lines · content.en.mdtitle: "Fallow" subtitle: "A quarterly of food and land" publishDate: "Autumn 2026" --- # Fallow {style="cover" issue="No. 14 · Autumn 2026" price="£12 · No. 14" kicker1="Features" line1="The year of rest: on a Chianti farm, every field gets a year off" kicker2="Field notes" line2="The grain archive: two hundred old wheats, kept alive in a village school" kicker3="Kitchen" line3="Threshing-day bread, and a loaf for the last sheaf"} # Contents {style="contents" number="14" issue="Autumn 2026"} :::toc ::resource{id="thumb"} :::columnbreak ## From the editor A field left unsown for a year has an image problem. To a passing driver it looks abandoned; to an accountant it is a line with nothing in it. The farmers in this issue use another word for it. They say the field is resting, the word a baker uses for a dough left under a cloth to rise. Our feature follows one farm above Castellina in Chianti through its seven-year rotation, including the year in which a field grows only weeds and the farmer gets a letter from the council about the thistles. In Field notes, a seed library in a former village school keeps two hundred old wheats alive by sowing a few of them every autumn. And in the Kitchen, the loaf baked on the day the last sheaf comes in, with a little of that old grain in the dough. We called this magazine *Fallow* fourteen issues ago, half as a joke. A quarterly is slow to make, and there were seasons when the name read more like a warning. We kept it because the pieces we are proudest of took a year or more, and for most of that year nothing seemed to happen. *Clara Ashdown, editor* ## Contributors :::paragraphs{style="bios"} **Tomás Arrieta** writes about seeds and the people who save them. He lives in Poggibonsi, where he keeps bees, badly. **Lucia Fenn** grew up on a dairy farm in Somerset and has reported on Italian farming for twelve years. **Nell Harrow** bakes and teaches in Siena. Her book on country breads comes out next spring. ::: :::paragraphs{style="colophon"} FALLOW is an imaginary quarterly of food and land, made for the Postext Cookbook. Set in Spectral, Bodoni Moda and Jost (SIL Open Font License). Text: CC BY 4.0. Photographs: Rowan Heuvel, chuttersnap and meriç tuna, CC0, via Wikimedia Commons; the fields and the barcode are drawn in code. ::: :::part{title="Field notes" palette="band=#a3472a"} ::: # The Grain Archive {style="grain" standfirst="In a former village school in the Val d’Elsa, a seed library keeps two hundred old wheats alive by sowing a few of them every autumn and putting the new grain back in the jars." byline="Words by Tomás Arrieta" credit="Photograph: chuttersnap, CC0"} The classroom still has its blackboard. On it, someone has chalked the week’s sowing list: Verna, Gentil Rosso, Frassineto and a wheat with no name at all, only a village and a year. Behind the desks, where the coats used to hang, stand the shelves: two hundred jars of grain, each sealed with wax and labelled by hand. The library began in 2009, when a retired teacher found a sack of seed in her late uncle’s barn and could not bring herself to feed it to the hens. Today it has forty members with borrowing cards, and a rule that whoever takes a jar in autumn brings back twice as much grain the following summer. On the Saturday we visited, the borrowers came in one by one with their sacks. A baker from Colle took two jars of Verna for a field he rents behind the bypass. A woman who grows beans for the market wanted a wheat short enough to stand up to the wind on her hilltop, and left with one from the Casentino that nobody had sown since 1987. Each loan went into a ledger in pencil: the jar, the name, the field, and a line left blank for the harvest. The rule is there because seed ages. In a cool, dry jar a wheat keeps its vigour for a few years; after that, fewer and fewer grains sprout, until one spring none do. National gene banks slow the clock by drying their seed and storing it at eighteen degrees below zero. A village library has no freezer that size, so it sows its wheats, a few rows at a time, and puts fresh seed back on the shelf. In the back room, under a bank of lamps, a row of glass flasks holds the hardest cases: grains so old that the librarians no longer trust them to a field. Most will not sprout. The few that grow are planted out in March in the test plot behind the school, where children from the new school down the road come in June to count the ears. Every jar has a card saying who grew the wheat, on which slope, and what it was good for. Some of the notes are older than the library. One, in a spidery hand on a ration book, says only: good for straw, poor for bread, Grandfather swore by it. Nobody here claims that the old wheats are better. They grow tall, so a storm flattens them, and they yield less than modern varieties. But some stand up to a dry spring or a late frost, and last year a plant breeder from Florence borrowed three of them for her drought trials. :::part{title="Features" palette="band=#5d6a2b"} ::: # The Year of Rest {style="rest" standfirst="On a farm above Castellina in Chianti, seven fields take turns at wheat, beans, sulla and barley. Every year one of them is left unsown, and the wheat that follows needs little fertiliser." byline="Words by Lucia Fenn" credit="Drawing: made in code for FALLOW"} Marco Rinaldi keeps a map of his farm on the kitchen wall, drawn on the back of a feed merchant’s calendar. It shows seven fields, numbered in pencil, and beside each a column of crops running back to 1998, when he took over from his father. Read across, the columns make a staircase: wheat, field beans, wheat again, two years of sulla for the sheep, a year of barley, and then a year in which the square is left blank. “That one,” he says, tapping this year’s blank, “is the field everybody asks about.” It is field four, twelve hectares of clay on the slope below the house. In May it was ploughed once, shallowly, and since then nobody has touched it. By late August, when we walked it, it had grown a shin-high crop of whatever wanted to grow: wild oats, poppies gone to seed, chicory with its blue flowers shut against the heat, and the thistles that make the neighbours shake their heads. ::resource{id="vines"} Leaving land unsown is one of the oldest practices in farming, and before artificial fertiliser most farmers could not do without it. Medieval villages across northern Europe worked their open fields in three: one sown in autumn, one in spring, one left to rest. Here the old word for it is *maggese*, from *maggio*, because the resting field was ploughed in May, and in the hills around Siena farmers kept the habit long after the tractors came. The rest works slowly, and most of what it does happens underground. A year without a crop breaks the life cycle of the pests and diseases that follow wheat from field to field. The weeds that come up are grazed or ploughed in before they set seed, and their roots open the clay. The beans and the sulla of the years before have already stored nitrogen in the soil, and the fallow gives it time to become something the next wheat can use. It also costs money, which is why most farms gave it up. A field that grows nothing earns nothing, and a farm that must pay for its tractor every month finds it hard to leave a seventh of its land alone. Rinaldi did the sums as everyone does. “On paper I lose a seventh of the farm every year,” he says. “On paper.” :::callout{type="facts" title="Seven fields, seven years"} Every autumn each field moves one step down the list, so one of the seven is always resting. 1. Wheat 2. Field beans 3. Wheat 4. Sulla, grazed by sheep 5. Sulla, grazed by sheep 6. Barley 7. Fallow ::: The paper leaves out the next wheat. On the fields that have rested, his yields in the following year come close to those of neighbours who spread fertiliser every spring, and his bill for fertiliser and sprays is a fraction of theirs. In the wet autumn of 2023, when the valley’s wheat went in late and patchy, field six, the year after its rest, came up so evenly that a neighbour stopped to look at it. A fallow field does look untidy, and in a landscape sold on postcards, untidiness is noticed. Rinaldi has had a letter from the council about his thistles and a visit from a man selling weedkiller who thought that the farm had been abandoned. “I tell them it is on holiday,” he says. “They don’t laugh.” A young couple farming below Radda have begun a five-field rotation of their own, and the seed library in the Val d’Elsa now sends its members a sheet on how to rest a plot. In June, the resting field was the only one on the slope where skylarks nested, and the barn owls from the ruin up the hill hunt it every evening, because the voles have moved in. :::callout{type="quote"} *“On paper I lose a seventh of the farm every year. On paper.”* ::: Towards the end of our visit, Rinaldi took us back into field four. Near the hedge the soil had cracked in the heat, and he knelt and broke off a clod. Inside, it was dark and crumbly and threaded with roots, and a worm withdrew from the light. “Look at that,” he said, the way another farmer might show you a prize heifer. “That is a year’s work.” In October he will plough the field again and sow it with wheat, and next summer the square on the calendar will have a crop in it. Another field will take its turn to rest. By then, he says, the neighbours will have found something else to talk about. :::part{title="Kitchen" palette="band=#7a2c3a"} ::: # Threshing-Day Bread {style="bread" standfirst="When the last load of grain came in, farm kitchens baked with the new flour. This loaf keeps a fifth of an old wheat in the dough." byline="Recipe by Nell Harrow" credit="Photograph: meriç tuna, CC0"} All over Europe, the end of the harvest had its rites. The last sheaf was plaited into a doll, drenched with water or carried home on the last cart, and the kitchen baked with grain from the new crop. This loaf takes its cue from those, with a fifth of the flour milled from an old Tuscan wheat such as Verna. The old flour is weaker, so the dough is wetter than it looks; fold it instead of adding flour. It makes two loaves, one to keep and one to give away. ## Ingredients - 800 g strong white bread flour - 200 g stoneground flour from an old wheat - 700 g water, lukewarm - 200 g active sourdough starter - 20 g fine sea salt :::columnbreak ## Method 1. Mix both flours with 650 g of the water and leave for an hour. 2. Add the starter, the salt and the rest of the water, and squeeze them in with a wet hand until the dough is smooth. 3. For the next two hours, fold the dough over itself every half hour. Then leave it covered until it has risen by half, three to four hours in a warm kitchen. 4. Divide the dough, shape each half into a tight round and set it seam up in a floured basket. Cover and chill overnight. 5. Heat the oven to 250 °C with a lidded cast-iron pot inside. Tip in one loaf, slash the top and bake for 20 minutes with the lid on, then 20 to 25 minutes more without it at 230 °C, until deep brown. Bake the second loaf the same way. 6. Leave to cool for an hour before cutting. On threshing day, the first slice goes to whoever brought in the last sheaf. :::space If you have no old wheat, use any stoneground wholemeal flour. The loaf comes out a shade darker, with a closer crumb.
`; // content.<lang>.md, inlined by the Cookbook // #region art: the fields from above and a made-up barcode, drawn in code with a seeded PRNG 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) => v.toFixed(2); const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k // The part of the line p + t·d inside a convex polygon (Cyrus–Beck), or null. function clipLine(poly, p, d) { let lo = -1e9; let hi = 1e9; for (let i = 0; i < poly.length; i++) { const [a, b] = [poly[i], poly[(i + 1) % poly.length]]; const nx = a[1] - b[1]; const ny = b[0] - a[0]; // the inward normal of a clockwise polygon (y points down) const den = nx * d[0] + ny * d[1]; const num = nx * (p[0] - a[0]) + ny * (p[1] - a[1]); if (Math.abs(den) < 1e-9) { if (num < 0) return null; continue; } const tt = -num / den; if (den > 0) lo = Math.max(lo, tt); else hi = Math.min(hi, tt); } if (lo >= hi) return null; return [[p[0] + lo * d[0], p[1] + lo * d[1]], [p[0] + hi * d[0], p[1] + hi * d[1]]]; } function fieldsArt() { // TRIM.width × PHOTO mm: the valley from above, one field resting const rand = mulberry32(1998); const [W, H] = [TRIM.width, PHOTO]; const hedge = mix(palette.olive, palette.ink, 0.5); const crops = { // [fill, furrow] per crop, all mixed from the page's palette wheat: [mix(palette.band, '#f0c95a', 0.6), mix(palette.band, '#f0c95a', 0.25)], barley: [mix(palette.band, palette.paper, 0.55), mix(palette.band, palette.paper, 0.3)], beans: [mix(palette.olive, palette.paper, 0.2), mix(palette.olive, palette.ink, 0.25)], sulla: [mix(palette.wine, palette.paper, 0.5), mix(palette.wine, palette.paper, 0.25)], earth: [mix(palette.band, palette.paper, 0.1), mix(palette.band, palette.ink, 0.3)], }; const kinds = Object.keys(crops); const [cols, rows, REST] = [7, 5, [4, 1]]; // REST: the column and row of the resting field const [cx, cy, ang] = [W / 2, H / 2, (-14 * Math.PI) / 180]; const grid = []; // a jittered lattice over a larger area, turned 14° for (let j = 0; j <= rows; j++) { grid.push([]); for (let i = 0; i <= cols; i++) { const edge = i === 0 || j === 0 || i === cols || j === rows; const x = -30 + (i * (W + 60)) / cols + (edge ? 0 : (rand() - 0.5) * 14); const y = -40 + (j * (H + 80)) / rows + (edge ? 0 : (rand() - 0.5) * 12); const [dx, dy] = [x - cx, y - cy]; grid[j].push([cx + dx * Math.cos(ang) - dy * Math.sin(ang), cy + dx * Math.sin(ang) + dy * Math.cos(ang)]); } } let fields = ''; for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const poly = [grid[j][i], grid[j][i + 1], grid[j + 1][i + 1], grid[j + 1][i]]; // clockwise const pts = poly.map(([x, y]) => `${n1(x)},${n1(y)}`).join(' '); const [mx, my] = poly.reduce(([sx, sy], [x, y]) => [sx + x / 4, sy + y / 4], [0, 0]); if (i === REST[0] && j === REST[1]) { // fallow: grass, and poppies gone to seed fields += `<polygon points="${pts}" fill="${mix(palette.olive, palette.paper, 0.62)}"/>`; for (let f = 0; f < 420; f++) { const p = [mx + (rand() - 0.5) * 60, my + (rand() - 0.5) * 60]; const row = clipLine(poly, p, [1, 0]); // the field's width at this height if (!row || p[0] < row[0][0] || p[0] > row[1][0]) continue; const poppy = rand() < 0.22; fields += `<circle cx="${n1(p[0])}" cy="${n1(p[1])}" r="${poppy ? 0.5 : 0.32}" ` + `fill="${poppy ? palette.terracotta : mix(palette.olive, palette.paper, 0.25)}"/>`; } continue; } const [fill, furrow] = crops[kinds[Math.floor(rand() * kinds.length)]]; fields += `<polygon points="${pts}" fill="${fill}"/>`; const a = ang + (rand() < 0.5 ? 0 : Math.PI / 2) + (rand() - 0.5) * 0.25; const [d, nrm] = [[Math.cos(a), Math.sin(a)], [-Math.sin(a), Math.cos(a)]]; for (let s = -40; s <= 40; s += 2.4) { // furrows, 2.4 mm apart const seg = clipLine(poly, [mx + nrm[0] * s, my + nrm[1] * s], d); if (seg) { fields += `<path d="M${n1(seg[0][0])} ${n1(seg[0][1])}L${n1(seg[1][0])} ` + `${n1(seg[1][1])}" stroke="${furrow}" stroke-width="0.3"/>`; } } } } let hedges = ''; // the field edges, and trees along them with their shadows let trees = ''; for (let j = 0; j <= rows; j++) { for (let i = 0; i <= cols; i++) { for (const [ni, nj] of [[i + 1, j], [i, j + 1]]) { if (ni > cols || nj > rows) continue; const [a, b] = [grid[j][i], grid[nj][ni]]; hedges += `<path d="M${n1(a[0])} ${n1(a[1])}L${n1(b[0])} ${n1(b[1])}" stroke="${hedge}" ` + 'stroke-width="0.7" stroke-linecap="round"/>'; for (let tr = rand() < 0.45 ? 2 + Math.floor(rand() * 6) : 0; tr > 0; tr--) { const u = rand(); const [x, y] = [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u]; const r = 0.9 + rand() * 1.3; trees += `<circle cx="${n1(x + r * 0.55)}" cy="${n1(y + r * 0.45)}" r="${n1(r)}" ` + `fill="${palette.ink}" fill-opacity="0.22"/><circle cx="${n1(x)}" cy="${n1(y)}" ` + `r="${n1(r)}" fill="${hedge}"/>`; } } } } const road = `<path d="M-5 ${H * 0.86} C ${W * 0.3} ${H * 0.66}, ${W * 0.46} ${H * 0.82}, ` + `${W * 0.6} ${H * 0.5} S ${W * 0.86} ${H * 0.14}, ${W + 5} ${H * 0.18}" fill="none" ` + `stroke="${palette.paper}" stroke-width="1.6"/>`; const farm = [[0, 0, 8, 5], [8.4, 1.2, 4.2, 6.5], [1.5, 5.6, 5.5, 3.6]].map(([x, y, w, h]) => { const [fx, fy] = [W * 0.61 + x, H * 0.4 + y]; return `<rect x="${n1(fx + 0.7)}" y="${n1(fy + 0.7)}" width="${w}" height="${h}" ` + `fill="${palette.ink}" fill-opacity="0.28"/><rect x="${n1(fx)}" y="${n1(fy)}" ` + `width="${w}" height="${h}" fill="${palette.terracotta}"/><path d="M${n1(fx)} ` + `${n1(fy + h / 2)}h${w}" stroke="${mix(palette.terracotta, palette.ink, 0.35)}" ` + 'stroke-width="0.35"/>'; }).join(''); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" ` + `viewBox="0 0 ${W} ${H}">${fields}${hedges}${road}${farm}${trees}</svg>`; } function barcodeArt() { // 32 × 14 mm on paper: bars and no digits, so it reads as decoration const rand = mulberry32(14); let [x, bars] = [2, '']; while (x < 30) { const w = 0.25 + Math.floor(rand() * 3) * 0.25; if (rand() < 0.55) { bars += `<rect x="${n1(x)}" y="1.5" width="${w}" height="11" fill="${palette.ink}"/>`; } x += w + 0.25; } return '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="140" viewBox="0 0 32 14">' + `<rect width="32" height="14" fill="${palette.paper}"/>${bars}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Spectral: ['400', '400i', '700'], 'Bodoni Moda': ['400', '400i', '600', '700', '900'], Jost: ['400', '600'] }; // Spectral 700: the contributors' names; Jost 400: the captions // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const photos = [...new Set(resources.flatMap((r) => r.bitmap?.fileId ?? []))]; // each JPEG once await Promise.all([...photos.map((file) => loadImage(file, asset(file))), loadSvg('fields.svg', fieldsArt()), loadSvg('barcode.svg', barcodeArt())]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Magazine cover and sectioned contents', es: 'Portada de revista e índice por secciones' }) });
Kit · core, fonts, viewer, images: the same in every recipe · 270 lines// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗

Variations

#Set the masthead in the house colour

The masthead takes the earth brown of the big 14 on the contents page.

-    textTransform: 'uppercase', align: 'center', color: col('ink') },
+    textTransform: 'uppercase', align: 'center', color: col('band') },

#Open each section on a divider page

For parts with a page of their own, and a page number in each part row of the contents, see Parts in colour from one attribute.

Pitfalls

Pitfall

Part rows without a divider page have no page label

With parts.page: false there is no divider page, so a contents part row has no page label to print. Leave {pageNumber} out of the part-row design in that case. Table of contents →

Pitfall

A swapped palette misses design elements and the reference colour

postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →

Pitfall

An opener's images never count towards the height it reserves

In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →

Pitfall

Any headings object switches off the H1 page break

By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →

Pitfall

A 'top' float never lands on its citing page

A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page. Figure placement →

Pitfall

Bitmaps are laid out in px at the document dpi: declare print size

A bitmap resource is sized from the width and height it declares, in pixels at the document's dpi, not from the file. Declare the print-size pixels (about 300 dpi at the printed width) so the figure lands at the right size and stays sharp. Figures and tables as resources →

Pitfall

A design text's lineHeight is a multiple, never a dimension

In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →

Pitfall

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. Widows, orphans and runts →

Pitfall

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. Hyphenation and document language →

Pitfall

Load every face before layout

Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →

  • An image element fits its picture inside its box and never crops it. Cut each JPEG to its box first, 23 : 30 for the cover and 230 × 150 mm for the openers, or the picture shrinks and leaves white strips at the edges.
  • In the text, a part recolours every colour whose hex equals band's base hex, whether or not it links to band. Keep that hex for what should change with the section.
  • The stories are copy-fitted in both languages: some sentences of the Spanish Field notes were shortened so that it stays on one page, and one sentence of the English feature lost three words so that both columns of page 4 end level. After an edit, check that no story spills a few lines onto a new page and that every page except a story's last ends level.

Credits

Text
Original prose, CC BY 4.0
Images
Fonts
Spectral (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Jost (SIL OFL 1.1)