# MusicXMLから数ページにわたるピアノ譜を組む

> 2曲の鍵盤用メヌエットをMusicXMLファイルから大譜表に刻譜し、段と段のあいだで改ページして、1曲が見開きに収まるようにします。

- HTML版: https://postext.dev/ja/cookbook/piano-score-from-musicxml
- レシピ No. 167 · 図と画像 · 難易度 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/piano-score-from-musicxml/en/p01.webp?v=c38bdad1), [2](https://postext.dev/cookbook/piano-score-from-musicxml/en/p02.webp?v=c38bdad1), [3](https://postext.dev/cookbook/piano-score-from-musicxml/en/p03.webp?v=c38bdad1), [4](https://postext.dev/cookbook/piano-score-from-musicxml/en/p04.webp?v=c38bdad1), [5](https://postext.dev/cookbook/piano-score-from-musicxml/en/p05.webp?v=c38bdad1)
- PDF: https://postext.dev/cookbook/piano-score-from-musicxml/en/piano-score-from-musicxml.pdf?v=c38bdad1
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=piano-score-from-musicxml&lang=en (.postext: https://postext.dev/cookbook/piano-score-from-musicxml/en/piano-score-from-musicxml.postext)
- 最終更新: 2026-10-22
- 他の言語: [en](https://postext.dev/en/cookbook/piano-score-from-musicxml.md), [es](https://postext.dev/es/cookbook/piano-score-from-musicxml.md), [ca](https://postext.dev/ca/cookbook/piano-score-from-musicxml.md), [de](https://postext.dev/de/cookbook/piano-score-from-musicxml.md), [fr](https://postext.dev/fr/cookbook/piano-score-from-musicxml.md), [it](https://postext.dev/it/cookbook/piano-score-from-musicxml.md), [pt](https://postext.dev/pt/cookbook/piano-score-from-musicxml.md), [zh](https://postext.dev/zh/cookbook/piano-score-from-musicxml.md), [ar](https://postext.dev/ar/cookbook/piano-score-from-musicxml.md)

## かんたんな説明

よく知られたピアノのメヌエット2曲を収めた小さな楽譜です。記譜ソフトから書き出したファイルを、ページをまたいで続き、そのまま弾けるピアノ譜に組む方法を示します。

## できあがり

1725年の『アンナ・マグダレーナ・バッハの音楽帳』にある2曲のメヌエットの演奏用の版です。ピアノを習う人ならだれもが弾くト長調の曲と、対になるト短調の曲で、いまはクリスティアン・ペツォールトの作とされています。判型は230 × 305 mmで、楽譜集の大きさです。最初の奇数ページで、52ポイントのBodoniの題の下に、Libre Caslonの2段組みで曲を紹介します。続いて各メヌエットは偶数ページから始まり、見開きいっぱいに7 mmの大譜表で、MusicXMLファイルからBravuraで刻譜されます。1段に4小節、第1〜16小節が左ページ、第17〜32小節が右ページなので、1曲の途中で譜めくりはありません。

**このレシピが答える問い:**

- MusicXMLファイルのピアノ譜を数ページにわたって組むには？

## 手短な答え

```js
// script.js, 行 36–61
const scoreStyle = {
  staffSize: mm(7), // the rastral of a performing edition: read at arm's length
  musicFont: 'Bravura', // the engraved plate look
  barNumbers: 'system', // a number at the head of each system, as editions print them
  spacing: 1.7, // about four bars to a system, eight systems to a piece
  systemGap: mm(14), // room for the ledger lines above and below the grand staff
  marginTop: pt(LEAD),
  marginBottom: pt(0),
  minSystemsAtBreak: 2, // never one system alone at a foot or the head of a page
  color: col('ink'),
};
// A fence breaks between systems, so a piece runs on over pages; a score resource is kept
// whole. The file is what notation software exports: the Markdown keeps a placeholder
// line in each fence and the pen puts the fetched file there.
const SCORES = {
  'bwv-anh-114': asset('bwv-anh-114.musicxml'),
  'bwv-anh-115': asset('bwv-anh-115.musicxml'),
};
async function withScores(text) {
  const files = await Promise.all(Object.entries(SCORES).map(async ([id, url]) => {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Score not found (${res.status}): ${url}`);
    return [id, (await res.text()).trim()];
  }));
  return files.reduce((md, [id, xml]) => md.replace(`{{${id}}}`, () => xml), text);
}
```

## 材料

**学べること**

- [楽譜の記譜](https://postext.dev/ja/docs/music.md#本文中の楽譜): ```abc、```musicxml、```mei、```humdrumのフェンスにテキストで書いた楽譜です。Verovioが段の幅で浄書し、段や次のページへ続く段落（システム）に分けます。
- [楽譜スタイル](https://postext.dev/ja/docs/configuration-resources.md#楽譜スタイル): scoreStyleと名前付きのscoreStyles。五線の大きさ、音楽フォント、文字のフォント、間隔、小節番号、段（システム）の間隔、余白、インク、改ページで一緒に保つ段の数を決めます。

**ほかに使うもの**

- [デザインした章扉](https://postext.dev/ja/docs/configuration-headings.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [見出しスタイル](https://postext.dev/ja/docs/configuration-styles.md#見出しスタイル)
- [柱とノンブル](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル)
- [見開きの余白](https://postext.dev/ja/docs/configuration-page-layout.md#見開きの余白)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration-headers-footers.md#テキスト要素)
- [囲みの中や本文中の段組み](https://postext.dev/ja/docs/document-format-directives.md#columns)
- [段落スタイル](https://postext.dev/ja/docs/configuration-styles.md#段落スタイル)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.md#カラーパレット)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration-pdf-output.md#pdfの生成)
- [コードリスト](https://postext.dev/ja/docs/configuration-styles.md#コードリスト)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration-pdf-output.md#なぜフォントプロバイダーが必要か)
- [節ごとの柱](https://postext.dev/ja/docs/configuration-styles.md#見出しスタイル)
- [番号のない章](https://postext.dev/ja/docs/configuration-styles.md#見出しスタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration-text.md#本文), [`colorPalette`](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration-styles.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration-headings.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration-page-layout.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration-language.md#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration-page-layout.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration-styles.md#段落スタイル), [`scoreStyle`](https://postext.dev/ja/docs/configuration-resources.md#楽譜スタイル)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ja/docs/configuration-programmatic-usage.md#レイアウトの前にフォントを読み込む), [`decompressWoff2`](https://postext.dev/ja/docs/configuration-pdf-output.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`initMusicEngine`](https://postext.dev/ja/docs/music.md#本文中の楽譜), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration-programmatic-usage.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration-pdf-output.md#pdfの生成)

**書体**

- Libre Caslon Text (OFL-1.1), Bodoni Moda (OFL-1.1), Archivo Narrow (OFL-1.1)

## 作り方

### 1 · 曲をMusicXMLで持ち込む

`assets/`の2つのファイルはふつうのMusicXML 4.0で、Dorico、Sibelius、Finale、MuseScoreが楽譜を書き出すときの形式です（`.mxl`ではなく、圧縮しない`.musicxml`を選びます）。2つの譜表に1つのパートがあり、音部記号、調号、連桁、2声の小節の符幹の向き、装飾音、反復記号を含みます。パート名には`print-object="no"`を付けています。「Piano」というラベルが印刷されると、すべての段が右へずれるためです。`<work-title>`の題は印刷されず、楽譜の説明として読まれるだけなので、Markdownで曲ごとに見出しを付けます。

### 2 · リソースではなくコードブロックに入れる

[手短な答え](https://postext.dev/ja/cookbook/piano-score-from-musicxml.md#手短な答え)は各ファイルを取得し、Markdownに仮の1行を置いた` ```musicxml `ブロックに書き込みます。ブロックは段の幅で刻譜されて段ごとに分かれ、各段が段落の1行のように配置されるので、曲は段と段のあいだで次のページへ続きます。`score`リソースはひとまとまりのまま置かれ、ページをまたげません。`minSystemsAtBreak: 2`により、ページの下端に1段だけが残ったり、次のページの頭に1段だけが送られたりしません。

