Skip to content

Commit da4ce04

Browse files
committed
feat(layout): 引入 VoiceGroupTransformer 以支持声部分组布局
- 在 SNLayoutBuilder 中添加 VoiceGroupTransformer,优化声部节点的构建逻辑,确保声部在布局中能够正确对齐和分行。 - 更新构建方法,重构 Voice 节点为 VoiceGroup,提升布局的灵活性和准确性。 - 在 SVG 渲染中增强对 VoiceGroup 的支持,确保渲染效果符合乐谱要求。 该变更提升了布局构建的能力,增强了代码的可维护性和可扩展性。
1 parent 21b211e commit da4ce04

File tree

11 files changed

+506
-49
lines changed

11 files changed

+506
-49
lines changed

packages/simple-notation/src/layout/builder.ts

Lines changed: 181 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
RootTransformer,
1313
ScoreTransformer,
1414
SectionTransformer,
15+
VoiceGroupTransformer,
1516
VoiceTransformer,
1617
MeasureTransformer,
1718
PageTransformer,
@@ -46,6 +47,7 @@ export class SNLayoutBuilder {
4647
private rootTransformer: RootTransformer;
4748
private scoreTransformer: ScoreTransformer;
4849
private sectionTransformer: SectionTransformer;
50+
private voiceGroupTransformer: VoiceGroupTransformer;
4951
private voiceTransformer: VoiceTransformer;
5052
private measureTransformer: MeasureTransformer;
5153
private pageTransformer: PageTransformer;
@@ -72,6 +74,10 @@ export class SNLayoutBuilder {
7274
this.rootTransformer = new RootTransformer(this.layoutConfig);
7375
this.scoreTransformer = new ScoreTransformer(this.layoutConfig);
7476
this.sectionTransformer = new SectionTransformer(this.scoreConfig);
77+
this.voiceGroupTransformer = new VoiceGroupTransformer(
78+
this.layoutConfig,
79+
this.scoreConfig,
80+
);
7581
this.voiceTransformer = new VoiceTransformer(
7682
this.layoutConfig,
7783
this.scoreConfig,
@@ -187,11 +193,11 @@ export class SNLayoutBuilder {
187193

188194
if (!sectionBlock) continue;
189195

190-
// 先计算 Block 的宽度(这样子节点 Line 可以获取父节点宽度)
196+
// 先计算 Block 的宽度(这样子节点 VoiceGroup 可以获取父节点宽度)
191197
this.calculateNodeWidth(sectionBlock);
192198

193-
// 构建 Voice 节点
194-
this.buildVoices(
199+
// 构建 VoiceGroup(包含所有 Voice,并处理分行逻辑)
200+
this.buildVoiceGroups(
195201
(section.children || []) as SNParserNode[],
196202
sectionBlock,
197203
);
@@ -203,31 +209,189 @@ export class SNLayoutBuilder {
203209
}
204210

205211
/**
206-
* 构建 Voice 节点
212+
* 构建 VoiceGroup 节点
213+
*
214+
* 将同一 Section 的所有 Voice 组织成一个 VoiceGroup,实现小节对齐和同步换行
207215
*
208216
* 自底向上构建:先计算当前节点的宽度,再构建子节点,最后计算高度和位置
209-
* Line 的宽度应该立即计算,因为它是撑满父级的
210217
*
211218
* @param voices - Voice 节点数组
212-
* @param parentNode - 父节点(Block)
219+
* @param parentNode - 父节点(Section Block)
213220
*/
214-
private buildVoices(voices: SNParserNode[], parentNode: SNLayoutBlock): void {
221+
private buildVoiceGroups(
222+
voices: SNParserNode[],
223+
parentNode: SNLayoutBlock,
224+
): void {
225+
if (!voices || voices.length === 0) return;
226+
227+
// 创建 VoiceGroup
228+
const voiceGroup = this.voiceGroupTransformer.transform(voices, parentNode);
229+
if (!voiceGroup) return;
230+
231+
// 计算 VoiceGroup 的宽度(撑满父级)
232+
this.calculateNodeWidth(voiceGroup);
233+
234+
// 获取可用宽度(减去 padding)
235+
const availableWidth = this.getAvailableWidth(voiceGroup);
236+
237+
// 收集所有 Voice 的小节信息
238+
const voiceMeasures: Array<{
239+
voice: SNParserNode;
240+
measures: SNParserNode[];
241+
measureCount: number;
242+
}> = [];
243+
215244
for (const voice of voices) {
216-
// 使用 VoiceTransformer 转换 Voice 为 Line
217-
const line = this.voiceTransformer.transform(voice, parentNode);
245+
if (voice.type !== 'voice') continue;
246+
const measures = (voice.children || []) as SNParserNode[];
247+
voiceMeasures.push({
248+
voice,
249+
measures,
250+
measureCount: measures.length,
251+
});
252+
}
253+
254+
if (voiceMeasures.length === 0) return;
255+
256+
// 计算小节间距
257+
const measureConfig = this.scoreConfig.getMeasure();
258+
const measureGap = measureConfig.spacing.measureGap || 10;
259+
260+
// 1) 计算每个小节在每个 Voice 中的实际宽度,并取同一小节索引的最大值,确保跨声部对齐
261+
const maxMeasureCount = Math.max(
262+
...voiceMeasures.map((vm) => vm.measureCount),
263+
);
264+
const maxWidthsByIndex: number[] = [];
265+
for (let i = 0; i < maxMeasureCount; i++) {
266+
let maxWidth = 0;
267+
for (const { measures } of voiceMeasures) {
268+
const m = measures[i];
269+
if (!m) continue;
270+
const w = this.computeMeasureLayoutWidth(m);
271+
if (w > maxWidth) maxWidth = w;
272+
}
273+
// 后备:至少给一个基础宽度,避免0
274+
if (maxWidth <= 0) maxWidth = 40;
275+
maxWidthsByIndex.push(maxWidth);
276+
}
277+
278+
// 2) 基于最大宽度,使用贪心切分行,得到统一的换行断点
279+
const lineBreaks: Array<{ start: number; end: number }> = [];
280+
let cursor = 0;
281+
while (cursor < maxMeasureCount) {
282+
let lineWidth = 0;
283+
const start = cursor;
284+
while (cursor < maxMeasureCount) {
285+
const nextWidth = maxWidthsByIndex[cursor];
286+
const addWidth = (lineWidth === 0 ? 0 : measureGap) + nextWidth;
287+
if (lineWidth + addWidth <= availableWidth) {
288+
lineWidth += addWidth;
289+
cursor++;
290+
} else {
291+
break;
292+
}
293+
}
294+
if (cursor === start) {
295+
// 单个小节都放不下,强制至少放一个,防止死循环
296+
cursor++;
297+
}
298+
lineBreaks.push({ start, end: cursor });
299+
}
218300

219-
if (!line) continue;
301+
// 3) 按统一断点为每个 Voice 创建行并分配小节
302+
for (let lineIndex = 0; lineIndex < lineBreaks.length; lineIndex++) {
303+
const { start, end } = lineBreaks[lineIndex];
304+
const isLastLine = lineIndex === lineBreaks.length - 1;
220305

221-
// Line 一旦创建,宽度应该立即撑满父级(Block 的宽度已经在前面的步骤中计算好了)
222-
this.calculateNodeWidth(line);
306+
voiceMeasures.forEach(({ voice, measures }, voiceIndex) => {
307+
const lineMeasures = measures.slice(start, end);
223308

224-
// 构建 Measure 节点
225-
this.buildMeasures((voice.children || []) as SNParserNode[], line);
309+
if (lineMeasures.length === 0 && !isLastLine) {
310+
return;
311+
}
226312

227-
// 子节点构建完成后,计算 Line 的高度和位置
228-
// Line 的高度已经在转换器中设置,只需要计算位置
229-
this.calculateNodePosition(line);
313+
const isLastVoice = voiceIndex === voiceMeasures.length - 1;
314+
// 每个行组(同一 lineIndex)之后都加行间距,仅在该行组的最后一个 voice 行上加
315+
const shouldAddBottomMargin = isLastVoice;
316+
317+
const lineId = `layout-${voice.id}-line-${lineIndex}`;
318+
const line = this.voiceTransformer.transformLine(
319+
voice,
320+
lineId,
321+
lineIndex,
322+
shouldAddBottomMargin,
323+
voiceGroup,
324+
);
325+
if (!line) return;
326+
327+
this.calculateNodeWidth(line);
328+
if (lineMeasures.length > 0) {
329+
this.buildMeasures(lineMeasures, line);
330+
}
331+
this.calculateNodePosition(line);
332+
});
230333
}
334+
335+
// 子节点构建完成后,计算 VoiceGroup 的高度和位置
336+
this.calculateNodeHeight(voiceGroup);
337+
this.calculateNodePosition(voiceGroup);
338+
}
339+
340+
/**
341+
* 获取节点的可用宽度(减去 padding)
342+
*
343+
* @param node - 布局节点
344+
* @returns 可用宽度
345+
*/
346+
private getAvailableWidth(node: SNLayoutNode): number {
347+
if (!node.layout) return 0;
348+
349+
const width = typeof node.layout.width === 'number' ? node.layout.width : 0;
350+
const padding = node.layout.padding || {
351+
top: 0,
352+
right: 0,
353+
bottom: 0,
354+
left: 0,
355+
};
356+
357+
return Math.max(0, width - padding.left - padding.right);
358+
}
359+
360+
/**
361+
* 计算单个 Measure 的布局宽度(基于其子元素宽度汇总)
362+
*
363+
* 为了在分行前得到更准确的小节宽度,这里创建一个临时的 Line,
364+
* 将 Measure 构建为临时的 Element,并构建其子元素,然后使用
365+
* finalizeNodeLayout 计算得到该 Measure Element 的宽度。
366+
* 该临时节点不会加入实际的布局树。
367+
*/
368+
private computeMeasureLayoutWidth(measure: SNParserNode): number {
369+
// 创建临时 Line(不挂到树上)
370+
const tempLine = new SNLayoutLine('temp-line');
371+
tempLine.updateLayout({
372+
x: 0,
373+
y: 0,
374+
width: 0,
375+
height: 0,
376+
padding: { top: 0, right: 0, bottom: 0, left: 0 },
377+
});
378+
379+
// 使用现有转换器构建临时 Measure Element
380+
const tempElement = this.measureTransformer.transform(measure, tempLine);
381+
if (!tempElement) return 0;
382+
383+
// 构建子元素
384+
this.buildMeasureElements(
385+
(measure.children || []) as SNParserNode[],
386+
tempElement,
387+
);
388+
389+
// 完成临时 Element 的布局计算
390+
this.finalizeNodeLayout(tempElement);
391+
392+
return typeof tempElement.layout?.width === 'number'
393+
? tempElement.layout.width
394+
: 0;
231395
}
232396

233397
/**
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { RootTransformer } from './root-transformer';
22
export { ScoreTransformer } from './score-transformer';
33
export { SectionTransformer } from './section-transformer';
4+
export { VoiceGroupTransformer } from './voice-group-transformer';
45
export { VoiceTransformer } from './voice-transformer';
56
export { MeasureTransformer } from './measure-transformer';
67
export { PageTransformer } from './page-transformer';

packages/simple-notation/src/layout/trans/measure-transformer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ export class MeasureTransformer {
8080
elementWidth = 40;
8181
} else if (element.type === 'tuplet') {
8282
elementWidth = 50; // 连音可能包含多个音符
83+
} else if (element.type === 'tie') {
84+
elementWidth = 40; // 连音线默认更宽一些
8385
}
8486

8587
// 设置配置
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { SNLayoutBlock } from '@layout/node';
2+
import { LayoutConfig, ScoreConfig } from '@manager/config';
3+
import type { SNParserNode } from '@data/node';
4+
import type { SNLayoutBlock as SNLayoutBlockType } from '@layout/node';
5+
6+
/**
7+
* VoiceGroup 转换器
8+
*
9+
* 将数据层的 Section 下的所有 Voice 节点组织成一个 VoiceGroup Block
10+
* VoiceGroup 用于管理同一 Section 的多个 Voice,确保它们的小节对齐和同步换行
11+
*
12+
* 设计理念:
13+
* - 一个 Section 对应一个 VoiceGroup
14+
* - VoiceGroup 内部包含多个 Voice,每个 Voice 可能对应多个 Line(如果需要分行)
15+
* - 同一 VoiceGroup 内的所有 Voice 的小节必须对齐
16+
*/
17+
export class VoiceGroupTransformer {
18+
constructor(
19+
private layoutConfig: LayoutConfig,
20+
private scoreConfig: ScoreConfig,
21+
) {}
22+
23+
/**
24+
* 转换 Section 下的所有 Voice 为一个 VoiceGroup Block
25+
*
26+
* @param voices - Section 下的所有 Voice 节点
27+
* @param parentNode - 父布局节点(Section Block)
28+
* @returns 布局层 Block 节点(VoiceGroup)
29+
*/
30+
transform(
31+
voices: SNParserNode[],
32+
parentNode: SNLayoutBlockType,
33+
): SNLayoutBlock | null {
34+
if (!voices || voices.length === 0) {
35+
return null;
36+
}
37+
38+
// 创建 VoiceGroup Block
39+
const voiceGroup = new SNLayoutBlock('voice-group');
40+
41+
// 设置配置
42+
voiceGroup.updateLayout({
43+
x: 0, // 初始位置,由布局计算填充
44+
y: 0, // 初始位置,由布局计算填充
45+
width: 0, // 由布局计算填充(撑满父级)
46+
height: 0, // 由布局计算填充
47+
margin: { top: 0, right: 0, bottom: 0, left: 0 },
48+
});
49+
50+
// 建立父子关系
51+
parentNode.addChildren(voiceGroup);
52+
53+
return voiceGroup;
54+
}
55+
}

packages/simple-notation/src/layout/trans/voice-transformer.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,48 @@ export class VoiceTransformer {
3131
return null;
3232
}
3333

34+
// 使用默认的 lineId 和 lineIndex,默认添加底部 margin(向后兼容)
35+
return this.transformLine(voice, `layout-${voice.id}`, 0, true, parentNode);
36+
}
37+
38+
/**
39+
* 转换 Voice 节点为 Line(支持创建多个 Line)
40+
*
41+
* 当 Voice 需要分行时,可以通过此方法创建多个 Line
42+
*
43+
* @param voice - 数据层 Voice 节点
44+
* @param lineId - Line 的唯一标识
45+
* @param lineIndex - Line 的索引(用于区分同一 Voice 的多个 Line)
46+
* @param shouldAddBottomMargin - 是否在底部添加 margin(用于 VoiceGroup 的最后一个 Line)
47+
* @param parentNode - 父布局节点(Block 或 VoiceGroup)
48+
* @returns 布局层 Line 节点
49+
*/
50+
transformLine(
51+
voice: SNParserNode,
52+
lineId: string,
53+
lineIndex: number,
54+
shouldAddBottomMargin: boolean,
55+
parentNode: SNLayoutBlock,
56+
): SNLayoutLine | null {
57+
// 类型检查:确保是 Voice
58+
if (voice.type !== 'voice') {
59+
return null;
60+
}
61+
3462
const lineConfig = this.layoutConfig.getLine();
3563
const voiceConfig = this.scoreConfig.getVoice();
3664

3765
// 创建行节点
38-
const line = new SNLayoutLine(`layout-${voice.id}`);
66+
const line = new SNLayoutLine(lineId);
3967
line.data = voice;
4068

4169
// 设置配置
4270
const lineHeight = lineConfig.size.height || 50;
4371
const voiceGap = voiceConfig.spacing.voiceGap || 20;
4472

73+
// 行间距:对 voice-group 中的每一条 line 都应用 voiceGap
74+
const marginBottom = voiceGap;
75+
4576
line.updateLayout({
4677
x: 0, // 初始位置,由布局计算填充
4778
y: 0, // 初始位置,由布局计算填充
@@ -53,7 +84,7 @@ export class VoiceTransformer {
5384
bottom: 0,
5485
left: 0,
5586
},
56-
margin: { top: 0, right: 0, bottom: voiceGap, left: 0 },
87+
margin: { top: 0, right: 0, bottom: marginBottom, left: 0 },
5788
});
5889

5990
// 建立父子关系

0 commit comments

Comments
 (0)