# Una partitura de piano desde MusicXML en varias páginas

> Dos minuetos para teclado grabados desde archivos MusicXML en doble pentagrama, partidos entre sistemas para que cada pieza ocupe una doble página.

- Versión HTML: https://postext.dev/es/cookbook/piano-score-from-musicxml
- Receta N.º 167 · Figuras e imágenes · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Cualquier género
- Requiere postext ≥ 1.54.0, postext-pdf ≥ 1.54.0 · probada con 1.54.0, postext-pdf 1.54.0 el 2026-10-22
- Páginas: [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
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=piano-score-from-musicxml&lang=es (.postext: https://postext.dev/cookbook/piano-score-from-musicxml/es/piano-score-from-musicxml.postext)
- Última actualización: 2026-10-22
- Otros idiomas: [en](https://postext.dev/en/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), [ja](https://postext.dev/ja/cookbook/piano-score-from-musicxml.md), [ar](https://postext.dev/ar/cookbook/piano-score-from-musicxml.md)

## En pocas palabras

Una pequeña edición de dos minuetos de piano muy conocidos. Enseña a tomar un archivo exportado de un programa de notación y componerlo como una partitura que sigue de una página a otra, lista para tocar.

## Lo que vas a componer

Una edición para intérpretes de los dos minuetos del Cuaderno de Anna Magdalena Bach de 1725: el de sol mayor, que aprende todo estudiante de piano, y su pareja en sol menor, hoy atribuidos a Christian Petzold. La página mide 230 × 305 mm, el tamaño de un álbum de partituras. Una página impar presenta las piezas a dos columnas de Libre Caslon bajo un título de 52 pt en Bodoni. Después cada minueto empieza en una página par y ocupa la doble página en un doble pentagrama de 7 mm, grabado en Bravura desde un archivo MusicXML: cuatro compases por sistema, los compases 1 a 16 a la izquierda y del 17 al 32 a la derecha, así que el pianista no pasa página dentro de una pieza.

**Esta receta responde a:**

- ¿Cómo compongo una partitura de piano de un archivo MusicXML a lo largo de varias páginas?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Notación musical](https://postext.dev/es/docs/music.md#una-partitura-en-el-texto): Partituras escritas como texto en bloques ```abc, ```musicxml, ```mei o ```humdrum, grabadas por Verovio al ancho de la columna y cortadas en sistemas que siguen en la columna o la página siguiente.
- [Estilo de partitura](https://postext.dev/es/docs/configuration-resources.md#estilo-de-partitura): scoreStyle y los estilos con nombre de scoreStyles: tamaño del pentagrama, fuente musical, fuente del texto, espaciado, números de compás, separación entre sistemas, márgenes, tinta y cuántos sistemas se mantienen juntos en un corte.

**También usa**

- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Estilos de título](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration-page-layout.md#márgenes-simétricos-espejo)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Columnas en un recuadro o en el texto](https://postext.dev/es/docs/document-format-directives.md#columns)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Exportación a PDF](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)
- [Listados de código](https://postext.dev/es/docs/configuration-styles.md#listados-de-código)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration-pdf-output.md#por-qué-un-proveedor-de-fuentes)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)
- [Capítulos sin número](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo), [`scoreStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-partitura)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer), [`decompressWoff2`](https://postext.dev/es/docs/configuration-pdf-output.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`initMusicEngine`](https://postext.dev/es/docs/music.md#una-partitura-en-el-texto), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits), [`renderToPdf`](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Trae la pieza en MusicXML

Los dos archivos de `assets/` son MusicXML 4.0 sin más, el formato que escriben Dorico, Sibelius, Finale y MuseScore al exportar una partitura (elige el `.musicxml` sin comprimir, no el `.mxl`). Contienen una parte en dos pentagramas, con las claves, la armadura, las barras, la dirección de las plicas en los compases a dos voces, los adornos y las barras de repetición. El nombre de la parte lleva `print-object="no"`: una etiqueta «Piano» impresa empujaría todos los sistemas a la derecha. El título de `<work-title>` no se imprime, solo sirve de descripción de la partitura, así que el Markdown da a cada pieza su propio título.

### 2 · Componla en un bloque de código, no como recurso

La [respuesta corta](https://postext.dev/es/cookbook/piano-score-from-musicxml.md#la-respuesta-corta) descarga cada archivo y lo escribe en un bloque ` ```musicxml `, donde el Markdown deja una línea de marcador. Un bloque se graba al ancho de la columna y se corta en sistemas, y cada sistema se coloca como una línea de párrafo, de modo que la pieza sigue en la página siguiente entre dos sistemas. Un recurso `score` se mantiene entero y no podría cruzar una página. `minSystemsAtBreak: 2` evita que un sistema quede solo al pie de una página o pase solo a la cabeza de la siguiente.

### 3 · Ajusta el pentagrama a quien toca

El pentagrama mide 7 mm de alto, lo que mide en un álbum de piano impreso que se lee desde el atril. `spacing: 1.7` separa las notas hasta que un sistema de 186 mm de medida contiene cuatro compases, así que cada mitad de un minueto ocupa cuatro sistemas. `systemGap: 14 mm` deja sitio a las líneas adicionales entre las dos manos, y con él caben cuatro sistemas bajo el título de la primera página y cuatro en la de enfrente: la doble barra cae en el cambio de página del pliego.

### 4 · Abre cada pieza en página par

```js
// script.js, líneas 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' }` empieza cada pieza en una página de la izquierda, y la portada del editor usa un estilo de título que abre en página impar y sin cabeceras. El número de catálogo y el compositor salen de los atributos del título y van en las esquinas inferiores de la apertura, donde los imprimen las ediciones grabadas.

### 5 · Pon el nombre de la pieza en la cabecera de la derecha

```js
// script.js, líneas 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 de una pieza ya lleva su título, así que las cabeceras quedan para las páginas siguientes (`pages: 'body'`). La página impar imprime el título de la pieza y el folio en el borde exterior, con la letra de rótulos a 8 pt.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Haz una edición de estudio con un pentagrama menor

Un pentagrama de 6 mm con el espaciado propio del grabador lleva siete u ocho compases por sistema, así que cada minueto cabe en su primera página.

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

### Numera los ejemplos breves

Cuando la música son ejemplos breves citados en el texto, encaja mejor un recurso `score` numerado: [corales a cuatro voces como ejemplos numerados](https://postext.dev/es/cookbook/conservatory-method-chorales.md).

## Errores frecuentes

- Los saltos de sistema escritos en el archivo (`<print new-system="yes">`) no se conservan: los sistemas llenan el ancho de la columna, así que ajusta `spacing` y `staffSize` hasta que lleven los compases que quieres.
- El grabador no reparte los compases entre sistemas, y el último sistema se compone a su ancho natural: a 6 mm y con `spacing: 1`, el compás 32 queda solo en un sistema. Cambia `spacing` hasta que el último sistema tenga más de un compás.
- Un `.mxl` comprimido es un archivo zip: descomprímelo, o exporta el formato sin comprimir, antes de descargarlo.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: La introducción y las notas de interpretación, escritas para esta edición: Ignacio Ferro, CC-BY-4.0
- Imágenes: Minueto en sol mayor, BWV Anh. 114: las notas de la edición de la Bach-Gesellschaft tal como se introdujeron para el Mutopia Project (Mutopia-2017/01/19-75), convertidas a MusicXML 4.0 y revisadas compás a compás para esta edición: Christian Petzold; Allen Garvin (Mutopia Project) ([fuente](https://www.mutopiaproject.org/cgibin/piece-info.cgi?id=75)), dominio público
- Imágenes: Minueto en sol menor, BWV Anh. 115: las notas de la edición de la Bach-Gesellschaft tal como se introdujeron para el Mutopia Project (Mutopia-2015/08/21-76), convertidas a MusicXML 4.0 y revisadas compás a compás para esta edición: Christian Petzold; Allen Garvin (Mutopia Project) ([fuente](https://www.mutopiaproject.org/cgibin/piece-info.cgi?id=76)), dominio público
- Tipografías: Libre Caslon Text (OFL-1.1), Bodoni Moda (OFL-1.1), Archivo Narrow (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 163 · Un manual de armonía con ejemplos musicales en ABC](https://postext.dev/es/cookbook/harmony-textbook-abc-examples.md): Un capítulo sobre las cadencias cuyos ejemplos son bloques ABC en el Markdown, grabados al ancho de la columna; uno de ellos sigue en la página siguiente. · Nivel 2 (Intermedio) · Libros de texto
- [N.º 164 · Un cancionero con melodía, letra y acordes](https://postext.dev/es/cookbook/songbook-melody-words-chords.md): Cada canción es un bloque ABC: melodía, cifrados sobre el pentagrama y primera estrofa bajo las notas, grabados al ancho de columna con un estilo de partitura. · Nivel 2 (Intermedio) · Poesía, Hojas sueltas y efímeros
- [N.º 165 · Ejemplos musicales en MEI con aparato crítico en las notas](https://postext.dev/es/cookbook/musicology-mei-apparatus.md): Bloques MEI 5 grabados en el texto con un pentagrama de 5 mm, variantes codificadas con app, lem y rdg, y el aparato por compás y nota en notas al pie. · Nivel 2 (Intermedio) · Artículos y trabajos académicos