### 3 · 弾く人に合わせて譜表の大きさを決める

譜表の高さは7 mmで、譜面台に置いて読む印刷されたピアノ曲集の大きさです。`spacing: 1.7`で音符の間隔を広げ、186 mmの行長の1段に4小節が入るようにしたので、メヌエットの前半と後半がそれぞれ4段になります。`systemGap: 14 mm`は両手のあいだの加線の場所を取り、この間隔なら1ページ目の題の下に4段、向かいのページに4段が入ります。複縦線が見開きの折り目にちょうど来ます。

### 4 · どの曲も偶数ページから始める

```js
// script.js, 行 65–103
const label = (id, content, edge, x, y, extra = {}) => ({
  kind: 'text', id, content, fontFamily: LABEL, fontSize: pt(9), fontWeight: 500,
  color: col('ink'), align: edge.endsWith('right') ? 'right' : 'left',
  placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(y) } }, ...extra,
});
const kicker = { fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.6),
  textTransform: 'uppercase', color: col('accent'), align: 'center' };
const centred = (to, y) => ({ anchor: { to, edge: to === 'container' ? 'top' : 'below' },
  offset: { x: mm(0), y: mm(y) }, size: { width: 'fill' } });
const pieceOpener = {
  enabled: true,
  minHeight: mm(34),
  slot: { elements: [
    label('kicker', '{attr.kicker}', 'top', 0, 2,
      { ...kicker, placement: centred('container', 2) }),
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(26),
      fontWeight: 500, color: col('ink'), align: 'center',
      placement: centred('#kicker', 2.5) },
    label('catalogue', '{attr.catalogue}', 'bottom-left', 0, -3),
    label('composer', '{attr.composer}', 'bottom-right', 0, -3, { italic: true }),
  ] },
};
const frontOpener = {
  enabled: true,
  minHeight: mm(78),
  slot: { elements: [
    label('kicker', '{attr.kicker}', 'top', 0, 14,
      { ...kicker, placement: centred('container', 14) }),
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(52),
      fontWeight: 500, color: col('ink'), align: 'center',
      placement: centred('#kicker', 5) },
    { kind: 'text', id: 'composer', content: '{attr.composer}', fontFamily: SERIF,
      fontSize: pt(13), italic: true, color: col('ink'), align: 'center',
      placement: centred('#title', 4) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('accent'),
      placement: { anchor: { to: '#composer', edge: 'below', at: 'center' }, offset: { y: mm(8) },
        size: { width: mm(24) } } },
  ] },
};
```

