跳到主要内容
食谱编号167

排版食谱 · 第7章 · 图与图片

用MusicXML排出跨越数页的钢琴谱

两首键盘小步舞曲从MusicXML文件刻成大谱表,在谱行之间分页,每首曲子正好占一个对页。

本页内容
输出
Canvas · PDF
难度
中级
Postext
已用Postext 1.54.0测试
需要≥ 1.54.0 · postext-pdf ≥ 1.54.0
许可证
更新于2026年10月22日
代码MIT · 文本CC BY 4.0

第2–3页,共5页

  • 英文样例:尚无中文版本
  • 成品尺寸230 × 305 mm
  • 1栏
  • Libre Caslon Text 10/14.5
  • Archivo Narrow
  • Bodoni Moda
  • 5页
  • 难度
  • Postext 1.54.0
  • 排版用时511 ms
  • 165行代码

简单来说

一本小小的乐谱,收两首广为人知的钢琴小步舞曲。示例演示怎样把记谱软件导出的文件排成一份可以翻页接续、拿来就能弹的钢琴谱。

成品一览

一本演奏版乐谱,收1725年《安娜·玛格达莱娜·巴赫曲集》中的两首小步舞曲:每个学钢琴的人都弹过的G大调那首,以及与它成对的g小调那首,如今都归在克里斯蒂安·佩措尔德名下。页面为230 × 305毫米,是乐谱集的开本。第一页是右页,在52点Bodoni标题下用两栏Libre Caslon介绍曲目。之后每首小步舞曲都从左页开始,横跨整个对页,用7毫米的大谱表、Bravura音乐字体从MusicXML文件刻出:每行谱四小节,第1至16小节在左页,第17至32小节在右页,一首曲子弹到中途不必翻页。

这道食谱解答

  • 怎样把MusicXML文件里的钢琴谱排成跨越数页的乐谱?

简短回答

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

用料

类型
Libre Caslon Text, Bodoni Moda, Archivo Narrow(SIL OFL 1.1)
素材
  • bwv-anh-114.musicxml
  • bwv-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), 公有领域)
  • 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), 公有领域)

做法

#1 · 用MusicXML带入曲子

assets/里的两个文件是普通的MusicXML 4.0,也就是Dorico、Sibelius、Finale和MuseScore导出乐谱时写出的格式(选未压缩的.musicxml,不要.mxl)。文件里是一个声部、两行谱表,带谱号、调号、符杠、两个声部的小节里的符干方向、装饰音和反复记号。声部名标了print-object="no":印出来的“Piano”字样会把每行谱都推向右边。<work-title>里的标题不会印出,只作为乐谱的说明,所以Markdown给每首曲子单独写一个标题。

#2 · 放进代码块,而不是资源

简短回答取回每个文件,写进一个```musicxml代码块;Markdown里那一处只留一行占位符。代码块按栏宽刻谱并切成谱行,每行谱像段落的一行那样排放,所以曲子在两行谱之间接到下一页。score资源会保持完整,无法跨页。minSystemsAtBreak: 2保证页脚不会只剩一行谱,下一页开头也不会只接过去一行。

#3 · 按演奏者定谱表大小

谱表高7毫米,相当于放在谱架上看的印刷钢琴谱集。spacing: 1.7把音符拉开,直到186毫米宽的一行谱正好放四小节,于是每首小步舞曲的一半占四行谱。systemGap: 14 mm给两手之间的加线留出空间;这个间距下,第一页标题下放四行谱,对面一页再放四行:双纵线正好落在对页的翻转处。

#4 · 每首曲子从左页开始

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

breakBefore: { parity: 'even' }让每首曲子从左页开始;编者的标题页用另一个标题样式,从右页开始,不带书眉。作品编号和作曲者取自标题的属性,排在开篇的两个下角,刻印版乐谱向来印在那里。

#5 · 右页书眉印曲名

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

一首曲子的第一页已经有标题,所以书眉只出现在后面的页上(pages: 'body')。右页在外侧印曲名和页码,用8点的标签字体。

完整食谱

沙盒
// ═══ 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

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)

变化

#用较小的谱表排学习版

6毫米的谱表加上刻谱引擎自己的间距,一行谱能放七到八小节,每首小步舞曲就能排在它的第一页上。

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

#给短小的谱例编号

如果音乐是正文里引用的短小谱例,带编号的score资源更合适:把四声部众赞歌排成编号谱例。

常见问题

  • 文件里写的换行(<print new-system="yes">)不会保留:谱行总是排满栏宽,所以要调spacing和staffSize,直到每行放下你想要的小节数。
  • 刻谱引擎不会在各行之间平均分配小节,最后一行按自然宽度排:6毫米、spacing: 1时,第32小节会单独占一行。调整spacing,让最后一行不止一小节。
  • 压缩的.mxl文件是zip压缩包:取用前先解压,或者导出未压缩的格式。

致谢

文本
  • The introduction and performance notes, written for this edition · Ignacio Ferro · CC BY 4.0
图片
字体
Libre Caslon Text (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
沙盒PDF