UNPKG

vexflow

Version:

A JavaScript library for rendering music notation and guitar tablature.

922 lines (780 loc) 27.5 kB
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010. // MIT License import { BoundingBox, Bounds } from './boundingbox'; import { Clef } from './clef'; import { Element, ElementStyle } from './element'; import { Font, FontInfo, FontStyle, FontWeight } from './font'; import { KeySignature } from './keysignature'; import { Barline, BarlineType } from './stavebarline'; import { StaveModifier, StaveModifierPosition } from './stavemodifier'; import { Repetition } from './staverepetition'; import { StaveSection } from './stavesection'; import { StaveTempo, StaveTempoOptions } from './stavetempo'; import { StaveText } from './stavetext'; import { Volta } from './stavevolta'; import { Tables } from './tables'; import { TimeSignature } from './timesignature'; import { Category, isBarline } from './typeguard'; import { RuntimeError } from './util'; export interface StaveLineConfig { visible?: boolean; } export interface StaveOptions { bottom_text_position?: number; line_config?: StaveLineConfig[]; space_below_staff_ln?: number; space_above_staff_ln?: number; vertical_bar_width?: number; fill_style?: string; left_bar?: boolean; right_bar?: boolean; spacing_between_lines_px?: number; top_text_position?: number; num_lines?: number; } // Used by Stave.format() to sort the modifiers at the beginning and end of a stave. // The keys (computed property names) match the CATEGORY property in the // Barline, Clef, KeySignature, TimeSignature classes. const SORT_ORDER_BEG_MODIFIERS = { [Barline.CATEGORY]: 0, [Clef.CATEGORY]: 1, [KeySignature.CATEGORY]: 2, [TimeSignature.CATEGORY]: 3, }; const SORT_ORDER_END_MODIFIERS = { [TimeSignature.CATEGORY]: 0, [KeySignature.CATEGORY]: 1, [Barline.CATEGORY]: 2, [Clef.CATEGORY]: 3, }; export class Stave extends Element { static get CATEGORY(): string { return Category.Stave; } static TEXT_FONT: Required<FontInfo> = { family: Font.SANS_SERIF, size: 8, weight: FontWeight.NORMAL, style: FontStyle.NORMAL, }; readonly options: Required<StaveOptions>; protected start_x: number; protected clef: string; protected endClef?: string; protected x: number; protected y: number; protected width: number; // Initialized by the constructor via this.resetLines(). protected height: number = 0; protected formatted: boolean; protected end_x: number; protected measure: number; protected bounds: Bounds; protected readonly modifiers: StaveModifier[]; protected defaultLedgerLineStyle: ElementStyle; // This is the sum of the padding that normally goes on left + right of a stave during // drawing. Used to size staves correctly with content width. static get defaultPadding(): number { const musicFont = Tables.currentMusicFont(); return musicFont.lookupMetric('stave.padding') + musicFont.lookupMetric('stave.endPaddingMax'); } // Right padding, used by system if startX is already determined. static get rightPadding(): number { const musicFont = Tables.currentMusicFont(); return musicFont.lookupMetric('stave.endPaddingMax'); } constructor(x: number, y: number, width: number, options?: StaveOptions) { super(); this.x = x; this.y = y; this.width = width; this.formatted = false; this.start_x = x + 5; this.end_x = x + width; this.modifiers = []; // stave modifiers (clef, key, time, barlines, coda, segno, etc.) this.measure = 0; this.clef = 'treble'; this.endClef = undefined; this.resetFont(); this.options = { vertical_bar_width: 10, // Width around vertical bar end-marker num_lines: 5, fill_style: '#999999', left_bar: true, // draw vertical bar on left right_bar: true, // draw vertical bar on right spacing_between_lines_px: Tables.STAVE_LINE_DISTANCE, // in pixels space_above_staff_ln: 4, // in staff lines space_below_staff_ln: 4, // in staff lines top_text_position: 1, // in staff lines bottom_text_position: 4, // in staff lines line_config: [], ...options, }; this.bounds = { x: this.x, y: this.y, w: this.width, h: 0 }; this.defaultLedgerLineStyle = { strokeStyle: '#444', lineWidth: 1.4 }; this.resetLines(); // beg bar this.addModifier(new Barline(this.options.left_bar ? BarlineType.SINGLE : BarlineType.NONE)); // end bar this.addEndModifier(new Barline(this.options.right_bar ? BarlineType.SINGLE : BarlineType.NONE)); } /** Set default style for ledger lines. */ setDefaultLedgerLineStyle(style: ElementStyle): void { this.defaultLedgerLineStyle = style; } /** Get default style for ledger lines. */ getDefaultLedgerLineStyle(): ElementStyle { return { ...this.getStyle(), ...this.defaultLedgerLineStyle }; } space(spacing: number): number { return this.options.spacing_between_lines_px * spacing; } resetLines(): void { this.options.line_config = []; for (let i = 0; i < this.options.num_lines; i++) { this.options.line_config.push({ visible: true }); } this.height = (this.options.num_lines + this.options.space_above_staff_ln) * this.options.spacing_between_lines_px; this.options.bottom_text_position = this.options.num_lines; } setNoteStartX(x: number): this { if (!this.formatted) this.format(); this.start_x = x; return this; } getNoteStartX(): number { if (!this.formatted) this.format(); return this.start_x; } getNoteEndX(): number { if (!this.formatted) this.format(); return this.end_x; } getTieStartX(): number { return this.start_x; } getTieEndX(): number { return this.end_x; } getX(): number { return this.x; } getNumLines(): number { return this.options.num_lines; } setNumLines(n: number): this { this.options.num_lines = n; this.resetLines(); return this; } setY(y: number): this { this.y = y; return this; } getY(): number { return this.y; } getTopLineTopY(): number { return this.getYForLine(0) - Tables.STAVE_LINE_THICKNESS / 2; } getBottomLineBottomY(): number { return this.getYForLine(this.getNumLines() - 1) + Tables.STAVE_LINE_THICKNESS / 2; } setX(x: number): this { const shift = x - this.x; this.formatted = false; this.x = x; this.start_x += shift; this.end_x += shift; for (let i = 0; i < this.modifiers.length; i++) { const mod = this.modifiers[i]; mod.setX(mod.getX() + shift); } return this; } setWidth(width: number): this { this.formatted = false; this.width = width; this.end_x = this.x + width; // reset the x position of the end barline (TODO(0xfe): This makes no sense) // this.modifiers[1].setX(this.end_x); return this; } getWidth(): number { return this.width; } getStyle(): ElementStyle { return { fillStyle: this.options.fill_style, strokeStyle: this.options.fill_style, // yes, this is correct for legacy compatibility lineWidth: Tables.STAVE_LINE_THICKNESS, ...super.getStyle(), }; } /** * Set the measure number of this Stave. */ setMeasure(measure: number): this { this.measure = measure; return this; } /** * Return the measure number of this Stave. */ getMeasure(): number { return this.measure; } /** * Gets the pixels to shift from the beginning of the stave * following the modifier at the provided index * @param {Number} index The index from which to determine the shift * @return {Number} The amount of pixels shifted */ getModifierXShift(index: number = 0): number { if (typeof index !== 'number') { throw new RuntimeError('InvalidIndex', 'Must be of number type'); } if (!this.formatted) this.format(); if (this.getModifiers(StaveModifierPosition.BEGIN).length === 1) { return 0; } // for right position modifiers zero shift seems correct, see 'Volta + Modifier Measure Test' if (this.modifiers[index].getPosition() === StaveModifierPosition.RIGHT) { return 0; } let start_x = this.start_x - this.x; const begBarline = this.modifiers[0] as Barline; if (begBarline.getType() === BarlineType.REPEAT_BEGIN && start_x > begBarline.getWidth()) { start_x -= begBarline.getWidth(); } return start_x; } /** Coda & Segno Symbol functions */ setRepetitionType(type: number, yShift: number = 0): this { this.modifiers.push(new Repetition(type, this.x, yShift)); return this; } // Volta functions setVoltaType(type: number, number_t: string, y: number): this { this.modifiers.push(new Volta(type, number_t, this.x, y)); return this; } // Section functions setSection(section: string, y: number, xOffset = 0, fontSize?: number, drawRect = true) { const staveSection = new StaveSection(section, this.x + xOffset, y, drawRect); if (fontSize) staveSection.setFontSize(fontSize); this.modifiers.push(staveSection); return this; } // Tempo functions setTempo(tempo: StaveTempoOptions, y: number): this { this.modifiers.push(new StaveTempo(tempo, this.x, y)); return this; } // Text functions setText( text: string, position: number, options: { shift_x?: number; shift_y?: number; justification?: number; } = {} ): this { this.modifiers.push(new StaveText(text, position, options)); return this; } getHeight(): number { return this.height; } getSpacingBetweenLines(): number { return this.options.spacing_between_lines_px; } getBoundingBox(): BoundingBox { return new BoundingBox(this.x, this.y, this.width, this.getBottomY() - this.y); } getBottomY(): number { const options = this.options; const spacing = options.spacing_between_lines_px; const score_bottom = this.getYForLine(options.num_lines) + options.space_below_staff_ln * spacing; return score_bottom; } getBottomLineY(): number { return this.getYForLine(this.options.num_lines); } // This returns /** @returns the y for the *center* of a staff line */ getYForLine(line: number): number { const options = this.options; const spacing = options.spacing_between_lines_px; const headroom = options.space_above_staff_ln; const y = this.y + line * spacing + headroom * spacing; return y; } getLineForY(y: number): number { // Does the reverse of getYForLine - somewhat dumb and just calls // getYForLine until the right value is reaches const options = this.options; const spacing = options.spacing_between_lines_px; const headroom = options.space_above_staff_ln; return (y - this.y) / spacing - headroom; } getYForTopText(line: number = 0): number { return this.getYForLine(-line - this.options.top_text_position); } getYForBottomText(line: number = 0): number { return this.getYForLine(this.options.bottom_text_position + line); } getYForNote(line: number): number { const options = this.options; const spacing = options.spacing_between_lines_px; const headroom = options.space_above_staff_ln; return this.y + headroom * spacing + 5 * spacing - line * spacing; } getYForGlyphs(): number { return this.getYForLine(3); } // This method adds a stave modifier to the stave. Note that the first two // modifiers (BarLines) are automatically added upon construction. addModifier(modifier: StaveModifier, position?: number): this { if (position !== undefined) { modifier.setPosition(position); } modifier.setStave(this); this.formatted = false; this.modifiers.push(modifier); return this; } addEndModifier(modifier: StaveModifier): this { this.addModifier(modifier, StaveModifierPosition.END); return this; } // Bar Line functions setBegBarType(type: number | BarlineType): this { // Only valid bar types at beginning of stave is none, single or begin repeat const { SINGLE, REPEAT_BEGIN, NONE } = BarlineType; if (type === SINGLE || type === REPEAT_BEGIN || type === NONE) { (this.modifiers[0] as Barline).setType(type); this.formatted = false; } return this; } setEndBarType(type: number | BarlineType): this { // Repeat end not valid at end of stave if (type !== BarlineType.REPEAT_BEGIN) { (this.modifiers[1] as Barline).setType(type); this.formatted = false; } return this; } setClef(clefSpec: string, size?: string, annotation?: string, position?: number): this { if (position === undefined) { position = StaveModifierPosition.BEGIN; } if (position === StaveModifierPosition.END) { this.endClef = clefSpec; } else { this.clef = clefSpec; } const clefs = this.getModifiers(position, Clef.CATEGORY) as Clef[]; if (clefs.length === 0) { this.addClef(clefSpec, size, annotation, position); } else { clefs[0].setType(clefSpec, size, annotation); } return this; } getClef(): string { return this.clef; } setEndClef(clefSpec: string, size?: string, annotation?: string): this { this.setClef(clefSpec, size, annotation, StaveModifierPosition.END); return this; } getEndClef(): string | undefined { return this.endClef; } setKeySignature(keySpec: string, cancelKeySpec?: string, position?: number): this { if (position === undefined) { position = StaveModifierPosition.BEGIN; } const keySignatures = this.getModifiers(position, KeySignature.CATEGORY) as KeySignature[]; if (keySignatures.length === 0) { this.addKeySignature(keySpec, cancelKeySpec, position); } else { keySignatures[0].setKeySig(keySpec, cancelKeySpec); } return this; } setEndKeySignature(keySpec: string, cancelKeySpec?: string): this { this.setKeySignature(keySpec, cancelKeySpec, StaveModifierPosition.END); return this; } setTimeSignature(timeSpec: string, customPadding?: number, position?: number): this { if (position === undefined) { position = StaveModifierPosition.BEGIN; } const timeSignatures = this.getModifiers(position, TimeSignature.CATEGORY) as TimeSignature[]; if (timeSignatures.length === 0) { this.addTimeSignature(timeSpec, customPadding, position); } else { timeSignatures[0].setTimeSig(timeSpec); } return this; } setEndTimeSignature(timeSpec: string, customPadding?: number): this { this.setTimeSignature(timeSpec, customPadding, StaveModifierPosition.END); return this; } /** * Add a key signature to the stave. * * Example: * `stave.addKeySignature('Db');` * @param keySpec new key specification `[A-G][b|#]?` * @param cancelKeySpec * @param position * @returns */ addKeySignature(keySpec: string, cancelKeySpec?: string, position?: number): this { if (position === undefined) { position = StaveModifierPosition.BEGIN; } this.addModifier(new KeySignature(keySpec, cancelKeySpec).setPosition(position), position); return this; } /** * Add a clef to the stave. * * Example: * * stave.addClef('treble') * @param clef clef (treble|bass|...) see {@link Clef.types} * @param size * @param annotation * @param position * @returns */ addClef(clef: string, size?: string, annotation?: string, position?: number): this { if (position === undefined || position === StaveModifierPosition.BEGIN) { this.clef = clef; } else if (position === StaveModifierPosition.END) { this.endClef = clef; } this.addModifier(new Clef(clef, size, annotation), position); return this; } addEndClef(clef: string, size?: string, annotation?: string): this { this.addClef(clef, size, annotation, StaveModifierPosition.END); return this; } /** * Add a time signature to the stave * * Example: * * `stave.addTimeSignature('4/4');` * @param timeSpec time signature specification `(C\||C|\d\/\d)` * @param customPadding * @param position * @returns */ addTimeSignature(timeSpec: string, customPadding?: number, position?: number): this { this.addModifier(new TimeSignature(timeSpec, customPadding), position); return this; } addEndTimeSignature(timeSpec: string, customPadding?: number): this { this.addTimeSignature(timeSpec, customPadding, StaveModifierPosition.END); return this; } // Deprecated addTrebleGlyph(): this { this.addClef('treble'); return this; } /** * @param position * @param category * @returns array of StaveModifiers that match the provided position and category. */ getModifiers(position?: number, category?: string): StaveModifier[] { const noPosition = position === undefined; const noCategory = category === undefined; if (noPosition && noCategory) { return this.modifiers; } else if (noPosition) { // A category was provided. return this.modifiers.filter((m: StaveModifier) => category === m.getCategory()); } else if (noCategory) { // A position was provided. return this.modifiers.filter((m: StaveModifier) => position === m.getPosition()); } else { // Both position and category were provided! return this.modifiers.filter((m: StaveModifier) => position === m.getPosition() && category === m.getCategory()); } } /** * Use the modifier's `getCategory()` as a key for the `order` array. * The retrieved value is used to sort modifiers from left to right (0 to to 3). */ sortByCategory(items: StaveModifier[], order: Record<string, number>): void { for (let i = items.length - 1; i >= 0; i--) { for (let j = 0; j < i; j++) { if (order[items[j].getCategory()] > order[items[j + 1].getCategory()]) { const temp = items[j]; items[j] = items[j + 1]; items[j + 1] = temp; } } } } format(): void { const begBarline = this.modifiers[0] as Barline; const endBarline = this.modifiers[1]; const begModifiers = this.getModifiers(StaveModifierPosition.BEGIN); const endModifiers = this.getModifiers(StaveModifierPosition.END); this.sortByCategory(begModifiers, SORT_ORDER_BEG_MODIFIERS); this.sortByCategory(endModifiers, SORT_ORDER_END_MODIFIERS); if (begModifiers.length > 1 && begBarline.getType() === BarlineType.REPEAT_BEGIN) { begModifiers.push(begModifiers.splice(0, 1)[0]); begModifiers.splice(0, 0, new Barline(BarlineType.SINGLE)); } if (endModifiers.indexOf(endBarline) > 0) { endModifiers.splice(0, 0, new Barline(BarlineType.NONE)); } let width; let padding; let modifier; let offset = 0; let x = this.x; for (let i = 0; i < begModifiers.length; i++) { modifier = begModifiers[i]; padding = modifier.getPadding(i + offset); width = modifier.getWidth(); x += padding; modifier.setX(x); x += width; if (padding + width === 0) offset--; } this.start_x = x; x = this.x + this.width; const widths = { left: 0, right: 0, paddingRight: 0, paddingLeft: 0, }; let lastBarlineIdx = 0; for (let i = 0; i < endModifiers.length; i++) { modifier = endModifiers[i]; lastBarlineIdx = isBarline(modifier) ? i : lastBarlineIdx; widths.right = 0; widths.left = 0; widths.paddingRight = 0; widths.paddingLeft = 0; const layoutMetrics = modifier.getLayoutMetrics(); if (layoutMetrics) { if (i !== 0) { widths.right = layoutMetrics.xMax || 0; widths.paddingRight = layoutMetrics.paddingRight || 0; } widths.left = -layoutMetrics.xMin || 0; widths.paddingLeft = layoutMetrics.paddingLeft || 0; if (i === endModifiers.length - 1) { widths.paddingLeft = 0; } } else { widths.paddingRight = modifier.getPadding(i - lastBarlineIdx); if (i !== 0) { widths.right = modifier.getWidth(); } if (i === 0) { widths.left = modifier.getWidth(); } } x -= widths.paddingRight; x -= widths.right; modifier.setX(x); x -= widths.left; x -= widths.paddingLeft; } this.end_x = endModifiers.length === 1 ? this.x + this.width : x; this.formatted = true; } /** * All drawing functions below need the context to be set. */ draw(): this { const ctx = this.checkContext(); this.setRendered(); this.applyStyle(); ctx.openGroup('stave', this.getAttribute('id')); if (!this.formatted) this.format(); const num_lines = this.options.num_lines; const width = this.width; const x = this.x; let y; // Render lines for (let line = 0; line < num_lines; line++) { y = this.getYForLine(line); if (this.options.line_config[line].visible) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + width, y); ctx.stroke(); } } ctx.closeGroup(); this.restoreStyle(); // Draw the modifiers (bar lines, coda, segno, repeat brackets, etc.) for (let i = 0; i < this.modifiers.length; i++) { const modifier = this.modifiers[i]; // Only draw modifier if it has a draw function if (typeof modifier.draw === 'function') { modifier.applyStyle(ctx); modifier.draw(this, this.getModifierXShift(i)); modifier.restoreStyle(ctx); } } // Render measure numbers if (this.measure > 0) { ctx.save(); ctx.setFont(this.textFont); const textWidth = ctx.measureText('' + this.measure).width; y = this.getYForTopText(0) + 3; ctx.fillText('' + this.measure, this.x - textWidth / 2, y); ctx.restore(); } return this; } getVerticalBarWidth(): number { return this.options.vertical_bar_width; } /** * Get the current configuration for the Stave. * @return {Array} An array of configuration objects. */ getConfigForLines(): StaveLineConfig[] { return this.options.line_config; } /** * Configure properties of the lines in the Stave * @param line_number The index of the line to configure. * @param line_config An configuration object for the specified line. * @throws RuntimeError "StaveConfigError" When the specified line number is out of * range of the number of lines specified in the constructor. */ setConfigForLine(line_number: number, line_config: StaveLineConfig): this { if (line_number >= this.options.num_lines || line_number < 0) { throw new RuntimeError( 'StaveConfigError', 'The line number must be within the range of the number of lines in the Stave.' ); } if (line_config.visible === undefined) { throw new RuntimeError('StaveConfigError', "The line configuration object is missing the 'visible' property."); } if (typeof line_config.visible !== 'boolean') { throw new RuntimeError( 'StaveConfigError', "The line configuration objects 'visible' property must be true or false." ); } this.options.line_config[line_number] = line_config; return this; } /** * Set the staff line configuration array for all of the lines at once. * @param lines_configuration An array of line configuration objects. These objects * are of the same format as the single one passed in to setLineConfiguration(). * The caller can set null for any line config entry if it is desired that the default be used * @throws RuntimeError "StaveConfigError" When the lines_configuration array does not have * exactly the same number of elements as the num_lines configuration object set in * the constructor. */ setConfigForLines(lines_configuration: StaveLineConfig[]): this { if (lines_configuration.length !== this.options.num_lines) { throw new RuntimeError( 'StaveConfigError', 'The length of the lines configuration array must match the number of lines in the Stave' ); } // Make sure the defaults are present in case an incomplete set of // configuration options were supplied. // eslint-disable-next-line for (const line_config in lines_configuration) { // Allow '{}' to be used if the caller just wants the default for a particular node. if (lines_configuration[line_config].visible == undefined) { lines_configuration[line_config] = this.options.line_config[line_config]; } this.options.line_config[line_config] = { ...this.options.line_config[line_config], ...lines_configuration[line_config], }; } this.options.line_config = lines_configuration; return this; } static formatBegModifiers(staves: Stave[]): void { const adjustCategoryStartX = (category: Category) => { let minStartX = 0; // Calculate min start X for the category staves.forEach((stave) => { const modifiers = stave.getModifiers(StaveModifierPosition.BEGIN, category); // Consider only the first instance if (modifiers.length > 0 && modifiers[0].getX() > minStartX) minStartX = modifiers[0].getX(); }); let adjustX = 0; staves.forEach((stave) => { adjustX = 0; const modifiers = stave.getModifiers(StaveModifierPosition.BEGIN, category); // Calculate adjustement required for the stave modifiers.forEach((modifier) => { if (minStartX - modifier.getX() > adjustX) adjustX = minStartX - modifier.getX(); }); const allModifiers = stave.getModifiers(StaveModifierPosition.BEGIN); let bAdjust = false; // Apply adjustment to all the modifiers in and beyond the category allModifiers.forEach((modifier) => { if (modifier.getCategory() === category) bAdjust = true; if (bAdjust && adjustX > 0) modifier.setX(modifier.getX() + adjustX); }); // Apply adjustment also to note start. stave.setNoteStartX(stave.getNoteStartX() + adjustX); }); }; // Make sure that staves are formatted staves.forEach((stave) => { if (!stave.formatted) stave.format(); }); // Align Clefs adjustCategoryStartX(Category.Clef); // Align key signatures adjustCategoryStartX(Category.KeySignature); // Align time signatures adjustCategoryStartX(Category.TimeSignature); let maxX = 0; // align note start staves.forEach((stave) => { if (stave.getNoteStartX() > maxX) maxX = stave.getNoteStartX(); }); staves.forEach((stave) => { stave.setNoteStartX(maxX); }); maxX = 0; // align REPEAT_BEGIN staves.forEach((stave) => { const modifiers = stave.getModifiers(StaveModifierPosition.BEGIN, Category.Barline); modifiers.forEach((modifier) => { if ((modifier as Barline).getType() == BarlineType.REPEAT_BEGIN) if (modifier.getX() > maxX) maxX = modifier.getX(); }); }); staves.forEach((stave) => { const modifiers = stave.getModifiers(StaveModifierPosition.BEGIN, Category.Barline); modifiers.forEach((modifier) => { if ((modifier as Barline).getType() == BarlineType.REPEAT_BEGIN) modifier.setX(maxX); }); }); } }