# A harmony textbook with music examples in ABC

> A chapter on cadences whose examples are ABC fences in the Markdown, engraved at the column width, one of them running on over two pages.

- HTML version: https://postext.dev/en/cookbook/harmony-textbook-abc-examples
- Recipe Nº 163 · Textbooks · Level 2 (Intermediate) · Outputs: Canvas, PDF
- Genres: Textbooks
- Requires postext ≥ 1.54.0, postext-pdf ≥ 1.54.0 · tested with 1.54.0, postext-pdf 1.54.0 on 2026-10-22
- Pages: [1](https://postext.dev/cookbook/harmony-textbook-abc-examples/en/p01.webp?v=75766f03), [2](https://postext.dev/cookbook/harmony-textbook-abc-examples/en/p02.webp?v=75766f03), [3](https://postext.dev/cookbook/harmony-textbook-abc-examples/en/p03.webp?v=75766f03), [4](https://postext.dev/cookbook/harmony-textbook-abc-examples/en/p04.webp?v=75766f03)
- PDF: https://postext.dev/cookbook/harmony-textbook-abc-examples/en/harmony-textbook-abc-examples.pdf?v=75766f03
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=harmony-textbook-abc-examples&lang=en (.postext: https://postext.dev/cookbook/harmony-textbook-abc-examples/en/harmony-textbook-abc-examples.postext)
- Last updated: 2026-10-22
- Other languages: [es](https://postext.dev/es/cookbook/harmony-textbook-abc-examples.md), [ca](https://postext.dev/ca/cookbook/harmony-textbook-abc-examples.md), [de](https://postext.dev/de/cookbook/harmony-textbook-abc-examples.md), [fr](https://postext.dev/fr/cookbook/harmony-textbook-abc-examples.md), [it](https://postext.dev/it/cookbook/harmony-textbook-abc-examples.md), [pt](https://postext.dev/pt/cookbook/harmony-textbook-abc-examples.md), [zh](https://postext.dev/zh/cookbook/harmony-textbook-abc-examples.md), [ja](https://postext.dev/ja/cookbook/harmony-textbook-abc-examples.md), [ar](https://postext.dev/ar/cookbook/harmony-textbook-abc-examples.md)

## In short

A chapter of a music theory textbook about how phrases end. The music examples are written as short lines of text in the chapter, and Postext draws them as staves and notes; the longest one continues on the next page like a paragraph.

## What you'll build

Chapter 5 of a keyboard harmony course, 180 × 250 mm in Libre Caslon, on the five cadences: perfect and imperfect authentic, half, plagal and deceptive. Each cadence gets two short paragraphs and an example in three voices with its Roman numerals under the staff, written in ABC right in the Markdown. The chapter ends with *Greensleeves* in A minor, a numeral over every bar, and that melody is too long for the room left on page 3: it sets three systems there and the last two at the head of page 4, broken between systems as a paragraph breaks between lines. Staff size, music font and spacing are set once in the configuration, so the five examples and the melody share one look.

**This recipe answers:**

- How do I put music examples in the text of a textbook, written as notation?

## The short answer

One score style for every example in the text.

````js
// script.js, lines 36–51
// Each ```abc fence in the chapter is engraved at the width of the text column and cut
// into systems. The style is set once here; the Markdown only holds the notation.
const scoreStyle = {
  staffSize: mm(5.5), // a textbook's rastral: examples read at arm's length on a desk
  musicFont: 'Bravura', // the traditional engraved look, heavy enough beside Caslon
  textFont: 'Libre Caslon Text', // the Roman numerals of `w:` lines, in the text face
  spacing: 1.6, // air between the chords: an example reads as a progression, not a cluster
  barNumbers: 'system',
  systemGap: mm(4),
  marginTop: pt(LEAD / 2),
  marginBottom: pt(LEAD / 2),
  color: col('ink'),
  // A long example runs on into the next page between two systems, never leaving one
  // system alone at the foot of a page or at the head of the next.
  minSystemsAtBreak: 2,
};
````

## Ingredients

**Teaches**

- [Music notation](https://postext.dev/en/docs/music.md#a-score-in-the-text): Scores written as text in ```abc, ```musicxml, ```mei or ```humdrum fences, engraved by Verovio at the width of the column and cut into systems that run on over columns and pages.
- [Score style](https://postext.dev/en/docs/configuration-resources.md#score-style): scoreStyle and named scoreStyles: staff size, music font, text font, spacing, bar numbers, system gap, margins, ink and how many systems a score keeps together at a break.

**Also uses**

- [Designed openers](https://postext.dev/en/docs/configuration-headings.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Running heads and folios](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers)
- [Mirrored margins](https://postext.dev/en/docs/configuration-page-layout.md#mirrored-margins)
- [Heads by page role](https://postext.dev/en/docs/configuration-headers-footers.md#text-elements)
- [Callout boxes](https://postext.dev/en/docs/configuration-callouts.md#callout-styles)
- [Paragraph styles](https://postext.dev/en/docs/configuration-styles.md#paragraph-styles)
- [Semantic colour palette](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette)
- [Widows, orphans and runts](https://postext.dev/en/docs/configuration-page-flow.md#orphans-widows-runts-and-keep-together-rules)
- [PDF export](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)
- [Code listings](https://postext.dev/en/docs/configuration-styles.md#code-listings)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration-pdf-output.md#why-a-font-provider)
- [Superscripts and subscripts](https://postext.dev/en/docs/document-format-inline.md#inline-formatting)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration-text.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration-callouts.md#callout-styles), [`colorPalette`](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration-headings.md#headings), [`layout`](https://postext.dev/en/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/en/docs/configuration-language.md#hyphenation), [`orderedLists`](https://postext.dev/en/docs/configuration-lists-math.md#ordered-lists), [`page`](https://postext.dev/en/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration-styles.md#paragraph-styles), [`scoreStyle`](https://postext.dev/en/docs/configuration-resources.md#score-style), [`tableStyle`](https://postext.dev/en/docs/configuration-resources.md#table-style)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/en/docs/configuration-programmatic-usage.md#loading-fonts-before-layout), [`decompressWoff2`](https://postext.dev/en/docs/configuration-pdf-output.md#browser-font-provider-fontsource--woff2), [`initMusicEngine`](https://postext.dev/en/docs/music.md#a-score-in-the-text), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration-programmatic-usage.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)

**Typefaces**

- Libre Caslon Text (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Franklin (OFL-1.1)

## Method

### 1 · Write the example where the text needs it

A fence tagged `abc` holds the notation, and the text goes on after it. `L:1/2` makes each bare chord a half note, `[Bdg]` stacks three notes in one voice, and a `w:` line puts one word under each chord, here the Roman numerals. The `alt` attribute is what a screen reader and the tagged PDF say instead of the picture.

````md
```abc {alt="Perfect authentic cadence in G major: I, IV, V, I"}
X:1
M:4/4
L:1/2
K:G
[Bdg] [ceg] | [Adf] [Bdg] |]
w: I IV V I
```
````

### 2 · Give every score the book's look

The [short answer](https://postext.dev/en/cookbook/harmony-textbook-abc-examples.md#the-short-answer) is the whole design of the music: a 5.5 mm staff, as harmony books use for examples read at the keyboard, Bravura beside the Caslon text, and the numerals in the text face instead of the engraver's Times. `spacing: 1.6` spreads four half-note chords over a third of the measure, so a progression reads chord by chord. The same setting decides how many bars of *Greensleeves* fit on a system: three, in a 125 mm measure.

### 3 · Let a long example run on

`minSystemsAtBreak: 2` is what keeps *Greensleeves* readable across the turn. The melody comes out as five systems; three fit at the foot of page 3, so two go to page 4, and neither page is left with a single system. A score with fewer than four systems moves whole to the next page, as the short cadence examples would.

### 4 · Start the engraver before the build

```js
// script.js, lines 264–270
// The engraver (Verovio) loads only for books with music: start it before the build,
// or every score is laid out as a grey placeholder box.
await initMusicEngine();
const doc = await buildDocumentWithFonts({ markdown }, config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Harmony textbook', es: 'Manual de armonía' }) });
// The PDF draws every system as vector paths, the numerals in the embedded text face.
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);
```

Verovio is about 3.7 MB of WebAssembly, so the engine loads it only when you ask. Without `initMusicEngine()` the layout still runs, and every score is a grey box of the right size. `renderToPdf` needs nothing more: the PDF draws each system as vector paths and sets the numerals in the embedded Caslon.

### 5 · Open the chapter with a sunk title

```js
// script.js, lines 55–72
const opener = {
  enabled: true,
  minHeight: mm(48),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(8.5),
      fontWeight: 700, letterSpacing: pt(1.7), textTransform: 'uppercase', color: col('accent'),
      align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(0), y: mm(12) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(34), lineHeight: 1.05, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
        size: { width: mm(110), height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('accent'),
      placement: { anchor: { to: '#title', edge: 'below' }, offset: { x: mm(0), y: mm(5) },
        size: { width: mm(16) } } },
  ] },
};
```

The opener takes its kicker from the heading's `kicker` attribute and reserves 48 mm, enough for the title and its rule with the first example still on page 1.

## 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/harmony-textbook-abc-examples

### script.js

````js
// ═══ Postext Cookbook · Nº 163 · Harmony textbook with ABC examples ═════════════
// https://postext.dev/en/cookbook/harmony-textbook-abc-examples
// Code: MIT · Text: original (CC BY 4.0) · Music: Greensleeves (traditional, PD)
// Fonts: Libre Caslon Text & Display, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.54.0
import {
  buildDocumentWithFonts, renderPageToCanvas, initMusicEngine,
} 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' | 'es')
const RECIPE = 'harmony-textbook-abc-examples';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a conservatory green for the accents, warm ink for text and notes
const palette = {
  ink: '#1f1c19', // text and notation: a warm near-black
  accent: '#2f5d4f', // kickers, rules, Roman numerals in the table
  tint: '#edf2ef', // the box of key terms
  rule: '#c3cfc9', // hairlines
  muted: '#5c6863', // running heads, colophon
  paper: '#ffffff',
};
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 accent, never the default blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const TRIM = { width: 180, height: 250 };
const MARGIN = { top: 22, bottom: 24, inner: 22, outer: 33 }; // a 125 mm measure
const LEAD = 14.5; // body leading in pt: the baseline grid
const LABEL = 'Libre Franklin';

// #region answer: one score style for every example in the text
// Each ```abc fence in the chapter is engraved at the width of the text column and cut
// into systems. The style is set once here; the Markdown only holds the notation.
const scoreStyle = {
  staffSize: mm(5.5), // a textbook's rastral: examples read at arm's length on a desk
  musicFont: 'Bravura', // the traditional engraved look, heavy enough beside Caslon
  textFont: 'Libre Caslon Text', // the Roman numerals of `w:` lines, in the text face
  spacing: 1.6, // air between the chords: an example reads as a progression, not a cluster
  barNumbers: 'system',
  systemGap: mm(4),
  marginTop: pt(LEAD / 2),
  marginBottom: pt(LEAD / 2),
  color: col('ink'),
  // A long example runs on into the next page between two systems, never leaving one
  // system alone at the foot of a page or at the head of the next.
  minSystemsAtBreak: 2,
};
// #endregion

// #region opener: kicker, Caslon Display title and a short green rule, sunk 48 mm
const opener = {
  enabled: true,
  minHeight: mm(48),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(8.5),
      fontWeight: 700, letterSpacing: pt(1.7), textTransform: 'uppercase', color: col('accent'),
      align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(0), y: mm(12) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(34), lineHeight: 1.05, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
        size: { width: mm(110), height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('accent'),
      placement: { anchor: { to: '#title', edge: 'below' }, offset: { x: mm(0), y: mm(5) },
        size: { width: mm(16) } } },
  ] },
};
// #endregion

// #region running-heads: book title on the verso, chapter on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5),
  fontWeight: 600, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } }, ...extra,
});
const folio = { fontWeight: 700, color: col('accent') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio),
  head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 9),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, folio),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom', 0, {
  ...folio, pages: 'opener', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { x: mm(0), y: mm(-12) } },
})] };
// #endregion

const config = {
  locale: t({ en: 'en-us', es: 'es' }), // the language the text is hyphenated in
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true }, // left = inner
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: 'Libre Caslon Text', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4.5),
    indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: 'Libre Caslon Text', color: col('ink'), fontWeight: 700,
    levels: [
      { level: 1, fontSize: pt(34), breakBefore: { parity: 'odd' }, advancedDesign: opener },
      { level: 2, fontFamily: LABEL, fontSize: pt(9), lineHeight: pt(LEAD), fontWeight: 700,
        letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('accent'),
        marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) },
    ],
  },
  orderedLists: { numberFormat: 'arabic', color: col('accent'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  calloutStyles: [
    { id: 'terms', title: t({ en: 'Key terms', es: 'Términos clave' }), background: col('tint'),
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
      padding: { top: mm(3), right: mm(4), bottom: mm(3), left: mm(4) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 700,
        textTransform: 'uppercase', letterSpacing: pt(1.2), color: col('accent') },
      body: { fontSize: pt(9.5), lineHeight: pt(13), color: col('ink'), firstLineIndent: pt(0),
        boldColor: col('accent'), italicColor: col('ink'), textAlign: 'left' } },
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('accent'), headerColor: col('paper'), headerFontFamily: LABEL,
    headerFontSize: pt(7.5), bodyFontFamily: 'Libre Caslon Text', bodyFontSize: pt(9),
    bodyColor: col('ink'), cellPadding: mm(1.4) },
  scoreStyle,
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = `---
title: "Harmony at the Keyboard"
---

# Cadences {kicker="Chapter 5"}

A phrase of music ends the way a sentence does: with a pause that tells the listener how much has been said. Harmony marks that pause with a cadence, a short chord progression at the end of a phrase. Some cadences close the thought, others leave it open and ask for the next phrase. Hearing the difference is the first step towards writing phrases that end where you mean them to end.

This chapter works in G major, in three upper voices in close position. The bass is not written: it takes the root of each chord, as the Roman numeral under the staff says, upper case for a major chord and lower case for a minor one. Play every example at the keyboard, the right hand on the staff and the left hand on the bass note, and sing the top voice before you read on.

:::callout{style="terms"}
**Cadence:** the last two chords of a phrase. **Soprano:** the top voice. **Leading tone:** the seventh degree of the scale, a semitone below the tonic, which wants to rise to it.
:::

## The perfect authentic cadence

The strongest close is dominant to tonic, V–I, with both chords in root position and the tonic in the soprano at the end. The leading tone (F-sharp in G major) rises a semitone to the tonic.

\`\`\`abc {alt="Perfect authentic cadence in G major: I, IV, V, I, the soprano ending on G"}
X:1
M:4/4
L:1/2
K:G
[Bdg] [ceg] | [Adf] [Bdg] |]
w: I IV V I
\`\`\`

The approach to the dominant matters as much as the cadence. Here IV prepares V: the upper voices hold G, then step down, and nothing jumps. A phrase that reaches V by leap sounds hurried, as if it had skipped a word.

## The imperfect authentic cadence

Keep the same chords and end with the third of the tonic in the soprano, and the close softens. The progression is still V–I, but the melody stops on B instead of G and the ear expects more. Composers use this cadence in the middle of a period and save the perfect one for its end.

\`\`\`abc {alt="Imperfect authentic cadence in G major: V to I with B in the soprano"}
X:2
M:4/4
L:1/2
K:G
[dgb] [egc'] | [dfa] [dgb] |]
w: I IV V I
\`\`\`

## The half cadence

A phrase may stop on the dominant itself. The half cadence ends on V, reached from I, ii or IV, and it sounds like a comma: the music has said something and owes us the rest. Most folk songs and hymns put a half cadence at the end of their first line and an authentic cadence at the end of the second.

\`\`\`abc {alt="Half cadence in G major: I, IV, V, held"}
X:3
M:4/4
L:1/2
K:G
[Bdg] [ceg] | [A2d2f2] |]
w: I IV V
\`\`\`

## The plagal cadence

IV–I closes without the leading tone. It is the cadence of the *Amen* sung after a hymn, and it usually follows an authentic cadence that has already ended the phrase: the plagal one adds a coda, not a conclusion.

\`\`\`abc {alt="Plagal cadence in G major: I, IV, I"}
X:4
M:4/4
L:1/2
K:G
[Bdg] [ceg] | [B2d2g2] |]
w: I IV I
\`\`\`

## The deceptive cadence

When V moves to vi instead of I, the listener gets a chord that shares two notes with the tonic but is not the tonic. The leading tone still rises to G, so the voice leading keeps its promise while the harmony breaks it. The other two upper voices step down, and the chord doubles its third, G, which keeps the bass and the inner voices out of parallel octaves. The phrase has to go on, and it usually ends with an authentic cadence a few bars later.

\`\`\`abc {alt="Deceptive cadence in G major: V to VI, the leading tone rising to G"}
X:5
M:4/4
L:1/2
K:G
[ceg] [Adf] | [G2B2g2] |]
w: IV V vi
\`\`\`

## Cadences in a melody

A melody shows its cadences even without chords. *Greensleeves*, an English tune from the sixteenth century, is written here in A minor with a Roman numeral over each bar, the chords a guitarist would play. Its first phrase stops on E, the dominant, over a half cadence; the second ends on A over V–i, the leading tone G-sharp rising to the tonic. The same plan returns in the refrain, which starts on a high G over C, the relative major, and comes back to A minor for the final close. Sing it, mark each cadence and say which kind it is before you check the numerals over the staff.

\`\`\`abc {alt="Greensleeves in A minor with a Roman numeral over each bar"}
X:6
M:6/8
L:1/8
K:Am
A | "i"c2 d e>fe | "VII"d2 B G>AB | "i"c2 A A>^GA | "V"B2 ^G E2 A |
"i"c2 d e>fe | "VII"d2 B G>AB | "i"c>BA "V"^G>^FG | "i"A3 A2 z |
"III"g3 g>fe | "VII"d2 B G>AB | "i"c2 A A>^GA | "V"B2 ^G E3 |
"III"g3 g>fe | "VII"d2 B G>AB | "i"c>BA "V"^G>^FG | "i"A3 A2 |]
\`\`\`

The four phrases follow the plan of most folk songs: open, closed, open, closed. Notice where the leading tone appears. In the half cadences G-sharp sits inside the dominant chord and goes nowhere; in the authentic cadences it is the note before the last, and it resolves.

## Exercises

1. Write each cadence of this chapter in D major and in F major, in three voices, and play it with the bass in the left hand.
2. Turn the imperfect authentic cadence into a perfect one by changing a single note of the soprano.
3. Find the cadences of a hymn you know. For each phrase, write the last two Roman numerals and name the cadence.
4. Harmonize the first phrase of *Greensleeves* again, ending it with an imperfect authentic cadence instead of a half cadence. What changes in the second phrase?


:::paragraphs{style="colophon"}
Set in Libre Caslon Text, Libre Caslon Display and Libre Franklin (SIL OFL) · Music engraved by Verovio in Bravura · Text: CC BY 4.0 · *Greensleeves*: traditional
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Libre Caslon Text': ['400', '400i', '700'],
  'Libre Caslon Display': ['400'],
  'Libre Franklin': ['600', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region engine: start the engraver, build, and draw the scores into the PDF too
// The engraver (Verovio) loads only for books with music: start it before the build,
// or every score is laid out as a grey placeholder box.
await initMusicEngine();
const doc = await buildDocumentWithFonts({ markdown }, config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Harmony textbook', es: 'Manual de armonía' }) });
// The PDF draws every system as vector paths, the numerals in the embedded text face.
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);
// #endregion

// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  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 text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no 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

### Use Leland for a lighter page

Leland, MuseScore's font, has thinner stems and smaller noteheads than Bravura; it suits a book set in a light text face.

```diff
-  musicFont: 'Bravura',
+  musicFont: 'Leland',
```

### Write a second voice in another format

The ABC reader takes one voice. For a soprano and bass on separate staves, write the example in Humdrum or MusicXML; [the conservatory method](https://postext.dev/en/cookbook/conservatory-method-chorales.md) sets four-part chorales that way.

## Pitfalls

- Write a chord's length inside the brackets, `[B2d2g2]`, or set `L:` to the chord's value: a length after the bracket, `[Bdg]2`, is read as the default length.
- Verovio's ABC reader gives each syllable of a `w:` line to the next note and ignores `*` and `_`, so a `w:` line cannot skip notes. To mark a few bars, write the marks as chord symbols over the staff, as the *Greensleeves* example does.
- A text face rarely has the ♯ and ♭ signs: write "F-sharp" in the prose, or the browser sets the sign from a font of the system.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The chapter on cadences and its examples, written for this recipe: Ignacio Ferro, CC-BY-4.0
- Text: Greensleeves, English tune of the sixteenth century, in its common modern form: Traditional ([source](https://en.wikipedia.org/wiki/Greensleeves)), public domain
- Type: Libre Caslon Text (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Franklin (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 008 · A colour-coded family of textbook boxes](https://postext.dev/en/cookbook/textbook-box-family.md): Six kinds of textbook box in three colours, told apart by a stripe with an icon, a badge, a numbered tab, a strip of pictograms or a marker outside the frame. · Level 2 (Intermediate) · Textbooks
- [Nº 001 · Textbook with a margin column](https://postext.dev/en/cookbook/textbook-margin-column.md): A column-and-a-half page whose outer column holds only floats: span 'side' figures and glosses stack there, and captionSide moves the other captions into it. · Level 3 (Advanced) · Textbooks
- [Nº 124 · A school reader with furigana over every kanji](https://postext.dev/en/cookbook/jukugo-furigana-textbook.md): Niimi Nankichi’s “Gon the Fox” on a reader page: per-character, word and jukugo furigana, spaced 1:2:1 and running onto kana but never onto kanji. · Level 2 (Intermediate) · Textbooks
