diff --git a/src/config.ts b/src/config.ts index 895122737..45912c57f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -62,6 +62,14 @@ export const CONFIG = { 'LAST_SYSTEM_WIDTH_STRETCH_THRESHOLD is the total width fraction that the measures must exceed to stretch the ' + 'measures in the last system.', }), + CONTINUATION_MEASURE_WIDTH_THRESHOLD: t.number({ + defaultValue: null, + help: + 'CONTINUATION_MEASURE_WIDTH_THRESHOLD is the calculated measure width (in pixels) above which an eligible ' + + 'measure is split into continuation pieces. Pieces flow across systems via the normal bin-packer, with the ' + + 'inner pieces having no start or end barlines as the continuation cue. Only supported by DefaultFormatter ' + + '(requires WIDTH to be set). When this is null, no fragmentation occurs.', + }), PART_LABEL_FONT_FAMILY: t.string({ defaultValue: 'Arial', help: 'PART_LABEL_FONT_FAMILY is the font family for part names.', diff --git a/src/data/types.ts b/src/data/types.ts index 64d5441c5..f0bc4be9a 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -95,6 +95,19 @@ export type Measure = { startBarlineStyle: BarlineStyle | null; endBarlineStyle: BarlineStyle | null; repetitionSymbols: RepetitionSymbol[]; + /** + * Non-null when this measure is part of a continuation chain (a measure that was too wide and got split into + * pieces). Otherwise null. + */ + continuation: Continuation | null; +}; + +export type Continuation = { + type: 'continuation'; + /** 0-based position of this piece in its continuation chain. */ + index: number; + /** Total number of pieces in the chain (>= 2). */ + total: number; }; export type Jump = diff --git a/src/elements/measure.ts b/src/elements/measure.ts index d8f43976b..48b08e0a7 100644 --- a/src/elements/measure.ts +++ b/src/elements/measure.ts @@ -71,6 +71,14 @@ export class Measure { return this.measureRender.absoluteIndex; } + /** + * Returns the continuation metadata if this measure is part of a continuation chain (a measure that was too wide + * and got fragmented into pieces). Returns null otherwise. + */ + getContinuation(): data.Continuation | null { + return this.measureRender.continuation; + } + /** * Sometimes document measures are folded into one (e.g. multimeasure rest). This method returns the [start, end] * _absolute_ index range that the measure covers. diff --git a/src/formatting/continuationpass.ts b/src/formatting/continuationpass.ts new file mode 100644 index 000000000..38ec6a326 --- /dev/null +++ b/src/formatting/continuationpass.ts @@ -0,0 +1,106 @@ +import * as data from '@/data'; +import * as rendering from '@/rendering'; +import * as util from '@/util'; +import { Config } from '@/config'; +import { Logger } from '@/debug'; +import { Rect } from '@/spatial'; +import { MeasureSplitter } from './measuresplitter'; + +export type ContinuationPassResult = { + /** Flattened post-split measures across all systems. */ + measures: data.Measure[]; + /** + * Synthesized {@link rendering.MeasureRender}s — one per piece — with widths suitable for downstream bin-packing. + * Most fields beyond `rect.w` and `absoluteIndex` are stubs; the bin-packer only reads those two. + */ + measureRenders: rendering.MeasureRender[]; +}; + +/** + * Mutates `document.score.systems` to replace eligible too-wide measures with continuation pieces. Returns the flat + * post-split measures list paired with synthesized panoramic-width renders for the bin-packer. + * + * Caller is expected to pass a cloned document — this function will mutate it. + */ +export function applyContinuationSplit( + document: data.Document, + panoramicScoreRender: rendering.ScoreRender, + config: Config, + log: Logger +): ContinuationPassResult { + if (config.CONTINUATION_MEASURE_WIDTH_THRESHOLD === null) { + return { + measures: document.score.systems.flatMap((s) => s.measures), + measureRenders: panoramicScoreRender.systemRenders.flatMap((s) => s.measureRenders), + }; + } + + const splitter = new MeasureSplitter(config, log); + + const flatOriginalRenders = panoramicScoreRender.systemRenders.flatMap((s) => s.measureRenders); + const flatOriginalMeasures = document.score.systems.flatMap((s) => s.measures); + util.assert( + flatOriginalRenders.length === flatOriginalMeasures.length, + 'panoramic render must have one MeasureRender per data.Measure' + ); + + const splitResults: Array<{ pieces: data.Measure[]; pieceWidths: number[]; reference: rendering.MeasureRender }> = []; + + let renderCursor = 0; + const newSystems: data.System[] = []; + for (const system of document.score.systems) { + const newMeasures: data.Measure[] = []; + for (const measure of system.measures) { + const reference = flatOriginalRenders[renderCursor++]; + const result = splitter.split(measure, reference); + newMeasures.push(...result.pieces); + splitResults.push({ ...result, reference }); + } + newSystems.push({ type: 'system', measures: newMeasures }); + } + document.score.systems = newSystems; + + const measures = newSystems.flatMap((s) => s.measures); + const measureRenders: rendering.MeasureRender[] = []; + let absoluteIndex = 0; + for (const result of splitResults) { + if (result.pieces.length === 1) { + measureRenders.push({ ...result.reference, absoluteIndex }); + absoluteIndex++; + continue; + } + for (let pieceIndex = 0; pieceIndex < result.pieces.length; pieceIndex++) { + measureRenders.push( + synthesizePieceRender( + result.reference, + result.pieceWidths[pieceIndex], + absoluteIndex, + pieceIndex, + result.pieces.length + ) + ); + absoluteIndex++; + } + } + + return { measures, measureRenders }; +} + +function synthesizePieceRender( + reference: rendering.MeasureRender, + pieceWidth: number, + absoluteIndex: number, + pieceIndex: number, + total: number +): rendering.MeasureRender { + return { + type: 'measure', + key: reference.key, + rect: new Rect(reference.rect.x, reference.rect.y, pieceWidth, reference.rect.h), + absoluteIndex, + fragmentRenders: [], + multiRestCount: 0, + jumps: [], + continuation: { type: 'continuation', index: pieceIndex, total }, + }; +} diff --git a/src/formatting/defaultformatter.ts b/src/formatting/defaultformatter.ts index 5284420e0..569ad6898 100644 --- a/src/formatting/defaultformatter.ts +++ b/src/formatting/defaultformatter.ts @@ -5,6 +5,7 @@ import { Config, DEFAULT_CONFIG } from '@/config'; import { Logger, NoopLogger } from '@/debug'; import { Formatter } from './types'; import { PanoramicFormatter } from './panoramicformatter'; +import { applyContinuationSplit } from './continuationpass'; type SystemSlice = { from: number; @@ -35,40 +36,46 @@ export class DefaultFormatter implements Formatter { // First, ensure the document is formatted for infinite x-scrolling. This will allow us to measure the width of the // measures and make decisions on how to group them into systems. - const panoramicConfig = { ...this.config, WIDTH: null, HEIGHT: null }; + const panoramicConfig = { + ...this.config, + WIDTH: null, + HEIGHT: null, + CONTINUATION_MEASURE_WIDTH_THRESHOLD: null, + }; const panoramicFormatter = new PanoramicFormatter({ config: panoramicConfig }); const panoramicDocument = new rendering.Document(panoramicFormatter.format(document)); const panoramicScoreRender = new rendering.Score(panoramicConfig, this.log, panoramicDocument, null).render(); - const slices = this.getSystemSlices(this.config, panoramicScoreRender); + const { measures, measureRenders } = applyContinuationSplit(clone, panoramicScoreRender, this.config, this.log); - this.applySystemSlices(clone, slices); + const slices = this.getSystemSlices(this.config, measureRenders); + + this.applySystemSlices(clone, slices, measures); return clone; } - private getSystemSlices(config: Config, scoreRender: rendering.ScoreRender): SystemSlice[] { - const slices = [{ from: 0, to: 0 }]; - - let remaining = config.WIDTH!; - let count = 0; - - const measureRenders = scoreRender.systemRenders.flatMap((systemRender) => systemRender.measureRenders); - - for (let measureIndex = 0; measureIndex < measureRenders.length; measureIndex++) { - const measure = measureRenders[measureIndex]; + private getSystemSlices(config: Config, measureRenders: rendering.MeasureRender[]): SystemSlice[] { + const slices: SystemSlice[] = []; + let remaining = 0; + // Continuation pieces occupy their own system, and the system following a continuation piece must start fresh. + let lockedFromContinuation = false; + for (const measure of measureRenders) { const required = measure.rect.w; + const isContinuationPiece = measure.continuation !== null; + const currentSlice = slices.at(-1); - if (required > remaining && count > 0) { + const needNewSlice = !currentSlice || isContinuationPiece || lockedFromContinuation || required > remaining; + + if (needNewSlice) { slices.push({ from: measure.absoluteIndex, to: measure.absoluteIndex }); - remaining = config.WIDTH!; - count = 0; + remaining = config.WIDTH! - required; + } else { + currentSlice!.to = measure.absoluteIndex; + remaining -= required; } - - slices.at(-1)!.to = measure.absoluteIndex; - remaining -= required; - count++; + lockedFromContinuation = isContinuationPiece; } this.log.debug(`grouped ${measureRenders.length} measures into ${slices.length} system(s)`); @@ -76,9 +83,7 @@ export class DefaultFormatter implements Formatter { return slices; } - private applySystemSlices(document: data.Document, slices: SystemSlice[]): void { - const measures = document.score.systems.flatMap((s) => s.measures); - + private applySystemSlices(document: data.Document, slices: SystemSlice[], measures: data.Measure[]): void { document.score.systems = []; for (const slice of slices) { diff --git a/src/formatting/measuresplitter.ts b/src/formatting/measuresplitter.ts new file mode 100644 index 000000000..2ce15edca --- /dev/null +++ b/src/formatting/measuresplitter.ts @@ -0,0 +1,403 @@ +import * as data from '@/data'; +import * as util from '@/util'; +import { Config } from '@/config'; +import { Logger } from '@/debug'; +import { MeasureRender } from '@/rendering'; +import { Fraction } from '@/util'; + +/** + * Reserved padding (pixels) subtracted from the system width when computing the per-piece budget. Accounts for the + * courtesy clef/key/time modifiers that the rendering engine prepends to each piece (every piece becomes the first + * measure of its own system) plus barline padding. + */ +const RESERVED_PADDING = 80; + +export type EligibilityResult = { eligible: true } | { eligible: false; reason: string }; + +type EntryView = { + measureBeat: Fraction; + duration: Fraction; + beamId: string | null; + tupletIds: string[]; + rectRight: number; +}; + +type SplitResult = { + pieces: data.Measure[]; + pieceWidths: number[]; +}; + +export class MeasureSplitter { + constructor(private config: Config, private log: Logger) {} + + isEligible(measure: data.Measure, measureRender: MeasureRender): EligibilityResult { + const threshold = this.config.CONTINUATION_MEASURE_WIDTH_THRESHOLD; + if (threshold === null) { + return { eligible: false, reason: 'feature disabled (threshold is null)' }; + } + if (measureRender.rect.w <= threshold) { + return { eligible: false, reason: 'measure already fits within threshold' }; + } + if (measure.fragments.some((f) => f.kind === 'nonmusical')) { + return { eligible: false, reason: 'measure contains a non-musical (gap) fragment' }; + } + if (measure.fragments.some((f) => typeof f.minWidth === 'number' && f.minWidth > 0)) { + return { eligible: false, reason: 'fragment has explicit minWidth' }; + } + if (measureRender.multiRestCount > 1) { + return { eligible: false, reason: 'multi-rest measure' }; + } + if ( + measure.startBarlineStyle === 'repeatstart' || + measure.startBarlineStyle === 'repeatboth' || + measure.endBarlineStyle === 'repeatend' || + measure.endBarlineStyle === 'repeatboth' + ) { + return { eligible: false, reason: 'measure has repeat barlines' }; + } + if (measure.jumps.some((j) => j.type === 'repeatending')) { + return { eligible: false, reason: 'measure has volta (repeat ending)' }; + } + if (measure.continuation !== null) { + return { eligible: false, reason: 'measure is already a continuation piece' }; + } + return { eligible: true }; + } + + /** Returns the resulting pieces and their widths. Length 1 means no split was performed. */ + split(measure: data.Measure, measureRender: MeasureRender): SplitResult { + const noSplit: SplitResult = { pieces: [measure], pieceWidths: [measureRender.rect.w] }; + + const reject = (reason: string): SplitResult => { + this.log.debug('measure ineligible for continuation', { absoluteIndex: measureRender.absoluteIndex, reason }); + return noSplit; + }; + + const eligibility = this.isEligible(measure, measureRender); + if (!eligibility.eligible) { + return reject(eligibility.reason); + } + + const width = this.config.WIDTH; + if (width === null) { + return reject('WIDTH is null'); + } + + const splitWidth = width - RESERVED_PADDING; + if (splitWidth <= 0) { + return reject('system width too small after reserved padding'); + } + + if (measureRender.rect.w <= splitWidth) { + return reject('measure fits within system width despite exceeding threshold'); + } + + const entryViews = this.collectEntryViews(measure, measureRender); + if (entryViews.length === 0) { + return reject('no entries to split'); + } + + const candidates = this.findCandidateSplitBeats(measure, entryViews); + if (candidates.length === 0) { + return reject('no valid split candidates (beams/tuplets/voices block all)'); + } + + const layout = this.findBalancedLayout(measureRender, entryViews, candidates, splitWidth); + if (!layout) { + return reject('no balanced split fits within system width'); + } + + const pieces = this.buildPieces(measure, layout.boundaries); + return { pieces, pieceWidths: layout.pieceWidths }; + } + + private collectEntryViews(measure: data.Measure, measureRender: MeasureRender): EntryView[] { + const views: EntryView[] = []; + for (const fragmentRender of measureRender.fragmentRenders) { + const fragment = measure.fragments[fragmentRender.key.fragmentIndex]; + if (!fragment || fragment.kind === 'nonmusical') { + continue; + } + for (const partRender of fragmentRender.partRenders) { + const part = fragment.parts[partRender.key.partIndex]; + if (!part) { + continue; + } + for (const staveRender of partRender.staveRenders) { + const stave = part.staves[staveRender.key.staveIndex]; + if (!stave) { + continue; + } + for (const voiceRender of staveRender.voiceRenders) { + const voice = stave.voices[voiceRender.key.voiceIndex]; + if (!voice) { + continue; + } + for (const entryRender of voiceRender.entryRenders) { + const entry = voice.entries[entryRender.key.voiceEntryIndex]; + if (!entry) { + continue; + } + views.push({ + measureBeat: Fraction.fromFractionLike(entry.measureBeat), + duration: Fraction.fromFractionLike(entry.duration), + beamId: this.getBeamId(entry), + tupletIds: this.getTupletIds(entry), + rectRight: entryRender.rect.right(), + }); + } + } + } + } + } + return views; + } + + private getBeamId(entry: data.VoiceEntry): string | null { + if (entry.type === 'note' || entry.type === 'chord' || entry.type === 'rest') { + return entry.beamId; + } + return null; + } + + private getTupletIds(entry: data.VoiceEntry): string[] { + if (entry.type === 'note' || entry.type === 'chord' || entry.type === 'rest') { + return entry.tupletIds; + } + return []; + } + + /** Returns valid candidate split beats sorted ascending. Excludes beat 0 and beat >= measure end. */ + private findCandidateSplitBeats(measure: data.Measure, entryViews: EntryView[]): Fraction[] { + const positive = entryViews.filter((v) => v.measureBeat.toDecimal() > 0).map((v) => v.measureBeat); + const sorted = util.sortBy( + util.uniqueBy(positive, (b) => b.toDecimal()), + (b) => b.toDecimal() + ); + return sorted.filter((boundary) => this.isValidSplit(measure, entryViews, boundary)); + } + + private isValidSplit(measure: data.Measure, entryViews: EntryView[], boundary: Fraction): boolean { + // 1. No entry can span the boundary. + for (const v of entryViews) { + const start = v.measureBeat; + const end = start.add(v.duration); + if (start.isLessThan(boundary) && end.isGreaterThan(boundary)) { + return false; + } + } + // 2. No beam or tuplet can span the boundary (within any voice). + for (const fragment of measure.fragments) { + if (fragment.kind === 'nonmusical') { + continue; + } + for (const part of fragment.parts) { + for (const stave of part.staves) { + for (const voice of stave.voices) { + const beamHasBefore = new Set(); + const beamHasAtOrAfter = new Set(); + const tupletHasBefore = new Set(); + const tupletHasAtOrAfter = new Set(); + for (const entry of voice.entries) { + const start = Fraction.fromFractionLike(entry.measureBeat); + const isBefore = start.isLessThan(boundary); + const beamId = this.getBeamId(entry); + const tupletIds = this.getTupletIds(entry); + if (beamId) { + if (isBefore) { + beamHasBefore.add(beamId); + } else { + beamHasAtOrAfter.add(beamId); + } + } + for (const tid of tupletIds) { + if (isBefore) { + tupletHasBefore.add(tid); + } else { + tupletHasAtOrAfter.add(tid); + } + } + } + for (const id of beamHasBefore) { + if (beamHasAtOrAfter.has(id)) { + return false; + } + } + for (const id of tupletHasBefore) { + if (tupletHasAtOrAfter.has(id)) { + return false; + } + } + } + } + } + } + return true; + } + + /** + * Target-balanced layout: try N = ceil(measureWidth / splitWidth) pieces, snapping each ideal boundary to the nearest + * candidate. If any piece exceeds `splitWidth`, retry with N + 1 (more pieces flatten the largest piece). Returns + * null if no N up to a sensible cap satisfies the budget. + */ + private findBalancedLayout( + measureRender: MeasureRender, + entryViews: EntryView[], + candidates: Fraction[], + splitWidth: number + ): { boundaries: Fraction[]; pieceWidths: number[] } | null { + const measureLeft = measureRender.rect.left(); + const measureWidth = measureRender.rect.w; + + const widthAt = (boundary: Fraction): number => { + const rights = entryViews.filter((v) => v.measureBeat.isLessThan(boundary)).map((v) => v.rectRight); + return util.max(rights, measureLeft) - measureLeft; + }; + + const candidateWidths = candidates.map(widthAt); + + const minPieces = Math.max(2, Math.ceil(measureWidth / splitWidth)); + const maxPieces = Math.min(candidates.length + 1, minPieces + candidates.length); + + for (let n = minPieces; n <= maxPieces; n++) { + const target = measureWidth / n; + const usedIndices = new Set(); + const boundaryIndices: number[] = []; + let prevWidth = 0; + let valid = true; + + for (let i = 1; i < n; i++) { + const idealW = i * target; + let bestIdx = -1; + let bestDist = Infinity; + for (let ci = 0; ci < candidates.length; ci++) { + if (usedIndices.has(ci)) { + continue; + } + const w = candidateWidths[ci]; + if (w <= prevWidth) { + continue; + } + const dist = Math.abs(w - idealW); + if (dist < bestDist) { + bestDist = dist; + bestIdx = ci; + } + } + if (bestIdx === -1) { + valid = false; + break; + } + boundaryIndices.push(bestIdx); + usedIndices.add(bestIdx); + prevWidth = candidateWidths[bestIdx]; + } + + if (!valid) { + continue; + } + + boundaryIndices.sort((a, b) => candidateWidths[a] - candidateWidths[b]); + const boundaries = boundaryIndices.map((bi) => candidates[bi]); + + const pieceWidths: number[] = []; + let prevW = 0; + for (const bi of boundaryIndices) { + const w = candidateWidths[bi]; + pieceWidths.push(w - prevW); + prevW = w; + } + pieceWidths.push(measureWidth - prevW); + + if (pieceWidths.every((w) => w <= splitWidth)) { + return { boundaries, pieceWidths }; + } + } + + return null; + } + + private buildPieces(measure: data.Measure, boundaries: Fraction[]): data.Measure[] { + const total = boundaries.length + 1; + + const ranges: Array<{ start: Fraction; end: Fraction }> = []; + let prev: Fraction = Fraction.zero(); + for (const b of boundaries) { + ranges.push({ start: prev, end: b }); + prev = b; + } + ranges.push({ start: prev, end: Fraction.max() }); + + return ranges.map((range, index) => this.buildPiece(measure, range, index, total)); + } + + private buildPiece( + measure: data.Measure, + range: { start: Fraction; end: Fraction }, + index: number, + total: number + ): data.Measure { + const isFirst = index === 0; + const isLast = index === total - 1; + + const pieceFragments: data.Fragment[] = measure.fragments.map((fragment) => { + const cloned = util.deepClone(fragment); + if (cloned.kind === 'nonmusical') { + return cloned; + } + cloned.parts = cloned.parts.map((part) => ({ + ...part, + staves: part.staves.map((stave) => ({ + ...stave, + voices: stave.voices.map((voice) => this.partitionVoice(voice, range)), + })), + })); + return cloned; + }); + + return { + type: 'measure', + label: isFirst ? measure.label : null, + fragments: pieceFragments, + jumps: isFirst ? util.deepClone(measure.jumps) : [], + startBarlineStyle: isFirst ? measure.startBarlineStyle : 'none', + endBarlineStyle: isLast ? measure.endBarlineStyle : 'none', + repetitionSymbols: isFirst ? [...measure.repetitionSymbols] : [], + continuation: { type: 'continuation', index, total }, + }; + } + + private partitionVoice(voice: data.Voice, range: { start: Fraction; end: Fraction }): data.Voice { + const inRange = (entry: data.VoiceEntry) => { + const start = Fraction.fromFractionLike(entry.measureBeat); + return start.isGreaterThanOrEqualTo(range.start) && start.isLessThan(range.end); + }; + // Shift each entry's measureBeat so the piece starts at 0. Without this, the voice renderer would insert a + // ghost note for the silent prefix and squeeze the piece's notes into a fraction of the available width. + const filteredEntries = voice.entries.filter(inRange).map((e) => { + const cloned = util.deepClone(e); + const shifted = Fraction.fromFractionLike(cloned.measureBeat).subtract(range.start); + cloned.measureBeat = { type: 'fraction', ...shifted.toFractionLike() }; + return cloned; + }); + + const usedBeamIds = new Set(); + const usedTupletIds = new Set(); + for (const entry of filteredEntries) { + const beamId = this.getBeamId(entry); + if (beamId) { + usedBeamIds.add(beamId); + } + for (const tid of this.getTupletIds(entry)) { + usedTupletIds.add(tid); + } + } + + return { + type: 'voice', + entries: filteredEntries, + beams: voice.beams.filter((b) => usedBeamIds.has(b.id)).map((b) => util.deepClone(b)), + tuplets: voice.tuplets.filter((t) => usedTupletIds.has(t.id)).map((t) => util.deepClone(t)), + }; + } +} diff --git a/src/formatting/panoramicformatter.ts b/src/formatting/panoramicformatter.ts index 96736f890..825acb996 100644 --- a/src/formatting/panoramicformatter.ts +++ b/src/formatting/panoramicformatter.ts @@ -21,6 +21,10 @@ export class PanoramicFormatter implements Formatter { this.log = opts?.logger ?? new NoopLogger(); util.assertNull(this.config.WIDTH, 'WIDTH must be null for PanoramicFormatter'); + util.assertNull( + this.config.CONTINUATION_MEASURE_WIDTH_THRESHOLD, + 'CONTINUATION_MEASURE_WIDTH_THRESHOLD must be null for PanoramicFormatter' + ); } format(document: data.Document): data.Document { diff --git a/src/parsing/musicxml/measure.ts b/src/parsing/musicxml/measure.ts index 3df82af2a..c81fb9cbd 100644 --- a/src/parsing/musicxml/measure.ts +++ b/src/parsing/musicxml/measure.ts @@ -162,6 +162,7 @@ export class Measure { startBarlineStyle: this.startBarlineStyle, endBarlineStyle: this.endBarlineStyle, repetitionSymbols: this.repetitionSymbols, + continuation: null, }; } } diff --git a/src/rendering/measure.ts b/src/rendering/measure.ts index 4e0a25e98..f7e16f69c 100644 --- a/src/rendering/measure.ts +++ b/src/rendering/measure.ts @@ -23,7 +23,7 @@ export class Measure { const absoluteIndex = this.document.getAbsoluteMeasureIndex(this.key); const multiRestCount = this.document.getMeasureMultiRestCount(this.key); const fragmentRenders = this.renderFragments(pen); - const jumps = this.document.getMeasure(this.key).jumps; + const measure = this.document.getMeasure(this.key); const rect = Rect.merge(fragmentRenders.map((fragment) => fragment.rect)); @@ -34,7 +34,8 @@ export class Measure { fragmentRenders, multiRestCount, absoluteIndex, - jumps, + jumps: measure.jumps, + continuation: measure.continuation, }; } diff --git a/src/rendering/stave.ts b/src/rendering/stave.ts index e2ebcf7da..61fc01347 100644 --- a/src/rendering/stave.ts +++ b/src/rendering/stave.ts @@ -174,9 +174,12 @@ export class Stave { } private renderMeasureLabel(vexflowStave: vexflow.Stave): void { - const measureLabel = this.document.getMeasure(this.key).label; - if (this.shouldShowMeasureLabel() && measureLabel) { - vexflowStave.setMeasure(measureLabel); + if (!this.shouldShowMeasureLabel()) { + return; + } + const measure = this.document.getMeasure(this.key); + if (measure.label) { + vexflowStave.setMeasure(measure.label); } } diff --git a/src/rendering/types.ts b/src/rendering/types.ts index d128e2c1b..5fbee54a8 100644 --- a/src/rendering/types.ts +++ b/src/rendering/types.ts @@ -115,6 +115,7 @@ export type MeasureRender = { fragmentRenders: FragmentRender[]; multiRestCount: number; jumps: data.Jump[]; + continuation: data.Continuation | null; }; export type FragmentRender = { diff --git a/tests/__data__/vexml/continuation_measures_basic.musicxml b/tests/__data__/vexml/continuation_measures_basic.musicxml new file mode 100644 index 000000000..d8c3d740a --- /dev/null +++ b/tests/__data__/vexml/continuation_measures_basic.musicxml @@ -0,0 +1,94 @@ + + + + + + Piano + + + + + + 4 + + 0 + + + 2 + + G + 2 + + + F + 4 + + + C41116th1beginbegin + D41116th1continuecontinue + E41116th1continuecontinue + F41116th1endend + G41116th1beginbegin + A41116th1continuecontinue + B41116th1continuecontinue + C51116th1endend + D51116th1beginbegin + E51116th1continuecontinue + F51116th1continuecontinue + G51116th1endend + F51116th1beginbegin + E51116th1continuecontinue + D51116th1continuecontinue + C51116th1endend + 16 + C342quarter2 + G342quarter2 + E342quarter2 + G342quarter2 + + + C51116th1beginbegin + B41116th1continuecontinue + A41116th1continuecontinue + G41116th1endend + F41116th1beginbegin + E41116th1continuecontinue + D41116th1continuecontinue + C41116th1endend + D41116th1beginbegin + E41116th1continuecontinue + F41116th1continuecontinue + G41116th1endend + A41116th1beginbegin + B41116th1continuecontinue + C51116th1continuecontinue + D51116th1endend + 16 + F342quarter2 + A342quarter2 + C442quarter2 + A342quarter2 + + + C441quarter1 + E441quarter1 + G441quarter1 + C541quarter1 + 16 + C3162whole2 + + + C541quarter1 + G441quarter1 + E441quarter1 + C441quarter1 + 16 + C3162whole2 + + + diff --git a/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w1200_tnull.png b/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w1200_tnull.png new file mode 100644 index 000000000..67889de1c Binary files /dev/null and b/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w1200_tnull.png differ diff --git a/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w300_t120.png b/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w300_t120.png new file mode 100644 index 000000000..9d829bc71 Binary files /dev/null and b/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w300_t120.png differ diff --git a/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w400_t250.png b/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w400_t250.png new file mode 100644 index 000000000..5bfcfd021 Binary files /dev/null and b/tests/integration/__image_snapshots__/continuation_continuation_measures_basic_w400_t250.png differ diff --git a/tests/integration/vexml.test.ts b/tests/integration/vexml.test.ts index 21debc66a..143c23244 100644 --- a/tests/integration/vexml.test.ts +++ b/tests/integration/vexml.test.ts @@ -9,6 +9,13 @@ type TestCase = { width: number; }; +type ContinuationCase = { + filename: string; + /** Score WIDTH config (must be set; PanoramicFormatter does not support continuation). */ + width: number; + continuationThreshold: number | null; +}; + const DATA_DIR = path.resolve(__dirname, '..', '__data__', 'vexml'); describe('vexml', () => { @@ -63,4 +70,47 @@ describe('vexml', () => { customSnapshotIdentifier: getSnapshotIdentifier({ filename: t.filename, width: t.width }), }); }); + + it.each([ + // Baseline: feature disabled — should match the un-fragmented rendering. + { + filename: 'continuation_measures_basic.musicxml', + width: 1200, + continuationThreshold: null, + }, + // Narrow system width → eligible measures fragment across multiple systems. + { + filename: 'continuation_measures_basic.musicxml', + width: 400, + continuationThreshold: 250, + }, + // Very narrow viewport → a single wide measure spans more than 2 systems (>=3 pieces). + { + filename: 'continuation_measures_basic.musicxml', + width: 300, + continuationThreshold: 120, + }, + ])(`continuation: $filename (width=$width, threshold=$continuationThreshold)`, async (t) => { + const { document, vexmlDiv, screenshotElementSelector } = setup(); + + const buffer = fs.readFileSync(path.join(DATA_DIR, t.filename)); + const musicXML = buffer.toString(); + vexml.renderMusicXML(musicXML, vexmlDiv, { + config: { + WIDTH: t.width, + CONTINUATION_MEASURE_WIDTH_THRESHOLD: t.continuationThreshold, + }, + }); + + await page.setViewport({ width: t.width, height: 0 }); + await page.setContent(document.documentElement.outerHTML); + + const element = await page.$(screenshotElementSelector); + const screenshot = Buffer.from((await element!.screenshot()) as any); + expect(screenshot).toMatchImageSnapshot({ + customSnapshotIdentifier: `continuation_${path.basename(t.filename, path.extname(t.filename))}_w${t.width}_t${ + t.continuationThreshold ?? 'null' + }`, + }); + }); }); diff --git a/tests/unit/formatting/measuresplitter.test.ts b/tests/unit/formatting/measuresplitter.test.ts new file mode 100644 index 000000000..cf602a666 --- /dev/null +++ b/tests/unit/formatting/measuresplitter.test.ts @@ -0,0 +1,173 @@ +import * as data from '@/data'; +import { Config, DEFAULT_CONFIG } from '@/config'; +import { MemoryLogger } from '@/debug'; +import { MeasureSplitter } from '@/formatting/measuresplitter'; +import { MeasureRender } from '@/rendering'; +import { Rect } from '@/spatial'; + +describe(MeasureSplitter, () => { + describe('isEligible', () => { + it('rejects when threshold is null', () => { + const log = new MemoryLogger(); + const splitter = new MeasureSplitter(makeConfig(null), log); + const result = splitter.isEligible(makeMeasure(), makeMeasureRender()); + expect(result).toEqual({ eligible: false, reason: 'feature disabled (threshold is null)' }); + }); + + it('rejects when measure already fits within threshold', () => { + const splitter = new MeasureSplitter(makeConfig(2000), new MemoryLogger()); + const result = splitter.isEligible(makeMeasure(), makeMeasureRender({ rect: new Rect(0, 0, 500, 100) })); + expect(result).toEqual({ eligible: false, reason: 'measure already fits within threshold' }); + }); + + it('rejects when the measure contains a non-musical fragment', () => { + const splitter = new MeasureSplitter(makeConfig(500), new MemoryLogger()); + const measure = makeMeasure({ + fragments: [ + { + type: 'fragment', + kind: 'nonmusical', + signature: { type: 'fragmentsignature', metronome: { type: 'metronome', playbackBpm: 100 } }, + parts: [], + minWidth: null, + label: null, + durationMs: 0, + }, + ], + }); + const result = splitter.isEligible(measure, makeMeasureRender()); + expect(result).toEqual({ eligible: false, reason: 'measure contains a non-musical (gap) fragment' }); + }); + + it('rejects when a fragment has explicit minWidth', () => { + const splitter = new MeasureSplitter(makeConfig(500), new MemoryLogger()); + const measure = makeMeasure({ + fragments: [ + { + type: 'fragment', + kind: 'musical', + signature: { type: 'fragmentsignature', metronome: { type: 'metronome', playbackBpm: 100 } }, + parts: [], + minWidth: 250, + }, + ], + }); + const result = splitter.isEligible(measure, makeMeasureRender()); + expect(result).toEqual({ eligible: false, reason: 'fragment has explicit minWidth' }); + }); + + it('rejects multi-rest measures', () => { + const splitter = new MeasureSplitter(makeConfig(500), new MemoryLogger()); + const result = splitter.isEligible(makeMeasure(), makeMeasureRender({ multiRestCount: 4 })); + expect(result).toEqual({ eligible: false, reason: 'multi-rest measure' }); + }); + + it.each(['repeatstart', 'repeatend', 'repeatboth'] as const)('rejects measures with %s barlines', (style) => { + const splitter = new MeasureSplitter(makeConfig(500), new MemoryLogger()); + const measure = makeMeasure({ + startBarlineStyle: style === 'repeatend' ? null : style, + endBarlineStyle: style === 'repeatstart' ? null : style, + }); + const result = splitter.isEligible(measure, makeMeasureRender()); + expect(result).toEqual({ eligible: false, reason: 'measure has repeat barlines' }); + }); + + it('rejects measures with voltas', () => { + const splitter = new MeasureSplitter(makeConfig(500), new MemoryLogger()); + const measure = makeMeasure({ + jumps: [{ type: 'repeatending', times: 1, label: '1.', endingBracketType: 'begin' }], + }); + const result = splitter.isEligible(measure, makeMeasureRender()); + expect(result).toEqual({ eligible: false, reason: 'measure has volta (repeat ending)' }); + }); + + it('rejects measures that are already continuation pieces', () => { + const splitter = new MeasureSplitter(makeConfig(500), new MemoryLogger()); + const measure = makeMeasure({ + continuation: { type: 'continuation', index: 0, total: 2 }, + }); + const result = splitter.isEligible(measure, makeMeasureRender()); + expect(result).toEqual({ eligible: false, reason: 'measure is already a continuation piece' }); + }); + + it('accepts a wide musical measure with no special markings', () => { + const splitter = new MeasureSplitter(makeConfig(500), new MemoryLogger()); + const result = splitter.isEligible(makeMeasure(), makeMeasureRender({ rect: new Rect(0, 0, 1000, 100) })); + expect(result).toEqual({ eligible: true }); + }); + }); + + describe('split', () => { + it('returns the original measure unchanged when ineligible, and logs a debug reason', () => { + const log = new MemoryLogger(); + const splitter = new MeasureSplitter(makeConfig(null), log); + const measure = makeMeasure(); + const measureRender = makeMeasureRender(); + + const { pieces, pieceWidths } = splitter.split(measure, measureRender); + + expect(pieces).toEqual([measure]); + expect(pieceWidths).toEqual([measureRender.rect.w]); + expect(log.getLogs()).toHaveLength(1); + expect(log.getLogs()[0].message).toBe('measure ineligible for continuation'); + expect(log.getLogs()[0].meta).toMatchObject({ reason: 'feature disabled (threshold is null)' }); + }); + + it('returns the original measure when there are no entries to split', () => { + const log = new MemoryLogger(); + const splitter = new MeasureSplitter(makeConfig(500), log); + const result = splitter.split(makeMeasure(), makeMeasureRender({ rect: new Rect(0, 0, 1000, 100) })); + expect(result.pieces).toHaveLength(1); + expect(log.getLogs().at(-1)?.meta?.reason).toBe('no entries to split'); + }); + + it('returns the original measure when WIDTH is null', () => { + const log = new MemoryLogger(); + const splitter = new MeasureSplitter(makeConfig(500, null), log); + const result = splitter.split(makeMeasure(), makeMeasureRender({ rect: new Rect(0, 0, 1000, 100) })); + expect(result.pieces).toHaveLength(1); + expect(log.getLogs().at(-1)?.meta?.reason).toBe('WIDTH is null'); + }); + + it('returns the original measure when it fits within the system width despite exceeding threshold', () => { + const log = new MemoryLogger(); + // threshold=200 (eligible), WIDTH=1000 > splitWidth=920 > measure (rect.w=400) fits + const splitter = new MeasureSplitter(makeConfig(200, 1000), log); + const result = splitter.split(makeMeasure(), makeMeasureRender({ rect: new Rect(0, 0, 400, 100) })); + expect(result.pieces).toHaveLength(1); + expect(log.getLogs().at(-1)?.meta?.reason).toBe('measure fits within system width despite exceeding threshold'); + }); + }); +}); + +function makeConfig(threshold: number | null, width: number | null = 600): Config { + return { ...DEFAULT_CONFIG, CONTINUATION_MEASURE_WIDTH_THRESHOLD: threshold, WIDTH: width }; +} + +function makeMeasure(overrides: Partial = {}): data.Measure { + return { + type: 'measure', + label: 1, + fragments: [], + jumps: [], + startBarlineStyle: null, + endBarlineStyle: null, + repetitionSymbols: [], + continuation: null, + ...overrides, + }; +} + +function makeMeasureRender(overrides: Partial = {}): MeasureRender { + return { + type: 'measure', + key: { systemIndex: 0, measureIndex: 0 }, + rect: new Rect(0, 0, 1000, 100), + absoluteIndex: 0, + fragmentRenders: [], + multiRestCount: 0, + jumps: [], + continuation: null, + ...overrides, + }; +}