Files
jp-editor/docs/JPW7_WEB排版实现参考.md
T
2026-09-22 16:33:35 +08:00

13 KiB
Raw Blame History

用 Web 实现 JP-Word 类简谱排版功能

1. 推荐的总体架构

不要把乐谱做成 contenteditable 文本,也不要把所有内容直接画到 Canvas 后再反推编辑状态。推荐使用:

JPW-ABC / 编辑命令
        ↓
Lexer + Parser
        ↓
语义模型 ScoreModel
        ↓
LayoutEngine
        ↓
LayoutSnapshot
        ↓
SVGRenderer + 交互层

最小实现建议:

  • TypeScript:模型、解析器、排版器、命令系统;
  • SVG:屏幕绘制、缩放、选择、打印和导出;
  • Canvas 2D:只用于字体宽度测量和性能优化;
  • Web Worker:文档较大时把解析和排版放到 Worker;
  • JSONWeb 内部编辑格式;.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 * 100staffUnit,例如 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]
}

调号、拍号、临时变音、八度标记最后再换算为显示音高和播放音高。不要让渲染器理解 d11'#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 mmstaffUnit

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 });

每个命令:

  1. 修改 ScoreModel
  2. 标记受影响的小节/行;
  3. 重新测量和排版;
  4. 更新 SVG
  5. 写入 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 兼容性,是成本最低、最不容易返工的路径。