# Thesis back matter: appendix, glossary and index

> The last pages of a thesis in black and white: a lettered appendix, a two-column glossary, APA references and an index whose page numbers the pen computes.

- HTML version: https://postext.dev/en/cookbook/thesis-back-matter
- Recipe Nº 031 · Book structure · Level 3 (Advanced) · Outputs: Canvas, PDF
- Genres: Papers & academic
- Requires postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · tested with 1.4.1, postext-pdf 1.4.1 on 2026-09-26
- Pages: [171](https://postext.dev/cookbook/thesis-back-matter/en/p01.webp?v=1edadd62), [172](https://postext.dev/cookbook/thesis-back-matter/en/p02.webp?v=1edadd62), [173](https://postext.dev/cookbook/thesis-back-matter/en/p03.webp?v=1edadd62), [174](https://postext.dev/cookbook/thesis-back-matter/en/p04.webp?v=1edadd62), [175](https://postext.dev/cookbook/thesis-back-matter/en/p05.webp?v=1edadd62), [176](https://postext.dev/cookbook/thesis-back-matter/en/p06.webp?v=1edadd62), [177](https://postext.dev/cookbook/thesis-back-matter/en/p07.webp?v=1edadd62)
- PDF: https://postext.dev/cookbook/thesis-back-matter/en/thesis-back-matter.pdf?v=1edadd62
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/thesis-back-matter.md)

## What you'll build

The last seven pages of a doctoral thesis on reading from screens and paper, set on a B5 page in black and white. Chapter 6, appendix A, the glossary, the references and the index each open under the same black band, 62 mm deep, with the title reversed out of it. The chapter shows its numeral in the band and the appendix its letter; the back-matter sections carry the kicker BACK MATTER and a short note in italics. The chapter runs in one justified column. The glossary and the index switch to two ragged columns and the references return to one, with the turnover lines of every entry indented. The pen reads the index's page numbers off the laid-out pages and joins consecutive ones into runs such as 171–73. Italic numbers point to a table, bold ones to the glossary.

**This recipe answers:**

- How do I set a thesis's glossary, references and index, with hanging indents and smaller type?
- How do I stop headings, bold words and bullets from coming out blue?
- How do I number headings (1, 1.1, 1.1.1) and style each level differently?
- How do I set running heads: book title on the left page, chapter title on the right, page number outside?
- How do I force a page or column break, and start every chapter on a right-hand page?

## The short answer

Back matter as unnumbered heading styles, entries in hanging indents.

```js
// script.js, lines 27–51
// '# Glossary {style="glossary"}' in the Markdown picks a style. Each style starts a page
// of either parity, the appendix a recto (a style that sets no break inherits its level's
// 'odd': gotcha style-inherits-break), stays out of the chapter count (numbered: false, so
// its band has no numeral) and brings its own running heads; the glossary and the index
// set their pages in two columns until the next '#'. config() takes both lists below.
const twoColumns = { layoutType: 'double', gutterWidth: mm(6) };
const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
const headingStyles = () => [
  backMatter('appendix', { breakBefore: { enabled: true, parity: 'odd' }, // {letter="A"}
    header: appendixHeads, advancedDesign: opener('Appendix', '{attr.letter}') }),
  backMatter('glossary', { layout: twoColumns }),
  backMatter('references'),
  backMatter('index', { layout: twoColumns }),
];
// One paragraph per entry, in :::paragraphs{style="…"}: the turnover lines hang, so the
// first word of every entry stands clear at the left. Ragged, as APA asks of references,
// and so never hyphenated (gotcha: ragged-no-hyphenation).
const entries = (id, size, lead, hang, extra) => ({ id, fontSize: pt(size),
  lineHeight: pt(lead), textAlign: 'left', hangingIndent: em(hang), ...extra });
const paragraphStyles = () => [
  entries('term', 9.3, 12.4, 1), // the glossary: a bold term, then its definition
  entries('reference', 9.3, 12.4, 1.5, { spaceBetween: pt(2.4) }),
  entries('entry', 9, 11.6, 2), // the index, written by writeIndex()
];
```

## Ingredients

**Teaches**