`breakBefore: { parity: 'even' }`で各曲を左ページから始めます。編者による扉は別の見出しスタイルを使い、柱を付けずに奇数ページから始まります。作品番号と作曲者は見出しの属性から取り、刻譜された版と同じく、曲の始まりの下の両隅に置きます。

### 5 · 右ページの柱に曲名を入れる

```js
// script.js, 行 107–120
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body',
  fontFamily: LABEL, fontSize: pt(8), fontWeight: 600, letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra,
});
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{chapterTitle}', 'even', 'top-left', OUTER + 9),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [] };
```

曲の最初のページにはすでに題があるので、柱はその後のページだけに付けます（`pages: 'body'`）。奇数ページは小口側に曲名とノンブルを、8ポイントのラベル書体で印刷します。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/piano-score-from-musicxml

### script.js

```js
// ═══ Postext Cookbook · Nº 167 · A piano score from MusicXML over several pages ═══════
// https://postext.dev/en/cookbook/piano-score-from-musicxml
// Code: MIT · Text: original (CC BY 4.0) · Music: Mutopia Project (public domain)
// Fonts: Libre Caslon Text, Bodoni Moda, Archivo Narrow (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 = 'piano-score-from-musicxml';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a plate-black ink, one deep green accent, every colour linked by id
const palette = {
  ink: '#1b1a18', // the notes and the text: a warm near-black
  accent: '#2f5a46', // titles' kickers, rules: the green of an old music-shop wrapper
  tint: '#eef2ee',
  rule: '#b9c4bc', // hairlines
  muted: '#625f59', // running heads, the 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' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const SERIF = 'Libre Caslon Text';
const DISPLAY = 'Bodoni Moda';
const LABEL = 'Archivo Narrow';
const OUTER = 20; // outer margin in mm: the running heads align to it
const LEAD = 14.5; // body leading in pt

// #region answer: the score style of a performing edition, and a MusicXML file in a fence
const scoreStyle = {
  staffSize: mm(7), // the rastral of a performing edition: read at arm's length
  musicFont: 'Bravura', // the engraved plate look
  barNumbers: 'system', // a number at the head of each system, as editions print them
  spacing: 1.7, // about four bars to a system, eight systems to a piece
  systemGap: mm(14), // room for the ledger lines above and below the grand staff
  marginTop: pt(LEAD),
  marginBottom: pt(0),
  minSystemsAtBreak: 2, // never one system alone at a foot or the head of a page
  color: col('ink'),
};
// A fence breaks between systems, so a piece runs on over pages; a score resource is kept
// whole. The file is what notation software exports: the Markdown keeps a placeholder
// line in each fence and the pen puts the fetched file there.
const SCORES = {
  'bwv-anh-114': asset('bwv-anh-114.musicxml'),
  'bwv-anh-115': asset('bwv-anh-115.musicxml'),
};
async function withScores(text) {
  const files = await Promise.all(Object.entries(SCORES).map(async ([id, url]) => {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Score not found (${res.status}): ${url}`);
    return [id, (await res.text()).trim()];
  }));
  return files.reduce((md, [id, xml]) => md.replace(`{{${id}}}`, () => xml), text);
}
// #endregion

