# Una partitura de piano des de MusicXML en diverses pàgines

> Dos minuets per a teclat gravats des de fitxers MusicXML en doble pentagrama, partits entre sistemes perquè cada peça ocupi una doble pàgina.

- Versió HTML: https://postext.dev/ca/cookbook/piano-score-from-musicxml
- Recepta Núm. 167 · Figures i imatges · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Qualsevol gènere
- Requereix postext ≥ 1.54.0, postext-pdf ≥ 1.54.0 · provada amb 1.54.0, postext-pdf 1.54.0 el 2026-10-22
- Pàgines: [1](https://postext.dev/cookbook/piano-score-from-musicxml/es/p01.webp?v=730eac7e), [2](https://postext.dev/cookbook/piano-score-from-musicxml/es/p02.webp?v=730eac7e), [3](https://postext.dev/cookbook/piano-score-from-musicxml/es/p03.webp?v=730eac7e), [4](https://postext.dev/cookbook/piano-score-from-musicxml/es/p04.webp?v=730eac7e), [5](https://postext.dev/cookbook/piano-score-from-musicxml/es/p05.webp?v=730eac7e)
- PDF: https://postext.dev/cookbook/piano-score-from-musicxml/es/piano-score-from-musicxml.pdf?v=730eac7e
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=piano-score-from-musicxml&lang=es (.postext: https://postext.dev/cookbook/piano-score-from-musicxml/es/piano-score-from-musicxml.postext)
- Última actualització: 2026-10-22
- Altres idiomes: [en](https://postext.dev/en/cookbook/piano-score-from-musicxml.md), [es](https://postext.dev/es/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), [ja](https://postext.dev/ja/cookbook/piano-score-from-musicxml.md), [ar](https://postext.dev/ar/cookbook/piano-score-from-musicxml.md)

## En poques paraules

Una petita edició de dos minuets de piano molt coneguts. Ensenya a agafar un fitxer exportat d'un programa de notació i compondre'l com una partitura que continua d'una pàgina a l'altra, a punt per tocar.

## Què compondràs

Una edició per a intèrprets dels dos minuets del Quadern d'Anna Magdalena Bach de 1725: el de sol major, que aprèn tot estudiant de piano, i la seva parella en sol menor, avui atribuïts a Christian Petzold. La pàgina fa 230 × 305 mm, la mida d'un àlbum de partitures. Una pàgina senar presenta les peces a dues columnes de Libre Caslon sota un títol de 52 pt en Bodoni. Després cada minuet comença en una pàgina parella i ocupa la doble pàgina en un doble pentagrama de 7 mm, gravat en Bravura des d'un fitxer MusicXML: quatre compassos per sistema, els compassos de l'1 al 16 a l'esquerra i del 17 al 32 a la dreta, de manera que el pianista no passa pàgina dins d'una peça.

**Aquesta recepta respon a:**

- Com componc una partitura de piano d'un fitxer MusicXML al llarg de diverses pàgines?

## La resposta curta

```js
// script.js, línies 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);
}
```

## Ingredients

**Ensenya**

- [Notació musical](https://postext.dev/ca/docs/music.md#una-partitura-dins-del-text): Partitures escrites com a text en blocs ```abc, ```musicxml, ```mei o ```humdrum, gravades per Verovio a l'amplada de la columna i tallades en sistemes que continuen a la columna o la pàgina següent.
- [Estil de partitura](https://postext.dev/ca/docs/configuration-resources.md#estil-de-partitura): scoreStyle i els estils amb nom de scoreStyles: mida del pentagrama, font musical, font del text, espaiat, números de compàs, separació entre sistemes, marges, tinta i quants sistemes es mantenen junts en un tall.

**També fa servir**

- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Estils de títol](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Marges simètrics](https://postext.dev/ca/docs/configuration-page-layout.md#marges-simètrics)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Columnes en un requadre o al text](https://postext.dev/ca/docs/document-format-directives.md#columns)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)
- [Llistats de codi](https://postext.dev/ca/docs/configuration-styles.md#llistats-de-codi)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#per-què-un-proveïdor-de-fonts)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament)
- [Capítols sense número](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf), [`scoreStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-de-partitura)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre), [`decompressWoff2`](https://postext.dev/ca/docs/configuration-pdf-output.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`initMusicEngine`](https://postext.dev/ca/docs/music.md#una-partitura-dins-del-text), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits), [`renderToPdf`](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)

**Tipus de lletra**

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

## Elaboració

### 1 · Porta la peça en MusicXML

Els dos fitxers d'`assets/` són MusicXML 4.0 sense més, el format que escriuen Dorico, Sibelius, Finale i MuseScore quan exporten una partitura (tria el `.musicxml` sense comprimir, no el `.mxl`). Contenen una part en dos pentagrames, amb les claus, l'armadura, les barres, la direcció de les pliques als compassos a dues veus, els ornaments i les barres de repetició. El nom de la part porta `print-object="no"`: una etiqueta «Piano» impresa empenyeria tots els sistemes cap a la dreta. El títol de `<work-title>` no s'imprimeix, només serveix de descripció de la partitura, així que el Markdown dona a cada peça el seu propi títol.

### 2 · Compon-la en un bloc de codi, no com a recurs

La [resposta curta](https://postext.dev/ca/cookbook/piano-score-from-musicxml.md#la-resposta-curta) descarrega cada fitxer i l'escriu en un bloc ` ```musicxml `, on el Markdown deixa una línia de marcador. Un bloc es grava a l'amplada de la columna i es talla en sistemes, i cada sistema es col·loca com una línia de paràgraf, de manera que la peça continua a la pàgina següent entre dos sistemes. Un recurs `score` es manté sencer i no podria creuar una pàgina. `minSystemsAtBreak: 2` evita que un sistema quedi sol al peu d'una pàgina o passi sol al capdamunt de la següent.

### 3 · Ajusta el pentagrama a qui toca

El pentagrama fa 7 mm d'alt, el que fa en un àlbum de piano imprès que es llegeix des del faristol. `spacing: 1.7` separa les notes fins que un sistema de 186 mm de mesura conté quatre compassos, així que cada meitat d'un minuet ocupa quatre sistemes. `systemGap: 14 mm` deixa lloc a les línies addicionals entre les dues mans, i amb aquest valor hi caben quatre sistemes sota el títol de la primera pàgina i quatre a la del davant: la doble barra cau al canvi de pàgina del plec.

### 4 · Obre cada peça en pàgina parella

```js
// script.js, línies 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' }` comença cada peça en una pàgina de l'esquerra, i la portada de l'editor fa servir un estil de títol que obre en pàgina senar i sense capçaleres. El número de catàleg i el compositor surten dels atributs del títol i van a les cantonades inferiors de l'obertura, on els imprimeixen les edicions gravades.

### 5 · Posa el nom de la peça a la capçalera de la dreta

```js
// script.js, línies 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: [] };
```

La primera pàgina d'una peça ja porta el seu títol, així que les capçaleres queden per a les pàgines següents (`pages: 'body'`). La pàgina senar imprimeix el títol de la peça i el foli a la vora exterior, amb la lletra de rètols a 8 pt.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: 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 = 'es'; // @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: "Dos minuetos"
---

# Dos minuetos {style="front" kicker="Del Cuaderno de Anna Magdalena Bach, 1725" composer="Christian Petzold (1677–1733)"}

:::columns{count=2}

## Las piezas

En 1725 Johann Sebastian Bach regaló a su mujer, Anna Magdalena, un cuaderno encuadernado de papel pautado en blanco. En los años siguientes ella y el resto de la casa lo llenaron de piezas para teclado, canciones y corales: dos partitas del propio Bach, arias, algunas marchas y polonesas, y obras de otros compositores que la familia tocaba en casa. Los dos minuetos de esta edición están copiados uno detrás de otro con la letra de Anna Magdalena, el primero en sol mayor y el segundo en sol menor.

Durante dos siglos ambos pasaron por obras de Bach. El catálogo Bach-Werke-Verzeichnis los incluye entre las obras dudosas, como Anh. 114 y Anh. 115, y el primero llegó a ser una de las piezas más conocidas de la enseñanza del piano con su nombre. En la década de 1970 aparecieron los dos en una suite para clave de Christian Petzold, organista en Dresde, y hoy se le atribuyen a él.

Cada minueto tiene 32 compases en dos mitades de 16, y cada mitad se repite. El primero no se aleja de sol mayor y pasa por re mayor en su segunda mitad; el segundo le responde en sol menor y llega a si bemol mayor en la doble barra. Las danzas emparejadas de la época solían tocarse como un conjunto: minueto I, minueto II y de nuevo el minueto I sin repeticiones.

## Esta edición

Las notas siguen la edición de la Bach-Gesellschaft, tal como la introdujo Allen Garvin para el Mutopia Project, que la puso en el dominio público. Se han convertido a MusicXML 4.0 y se han revisado compás a compás frente a esa fuente: alturas, ritmos, alteraciones, las dos voces de los compases finales y las barras de las corcheas. Los adornos son los de la fuente, como la apoyatura del compás 8 y el becuadro de precaución del compás 24 del minueto I.

Ni la fuente ni esta edición indican digitaciones, matices ni ligaduras. Los márgenes dejan sitio para anotarlos a lápiz.

## Notas de interpretación

Un minueto de este tipo se siente a un pulso por compás y no a tres, en torno a negra = 120 a 132. Las negras suenan bien algo separadas y las corcheas ligadas, que es como los clavecinistas frasean la pieza; en el piano, el pedal con mesura, en las cadencias y poco más.

El mordente, el signo atravesado por una raya, es la nota principal, la inferior y otra vez la principal, rápido y a tiempo. El trino breve sin raya, en el compás 30 del minueto I y en los compases 8, 9, 15 y 31 del minueto II, empieza por la nota superior, a tiempo.

Cada minueto ocupa una doble página, así que no hay que pasar página dentro de una pieza. El único paso de página cae entre los dos minuetos.

:::paragraphs{style="colophon"}
Compuesto en Libre Caslon Text, Bodoni Moda y Archivo Narrow (SIL OFL). Música en Bravura (SIL OFL), grabada con Verovio. Notas según el Mutopia Project (dominio público).
:::

:::

# Minueto en sol mayor {kicker="Minueto I" catalogue="BWV Anh. 114" composer="Christian Petzold"}

\`\`\`musicxml {alt="Minueto en sol mayor, BWV Anh. 114, de Christian Petzold, para teclado"}
{{bwv-anh-114}}
\`\`\`

# Minueto en sol menor {kicker="Minueto II" catalogue="BWV Anh. 115" composer="Christian Petzold"}

\`\`\`musicxml {alt="Minueto en sol menor, BWV Anh. 115, de Christian Petzold, para teclado"}
{{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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Fes una edició d'estudi amb un pentagrama més petit

Un pentagrama de 6 mm amb l'espaiat propi del gravador porta set o vuit compassos per sistema, així que cada minuet cap a la seva primera pàgina.

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

### Numera els exemples breus

Quan la música són exemples breus citats al text, hi encaixa millor un recurs `score` numerat: [corals a quatre veus com a exemples numerats](https://postext.dev/ca/cookbook/conservatory-method-chorales.md).

## Errors freqüents

- Els salts de sistema escrits al fitxer (`<print new-system="yes">`) no es conserven: els sistemes omplen l'amplada de la columna, així que ajusta `spacing` i `staffSize` fins que portin els compassos que vols.
- El gravador no reparteix els compassos entre sistemes, i l'últim sistema es compon a la seva amplada natural: a 6 mm i amb `spacing: 1`, el compàs 32 queda sol en un sistema. Canvia `spacing` fins que l'últim sistema tingui més d'un compàs.
- Un `.mxl` comprimit és un fitxer zip: descomprimeix-lo, o exporta el format sense comprimir, abans de descarregar-lo.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The introduction and performance notes, written for this edition: Ignacio Ferro, CC-BY-4.0
- Imatges: 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) ([font](https://www.mutopiaproject.org/cgibin/piece-info.cgi?id=75)), domini públic
- Imatges: 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) ([font](https://www.mutopiaproject.org/cgibin/piece-info.cgi?id=76)), domini públic
- Tipus de lletra: Libre Caslon Text (OFL-1.1), Bodoni Moda (OFL-1.1), Archivo Narrow (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 163 · Un manual d'harmonia amb exemples musicals en ABC](https://postext.dev/ca/cookbook/harmony-textbook-abc-examples.md): Un capítol sobre les cadències amb exemples en blocs ABC dins del Markdown, gravats a l'amplada de la columna; un d'ells continua a la pàgina següent. · Nivell 2 (Intermedi) · Llibres de text
- [Núm. 164 · Un cançoner amb melodia, lletra i acords](https://postext.dev/ca/cookbook/songbook-melody-words-chords.md): Cada cançó és un bloc ABC: melodia, xifrats sobre el pentagrama i primera estrofa sota les notes, gravats a l'amplada de columna amb un estil de partitura. · Nivell 2 (Intermedi) · Poesia, Fulls solts i efímers
- [Núm. 165 · Exemples musicals en MEI amb aparat crític a les notes](https://postext.dev/ca/cookbook/musicology-mei-apparatus.md): Blocs MEI 5 gravats dins del text amb un pentagrama de 5 mm, variants codificades amb app, lem i rdg, i l’aparat per compàs i nota en notes a peu de pàgina. · Nivell 2 (Intermedi) · Articles i treballs acadèmics
