13 KiB
用 Web 实现 JP-Word 类简谱排版功能
1. 推荐的总体架构
不要把乐谱做成 contenteditable 文本,也不要把所有内容直接画到 Canvas 后再反推编辑状态。推荐使用:
JPW-ABC / 编辑命令
↓
Lexer + Parser
↓
语义模型 ScoreModel
↓
LayoutEngine
↓
LayoutSnapshot
↓
SVGRenderer + 交互层
最小实现建议:
- TypeScript:模型、解析器、排版器、命令系统;
- SVG:屏幕绘制、缩放、选择、打印和导出;
- Canvas 2D:只用于字体宽度测量和性能优化;
- Web Worker:文档较大时把解析和排版放到 Worker;
- JSON:Web 内部编辑格式;
.jpwabc作为导入/导出格式。
SVG 比 Canvas 更适合作为第一版:每个符号都有 DOM 节点,命中测试、选择、拖动、打印和局部重绘简单很多。
1.1 字体不是一个,而是多个字体角色
JPW7 的音符有“标准、端庄、典雅、手写”四种字形风格,并支持字号和宽高比调整;歌词、标题、普通文字、特殊文字又分别使用独立字体角色。因此 Web 版至少应拆分:
noteFont 音符字形
lyricFont 歌词
titleFont 标题
textFont 普通文字
specialFont 特殊符号/指法
排版前必须等待 document.fonts.ready,用真实字体测量文字宽度。线条、弧线、房子等不要强行做成字体字符,应使用 SVG 几何图形。
1.2 固定纸张的方案选型
用户场景只考虑固定打印纸张宽度时,优先采用“固定页面 + 自定义 SVG 排版器”,不要把响应式网页排版当作核心。
推荐方案:自定义 SVG Engraving/Layout Engine
ScoreModel
→ MeasureLayout
→ LineLayout
→ PageLayout
→ 每页一个 SVG viewBox
为每种纸张定义固定配置:
interface PaperProfile {
id: 'A4' | 'A5' | '16K' | 'custom';
widthMm: number;
heightMm: number;
marginMm: { top: number; right: number; bottom: number; left: number };
}
内部统一使用 mm * 100 或 staffUnit,例如 A4 页面使用 29700 × 21000 的 SVG viewBox。浏览器只负责缩放显示,排版本身不依赖 viewport 宽度。
可借鉴但不宜直接套用的库
- abcjs:可借鉴 ABC 的分小节、分行和 SVG 输出思路,但它面向五线谱 ABC,不直接支持 JPW 简谱。
- VexFlow:可借鉴音符度量、formatter、SVG/Canvas 绘制,但其 glyph、拍号和五线谱模型与简谱差异很大。
- Verovio:固定页面和 SVG 生成能力强,适合研究分页/排版思想;输入主要是 MEI/MusicXML,直接用于简谱需要重写符号层。
- OpenSheetMusicDisplay / AlphaTab:更适合五线谱、MusicXML、吉他谱或 Tab,不建议作为 JPW7 简谱核心引擎。
- Paged.js 等 CSS 分页库:适合外层页眉、页脚和文档分页,不适合负责音符内部的间距、歌词对位和弧线布局。
结论:这些库可以借鉴排版思想,但没有一个能直接替代 JPW7 的简谱布局器。最省事的做法是自建简谱对象和布局层,只复用 SVG、字体测量和 PDF 输出能力。
固定纸张下的分页
@page { size: A4 portrait; margin: 0; }
.score-page {
width: 210mm;
height: 297mm;
break-after: page;
overflow: hidden;
}
实际谱面仍由 SVG 坐标控制;CSS 只负责把页面送到打印机。导出 PDF 时应嵌入字体,避免浏览器换字体后导致重新排版。
2. 不要直接使用字符串作为编辑模型
JP-Word 的核心是“基本符号 + 附属对象 + 页面对象 + 排版状态”。Web 版本也应保留这四层。
interface ScoreDocument {
meta: Meta;
voices: Voice[];
lyrics: LyricVerse[];
attachments: Attachment[];
pageObjects: PageObject[];
layout: LayoutOptions;
}
interface Voice {
id: string;
symbols: BasicSymbol[];
}
interface BasicSymbol {
id: string; // 永久 ID,不要使用数组下标做锚点
kind: 'note' | 'rest' | 'bar' | 'text' | 'control';
pitch?: Pitch;
duration: Rational; // 例如 1/4、1/8,不要只保存下划线数量
measureId: string;
attachments: string[]; // Attachment.id
manualGap?: number;
gapLocked?: boolean;
}
interface Attachment {
id: string;
kind: string;
anchorId: string; // 锚定 BasicSymbol 或小节线
offset: { x: number; y: number };
size: { sx: number; sy: number };
rotate: number;
color?: string;
occupancy: { top: boolean; right: boolean; bottom: boolean; left: boolean };
payload: unknown;
}
interface PageObject {
id: string;
x: number; y: number;
width: number; height: number;
payload: unknown;
}
关键原则:数组顺序表达音乐顺序,ID 表达关联关系,坐标属于排版结果,不是语义源数据。
3. 解析层:文本先变成语义对象
3.1 词法解析
先把输入拆成 token,不要在渲染器里用正则直接识别整行:
1 2 3 0 音级/休止符
#1 b5 变音
_ __ _. 时值修饰
| |: :| 小节/反复
[135] 和弦
( ... ) 弧线、伴奏或分组
{(3} ... 连音组
{C:...} 局部间距/连接控制
"文字" 标准文字
3.2 语义归一化
把不同写法归一化成同一内部形式:
{
degree: 1,
accidental: 0,
octave: 0,
duration: { numerator: 1, denominator: 8 },
tie: false,
sourceRange: [start, end]
}
调号、拍号、临时变音、八度标记最后再换算为显示音高和播放音高。不要让渲染器理解 d1、1'、#1 等原始字符串。
3.3 保留源位置
每个 token 保存 sourceRange,这样可以:
- ABC 编辑器与图形编辑器双向定位;
- 解析错误时高亮原文;
- 未支持的控制码可以原样保留;
- 保存时尽量避免破坏用户原始格式。
4. 排版器的核心:先测量,再定位
排版器不要边解析边画。建议生成不可变的 LayoutSnapshot:
interface LayoutSnapshot {
pages: PageLayout[];
symbols: Record<string, SymbolLayout>;
attachments: Record<string, AttachmentLayout>;
}
interface SymbolLayout {
id: string;
page: number;
line: number;
x: number;
y: number;
width: number;
height: number;
anchorX: number;
anchorY: number;
}
4.1 测量阶段
对每个基本符号计算:
baseBox = 音符/休止符/文字的字体或路径边界
lyricBox = 歌词字体实际宽度和高度
attachmentBoxes = 弧线、房子、技法、特殊文字等边界
浏览器中:
- 先
await document.fonts.ready; - 使用
CanvasRenderingContext2D.measureText()测文字宽度; - SVG 路径用固定几何尺寸或
getBBox(); - 字体、字号、缩放统一换算到同一内部单位,例如
1/100 mm或staffUnit。
4.2 横向间距
每个基本符号有一个参考点。相邻参考点的间距可以用以下模型:
preferredGap = Math.max(
minimumGap,
spacingTable[durationClass],
symbolBox.width + sidePadding,
lyricDemand,
attachmentDemand
);
gap = symbol.gapLocked
? symbol.manualGap!
: preferredGap;
目前通过实际 JPW7 的“设置水平定位(全局间距)”窗口和样例选项可以进一步确认:spacingTable 不是虚构的抽象,它对应可选的 HorzSpacing_AW 参数表;另有 HorzSpacing_Gap 参数表。程序提供“很紧、稍紧、标准、稍松、很松、线性、等距”七组方案,以及高级用户直接编辑参数表的入口。
因此 Web 实现应把间距设计成可替换表,而不是把公式写死:
const spacingTable = {
'16th': [1.30, 1.97, 2.513, 2.987, 3.809, 4.527, 5.774, 6.861],
// 具体索引含义、缩放系数和 Gap 表仍需用受控样本校准
};
这些数字是样例中读取到的真实参数,不是 JPW7 的完整官方公式。
4.3 四方向占位
附属对象先相对锚点定位,再把占位需求反馈给排版器:
function expandDemand(demand: Demand, box: Box, occupancy: Occupancy) {
if (occupancy.top) demand.top = Math.max(demand.top, -box.top);
if (occupancy.right) demand.right = Math.max(demand.right, box.right);
if (occupancy.bottom) demand.bottom = Math.max(demand.bottom, box.bottom);
if (occupancy.left) demand.left = Math.max(demand.left, -box.left);
}
例如:
- 歌词通常占用下方空间;
- 弧线/房子可能占用上方;
- 连谱号可能占用左右空间;
- 特殊文字可以四方向分别开关占位;
- 页面文本框不进入这个计算。
4.4 折行、对齐和分页
折行只允许发生在小节边界:
for measure of measures:
if currentLine.width + measure.width > contentWidth:
finishLine();
startLine();
append(measure);
一行完成后:
- 普通模式保留自然间距;
- 两端对齐模式把剩余宽度分配到基本符号间隙;
- 手动锁定的间距不被全局参数覆盖;
- 行高取本行上下占位的最大值;
- 当前页放不下时创建下一页。
5. 歌词、弧线和特殊文字
歌词
先建立歌词单位到基本符号的映射,再计算宽度:
for (const lyricUnit of verse.units) {
const symbol = nextLyricBearingSymbol(cursor);
if (symbol) symbol.lyrics.push(lyricUnit);
}
nextLyricBearingSymbol() 应跳过休止符、弧线、伴奏和不可承载歌词的对象。歌词对位完成后,歌词宽度参与符号间距和行高计算。
弧线/房子
弧线不要保存成一堆已经算好的点,而保存:
{ startAnchorId, endAnchorId, type, curve, direction, occupancy }
排版后根据两个锚点的最终坐标重新生成 SVG path。这样换行、拖动、改间距后弧线仍然正确。
特殊文字
特殊文字可以是:
- 普通文本;
- ABC 小片段;
- 线条/图形;
- 可旋转、缩放、改色的对象。
建议把它们统一成 Attachment,由专门的 AttachmentRenderer 根据 kind 渲染;不要把所有特殊符号硬编码到主音符渲染器里。
6. SVG 渲染建议
推荐的 SVG 层次:
<svg viewBox="0 0 21000 29700">
<g data-layer="page">
<rect .../>
<g data-layer="score-line" data-line-id="line-1">
<g data-symbol-id="s1"><text>1</text></g>
<g data-symbol-id="s2"><text>2</text></g>
<path data-attachment-id="a1" .../>
</g>
<g data-layer="page-object"><text .../></g>
</g>
</svg>
viewBox使用物理版心单位,CSS 缩放只改变显示比例;data-symbol-id支持命中测试和选中;- 选中框、拖动手柄、锚点线放在独立的 overlay layer;
- 打印时隐藏 overlay;
- 导出 PDF/PNG 时直接复用 SVG。
7. 编辑系统
使用命令而不是到处直接修改对象:
execute({ type: 'InsertNote', afterId, note });
execute({ type: 'MoveAttachment', id, dx, dy });
execute({ type: 'SetManualGap', id, gap });
execute({ type: 'UnlockGap', id });
execute({ type: 'InsertLineBreak', measureId });
每个命令:
- 修改
ScoreModel; - 标记受影响的小节/行;
- 重新测量和排版;
- 更新 SVG;
- 写入 undo/redo 栈。
第一版可以先全量重新排版;文档较大后再按“受影响小节 → 受影响行 → 受影响页”增量更新。
8. 推荐开发顺序
MVP-1:只做可见简谱
- 音符、休止符、小节线;
- 时值和调号;
- SVG 绘制;
- 按小节折行。
MVP-2:做出 JP-Word 的排版特征
- 歌词自动对位;
- 弧线、连音、和弦;
- 四方向占位;
- 两端对齐;
- 手动间距锁定。
MVP-3:做成可编辑软件
- 标准/扩展/页面/排版四种模式;
- 选择、拖动、缩放、旋转;
- 页面文本框;
- undo/redo;
.jpwabc导入/导出。
MVP-4:输出和兼容
- SVG/PNG/PDF;
- 打印版式;
- MusicXML/MIDI;
- 完善全部特殊符号和播放参数。
9. 最容易走错的方向
- 不要用
contenteditable + 空格实现谱面;换行和字体变化会破坏布局。 - 不要把
x/y作为唯一数据;坐标应由语义和排版状态派生。 - 不要把歌词画成普通文本;必须有歌词对位和占位模型。
- 不要把弧线保存为固定 SVG 路径;它应该通过起止锚点重算。
- 不要一开始实现所有 JPW-ABC 控制码;先做基本符号、歌词、弧线、占位和锁定间距。
10. 最小结论
要参考 JP-Word 实现 Web 版,最值得复用的不是它的界面,而是这个思想:
语法 = 语义对象
排版 = 约束求坐标
编辑 = 修改对象或排版状态
渲染 = 根据 LayoutSnapshot 绘制
先做一个“保留模式”的 SVG 编辑器,再逐步扩展 JPW-ABC 兼容性,是成本最低、最不容易返工的路径。