// #region openers: title centred, the catalogue number left, the composer right
const label = (id, content, edge, x, y, extra = {}) => ({
  kind: 'text', id, content, fontFamily: LABEL, fontSize: pt(9), fontWeight: 500,
  color: col('ink'), align: edge.endsWith('right') ? 'right' : 'left',
  placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(y) } }, ...extra,
});
const kicker = { fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.6),
  textTransform: 'uppercase', color: col('accent'), align: 'center' };
const centred = (to, y) => ({ anchor: { to, edge: to === 'container' ? 'top' : 'below' },
  offset: { x: mm(0), y: mm(y) }, size: { width: 'fill' } });
const pieceOpener = {
  enabled: true,
  minHeight: mm(34),
  slot: { elements: [
    label('kicker', '{attr.kicker}', 'top', 0, 2,
      { ...kicker, placement: centred('container', 2) }),
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(26),
      fontWeight: 500, color: col('ink'), align: 'center',
      placement: centred('#kicker', 2.5) },
    label('catalogue', '{attr.catalogue}', 'bottom-left', 0, -3),
    label('composer', '{attr.composer}', 'bottom-right', 0, -3, { italic: true }),
  ] },
};
const frontOpener = {
  enabled: true,
  minHeight: mm(78),
  slot: { elements: [
    label('kicker', '{attr.kicker}', 'top', 0, 14,
      { ...kicker, placement: centred('container', 14) }),
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(52),
      fontWeight: 500, color: col('ink'), align: 'center',
      placement: centred('#kicker', 5) },
    { kind: 'text', id: 'composer', content: '{attr.composer}', fontFamily: SERIF,
      fontSize: pt(13), italic: true, color: col('ink'), align: 'center',
      placement: centred('#title', 4) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('accent'),
      placement: { anchor: { to: '#composer', edge: 'below', at: 'center' }, offset: { y: mm(8) },
        size: { width: mm(24) } } },
  ] },
};
// #endregion

// #region running-heads: the piece on every page after its first, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body',
  fontFamily: LABEL, fontSize: pt(8), fontWeight: 600, letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra,
});
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{chapterTitle}', 'even', 'top-left', OUTER + 9),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [] };
// #endregion

