# 用ABC写谱例的和声教程

> 和声教程中讲终止式的一章：谱例是写在Markdown中的ABC代码块，按栏宽刻印成谱，其中最长的一例像段落一样从一页接排到下一页。

- HTML版本: https://postext.dev/zh/cookbook/harmony-textbook-abc-examples
- 食谱 No. 163 · 教科书 · 难度 2 (中级) · 输出: Canvas, PDF
- 体裁: 教科书
- 需要 postext ≥ 1.54.0, postext-pdf ≥ 1.54.0 · 已用 1.54.0, postext-pdf 1.54.0 测试，日期 2026-10-22
- 页面: [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
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=harmony-textbook-abc-examples&lang=en (.postext: https://postext.dev/cookbook/harmony-textbook-abc-examples/en/harmony-textbook-abc-examples.postext)
- 最后更新: 2026-10-22
- 其他语言: [en](https://postext.dev/en/cookbook/harmony-textbook-abc-examples.md), [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), [ja](https://postext.dev/ja/cookbook/harmony-textbook-abc-examples.md), [ar](https://postext.dev/ar/cookbook/harmony-textbook-abc-examples.md)

## 简单来说

一本乐理教材中讲乐句如何结束的一章。谱例在章节中写成几行简短的文字，Postext把它们画成谱表和音符；最长的一例像段落一样接排到下一页。

## 成品一览

一门键盘和声课程的第5章，开本180 × 250 mm，用Libre Caslon排，讲五种终止式：完全正格终止与不完全正格终止、半终止、变格终止和阻碍终止。每种终止式配两段短文和一个三声部谱例，谱表下方标出罗马数字级数，谱例用ABC直接写在Markdown里。本章以a小调的《Greensleeves》（《绿袖子》）结尾，每小节上方标一个级数。这段旋律太长，第3页剩下的空间放不下：它在那里排三行谱，最后两行排在第4页顶部，在谱行之间断开，就像段落在行与行之间断开一样。谱表大小、音乐字体和间距只在配置中设定一次，所以五个谱例和这段旋律外观一致。

**这份食谱回答的问题:**

- 怎样在教材正文里放入用记谱法写成的谱例？

## 简短回答

````js
// script.js, 行 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,
};
````

## 用料

**讲解**

- [乐谱记谱](https://postext.dev/zh/docs/music.md#正文中的乐谱): 以文本写在```abc、```musicxml、```mei或```humdrum代码块中的乐谱，由Verovio按栏宽刻印，并切成一行行谱表系统，可跨栏、跨页接排。
- [乐谱样式](https://postext.dev/zh/docs/configuration-resources.md#乐谱样式): scoreStyle及scoreStyles中的命名样式：谱表大小、音乐字体、文字字体、间距、小节号、系统间距、边距、墨色，以及断开时保留在一起的系统数。

**还用到**

- [设计过的章首页](https://postext.dev/zh/docs/configuration-headings.md#通栏与高级设计)
- [标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)
- [书眉与页码](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚)
- [对称页边距](https://postext.dev/zh/docs/configuration-page-layout.md#对称页边距)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration-headers-footers.md#文本元素)
- [标注框](https://postext.dev/zh/docs/configuration-callouts.md#标注框样式)
- [段落样式](https://postext.dev/zh/docs/configuration-styles.md#段落样式)
- [语义调色板](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板)
- [段末孤行、段首孤行与孤字](https://postext.dev/zh/docs/configuration-page-flow.md#段首孤行段末孤行孤字与不拆分规则)
- [导出PDF](https://postext.dev/zh/docs/configuration-pdf-output.md#生成pdf)
- [代码清单](https://postext.dev/zh/docs/configuration-styles.md#代码清单)
- [嵌入PDF的字体](https://postext.dev/zh/docs/configuration-pdf-output.md#为什么需要字体提供函数)
- [上标与下标](https://postext.dev/zh/docs/document-format-inline.md#行内格式)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration-text.md#正文), [`calloutStyles`](https://postext.dev/zh/docs/configuration-callouts.md#标注框样式), [`colorPalette`](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板), [`footer`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`header`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`headings`](https://postext.dev/zh/docs/configuration-headings.md#标题), [`layout`](https://postext.dev/zh/docs/configuration-page-layout.md#版式), [`locale`](https://postext.dev/zh/docs/configuration-language.md#断词), [`orderedLists`](https://postext.dev/zh/docs/configuration-lists-math.md#有序列表), [`page`](https://postext.dev/zh/docs/configuration-page-layout.md#页面), [`paragraphStyles`](https://postext.dev/zh/docs/configuration-styles.md#段落样式), [`scoreStyle`](https://postext.dev/zh/docs/configuration-resources.md#乐谱样式), [`tableStyle`](https://postext.dev/zh/docs/configuration-resources.md#表格样式)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#排版前加载字体), [`decompressWoff2`](https://postext.dev/zh/docs/configuration-pdf-output.md#浏览器字体提供函数fontsource--woff2), [`initMusicEngine`](https://postext.dev/zh/docs/music.md#正文中的乐谱), [`renderPageToCanvas`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#把页面渲染为位图), [`renderToPdf`](https://postext.dev/zh/docs/configuration-pdf-output.md#生成pdf)

**字体**

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

## 做法

### 1 · 在正文需要的地方写谱例

标为`abc`的代码块里写记谱，正文接在后面。`L:1/2`让每个不标时值的和弦成为二分音符，`[Bdg]`把三个音叠在同一个声部里，`w:`行在每个和弦下面放一个词，这里是罗马数字级数。`alt`属性是屏幕阅读器和带标签的PDF代替图片读出的内容。

````md
```abc {alt="G大调完全正格终止：I、IV、V、I"}
X:1
M:4/4
L:1/2
K:G
[Bdg] [ceg] | [Adf] [Bdg] |]
w: I IV V I
```
````

### 2 · 让每份乐谱都有这本书的样子

[简短回答](https://postext.dev/zh/cookbook/harmony-textbook-abc-examples.md#简短回答)就是乐谱的全部设计：5.5 mm的谱表，和声教程中供人在键盘前读的谱例常用这个尺寸；Bravura与Caslon正文搭配；级数用正文字体，而不用刻谱引擎的Times。`spacing: 1.6`让四个二分音符和弦铺满三分之一的版心宽度，和弦进行可以一个和弦一个和弦地读。同一设置也决定《Greensleeves》一行谱放几小节：在125 mm的版心宽度中放三小节。

### 3 · 让长谱例接排到下一页

`minSystemsAtBreak: 2`保证《Greensleeves》跨页时仍然好读。这段旋律排成五行谱；第3页底部放得下三行，所以两行移到第4页，哪一页都不会只剩一行谱。少于四行谱的乐谱会整个移到下一页，简短的终止式谱例遇到这种情况也会如此。

### 4 · 在构建之前启动刻谱引擎

```js
// script.js, 行 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约有3.7 MB的WebAssembly，所以引擎只在你要求时才加载它。不调用`initMusicEngine()`，排版照样进行，每份乐谱是一个大小正确的灰色方框。`renderToPdf`不需要别的准备：PDF把每行谱画成矢量路径，级数用嵌入的Caslon排出。

### 5 · 用下沉标题开启本章

```js
// script.js, 行 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) } } },
  ] },
};
```

章首页的眉题取自标题的`kicker`属性，并预留48 mm，足够放下标题和它下面的线，第一个谱例仍留在第1页。

## 完整食谱

一个文件，由食谱文件夹合成，示例文本和排版食谱的公共工具包都已内联；它自己排出页面。要运行它，把它放进空白页面的`<script type="module">`，或粘贴到新建CodePen的JS面板里（设为模块）。它从esm.sh导入postext，不需要安装或构建。

- 食谱文件夹: 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 ───────────────────────────────────────────────────────────────────────
````

## 变化

### 换用Leland，让页面更轻

Leland是MuseScore的字体，符干比Bravura细，符头也更小，适合用轻盈的正文字体排的书。

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

### 用另一种格式写第二个声部

ABC读取器只接受一个声部。要把女高音和低音写在不同的谱表上，就用Humdrum或MusicXML写谱例；[音乐学院教程](https://postext.dev/zh/cookbook/conservatory-method-chorales.md)就是这样排四声部众赞歌的。

## 常见问题

- 把和弦的时值写在方括号里面，如`[B2d2g2]`，或者把`L:`设成和弦的时值：写在方括号后面的时值，如`[Bdg]2`，会被当作默认时值。
- Verovio的ABC读取器把`w:`行的每个音节交给下一个音符，并忽略`*`和`_`，所以`w:`行不能跳过音符。要给少数几个小节加标记，就把标记写成谱表上方的和弦记号，《Greensleeves》的谱例就是这样做的。
- 正文字体很少带有♯和♭符号：在正文中写“升F”，否则浏览器会用系统字体排出这个符号。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 文字: The chapter on cadences and its examples, written for this recipe: Ignacio Ferro, CC-BY-4.0
- 文字: Greensleeves, English tune of the sixteenth century, in its common modern form: Traditional ([来源](https://en.wikipedia.org/wiki/Greensleeves)), 公有领域
- 字体: Libre Caslon Text (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Franklin (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 008 · 按颜色区分的一组教材标注框](https://postext.dev/zh/cookbook/textbook-box-family.md): 六种教材标注框、三种颜色，靠带图标的色条、徽标、编号页签、一排象形图或框外的标记区分。 · 难度 2 (中级) · 教科书
- [No. 001 · 带边栏的教材](https://postext.dev/zh/cookbook/textbook-margin-column.md): 一栏半的页面，外侧栏只放浮动体：span 'side'的图和术语注在那里依次堆叠，captionSide把其余图的题注移进侧栏。 · 难度 3 (高级) · 教科书
- [No. 124 · 每个汉字都标振假名的小学读本](https://postext.dev/zh/cookbook/jukugo-furigana-textbook.md): 新美南吉《小狐狸阿权》排在小学读本的页面上：单字、词组和熟语三种振假名，按1:2:1分布，可以伸到假名上，但不压到汉字上。 · 难度 2 (中级) · 教科书