- [Section geometry](https://postext.dev/en/docs/configuration.md#heading-styles): A heading style that changes margins and column layout for its section, such as a one-column preface in a two-column book.
- [Bibliographies and glossaries](https://postext.dev/en/docs/configuration.md#paragraph-styles): Reference lists and glossaries in smaller type with a hanging indent, one paragraph per entry.
- [Unnumbered chapters](https://postext.dev/en/docs/configuration.md#heading-styles): A preface, appendix or colophon heading that takes no number and leaves the chapter count untouched.

**Also uses**

- [Running heads per section](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Chapters that open on a recto](https://postext.dev/en/docs/configuration.md#break-before)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form)
- [Tables from data](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Caption style](https://postext.dev/en/docs/configuration.md#caption-style)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Bold, italic and their colours](https://postext.dev/en/docs/configuration.md#body-text)
- [Column balancing](https://postext.dev/en/docs/configuration.md#column-balancing)
- [PDF export](https://postext.dev/en/docs/configuration.md#generating-pdfs)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Figure and Table in your language](https://postext.dev/en/docs/configuration.md#resource-types)
- [Leaving the grid on purpose](https://postext.dev/en/docs/architecture.md#grid-breaking-elements)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration.md#why-a-font-provider)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`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), [`orderedLists`](https://postext.dev/en/docs/configuration.md#ordered-lists), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyle`](https://postext.dev/en/docs/configuration.md#table-style), [`unorderedLists`](https://postext.dev/en/docs/configuration.md#unordered-lists)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`decompressWoff2`](https://postext.dev/en/docs/configuration.md#browser-font-provider-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration.md#generating-pdfs)

**Typefaces**

- Libertinus Serif (OFL-1.1), Libertinus Serif Display (OFL-1.1), Libertinus Sans (OFL-1.1)

## Method

### 1 · Give each part of the back matter a heading style

The code is [the short answer](#the-short-answer) above. `# Glossary {style="glossary" note="…"}` opens a section that runs to the next level-1 heading, and its pages take the style's layout, running heads and opener ([heading styles](/en/docs/configuration#heading-styles)), so the glossary and the index switch to two columns while the references, whose style sets no layout, fall back to the document's single column. `numbered: false` keeps these headings out of the chapter count (delete it and the glossary's band prints 8), and each style names its page break, because one that names none inherits the chapter's `'odd'` and leaves blank versos. The glossary and reference entries are paragraphs of a `:::paragraphs{style="…"}` block at 9.3 pt on 12.4 pt, against the text's 11 on 14.6, with turnover lines that hang 1 em in the glossary and 1.5 em in the references ([paragraph styles](/en/docs/configuration#paragraph-styles)).

### 2 · Draw one band for every opener

```js
// script.js, lines 55–79
const SINK = 8; // lines reserved, 41.2 mm: 3.2 mm more than the band, and text on the grid
// Design text sets each baseline 0.8 of its line under the line's top, and a line is 1.2 × the
// size unless lineHeight says otherwise. In mm, a line's part above its baseline and below it:
const PT = 25.4 / 72;
const above = (size, lineHeight = 1.2) => 0.8 * size * lineHeight * PT;
const below = (size, lineHeight = 1.2) => 0.2 * size * lineHeight * PT;
const KICKER = 4.3, TITLE = BAND - TOP - 9.5; // mm under the text block's top: two baselines
// A bottom-aligned box that ends below() under a baseline sets its last line on it. The
// numeral's line is 0.72 of its size: a line taller than its box would hang from its top.
const text = (id, content, family, size, lineHeight, baseline, edge, w, extra) => ({
  kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight,
  color: col('paper'), overflow: 'wrap', align: edge.endsWith('right') ? 'right' : 'left',
  verticalAlign: 'bottom', ...extra, placement: { anchor: { to: 'container', edge },
    size: { width: mm(w), height: mm(baseline + below(size, lineHeight)) } } });
// The mark: '{number}', empty on an unnumbered heading, or the appendix's '{attr.letter}'.
const opener = (label, mark = '{number}') => ({ enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
      anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
    text('label', label, LABEL, 8, 1.2, KICKER, 'top-left', 80,
      { fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase' }),
    text('title', '{titleText}', DISPLAY, 34, 1.04, TITLE, 'top-left', 84),
    text('mark', mark, DISPLAY, 118, 0.72, TITLE, 'top-right', 34),
    text('note', '{attr.note}', TEXT, 8.6, 1.3, TITLE, 'top-right', 44, { italic: true }),
  ] } });
```

The mark is `{number}`, empty on an unnumbered heading, so one design serves the chapter, the appendix, which passes `{attr.letter}` as its mark, and the back-matter sections, which print their `{attr.note}` where the numeral would stand. `minHeight` reserves eight lines of 14.6 pt, 41.2 mm from the top of the text block, which clears the band by 3.2 mm and keeps the text on its grid. Design text sets its baseline at 0.8 of the line, so a bottom-aligned box whose foot sits `below()` under a baseline puts its last line on that baseline. The title, the 118 pt numeral and the last line of the note all stand on the title's baseline, 52.5 mm below the trim. The numeral's line is 0.72 of its size, because in 1.4.1 a line taller than its box ignores `verticalAlign: 'bottom'`.

### 3 · Letter the appendix by hand

```js
// script.js, lines 109–113
// In 1.4.1 a heading style cannot change the numbering: the appendix is unnumbered, and its
// letter feeds the band (see answer), the running head and a table type that counts A.1.
const appendixHeads = heads('Appendix {attr.letter}. {chapterTitle}');
const appendixTables = { ...defaultResourceTypes(LANG).find((type) => type.id === 'table'),
  id: 'table-a', numberingTemplate: 'A.{n}' }; // a copy of 'table'
```

In postext 1.4.1 a heading style cannot change its level's numbering, so the appendix is unnumbered and `# Interview guide {style="appendix" letter="A"}` carries the letter. The attribute feeds the band and the appendix's running head, and a resource type of its own numbers its tables A.1, A.2 and so on; with the default type the table would be Table 6.2, since an unnumbered heading leaves the chapter counter at 6. The chapter's own headings count from the level templates: `'{1}.{2}'` prints 6.1, and `continuation.headings.h1: 5` makes this the sixth chapter.

### 4 · Put the running heads on the outer edge

```js
// script.js, lines 83–105
const HEAD = 17.5, GAP = 9; // mm: the heads' baseline under the trim; the folio to the words
// Each text is placed by its top, above() over HEAD: the folio and the capitals share a baseline.
const head = (id, content, parity, edge, x, size = 7.5, extra) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('ink'), ...extra, placement: {
    anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD - above(size)) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0) };
const heads = (recto) => ({ elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, 9.5, folio),
  head('verso', '{title}', 'even', 'top-left', OUTER + GAP),
  head('recto', recto, 'odd', 'top-right', -(OUTER + GAP)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, 9.5, folio),
  // The header's container spans the text block, so one rule serves both pages.
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEAD + 2) }, size: { width: 'fill' } } },
] });
const chapterHeads = heads('Chapter {chapterNumber}. {chapterTitle}');
const sectionHeads = heads('{chapterTitle}'); // 'Glossary', 'References', 'Index'
// Openers drop the folio to the foot, centred under the text block, its baseline 12 mm below.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', ...folio, fontSize: pt(9.5), color: col('ink'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(12 - above(9.5)) } } }] };
```

The four text elements are anchored to the page and filtered by `parity`, which keeps the folio on the outer edge of both pages; each is placed by its top, `above()` over a baseline 17.5 mm below the trim, so the 9.5 pt folio and the 7.5 pt capitals stand on one line. The verso carries the thesis's title (`{title}`, from the frontmatter) and the recto the section's (`{chapterTitle}`), such as INDEX on [page 177](https://postext.dev/cookbook/thesis-back-matter/en/p07.webp?v=1edadd62). `pages: 'body'` keeps them off the openers, whose only folio is the footer's, centred under the text block. The chapter's recto would read *Chapter 6. Conclusion* and the appendix's *Appendix A. Interview guide*, but in this sample only the index runs on to a recto.

### 5 · Read the index's page numbers off the pages

```js
// script.js, lines 154–224
// The Markdown lists the entries in :::paragraphs{style="index-terms"}, one a line, as 'term:
// pattern' (a regular expression, in any case, from a word's start); two spaces: a sub-entry.
const TERMS = /^:::paragraphs\{style="index-terms"\}\n([\s\S]*?)\n:::$/m;
const KIND = { glossary: 'term', references: 'skip', index: 'skip' }; // other sections: 'text'
// Every searched line in one string (of the glossary, only the bold terms), with the page and
// kind of each character. A line ending in '-' runs into the next without it, a hard hyphen too
// ('meta-' + 'analyses' reads 'metaanalyses'), so every hyphen in a pattern is optional.
function pagesText(doc) {
  let text = ''; const at = [];
  const termOf = (line) => line.segments.filter((s) => s.bold).map((s) => s.text).join('');
  const read = (lines, page, kind) => (lines ?? []).forEach((line) => {
    const words = kind === 'term' ? termOf(line) : line.text; // the glossary: the term defined
    const part = words.endsWith('-') ? words.slice(0, -1) : `${words} `; // 'expos-' + 'itory'
    text += part;
    at.push(...Array(part.length).fill({ page, kind }));
  });
  let kind = 'text';
  for (const block of doc.blocks) {
    if (block.headingLevel === 1) kind = KIND[block.headingStyleId] ?? 'text'; // a new section
    if (kind !== 'skip') read(block.lines, doc.pages[block.pageIndex], kind);
  }
  for (const page of doc.pages) { // tables float: they hang on their page, not in doc.blocks
    page.floats?.flatMap((float) => float.resourceBlock?.table?.cells ?? [])
      .forEach((cell) => read(cell.lines, page, 'table'));
  }
  return { text, at };
}
// 'look-backs, *171*, 172, **174**': runs of text pages join (171–72, Chicago's short form); a
// page that names the term only in a table is set in italics, the glossary's page in bold.
const MARK = { text: '', table: '*', term: '**' };
function locators(pattern, { text, at }) {
  const pages = new Map(); // page number → 'text', 'table' or 'term'
  const start = new RegExp(`(?<![\\p{L}\\p{N}])(?:${pattern.replaceAll('-', '-?')})`, 'giu');
  for (const m of text.matchAll(start)) { // from the start of a word: 'índice' too
    const { page: { pageNumberValue: n }, kind } = at[m.index];
    if (pages.get(n) !== 'text') pages.set(n, kind); // the text outranks a table on its page
  }
  const runs = [];
  for (const [n, kind] of [...pages].sort(([a], [b]) => a - b)) {
    const run = runs.at(-1);
    if (run?.kind === 'text' && kind === 'text' && n === run.to + 1) run.to = n;
    else runs.push({ from: n, to: n, kind });
  }
  return runs.map(({ from, to, kind }) => {
    const last = from % 100 && Math.trunc(from / 100) === Math.trunc(to / 100) ? to % 100 : to;
    return `${MARK[kind]}${from === to ? from : `${from}–${last}`}${MARK[kind]}`;
  }).join(', ');
}
// Sorts the entries, heads each letter and adds the numbers (none on the first pass).
function writeIndex(markdown, found) {
  const tree = [];
  for (const line of TERMS.exec(markdown)[1].split('\n').filter((row) => row.trim())) {
    const [, indent, term, pattern] = /^( *)(.+?): (.+)$/.exec(line);
    (indent ? tree.at(-1).subs : tree).push({ term, pattern, subs: [] });
  }
  const byTerm = (a, b) => a.term.localeCompare(b.term, LANG);
  const entry = ({ term, pattern }, lead = '') => {
    const pages = found && locators(pattern, found);
    if (found && !pages) console.warn(`Index: no page mentions “${term}”`);
    return `${lead}${term}${pages ? `, ${pages}` : ''}`;
  };
  const out = []; let letter = '';
  for (const main of tree.sort(byTerm)) {
    const initial = main.term.normalize('NFD')[0].toUpperCase(); // 'Á' files under A
    if (initial !== letter) out.push(`### ${(letter = initial)}`); // an H3 among the entries
    // Two en spaces behind a zero-width space indent a sub-entry (gotcha: latin-subset): the
    // font lacks an em space, which a plain PDF line sets at no width; a bare space is trimmed.
    out.push(entry(main), ...main.subs.sort(byTerm).map((sub) => entry(sub, '\u200B\u2002\u2002')));
  }
  return markdown.replace(TERMS, () => `:::paragraphs{style="entry"}\n${out.join('\n\n')}\n:::`);
}
```

Postext has no index generator, but the laid-out document holds every line with its text and its page. The pen builds once with the entries unnumbered, joins the lines into one string (only the bold terms of the glossary, nothing from the references or the index), finds each entry's pattern in it and builds again with the numbers written in; the index is the last section and opens a page, so nothing before it moves between the two builds. Table cells, read from `page.floats`, give italic numbers and the glossary bold ones, as the note in the index's band says. A line that ends in a hyphen runs into the next without it, and every hyphen in a pattern is optional, because a justified line that breaks *meta-analyses* at its own hyphen is flagged as hyphenated, like one that breaks *expository* after *expos-*.

### 6 · Keep every default in ink

```js
// script.js, lines 15–19
const palette = { ink: '#000000', band: '#000000', rule: '#000000', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Bold, italic and list markers default to 'main-color': pointed at the ink, they print black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

The engine's defaults for bold, italic and list markers follow the palette entry `main-color`, so pointing it at the ink prints them black instead of blue ([color palette](/en/docs/configuration#color-palette)). The palette does not reach `bodyText.referenceColor` in 1.4.1, so the config restates it: without that line, *Table 6.1* in the text prints in #295AA3 blue. `referenceBold: false` sets the reference in roman, like the author–year citations around it.

> Three things stay manual: a list of tables, a cross-reference to a page of text (*see p. 172*) and, in 1.4.1, a lettered counter for appendices. The pen looks for the index's terms on pages 171 to 174, everything before the references; in a whole thesis built as one document it would search from the first page.

## 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/thesis-back-matter

### script.js

```js
// ═══ Postext Cookbook · Nº 031 · Thesis back matter: appendix, glossary and index ═══
// https://postext.dev/en/cookbook/thesis-back-matter
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Libertinus Serif, Serif Display and Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'thesis-back-matter';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink; every colour is black or white, each under its own name
const palette = { ink: '#000000', band: '#000000', rule: '#000000', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Bold, italic and list markers default to 'main-color': pointed at the ink, they print black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Libertinus Serif', DISPLAY = 'Libertinus Serif Display', LABEL = 'Libertinus Sans';
const TOP = 24, INNER = 25, OUTER = 31; // mm: a 120 mm measure, about 70 characters at 11 pt
const LEAD = 14.6; // pt: the body's leading, the grid every page is set on
const BAND = 62; // mm from the trim's top: the black band at the head of every opener

// #region answer: back matter as unnumbered heading styles, entries in hanging indents
// '# Glossary {style="glossary"}' in the Markdown picks a style. Each style starts a page
// of either parity, the appendix a recto (a style that sets no break inherits its level's
// 'odd': gotcha style-inherits-break), stays out of the chapter count (numbered: false, so
// its band has no numeral) and brings its own running heads; the glossary and the index
// set their pages in two columns until the next '#'. config() takes both lists below.
const twoColumns = { layoutType: 'double', gutterWidth: mm(6) };
const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
const headingStyles = () => [
  backMatter('appendix', { breakBefore: { enabled: true, parity: 'odd' }, // {letter="A"}
    header: appendixHeads, advancedDesign: opener('Appendix', '{attr.letter}') }),
  backMatter('glossary', { layout: twoColumns }),
  backMatter('references'),
  backMatter('index', { layout: twoColumns }),
];
// One paragraph per entry, in :::paragraphs{style="…"}: the turnover lines hang, so the
// first word of every entry stands clear at the left. Ragged, as APA asks of references,
// and so never hyphenated (gotcha: ragged-no-hyphenation).
const entries = (id, size, lead, hang, extra) => ({ id, fontSize: pt(size),
  lineHeight: pt(lead), textAlign: 'left', hangingIndent: em(hang), ...extra });
const paragraphStyles = () => [
  entries('term', 9.3, 12.4, 1), // the glossary: a bold term, then its definition
  entries('reference', 9.3, 12.4, 1.5, { spaceBetween: pt(2.4) }),
  entries('entry', 9, 11.6, 2), // the index, written by writeIndex()
];
// #endregion

// #region opener: a black band across the head of the page, the title reversed out of it
const SINK = 8; // lines reserved, 41.2 mm: 3.2 mm more than the band, and text on the grid
// Design text sets each baseline 0.8 of its line under the line's top, and a line is 1.2 × the
// size unless lineHeight says otherwise. In mm, a line's part above its baseline and below it:
const PT = 25.4 / 72;
const above = (size, lineHeight = 1.2) => 0.8 * size * lineHeight * PT;
const below = (size, lineHeight = 1.2) => 0.2 * size * lineHeight * PT;
const KICKER = 4.3, TITLE = BAND - TOP - 9.5; // mm under the text block's top: two baselines
// A bottom-aligned box that ends below() under a baseline sets its last line on it. The
// numeral's line is 0.72 of its size: a line taller than its box would hang from its top.
const text = (id, content, family, size, lineHeight, baseline, edge, w, extra) => ({
  kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight,
  color: col('paper'), overflow: 'wrap', align: edge.endsWith('right') ? 'right' : 'left',
  verticalAlign: 'bottom', ...extra, placement: { anchor: { to: 'container', edge },
    size: { width: mm(w), height: mm(baseline + below(size, lineHeight)) } } });
// The mark: '{number}', empty on an unnumbered heading, or the appendix's '{attr.letter}'.
const opener = (label, mark = '{number}') => ({ enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
      anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
    text('label', label, LABEL, 8, 1.2, KICKER, 'top-left', 80,
      { fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase' }),
    text('title', '{titleText}', DISPLAY, 34, 1.04, TITLE, 'top-left', 84),
    text('mark', mark, DISPLAY, 118, 0.72, TITLE, 'top-right', 34),
    text('note', '{attr.note}', TEXT, 8.6, 1.3, TITLE, 'top-right', 44, { italic: true }),
  ] } });
// #endregion

// #region running-heads: the thesis on the verso, the section on the recto, a hairline under
const HEAD = 17.5, GAP = 9; // mm: the heads' baseline under the trim; the folio to the words
// Each text is placed by its top, above() over HEAD: the folio and the capitals share a baseline.
const head = (id, content, parity, edge, x, size = 7.5, extra) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('ink'), ...extra, placement: {
    anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD - above(size)) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0) };
const heads = (recto) => ({ elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, 9.5, folio),
  head('verso', '{title}', 'even', 'top-left', OUTER + GAP),
  head('recto', recto, 'odd', 'top-right', -(OUTER + GAP)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, 9.5, folio),
  // The header's container spans the text block, so one rule serves both pages.
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEAD + 2) }, size: { width: 'fill' } } },
] });
const chapterHeads = heads('Chapter {chapterNumber}. {chapterTitle}');
const sectionHeads = heads('{chapterTitle}'); // 'Glossary', 'References', 'Index'
// Openers drop the folio to the foot, centred under the text block, its baseline 12 mm below.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', ...folio, fontSize: pt(9.5), color: col('ink'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(12 - above(9.5)) } } }] };
// #endregion

// #region appendix: the letter comes from the heading, '# Interview guide {letter="A"}'
// In 1.4.1 a heading style cannot change the numbering: the appendix is unnumbered, and its
// letter feeds the band (see answer), the running head and a table type that counts A.1.
const appendixHeads = heads('Appendix {attr.letter}. {chapterTitle}');
const appendixTables = { ...defaultResourceTypes(LANG).find((type) => type.id === 'table'),
  id: 'table-a', numberingTemplate: 'A.{n}' }; // a copy of 'table'
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, header: chapterHeads, footer, layout: { layoutType: 'single' },
  page: { sizePreset: 'custom', width: mm(176), height: mm(250), dpi: 150, // B5
    margins: { top: mm(TOP), bottom: mm(24), left: mm(INNER), right: mm(OUTER), mirror: true } },
  bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
    // 'Table 6.1' in roman and in ink, outside the palette's reach (gotcha: palette-skips-designs)
    referenceColor: col('ink'), referenceBold: false,
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.8 },
  // Exact heading margins (snapToGrid: false) keep the index's letters close to their entries,
  // and no lines are added above them; the chapter's heads measure whole grid lines.
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), snapToGrid: false,
    balancing: { maxLinesPerHeading: 0 }, levels: [
      // The H1 break restated (gotcha: headings-drop-h1-break). span: 'page' (the styles inherit
      // it) sets the band above the columns: inside a column, its top would be clipped.
      { level: 1, numberingTemplate: '{1}', span: 'page', marginBottom: pt(0),
        advancedDesign: opener('Chapter'), breakBefore: { enabled: true, parity: 'odd' } },
      { level: 2, numberingTemplate: '{1}.{2}', fontSize: pt(14), lineHeight: pt(LEAD),
        marginTop: pt(LEAD * 1.5), marginBottom: pt(LEAD / 2) }, // three lines in all
      // The index's letters carry their space in their own line, so both columns start level.
      { level: 3, fontSize: pt(13), lineHeight: pt(21), marginTop: pt(0), marginBottom: pt(0) },
    ] },
  headingStyles: headingStyles(), paragraphStyles: paragraphStyles(),
  orderedLists: { marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) },
  unorderedLists: { bulletChar: '–' },
  resourceTypes: [...defaultResourceTypes(LANG), appendixTables], // tables 6.1… and A.1…
  // Captions in the text face, as APA sets a table's number and title.
  captionStyle: { fontSize: pt(9), position: 'above', gap: pt(4), note: { fontSize: pt(8) } },
  // Rules only and a bold header: filled header cells show seams between the columns.
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackgroundEnabled: false, headerFontSize: pt(9.5),
    bodyFontSize: pt(9.5), cellPadding: mm(1) },
  calloutStyles: [{ id: 'colophon', span: 'page', marginTop: pt(LEAD), backgroundEnabled: false,
    stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
    padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) },
    body: { fontSize: pt(8), lineHeight: pt(10.5), firstLineIndent: pt(0), textAlign: 'left' } }],
});

// #region index: the index's page numbers, read off the laid-out pages
// The Markdown lists the entries in :::paragraphs{style="index-terms"}, one a line, as 'term:
// pattern' (a regular expression, in any case, from a word's start); two spaces: a sub-entry.
const TERMS = /^:::paragraphs\{style="index-terms"\}\n([\s\S]*?)\n:::$/m;
const KIND = { glossary: 'term', references: 'skip', index: 'skip' }; // other sections: 'text'
// Every searched line in one string (of the glossary, only the bold terms), with the page and
// kind of each character. A line ending in '-' runs into the next without it, a hard hyphen too
// ('meta-' + 'analyses' reads 'metaanalyses'), so every hyphen in a pattern is optional.
function pagesText(doc) {
  let text = ''; const at = [];
  const termOf = (line) => line.segments.filter((s) => s.bold).map((s) => s.text).join('');
  const read = (lines, page, kind) => (lines ?? []).forEach((line) => {
    const words = kind === 'term' ? termOf(line) : line.text; // the glossary: the term defined
    const part = words.endsWith('-') ? words.slice(0, -1) : `${words} `; // 'expos-' + 'itory'
    text += part;
    at.push(...Array(part.length).fill({ page, kind }));
  });
  let kind = 'text';
  for (const block of doc.blocks) {
    if (block.headingLevel === 1) kind = KIND[block.headingStyleId] ?? 'text'; // a new section
    if (kind !== 'skip') read(block.lines, doc.pages[block.pageIndex], kind);
  }
  for (const page of doc.pages) { // tables float: they hang on their page, not in doc.blocks
    page.floats?.flatMap((float) => float.resourceBlock?.table?.cells ?? [])
      .forEach((cell) => read(cell.lines, page, 'table'));
  }
  return { text, at };
}
// 'look-backs, *171*, 172, **174**': runs of text pages join (171–72, Chicago's short form); a
// page that names the term only in a table is set in italics, the glossary's page in bold.
const MARK = { text: '', table: '*', term: '**' };
function locators(pattern, { text, at }) {
  const pages = new Map(); // page number → 'text', 'table' or 'term'
  const start = new RegExp(`(?<![\\p{L}\\p{N}])(?:${pattern.replaceAll('-', '-?')})`, 'giu');
  for (const m of text.matchAll(start)) { // from the start of a word: 'índice' too
    const { page: { pageNumberValue: n }, kind } = at[m.index];
    if (pages.get(n) !== 'text') pages.set(n, kind); // the text outranks a table on its page
  }
  const runs = [];
  for (const [n, kind] of [...pages].sort(([a], [b]) => a - b)) {
    const run = runs.at(-1);
    if (run?.kind === 'text' && kind === 'text' && n === run.to + 1) run.to = n;
    else runs.push({ from: n, to: n, kind });
  }
  return runs.map(({ from, to, kind }) => {
    const last = from % 100 && Math.trunc(from / 100) === Math.trunc(to / 100) ? to % 100 : to;
    return `${MARK[kind]}${from === to ? from : `${from}–${last}`}${MARK[kind]}`;
  }).join(', ');
}
// Sorts the entries, heads each letter and adds the numbers (none on the first pass).
function writeIndex(markdown, found) {
  const tree = [];
  for (const line of TERMS.exec(markdown)[1].split('\n').filter((row) => row.trim())) {
    const [, indent, term, pattern] = /^( *)(.+?): (.+)$/.exec(line);
    (indent ? tree.at(-1).subs : tree).push({ term, pattern, subs: [] });
  }
  const byTerm = (a, b) => a.term.localeCompare(b.term, LANG);
  const entry = ({ term, pattern }, lead = '') => {
    const pages = found && locators(pattern, found);
    if (found && !pages) console.warn(`Index: no page mentions “${term}”`);
    return `${lead}${term}${pages ? `, ${pages}` : ''}`;
  };
  const out = []; let letter = '';
  for (const main of tree.sort(byTerm)) {
    const initial = main.term.normalize('NFD')[0].toUpperCase(); // 'Á' files under A
    if (initial !== letter) out.push(`### ${(letter = initial)}`); // an H3 among the entries
    // Two en spaces behind a zero-width space indent a sub-entry (gotcha: latin-subset): the
    // font lacks an em space, which a plain PDF line sets at no width; a bare space is trimmed.
    out.push(entry(main), ...main.subs.sort(byTerm).map((sub) => entry(sub, '\u200B\u2002\u2002')));
  }
  return markdown.replace(TERMS, () => `:::paragraphs{style="entry"}\n${out.join('\n\n')}\n:::`);
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Reading on Screens and Paper"
subtitle: "A Mixed-Methods Study of Comprehension, Confidence and Navigation"
author: "Ines Varley"
---

# Conclusion

This thesis set out to test whether it matters if a long text is read on paper or on a screen. Chapters 3 to 5 reported a within-subjects experiment with forty-eight undergraduates and interviews with sixteen of them, combined in the convergent design described by Creswell and Plano Clark (2018). This chapter brings the two strands together and sets out what they mean for teaching and for the design of reading software.

## What the study found

:ref{id="findings" style="full"} summarises the results. On literal questions, answerable from a single sentence, the medium made no difference. On inferential questions, which required connecting ideas across paragraphs, paper readers scored higher. The difference points the same way as the meta-analyses of Delgado et al. (2018) and Clinton (2019), which found the paper advantage in expository rather than narrative texts.

Calibration showed the larger difference. Screen readers predicted higher scores than paper readers and obtained lower ones, so the gap between confidence and accuracy was nearly three times as wide. The result repeats the overconfidence that Ackerman and Goldsmith (2011) found in students who read on screen and set their own study time. Such readers stop once they judge a text understood, so overconfidence cuts their study short.

Paper readers also turned back almost twice as often as screen readers scrolled back, most often just before an inferential question. In the interviews, eleven of the sixteen participants remembered where on a page an idea had been (“top left, next to the diagram”), and three gave up looking for a passage on the tablet because “it could have been anywhere”. Liu (2005) described a drift towards browsing and keyword spotting on screen; these readers went through the whole text but had fewer landmarks to return to.

## Implications for teaching and design

For short texts and factual questions, screens serve as well as paper. For long expository texts that students must understand rather than search, paper remains the safer choice. Where it is not available, students should test their understanding instead of trusting their sense of it: in the pilot sessions, a short self-test after reading halved the overconfidence on screen.

Readers also used the fixed position of text on a page as a map, one of the uses of paper that Sellen and Harper (2002) observed in offices. Reading applications that keep a stable page and show the reader’s place in the whole text may restore some of that map.

## Limitations and further work

The participants were students at one university who read English fluently, and the medium matters more for some readers, texts and tasks than it does for others (Singer & Alexander, 2017). The texts were expository and about 1,800 words long, and the screen condition used a single tablet. A replication with a larger sample, several devices and the eye-movement recording reviewed by Rayner (1998) would show where on the page the two media part company.

Huey (1908) thought that a complete analysis of what we do when we read would be almost the acme of a psychologist’s achievements. The experiments reported here add a small part to that analysis; the replication proposed above could measure how far readers rely on the position of a passage on the page when they look back.

# Interview guide {style="appendix" letter="A"}

The interviews took place within a week of each participant’s second session. They were audio-recorded, transcribed in full and analysed thematically following Braun and Clarke (2006); :ref{id="session-plan" style="full"} gives their timing. The questions were asked in this order, and a prompt only when the participant had not already covered its point.

1. Tell me about the last long text you read for a course.
   - Where did you read it, and on paper or on a screen?
2. Which of the texts in this study do you remember best, and why?
3. When you wanted to check an earlier passage, what did you do?
   - How did you know where to look?
4. How sure were you of your answers? What made you more or less sure?
5. Did reading on the tablet feel different from reading on paper?
6. Some students say they read more carefully on paper. Do you?
7. What would the ideal way to read a long text for study be like?

# Glossary {style="glossary" note="Words in italics are defined under entries of their own."}

:::paragraphs{style="term"}
**calibration** The agreement between a reader’s confidence in having understood a text and the accuracy of that understanding, measured here as the difference between predicted and actual scores.

**comprehension, inferential** Understanding that requires the reader to connect information from different parts of a text or to add knowledge the text does not state.

**comprehension, literal** Understanding of what a single sentence or passage states directly.

**confidence judgement** A reader’s estimate, made after reading and before seeing the questions, of how many answers will be correct.

**convergent design** A mixed-methods design in which quantitative and qualitative data are collected in the same period, analysed separately and then compared.

**expository text** A text written to explain or inform, such as a textbook chapter or a report, as opposed to a narrative text.

**fixation** A pause of the eyes, typically about a quarter of a second, during which the reader takes in text; fixations alternate with *saccades*.

**look-back** Any return to an earlier part of a text during reading: turning back a page, scrolling up or following a link to a previous section.

**metacomprehension** A reader’s knowledge and monitoring of their own understanding of a text; *calibration* is one of its measures.

**navigation** The movements a reader makes through a text as a whole, as distinct from the movements of the eyes along a line.

**overconfidence** Positive *calibration* bias: predicting a higher score than the one actually obtained.

**saccade** A rapid movement of the eyes from one *fixation* to the next, during which little or no text is taken in.

**screen inferiority effect** The finding that comprehension of the same text is lower on screen than on paper, most consistently for *expository texts* read under time pressure.

**self-regulated study** Reading in which the reader, not the experimenter, decides how long to spend on a text.

**spatial memory for text** Memory of where on a page or in a document a piece of information appeared, used as a cue for *look-backs*.

**thematic analysis** A method for identifying, analysing and reporting patterns of meaning across qualitative data such as interview transcripts.

**within-subjects design** An experimental design in which every participant takes part in every condition, here reading on both paper and screen.
:::

# References {style="references" note="Every work cited in the thesis, set in APA style (7th edition)."}

:::paragraphs{style="reference"}
Ackerman, R., & Goldsmith, M. (2011). Metacognitive regulation of text learning: On screen versus on paper. *Journal of Experimental Psychology: Applied, 17*(1), 18–32.

Baron, N. S. (2015). *Words onscreen: The fate of reading in a digital world.* Oxford University Press.

Braun, V., & Clarke, V. (2006). Using thematic analysis in psychology. *Qualitative Research in Psychology, 3*(2), 77–101.

Clinton, V. (2019). Reading from paper compared to screens: A systematic review and meta-analysis. *Journal of Research in Reading, 42*(2), 288–325.

Creswell, J. W., & Plano Clark, V. L. (2018). *Designing and conducting mixed methods research* (3rd ed.). SAGE.

Delgado, P., Vargas, C., Ackerman, R., & Salmerón, L. (2018). Don’t throw away your printed books: A meta-analysis on the effects of reading media on reading comprehension. *Educational Research Review, 25*, 23–38.

Dillon, A. (1992). Reading from paper versus screens: A critical review of the empirical literature. *Ergonomics, 35*(10), 1297–1326.

Huey, E. B. (1908). *The psychology and pedagogy of reading.* Macmillan.

Liu, Z. (2005). Reading behavior in the digital environment: Changes in reading behavior over the past ten years. *Journal of Documentation, 61*(6), 700–712.

Mangen, A., Walgermo, B. R., & Brønnick, K. (2013). Reading linear texts on paper versus computer screen: Effects on reading comprehension. *International Journal of Educational Research, 58*, 61–68.

Noyes, J. M., & Garland, K. J. (2008). Computer- vs. paper-based tasks: Are they equivalent? *Ergonomics, 51*(9), 1352–1375.

Paterson, D. G., & Tinker, M. A. (1940). *How to make type readable.* Harper & Brothers.

Rayner, K. (1998). Eye movements in reading and information processing: 20 years of research. *Psychological Bulletin, 124*(3), 372–422.

Sellen, A. J., & Harper, R. H. R. (2002). *The myth of the paperless office.* MIT Press.

Singer, L. M., & Alexander, P. A. (2017). Reading on paper and digitally: What the past decades of empirical research reveal. *Review of Educational Research, 87*(6), 1007–1041.

Tinker, M. A. (1963). *Legibility of print.* Iowa State University Press.

Wolf, M. (2018). *Reader, come home: The reading brain in a digital world.* Harper.
:::

# Index {style="index" note="Bold numbers refer to the glossary, italic numbers to tables."}

:::paragraphs{style="index-terms"}
accuracy: accura
Ackerman, Rakefet: Ackerman
Alexander, Patricia A.: Alexander
Braun, Virginia: Braun
browsing: brows
calibration: calibrat
  bias in: calibration bias|confidence and accuracy
  on screen: screen readers predicted|overconfidence on screen
Clarke, Victoria: Clarke
Clinton, Virginia: Clinton
comprehension: comprehen|understand
  inferential: inferential
  literal: literal
confidence: confiden|how sure
  judgements of: confidence judgement|predicted
convergent design: convergent
courses, reading for: course
Creswell, John W.: Creswell
debriefing: debrief
Delgado, Pablo: Delgado
design of reading software: design of reading|reading applications|ideal design
expository text: expository
eye movements: eye-movement|eyes
fixation: fixation
Goldsmith, Morris: Goldsmith
Harper, Richard H. R.: Harper
Huey, Edmund Burke: Huey
interviews: interview
  prompts in: prompt
  questions asked: questions were asked|Tell me about
  recording of: recorded|recorder
  timing of: timing|lasted
keyword spotting: keyword
landmarks: landmark|as a map
limitations: limitation|limits
Liu, Ziming: Liu
look-backs: look-back|turned back|scrolled back
meta-analyses: meta-analys
memory: remember
metacomprehension: metacomprehension
narrative text: narrative
navigation: navigat
offices, paper in: offices
overconfidence: overconfiden
paper advantage: paper advantage|safer choice
participants: participant
pilot sessions: pilot
Plano Clark, Vicki L.: Plano Clark
Rayner, Keith: Rayner
recall: recall
replication: replicat
saccade: saccade
screen inferiority effect: screen inferiority
scrolling: scroll
self-regulated study: self-regulated|set their own study
self-test: self-test
Sellen, Abigail J.: Sellen
Singer, Lauren M.: Singer
spatial memory: spatial memory|where on a page
tablet: tablet
teaching: teach
texts, length of: 1,800 words|long text
thematic analysis: thematic
transcription: transcri
undergraduates: undergraduate
university, single: one university
within-subjects design: within-subjects
:::

:::callout{type="colophon"}
Set in Libertinus Serif, Libertinus Serif Display and Libertinus Sans (SIL Open Font License). Text: original, CC BY 4.0. The thesis, its author, its participants and its results are fictional; the works in the references are real.
:::
`; // content.<lang>.md, inlined by the Cookbook

// A table from rows of 'cell|cell|cell'; aligns has a letter a column, l or r.
const table = (id, typeId, caption, note, widths, aligns, rows) => ({ id, typeId, kind: 'table',
  caption, note, createdAt: 0, updatedAt: 0, table: { model: { headerRowCount: 1,
    columnWidths: widths, rows: rows.map((row, r) => row.split('|').map((content, c) => ({
      content, isHeader: r === 0, align: aligns[c] === 'r' ? 'right' : 'left' }))) } } });
const resources = [
  table('findings', 'table', 'Main results by medium',
    'Means for 48 participants. Bias is the predicted minus the actual score.', [5, 1.4, 1.4],
    'lrr', ['Measure|Paper|Screen', 'Literal comprehension (of 10)|7.8|7.7',
      'Inferential comprehension (of 10)|6.4|5.6', 'Predicted score (%)|75|78',
      'Actual score (%)|71|66.5', 'Calibration bias (points)|+4.0|+11.5',
      'Look-backs per text|5.8|3.1']),
  table('session-plan', 'table-a', 'Timing of an interview session', undefined, [1, 6, 1.4],
    'llr', ['Part|Content|Minutes', '1|Welcome, consent and a check of the recorder|3',
      '2|Free recall of the two study texts|5',
      '3|Questions 1–4: reading habits, look-backs and confidence|12',
      '4|Questions 5–7: the two media and an ideal design|12', '5|Debrief|3']),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Libertinus Serif': ['400', '400i', '700'], 'Libertinus Serif Display': ['400'],
  'Libertinus Sans': ['700'] }; // every face the pages use, loaded first (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// The thesis's sixth and last chapter opens on page 171, a recto.
const continuation = { pageIndexOffset: 170, pageNumbering: { startAt: 171 }, headings: { h1: 5 } };
const build = (source) => buildDocument({ markdown: source, resources, continuation }, config());
await loadFonts(FONTS, markdown);
// Two passes: the first lays the index out without numbers, the second writes them in. The
// index is the last section and opens a page, so no page before it moves between passes.
const draft = await buildWithFonts(() => build(writeIndex(markdown, null)), markdown);
const doc = build(writeIndex(markdown, pagesText(draft)));
showPages(doc, { title: 'Reading on Screens and Paper: the back matter' });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);

// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variations

### Start every section on a recto

Theses bound for a library often open each section on a right-hand page. The glossary, the references and the index then move to pages 175, 177 and 179, each after a blank verso, and the index's bold numbers point to page 175.

```diff
-const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
-  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
+const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
+  parity: 'odd' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
```

### Set the front of the thesis too

The preliminaries, counted in roman numerals before page 1, are in [Front matter in roman folios, then page 1](https://postext.dev/en/cookbook/front-matter-roman-to-arabic.md).

## Pitfalls

- **A heading style inherits its level's page break.** A headingStyles entry takes every field it leaves out from its heading level, breakBefore included. A contents page or a colophon styled on an H1 after a :::pagebreak inherits parity 'odd' and lands behind a blank page. Give such a style breakBefore: { enabled: false }.
- **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.
- **Ragged text is never hyphenated.** Hyphenation applies to justified text only; ragged-right text breaks between words, so a narrow ragged column gets a deep rag. Justify the passage or widen the measure.
- **Fontsource latin files drop glyphs outside Latin.** The PDF provider embeds Fontsource's latin files, which cover Spanish and Western European text but not →, ≈, ✓, ★, Greek or Central European letters; those glyphs go missing in the PDF. Keep PDF text inside the latin range.
- **The PDF asks for every weight and style of every family.** renderToPdf asks the font provider for the bold, italic and bold-italic faces of every family a block could use, even ones never printed, and a single rejection stops the export. The provider must snap to the nearest weight the family ships and fall back to upright when there is no italic.
- **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.
- **Quote every frontmatter value.** YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984".
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().
- **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.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Type: Libertinus Serif (OFL-1.1), Libertinus Serif Display (OFL-1.1), Libertinus Sans (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 006 · Front matter in roman folios, then page 1](https://postext.dev/en/cookbook/front-matter-roman-to-arabic.md): The cover and prelims are unnumbered headings counted in lower-case roman; :::numbering restarts the count at 1 on the recto where the novel opens. · Level 3 (Advanced) · Fiction, drama & literary prose
- [Nº 020 · Endnotes in two columns instead of footnotes](https://postext.dev/en/cookbook/endnotes-instead-of-footnotes.md): A short preprocessor turns Markdown footnotes into raised numbers and a Notes section, which a heading style sets on a page of its own in two columns. · Level 2 (Intermediate) · Papers & academic
- [Nº 002 · Two-column paper with numbered equations](https://postext.dev/en/cookbook/journal-article-with-maths.md): A two-column physics paper whose inline formulas and seven numbered equations are set by MathJax from the ?bundle build, and stay vector in the PDF. · Level 3 (Advanced) · Papers & academic