const config = {
  locale: t({ en: 'en-us', es: 'es' }),
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(230), height: mm(305), dpi: 150,
    margins: { top: mm(22), bottom: mm(22), left: mm(24), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: SERIF, fontSize: pt(10), 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: DISPLAY, color: col('ink'), fontWeight: 500,
    levels: [
      // Each piece opens on a verso, so it reads across the spread with no page turn.
      { level: 1, fontSize: pt(26), breakBefore: { parity: 'even' }, advancedDesign: pieceOpener },
      { level: 2, fontFamily: LABEL, fontSize: pt(8.5), lineHeight: pt(LEAD), fontWeight: 700,
        letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('accent'),
        marginTop: pt(LEAD), marginBottom: pt(0) },
    ],
  },
  headingStyles: [
    { id: 'front', numbered: false, breakBefore: { parity: 'odd' }, advancedDesign: frontOpener,
      header: { elements: [] }, footer: { elements: [] } },
  ],
  paragraphStyles: [
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  scoreStyle,
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = await withScores(`---
title: "Two Menuets"
---

# Two Menuets {style="front" kicker="From the Notebook for Anna Magdalena Bach, 1725" composer="Christian Petzold (1677–1733)"}

:::columns{count=2}

## The pieces

In 1725 Johann Sebastian Bach gave his wife Anna Magdalena a bound book of blank music paper. Over the next years she and the rest of the household filled it with keyboard pieces, songs and chorales: two of Bach's own partitas, arias, a few marches and polonaises, and pieces by other composers that the family played at home. The two menuets in this edition stand one after the other in Anna Magdalena's hand, the first in G major, the second in G minor.

For two centuries both were taken for Bach's own work. The Bach-Werke-Verzeichnis lists them among the doubtful works, as Anh. 114 and Anh. 115, and the first became one of the best-known pieces of piano teaching under his name. In the 1970s both were found in a harpsichord suite by Christian Petzold, an organist in Dresden, and they are now given to him.

Each menuet has 32 bars in two halves of 16, and each half is repeated. The first stays close to G major and visits D major in its second half; the second answers it in G minor and turns to B flat major at its double bar. Paired dances of the period were often played as a set: Menuet I, Menuet II, then Menuet I again without its repeats.

## This edition

The notes follow the edition of the Bach-Gesellschaft, as entered by Allen Garvin for the Mutopia Project, which placed it in the public domain. They were converted to MusicXML 4.0 and proofread bar by bar against that source: pitches, rhythms, accidentals, the two voices of the closing bars and the beaming. The ornaments are the source's, and so are the grace note in bar 8 and the reminder natural in bar 24 of Menuet I.

Neither the source nor this edition gives fingering, dynamics or slurs. The margins leave room to add them in pencil.

## Performance notes

A menuet of this kind is felt in one beat to the bar rather than three, at around crotchet = 120 to 132. Crotchets sound well slightly detached and quavers joined, which is how harpsichordists phrase the piece; on a piano, use the pedal sparingly, at the cadences if at all.

The mordent, the sign with a stroke through it, is the main note, the note below and the main note again, quick and on the beat. The short trill without a stroke, in bar 30 of Menuet I and in bars 8, 9, 15 and 31 of Menuet II, starts on the note above, on the beat.

Each menuet is set across a pair of facing pages, so no page is turned inside a piece. The one turn comes between the two menuets.

:::paragraphs{style="colophon"}
Set in Libre Caslon Text, Bodoni Moda and Archivo Narrow (SIL OFL). Music in Bravura (SIL OFL), engraved by Verovio. Notes after the Mutopia Project (public domain).
:::

:::

# Menuet in G major {kicker="Menuet I" catalogue="BWV Anh. 114" composer="Christian Petzold"}

\`\`\`musicxml {alt="Menuet in G major, BWV Anh. 114, by Christian Petzold, for keyboard"}
{{bwv-anh-114}}
\`\`\`

# Menuet in G minor {kicker="Menuet II" catalogue="BWV Anh. 115" composer="Christian Petzold"}

\`\`\`musicxml {alt="Menuet in G minor, BWV Anh. 115, by Christian Petzold, for keyboard"}
{{bwv-anh-115}}
\`\`\`
`); // content.<lang>.md, then the two files

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Libre Caslon Text': ['400', '400i', '700'],
  'Bodoni Moda': ['500'],
  'Archivo Narrow': ['400', '500', '500i', '600', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await initMusicEngine(); // the engraver (Verovio) loads only for books with music
const doc = await buildDocumentWithFonts({ markdown }, config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Two Menuets', es: 'Dos minuetos' }) });
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 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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 小さめの譜表で学習用の版にする

6 mmの譜表と刻譜エンジン本来の間隔なら1段に7〜8小節が入り、各メヌエットが最初のページに収まります。

```diff
-  staffSize: mm(7),
+  staffSize: mm(6),
-  spacing: 1.7,
+  spacing: 1,
```

### 短い譜例には番号を付ける

本文で引用する短い譜例なら、番号付きの`score`リソースが向いています：[4声のコラールを番号付きの譜例にする](https://postext.dev/ja/cookbook/conservatory-method-chorales.md)。

## よくあるつまずき

- ファイルに書かれた段の区切り（`<print new-system="yes">`）は保たれません。段は段の幅いっぱいに組まれるので、`spacing`と`staffSize`を調整して、望む小節数が入るようにします。
- 刻譜エンジンは段ごとの小節数をならさず、最後の段は本来の幅で組みます。6 mm、`spacing: 1`では第32小節だけで1段になります。最後の段に2小節以上が入るまで`spacing`を変えます。
- 圧縮された`.mxl`はzipアーカイブです。取得する前に展開するか、圧縮しない形式で書き出します。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: The introduction and performance notes, written for this edition: Ignacio Ferro, CC-BY-4.0
- 画像: Menuet in G major, BWV Anh. 114: the notes of the Bach-Gesellschaft edition as entered for the Mutopia Project (Mutopia-2017/01/19-75), converted to MusicXML 4.0 and proofread bar by bar for this edition: Christian Petzold; Allen Garvin (Mutopia Project) ([出典](https://www.mutopiaproject.org/cgibin/piece-info.cgi?id=75)), パブリックドメイン
- 画像: Menuet in G minor, BWV Anh. 115: the notes of the Bach-Gesellschaft edition as entered for the Mutopia Project (Mutopia-2015/08/21-76), converted to MusicXML 4.0 and proofread bar by bar for this edition: Christian Petzold; Allen Garvin (Mutopia Project) ([出典](https://www.mutopiaproject.org/cgibin/piece-info.cgi?id=76)), パブリックドメイン
- 書体: Libre Caslon Text (OFL-1.1), Bodoni Moda (OFL-1.1), Archivo Narrow (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 163 · ABCで譜例を書く和声の教科書](https://postext.dev/ja/cookbook/harmony-textbook-abc-examples.md): 終止を扱う和声の教科書の1章です。譜例はMarkdownに書いたABCのブロックで、段の幅に合わせて浄書され、そのうち1つは2ページにわたって続きます。 · 難易度 2 (中級) · 教科書
- [No. 164 · 旋律と歌詞とコードを載せた歌集](https://postext.dev/ja/cookbook/songbook-melody-words-chords.md): 各曲はABCのブロックです。旋律、譜表の上のコード名、音符の下の1番の歌詞を、1つの楽譜スタイルで段の幅に合わせて浄書します。 · 難易度 2 (中級) · 詩歌, 一枚物とエフェメラ
- [No. 165 · MEIの譜例と、注に置く校異](https://postext.dev/ja/cookbook/musicology-mei-apparatus.md): MEI 5のブロックを5 mmの譜表で本文中に刻み、異読はapp・lem・rdgで符号化し、校異は小節と音符ごとに脚注に置きます。 · 難易度 2 (中級) · 論文・学術書
