UNPKG

vexflow

Version:

A JavaScript library for rendering music notation and guitar tablature.

719 lines (619 loc) 22.4 kB
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010. // MIT License // @author Gregory Ristow (2015) import { Font, FontInfo, FontStyle, FontWeight } from './font'; import { GroupAttributes, RenderContext, TextMeasure } from './rendercontext'; import { Tables } from './tables'; import { normalizeAngle, prefix, RuntimeError } from './util'; export type Attributes = { [name: string]: string | number | undefined; 'font-family'?: string; 'font-size'?: string | number; 'font-style'?: string; 'font-weight'?: string | number; scaleX?: number; scaleY?: number; }; /** For a particular element type (e.g., rect), we will not apply certain presentation attributes. */ const ATTRIBUTES_TO_IGNORE: Record<string /* element type */, Record<string, boolean> /* ignored attributes */> = { path: { x: true, y: true, width: true, height: true, 'font-family': true, 'font-weight': true, 'font-style': true, 'font-size': true, }, rect: { 'font-family': true, 'font-weight': true, 'font-style': true, 'font-size': true, }, text: { width: true, height: true, }, }; /** Create the SVG in the SVG namespace. */ const SVG_NS = 'http://www.w3.org/2000/svg'; const TWO_PI = 2 * Math.PI; export interface State { state: Attributes; attributes: Attributes; shadow_attributes: Attributes; lineWidth: number; } class MeasureTextCache { protected txt?: SVGTextElement; // The cache is keyed first by the text string, then by the font attributes // joined together. protected cache: Record<string, Record<string, TextMeasure>> = {}; lookup(text: string, svg: SVGSVGElement, attributes: Attributes): TextMeasure { let entries = this.cache[text]; if (entries === undefined) { entries = {}; this.cache[text] = entries; } const family = attributes['font-family']; const size = attributes['font-size']; const weight = attributes['font-weight']; const style = attributes['font-style']; const key = `${family}%${size}%${weight}%${style}`; let entry = entries[key]; if (entry === undefined) { entry = this.measureImpl(text, svg, attributes); entries[key] = entry; } return entry; } measureImpl(text: string, svg: SVGSVGElement, attributes: Attributes): TextMeasure { let txt = this.txt; if (!txt) { // Create the SVG text element that will be used to measure text in the event // of a cache miss. txt = document.createElementNS(SVG_NS, 'text'); this.txt = txt; } txt.textContent = text; if (attributes['font-family']) txt.setAttributeNS(null, 'font-family', attributes['font-family']); if (attributes['font-size']) txt.setAttributeNS(null, 'font-size', `${attributes['font-size']}`); if (attributes['font-style']) txt.setAttributeNS(null, 'font-style', attributes['font-style']); if (attributes['font-weight']) txt.setAttributeNS(null, 'font-weight', `${attributes['font-weight']}`); svg.appendChild(txt); const bbox = txt.getBBox(); svg.removeChild(txt); return { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height }; } } /** * SVG rendering context with an API similar to CanvasRenderingContext2D. */ export class SVGContext extends RenderContext { protected static measureTextCache = new MeasureTextCache(); element: HTMLElement; // the parent DOM object svg: SVGSVGElement; width: number = 0; height: number = 0; path: string; pen: { x: number; y: number }; lineWidth: number; attributes: Attributes; shadow_attributes: Attributes; state: Attributes; state_stack: State[]; // Always points to the current group. // Calls to add() or openGroup() will append the new element to `this.parent`. parent: SVGGElement; // The stack of groups. groups: SVGGElement[]; // The stack of attributes associated with each group. protected groupAttributes: Attributes[]; protected precision = 1; backgroundFillStyle: string = 'white'; /** Formatted as CSS font shorthand (e.g., 'italic bold 12pt Arial') */ protected fontCSSString: string = ''; constructor(element: HTMLElement) { super(); this.element = element; this.precision = Math.pow(10, Tables.RENDER_PRECISION_PLACES); // Create a SVG element and add it to the container element. const svg = this.create('svg'); this.element.appendChild(svg); this.svg = svg; this.parent = this.svg; this.groups = [this.svg]; this.path = ''; this.pen = { x: NaN, y: NaN }; this.lineWidth = 1.0; const defaultFontAttributes = { 'font-family': Font.SANS_SERIF, 'font-size': Font.SIZE + 'pt', 'font-weight': FontWeight.NORMAL, 'font-style': FontStyle.NORMAL, }; this.state = { scaleX: 1, scaleY: 1, ...defaultFontAttributes, }; this.attributes = { 'stroke-width': 0.3, 'stroke-dasharray': 'none', fill: 'black', stroke: 'black', ...defaultFontAttributes, }; this.groupAttributes = []; this.applyAttributes(svg, this.attributes); this.groupAttributes.push({ ...this.attributes }); this.shadow_attributes = { width: 0, color: 'black', }; this.state_stack = []; } protected round(n: number): number { return Math.round(n * this.precision) / this.precision; } /** * Use one of the overload signatures to create an SVG element of a specific type. * The last overload accepts an arbitrary string, and is identical to the * implementation signature. * Feel free to add new overloads for other SVG element types as required. */ create(svgElementType: 'g'): SVGGElement; create(svgElementType: 'path'): SVGPathElement; create(svgElementType: 'rect'): SVGRectElement; create(svgElementType: 'svg'): SVGSVGElement; create(svgElementType: 'text'): SVGTextElement; create(svgElementType: string): SVGElement; create(svgElementType: string): SVGElement { return document.createElementNS(SVG_NS, svgElementType); } // Allow grouping elements in containers for interactivity. openGroup(cls?: string, id?: string, attrs?: GroupAttributes): SVGGElement { const group = this.create('g'); this.groups.push(group); this.parent.appendChild(group); this.parent = group; if (cls) group.setAttribute('class', prefix(cls)); if (id) group.setAttribute('id', prefix(id)); if (attrs && attrs.pointerBBox) { group.setAttribute('pointer-events', 'bounding-box'); } this.applyAttributes(group, this.attributes); this.groupAttributes.push({ ...this.groupAttributes[this.groupAttributes.length - 1], ...this.attributes }); return group; } closeGroup(): void { this.groups.pop(); this.groupAttributes.pop(); this.parent = this.groups[this.groups.length - 1]; } add(elem: SVGElement): void { this.parent.appendChild(elem); } setFillStyle(style: string): this { this.attributes.fill = style; return this; } /** * Used to set the fill color for `clearRect()`. This allows us to simulate * cutting a "hole" into the SVG drawing. */ setBackgroundFillStyle(style: string): this { this.backgroundFillStyle = style; return this; } setStrokeStyle(style: string): this { this.attributes.stroke = style; return this; } setShadowColor(color: string): this { this.shadow_attributes.color = color; return this; } /** * @param blur A non-negative float specifying the level of shadow blur, where 0 * represents no blur and larger numbers represent increasingly more blur. * @returns this */ setShadowBlur(blur: number): this { this.shadow_attributes.width = blur; return this; } /** * @param width * @returns this */ setLineWidth(width: number): this { this.attributes['stroke-width'] = width; this.lineWidth = width; return this; } /** * @param lineDash an array of integers in the form of [dash, space, dash, space, etc...] * @returns this * * See: [SVG `stroke-dasharray` attribute](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray) */ setLineDash(lineDash: number[]): this { if (Object.prototype.toString.call(lineDash) === '[object Array]') { this.attributes['stroke-dasharray'] = lineDash.join(','); return this; } else { throw new RuntimeError('ArgumentError', 'lineDash must be an array of integers.'); } } /** * @param capType * @returns this */ setLineCap(capType: CanvasLineCap): this { this.attributes['stroke-linecap'] = capType; return this; } // ### Sizing & Scaling Methods: // TODO (GCR): See note at scale() -- separate our internal // conception of pixel-based width/height from the style.width // and style.height properties eventually to allow users to // apply responsive sizing attributes to the SVG. resize(width: number, height: number): this { this.width = width; this.height = height; this.element.style.width = width.toString(); this.svg.style.width = width.toString(); this.svg.style.height = height.toString(); const attributes = { width, height, }; this.applyAttributes(this.svg, attributes); this.scale(this.state.scaleX as number, this.state.scaleY as number); return this; } scale(x: number, y: number): this { // uses viewBox to scale // TODO (GCR): we may at some point want to distinguish the // style.width / style.height properties that are applied to // the SVG object from our internal conception of the SVG // width/height. This would allow us to create automatically // scaling SVG's that filled their containers, for instance. // // As this isn't implemented in Canvas contexts, // I've left as is for now, but in using the viewBox to // handle internal scaling, am trying to make it possible // for us to eventually move in that direction. this.state.scaleX = this.state.scaleX ? this.state.scaleX * x : x; this.state.scaleY = this.state.scaleY ? this.state.scaleY * y : y; const visibleWidth = this.width / this.state.scaleX; const visibleHeight = this.height / this.state.scaleY; this.setViewBox(0, 0, visibleWidth, visibleHeight); return this; } /** * 1 arg: string in the "x y w h" format * 4 args: x:number, y:number, w:number, h:number */ setViewBox(viewBox_or_minX: string | number, minY?: number, width?: number, height?: number): void { if (typeof viewBox_or_minX === 'string') { this.svg.setAttribute('viewBox', viewBox_or_minX); } else { const viewBoxString = viewBox_or_minX + ' ' + minY + ' ' + width + ' ' + height; this.svg.setAttribute('viewBox', viewBoxString); } } // ### Drawing helper methods: applyAttributes(element: SVGElement, attributes: Attributes): SVGElement { const attrNamesToIgnore = ATTRIBUTES_TO_IGNORE[element.nodeName]; for (const attrName in attributes) { if (attrNamesToIgnore && attrNamesToIgnore[attrName]) { continue; } if ( attributes[attrName] && (this.groupAttributes.length == 0 || attributes[attrName] != this.groupAttributes[this.groupAttributes.length - 1][attrName]) ) element.setAttributeNS(null, attrName, attributes[attrName] as string); } return element; } // ### Shape & Path Methods: clear(): void { // Clear the SVG by removing all inner children. // (This approach is usually slightly more efficient // than removing the old SVG & adding a new one to // the container element, since it does not cause the // container to resize twice. Also, the resize // triggered by removing the entire SVG can trigger // a touchcancel event when the element resizes away // from a touch point.) while (this.svg.lastChild) { this.svg.removeChild(this.svg.lastChild); } // Replace the viewbox attribute we just removed. this.scale(this.state.scaleX as number, this.state.scaleY as number); } // ## Rectangles: rect(x: number, y: number, width: number, height: number, attributes?: Attributes): this { // Avoid invalid negative height attributes by flipping the rectangle on its head: if (height < 0) { y += height; height *= -1; } const rectangle = this.create('rect'); attributes = attributes ?? { fill: 'none', 'stroke-width': this.lineWidth, stroke: 'black' }; x = this.round(x); y = this.round(y); width = this.round(width); height = this.round(height); this.applyAttributes(rectangle, { x, y, width, height, ...attributes }); this.add(rectangle); return this; } fillRect(x: number, y: number, width: number, height: number): this { const attributes = { fill: this.attributes.fill, stroke: 'none' }; this.rect(x, y, width, height, attributes); return this; } clearRect(x: number, y: number, width: number, height: number): this { // Currently this fills a rect with the backgroundFillStyle, rather // than "cut a hole" into the existing shapes. // // Since tabNote seems to be the only module that makes use of this // it may be worth creating a separate tabStave that would // draw lines around locations of tablature fingering. this.rect(x, y, width, height, { fill: this.backgroundFillStyle, stroke: 'none' }); return this; } // ## Paths: beginPath(): this { this.path = ''; this.pen.x = NaN; this.pen.y = NaN; return this; } moveTo(x: number, y: number): this { x = this.round(x); y = this.round(y); this.path += 'M' + x + ' ' + y; this.pen.x = x; this.pen.y = y; return this; } lineTo(x: number, y: number): this { x = this.round(x); y = this.round(y); this.path += 'L' + x + ' ' + y; this.pen.x = x; this.pen.y = y; return this; } bezierCurveTo(x1: number, y1: number, x2: number, y2: number, x: number, y: number): this { x = this.round(x); y = this.round(y); x1 = this.round(x1); y1 = this.round(y1); x2 = this.round(x2); y2 = this.round(y2); this.path += 'C' + x1 + ' ' + y1 + ',' + x2 + ' ' + y2 + ',' + x + ' ' + y; this.pen.x = x; this.pen.y = y; return this; } quadraticCurveTo(x1: number, y1: number, x: number, y: number): this { x = this.round(x); y = this.round(y); x1 = this.round(x1); y1 = this.round(y1); this.path += 'Q' + x1 + ' ' + y1 + ',' + x + ' ' + y; this.pen.x = x; this.pen.y = y; return this; } arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise: boolean): this { let x0 = x + radius * Math.cos(startAngle); let y0 = y + radius * Math.sin(startAngle); x0 = this.round(x0); y0 = this.round(y0); // svg behavior different from canvas. Don't normalize angles if // we are drawing a circle because they both normalize to 0 const tmpStartTest = normalizeAngle(startAngle); const tmpEndTest = normalizeAngle(endAngle); if ( (!counterclockwise && endAngle - startAngle >= TWO_PI) || (counterclockwise && startAngle - endAngle >= TWO_PI) || tmpStartTest === tmpEndTest ) { let x1 = x + radius * Math.cos(startAngle + Math.PI); let y1 = y + radius * Math.sin(startAngle + Math.PI); // There's no way to specify a completely circular arc in SVG so we have to // use two semi-circular arcs. x1 = this.round(x1); y1 = this.round(y1); radius = this.round(radius); this.path += `M${x0} ${y0} A${radius} ${radius} 0 0 0 ${x1} ${y1} `; this.path += `A${radius} ${radius} 0 0 0 ${x0} ${y0}`; this.pen.x = x0; this.pen.y = y0; } else { let x1 = x + radius * Math.cos(endAngle); let y1 = y + radius * Math.sin(endAngle); startAngle = tmpStartTest; endAngle = tmpEndTest; let large: boolean; if (Math.abs(endAngle - startAngle) < Math.PI) { large = counterclockwise; } else { large = !counterclockwise; } if (startAngle > endAngle) { large = !large; } const sweep = !counterclockwise; x1 = this.round(x1); y1 = this.round(y1); radius = this.round(radius); this.path += `M${x0} ${y0} A${radius} ${radius} 0 ${+large} ${+sweep} ${x1} ${y1}`; this.pen.x = x1; this.pen.y = y1; } return this; } closePath(): this { this.path += 'Z'; return this; } private getShadowStyle(): string { const sa = this.shadow_attributes; // A CSS drop-shadow filter blur looks different than a canvas shadowBlur // of the same radius, so we scale the drop-shadow radius here to make it // look close to the canvas shadow. return `filter: drop-shadow(0 0 ${(sa.width as number) / 1.5}px ${sa.color})`; } fill(attributes?: Attributes): this { const path = this.create('path'); if (typeof attributes === 'undefined') { attributes = { ...this.attributes, stroke: 'none' }; } attributes.d = this.path; if ((this.shadow_attributes.width as number) > 0) { attributes.style = this.getShadowStyle(); } this.applyAttributes(path, attributes); this.add(path); return this; } stroke(): this { const path = this.create('path'); const attributes: Attributes = { ...this.attributes, fill: 'none', 'stroke-width': this.lineWidth, d: this.path, }; if ((this.shadow_attributes.width as number) > 0) { attributes.style = this.getShadowStyle(); } this.applyAttributes(path, attributes); this.add(path); return this; } // ## Text Methods: measureText(text: string): TextMeasure { return SVGContext.measureTextCache.lookup(text, this.svg, this.attributes); } fillText(text: string, x: number, y: number): this { if (!text || text.length <= 0) { return this; } x = this.round(x); y = this.round(y); const attributes: Attributes = { ...this.attributes, stroke: 'none', x, y, }; const txt = this.create('text'); txt.textContent = text; this.applyAttributes(txt, attributes); this.add(txt); return this; } // TODO: State should be deep-copied. save(): this { this.state_stack.push({ state: { 'font-family': this.state['font-family'], 'font-weight': this.state['font-weight'], 'font-style': this.state['font-style'], 'font-size': this.state['font-size'], scale: this.state.scale, }, attributes: { 'font-family': this.attributes['font-family'], 'font-weight': this.attributes['font-weight'], 'font-style': this.attributes['font-style'], 'font-size': this.attributes['font-size'], fill: this.attributes.fill, stroke: this.attributes.stroke, 'stroke-width': this.attributes['stroke-width'], 'stroke-dasharray': this.attributes['stroke-dasharray'], }, shadow_attributes: { width: this.shadow_attributes.width, color: this.shadow_attributes.color, }, lineWidth: this.lineWidth, }); return this; } // TODO: State should be deep-restored. restore(): this { const savedState = this.state_stack.pop(); if (savedState) { const state = savedState; this.state['font-family'] = state.state['font-family']; this.state['font-weight'] = state.state['font-weight']; this.state['font-style'] = state.state['font-style']; this.state['font-size'] = state.state['font-size']; this.state.scale = state.state.scale; this.attributes['font-family'] = state.attributes['font-family']; this.attributes['font-weight'] = state.attributes['font-weight']; this.attributes['font-style'] = state.attributes['font-style']; this.attributes['font-size'] = state.attributes['font-size']; this.attributes.fill = state.attributes.fill; this.attributes.stroke = state.attributes.stroke; this.attributes['stroke-width'] = state.attributes['stroke-width']; this.attributes['stroke-dasharray'] = state.attributes['stroke-dasharray']; this.shadow_attributes.width = state.shadow_attributes.width; this.shadow_attributes.color = state.shadow_attributes.color; this.lineWidth = state.lineWidth; } return this; } set fillStyle(style: string | CanvasGradient | CanvasPattern) { this.setFillStyle(style as string); } get fillStyle(): string | CanvasGradient | CanvasPattern { return this.attributes.fill as string; } set strokeStyle(style: string | CanvasGradient | CanvasPattern) { this.setStrokeStyle(style as string); } get strokeStyle(): string | CanvasGradient | CanvasPattern { return this.attributes.stroke as string; } /** * @param f is 1) a `FontInfo` object or * 2) a string formatted as CSS font shorthand (e.g., 'bold 10pt Arial') or * 3) a string representing the font family (one of `size`, `weight`, or `style` must also be provided). * @param size a string specifying the font size and unit (e.g., '16pt'), or a number (the unit is assumed to be 'pt'). * @param weight is a string (e.g., 'bold', 'normal') or a number (100, 200, ... 900). It is inserted * into the font-weight attribute (e.g., font-weight="bold") * @param style is a string (e.g., 'italic', 'normal') that is inserted into the * font-style attribute (e.g., font-style="italic") */ setFont(f?: string | FontInfo, size?: string | number, weight?: string | number, style?: string): this { const fontInfo = Font.validate(f, size, weight, style); this.fontCSSString = Font.toCSSString(fontInfo); const fontAttributes = { 'font-family': fontInfo.family, 'font-size': fontInfo.size, 'font-weight': fontInfo.weight, 'font-style': fontInfo.style, }; this.attributes = { ...this.attributes, ...fontAttributes }; this.state = { ...this.state, ...fontAttributes }; return this; } /** Return a string of the form `'italic bold 15pt Arial'` */ getFont(): string { return this.fontCSSString; } }