UNPKG

@stringsync/vexml

Version:

MusicXML to Vexflow

349 lines (348 loc) 16.3 kB
import * as vexflow from 'vexflow'; import * as util from '../util'; import { Rect } from '../spatial'; import { NoopRenderContext } from './nooprenderctx'; const CURVE_EXTRA_WIDTH = 20; const TUPLET_EXTRA_WIDTH = 15; const TAB_RECT_WIDTH = 10; const STAVE_PLAYABLE_RECT_PADDING_LEFT = 10; // These modifiers cause the bounding box of the vexflow stave note to be incorrect. We filter them out when calculating // the bounding box of the vexflow StaveNote. Remove each member when they are fixed upstream. const PROBLEMATIC_VEXFLOW_MODIFIERS = [vexflow.Bend, vexflow.Stroke]; export class Ensemble { config; log; document; key; constructor(config, log, document, key) { this.config = config; this.log = log; this.document = document; this.key = key; } /** Formats the ensemble, updating the rects and vexflow objects in place. */ format(fragmentRender, { x, width, paddingLeft, paddingRight, cache }) { util.assert(fragmentRender.rectSrc === 'none', 'expected fragment render to be unformatted'); const vexflowFormatter = new vexflow.Formatter(); // Explode out the components of the ensemble. const staveRenders = fragmentRender.partRenders.flatMap((p) => p.staveRenders); const vexflowStaves = staveRenders.map((s) => s.vexflowStave); const voiceRenders = staveRenders.flatMap((s) => s.voiceRenders); const vexflowVoices = voiceRenders.flatMap((v) => v.vexflowVoices).filter((v) => v.getTickables().length > 0); const entryRenders = voiceRenders.flatMap((v) => v.entryRenders); if (staveRenders.length === 0) { return; } // Calculate the non-voice width. const nonVoiceWidth = vexflow.Stave.defaultPadding + this.getStartClefWidth(staveRenders) + this.getEndClefWidth(staveRenders) + this.getKeyWidth(staveRenders) + this.getTimeWidth(staveRenders); // Align the starting notes of each stave to the same x position. const maxNoteStartX = Math.max(...vexflowStaves.map((v) => v.getNoteStartX())); const noteStartOffsetXes = vexflowStaves.map((v) => maxNoteStartX - v.getNoteStartX()); // Calculate stave width. let staveWidth; if (width) { staveWidth = width; } else { staveWidth = this.getVoiceWidth(noteStartOffsetXes, staveRenders, voiceRenders, entryRenders) + nonVoiceWidth; } staveWidth -= paddingLeft; staveWidth -= paddingRight; // Set the width on the vexflow staves. for (const vexflowStave of vexflowStaves) { vexflowStave.setWidth(staveWidth); vexflowStave.setNoteStartX(maxNoteStartX); } // Assign each voice to a stave. for (const staveRender of staveRenders) { const staveVexflowVoices = staveRender.voiceRenders .flatMap((v) => v.vexflowVoices) .filter((v) => v.getTickables().length > 0); if (staveVexflowVoices.length > 0) { vexflowFormatter.joinVoices(staveVexflowVoices); } } // Format _all_ the voices. The voice width must be smaller than the stave or the stave won't contain it. const voiceWidth = staveWidth - nonVoiceWidth; if (vexflowVoices.length > 0) { vexflowFormatter.format(vexflowVoices, voiceWidth, { autoBeam: true }); } // Populate the rects by either using the cache or drawing. if (cache) { this.hydrate(fragmentRender, cache); } else { this.draw(fragmentRender, { x, paddingLeft, paddingRight }); } } draw(fragmentRender, { x, paddingLeft, paddingRight }) { fragmentRender.rectSrc = 'draw'; const vexflowVoices = fragmentRender.partRenders .flatMap((p) => p.staveRenders) .flatMap((s) => s.voiceRenders) .flatMap((v) => v.vexflowVoices) .filter((v) => v.getTickables().length > 0); // At this point, we can call getBoundingBox() on everything, but vexflow does some extra formatting in draw() that // mutates the objects. Before we set the rects, we need to draw the staves to a noop context, then unset the // context and rendered state. const ctx = new NoopRenderContext(); for (const vexflowVoice of vexflowVoices) { vexflowVoice.setContext(ctx).draw(); vexflowVoice.setRendered(false); } // At this point, we should be able to call getBoundingBox() on all of the vexflow objects. We can now update the // rects accordingly. let excessHeight = 0; for (const partRender of fragmentRender.partRenders) { for (const staveRender of partRender.staveRenders) { for (const voiceRender of staveRender.voiceRenders) { for (const entryRender of voiceRender.entryRenders) { entryRender.rect = this.hackEntryRenderRect(entryRender, staveRender); } voiceRender.rect = Rect.merge(voiceRender.entryRenders.map((e) => e.rect)); } staveRender.rect = this.overrideVexflowStaveRect(staveRender, x, paddingLeft, paddingRight); staveRender.intrinsicRect = this.calculateStaveIntrinsicRect(staveRender); staveRender.playableRect = this.calculateStavePlayableRect(staveRender); staveRender.excessHeight = this.getExcessHeight(staveRender); excessHeight = Math.max(excessHeight, staveRender.excessHeight); } partRender.rect = Rect.merge(partRender.staveRenders.map((s) => s.rect)); } fragmentRender.rect = Rect.merge(fragmentRender.partRenders.map((s) => s.rect)); fragmentRender.excessHeight = excessHeight; } hydrate(fragmentRender, cache) { util.assert(cache.rectSrc === 'draw', 'expected cache fragment to be from draw'); fragmentRender.rectSrc = 'cache'; fragmentRender.rect = cache.rect; fragmentRender.excessHeight = cache.excessHeight; for (let partIndex = 0; partIndex < fragmentRender.partRenders.length; partIndex++) { const partRender = fragmentRender.partRenders[partIndex]; const cachePartRender = cache.partRenders[partIndex]; partRender.rect = cachePartRender.rect; for (let staveIndex = 0; staveIndex < partRender.staveRenders.length; staveIndex++) { const staveRender = partRender.staveRenders[staveIndex]; const cacheStaveRender = cachePartRender.staveRenders[staveIndex]; staveRender.rect = cacheStaveRender.rect; staveRender.intrinsicRect = cacheStaveRender.intrinsicRect; staveRender.playableRect = cacheStaveRender.playableRect; staveRender.excessHeight = cacheStaveRender.excessHeight; for (let voiceIndex = 0; voiceIndex < staveRender.voiceRenders.length; voiceIndex++) { const voiceRender = staveRender.voiceRenders[voiceIndex]; const cacheVoiceRender = cacheStaveRender.voiceRenders[voiceIndex]; voiceRender.rect = cacheVoiceRender.rect; for (let entryIndex = 0; entryIndex < voiceRender.entryRenders.length; entryIndex++) { const entryRender = voiceRender.entryRenders[entryIndex]; const cacheEntryRender = cacheVoiceRender.entryRenders[entryIndex]; entryRender.rect = cacheEntryRender.rect; } } } } } /** Returns extra width to accommodate curves */ getCurveExtraWidth(entryRenders) { const curveCount = util.unique(entryRenders.filter((e) => e.type === 'note').flatMap((n) => n.curveIds)).length; if (curveCount <= 1) { return 0; } return curveCount * CURVE_EXTRA_WIDTH; } getTupletExtraWidth(voiceRenders) { const tupletCount = voiceRenders.flatMap((v) => v.tupletRenders).length; return tupletCount * TUPLET_EXTRA_WIDTH; } getStartClefWidth(staveRenders) { const widths = staveRenders .map((s) => s.startClefRender) .filter((c) => c !== null) .map((c) => c.width); if (widths.length > 0) { return Math.max(...widths); } return 0; } getEndClefWidth(staveRenders) { const widths = staveRenders .map((s) => s.endClefRender) .filter((c) => c !== null) .map((c) => c.width); if (widths.length > 0) { return Math.max(...widths); } return 0; } getTimeWidth(staveRenders) { const widths = staveRenders .map((s) => s.timeRender) .filter((t) => t !== null) .flatMap((t) => t.width); if (widths.length > 0) { return Math.max(...widths); } return 0; } getKeyWidth(staveRenders) { const widths = staveRenders .map((s) => s.keyRender) .filter((k) => k !== null) .map((k) => k.width); if (widths.length > 0) { return Math.max(...widths); } return 0; } getVoiceWidth(noteStartOffsetXes, staveRenders, voiceRenders, entryRenders) { let width = 0; const multiRestCount = this.document.getMeasureMultiRestCount(this.key); if (multiRestCount > 0) { width += this.getMultiRestStaveWidth(); } else { width += this.getMinRequiredStaveWidth(noteStartOffsetXes, staveRenders); } width += this.getTupletExtraWidth(voiceRenders); width += this.getCurveExtraWidth(entryRenders); return width; } getMultiRestStaveWidth() { return this.config.BASE_MULTI_REST_MEASURE_WIDTH; } getMinRequiredStaveWidth(noteStartOffsetXes, staveRenders) { const fragmentCount = this.document.getFragmentCount(this.key); return (this.config.BASE_VOICE_WIDTH / fragmentCount + this.getMinRequiredVoiceWidth(noteStartOffsetXes, staveRenders)); } getMinRequiredVoiceWidth(noteStartOffsetXes, staveRenders) { const widths = staveRenders.map((s, index) => { const vexflowVoices = s.voiceRenders.flatMap((v) => v.vexflowVoices).filter((v) => v.getTickables().length > 0); if (vexflowVoices.length === 0) { return 0; } else { return (new vexflow.Formatter().joinVoices(vexflowVoices).preCalculateMinTotalWidth(vexflowVoices) + noteStartOffsetXes[index]); } }); if (widths.length > 0) { return Math.max(...widths); } return 0; } /** * Returns the rect of the stave itself, ignoring any influence by child elements such as notes. */ calculateStaveIntrinsicRect(staveRender) { const vexflowStave = staveRender.vexflowStave; const box = vexflowStave.getBoundingBox(); const topLineY = vexflowStave.getTopLineTopY(); const bottomLineY = vexflowStave.getBottomLineBottomY(); const x = box.x; const y = topLineY; const w = box.w; const h = bottomLineY - topLineY; return new Rect(x, y, w, h); } calculateStavePlayableRect(staveRender) { const vexflowStave = staveRender.vexflowStave; const intrinsicRect = this.calculateStaveIntrinsicRect(staveRender); const hasStartingModifiers = vexflowStave .getModifiers() .filter((modifier) => modifier instanceof vexflow.Clef || modifier instanceof vexflow.TimeSignature || modifier instanceof vexflow.KeySignature).length > 0; if (!hasStartingModifiers) { return intrinsicRect; } const x = vexflowStave.getNoteStartX() + STAVE_PLAYABLE_RECT_PADDING_LEFT; const y = intrinsicRect.y; const w = intrinsicRect.w - (x - intrinsicRect.x); const h = intrinsicRect.h; return new Rect(x, y, w, h); } overrideVexflowStaveRect(staveRender, x, paddingLeft, paddingRight) { const vexflowStave = staveRender.vexflowStave; const box = vexflowStave.getBoundingBox(); const y = box.y; const w = box.w + paddingLeft + paddingRight; const h = box.h; const voiceRects = staveRender.voiceRenders.map((v) => v.rect); return Rect.merge([new Rect(x, y, w, h), ...voiceRects]); } /** The vexflow text dynamics bounding box is incorrect. This method returns a reasonable approximation. */ overrideVexflowTextDynamicsRect(vexflowTextDynamics, staveRender) { const textBox = vexflowTextDynamics.getBoundingBox(); const staveBox = staveRender.vexflowStave.getBoundingBox(); const x = vexflowTextDynamics.getAbsoluteX(); const y = staveBox.y - 2; const w = textBox.w; const h = textBox.h; return new Rect(x, y, w, h); } overrideVexflowTabNoteRect(vexflowTabNote) { const rects = new Array(); const x = vexflowTabNote.getAbsoluteX(); for (const y of vexflowTabNote.getYs()) { const rect = new Rect(x - TAB_RECT_WIDTH / 2, y - TAB_RECT_WIDTH / 2, TAB_RECT_WIDTH, TAB_RECT_WIDTH); rects.push(rect); } return Rect.merge(rects); } /** * Returns how much height the voice exceeded the normal vexflow.Stave (not EnsembleStave) boundaries. Callers may * need to account for this when positioning the system that this ensemble belongs to. */ getExcessHeight(staveRender) { if (staveRender.voiceRenders.length === 0) { return 0; } const highestY = Math.min(...staveRender.voiceRenders.map((v) => v.rect.y)); const vexflowStaveY = staveRender.vexflowStave.getBoundingBox().y; return Math.max(0, vexflowStaveY - highestY); } /** * A temporary hack to fix vexflow.GraceNoteGroup bounding boxes post-formatting. * * See https://github.com/vexflow/vexflow/issues/253 */ maybefixVexflowGraceNoteGroupBoundingBox(entryRender) { if (entryRender.type !== 'note') { return; } const vexflowGraceNoteGroup = entryRender.vexflowGraceNoteGroup; if (!vexflowGraceNoteGroup) { return; } const boxes = vexflowGraceNoteGroup.getGraceNotes().map((n) => n.getBoundingBox()); if (boxes.length === 0) { return; } const x = Math.min(...boxes.map((b) => b.x)); const y = Math.min(...boxes.map((b) => b.y)); vexflowGraceNoteGroup.setX(x); vexflowGraceNoteGroup.setY(y); } hackEntryRenderRect(entryRender, staveRender) { this.maybefixVexflowGraceNoteGroupBoundingBox(entryRender); if (entryRender.vexflowNote instanceof vexflow.TextDynamics) { return this.overrideVexflowTextDynamicsRect(entryRender.vexflowNote, staveRender); } if (entryRender.vexflowNote instanceof vexflow.TabNote) { return this.overrideVexflowTabNoteRect(entryRender.vexflowNote); } // HACK! Some modifiers cause the bounding box of the vexflow stave note to be incorrect. We filter them out here // and readd them later. We keep as much of the original modifiers as possible to get a more accurate bounding box. // See https://github.com/vexflow/vexflow/blob/d602715b1c05e21d3498f78b8b5904cb47ad3795/src/stavenote.ts#L616 const vexflowTickable = entryRender.vexflowNote; const originalMods = vexflowTickable.getModifiers(); const sanitizedMods = originalMods.filter((m) => PROBLEMATIC_VEXFLOW_MODIFIERS.every((p) => !(m instanceof p))); vexflowTickable.modifiers = sanitizedMods; const rect = Rect.fromRectLike(entryRender.vexflowNote.getBoundingBox()); vexflowTickable.modifiers = originalMods; return rect; } }