@@ -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 /**
0 commit comments