In short
A short sheet-music edition of two well-known piano minuets. It shows how to take a file exported from notation software and set it as a piano score that continues over pages, laid out so a pianist can play from it.
What you'll build
A performing edition of the two menuets from the 1725 Notebook for Anna Magdalena Bach, the G major one every piano pupil learns and its G minor companion, now given to Christian Petzold. The page is 230 × 305 mm, the size of a sheet-music album. A recto introduces the pieces in two columns of Libre Caslon under a 52 pt Bodoni title. Then each menuet opens on a verso and runs across the spread in a grand staff of 7 mm, set in Bravura from a MusicXML file: four bars to a system, bars 1–16 on the left page and 17–32 on the right, so the player turns no page inside a piece.
This recipe answers
- How do I set a piano score from a MusicXML file over several pages?
The short answer
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);
}
The score style of a performing edition, and a MusicXML file in a fence
Ingredients
- Features
- Music notationScore styleDesigned openersHeading attributesHeading stylesRunning heads and foliosMirrored marginsHeads by page roleColumns in a box or in the textParagraph stylesSemantic colour palettePDF export
- Type
- Libre Caslon Text, Bodoni Moda, Archivo Narrow (SIL OFL 1.1)
- Assets
bwv-anh-114.musicxmlbwv-anh-115.musicxml- 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), public domain)
- 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), public domain)
Method
#1 · Bring the piece in as MusicXML
The two files in assets/ are plain MusicXML 4.0, the format Dorico, Sibelius, Finale and MuseScore write when you export a score (choose the uncompressed .musicxml, not .mxl). They hold one part on two staves, with the clefs, key, beams, stem directions of the two-voice bars, ornaments and repeat barlines. The part name is marked print-object="no": a printed "Piano" label would push every system to the right. The title in <work-title> is not printed, only read as the score's description, so the Markdown gives each piece a heading of its own.
#2 · Set it in a fence, not a resource
The short answer fetches each file and writes it into a ```musicxml fence, where the Markdown keeps a placeholder line. A fence is engraved at the width of the column and cut into systems, and each system is placed like a paragraph line, so the piece goes on to the next page between two systems. A score resource would be kept whole and could not cross a page. minSystemsAtBreak: 2 keeps a single system from being left alone at the foot of a page or carried to the head of the next.
#3 · Size the staff for the player
The staff is 7 mm high, the size of a printed piano album read from the music desk. spacing: 1.7 widens the notes until a system of the 186 mm measure holds four bars, so each half of a menuet takes four systems. systemGap: 14 mm leaves room for the ledger lines between the hands, and with it four systems fill the opening page under the title and four the facing one: the double bar falls at the turn of the spread.
#4 · Open every piece on a verso
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' } starts each piece on a left-hand page, and the editor's title page uses a heading style that opens on a recto with no running heads. The catalogue number and the composer come from the heading's attributes and sit at the bottom corners of the opener, where engraved editions print them.
#5 · Name the piece at the head of the right page
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: [] };
The opening page of a piece carries its title, so the running heads are kept to the pages after it (pages: 'body'). The recto prints the piece's title and the folio at the outer edge, in the label face at 8 pt.
The whole recipe
// ═══ 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
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)
Variations
#Set a study edition on a smaller staff
A 6 mm staff at the engraver's own spacing holds seven or eight bars to a system, so each menuet fits on its opening page.
- staffSize: mm(7),
+ staffSize: mm(6),
- spacing: 1.7,
+ spacing: 1,#Number short examples instead
When the music is a set of short examples cited in the text, a numbered score resource is the better fit: four-part chorales as numbered examples.
Pitfalls
- System breaks written in the file (
<print new-system="yes">) are not kept: the systems fill the width of the column, so tunespacingandstaffSizeuntil they hold the bars you want. - The engraver does not even out the bars between systems, and the last system is set at its natural width: at 6 mm and
spacing: 1, bar 32 stands alone on a system of its own. Changespacinguntil the last system holds more than a bar. - A compressed
.mxlfile is a zip archive: unzip it, or export the uncompressed format, before you fetch it.
Credits
- Recipe
- Ignacio Ferro
- Text
- The introduction and performance notes, written for this edition · Ignacio Ferro · CC BY 4.0
- Images
- 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) · public domain
- 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) · public domain
- Fonts
- Libre Caslon Text (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


