opensheetmusicdisplay/opensheetmusicdisplay

View on GitHub
src/VexFlowPatch/src/svgcontext.js

Summary

Maintainability
F
3 days
Test Coverage
// [VexFlow](http://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
// @author Gregory Ristow (2015)

import { Vex } from './vex';

const attrNamesToIgnoreMap = {
  path: {
    x: true,
    y: true,
    width: true,
    height: true,
  },
  rect: {
  },
  text: {
    width: true,
    height: true,
  },
};

{
  const fontAttrNamesToIgnore = {
    'font-family': true,
    'font-weight': true,
    'font-style': true,
    'font-size': true,
  };

  Vex.Merge(attrNamesToIgnoreMap.rect, fontAttrNamesToIgnore);
  Vex.Merge(attrNamesToIgnoreMap.path, fontAttrNamesToIgnore);
}

export class SVGContext {
  constructor(element) {
    // element is the parent DOM object
    this.element = element;
    // Create the SVG in the SVG namespace:
    this.svgNS = 'http://www.w3.org/2000/svg';
    const svg = this.create('svg');
    // Add it to the canvas:
    this.element.appendChild(svg);

    // Point to it:
    this.svg = svg;
    this.groups = [this.svg]; // Create the group stack
    this.parent = this.svg;

    this.path = '';
    this.pen = { x: NaN, y: NaN };
    this.lineWidth = 1.0;
    this.state = {
      scale: { x: 1, y: 1 },
      'font-family': 'Arial',
      'font-size': '8pt',
      'font-weight': 'normal',
    };

    this.attributes = {
      'stroke-width': 0.3,
      'fill': 'black',
      'stroke': 'black',
      'stroke-dasharray': 'none',
      'font-family': 'Arial',
      'font-size': '10pt',
      'font-weight': 'normal',
      'font-style': 'normal',
    };

    this.background_attributes = {
      'stroke-width': 0,
      'fill': 'white',
      'stroke': 'white',
      'stroke-dasharray': 'none',
      'font-family': 'Arial',
      'font-size': '10pt',
      'font-weight': 'normal',
      'font-style': 'normal',
    };

    this.shadow_attributes = {
      width: 0,
      color: 'black',
    };

    this.state_stack = [];

    // Test for Internet Explorer
    this.iePolyfill();
  }

  create(svgElementType) {
    return document.createElementNS(this.svgNS, svgElementType);
  }

  // Allow grouping elements in containers for interactivity.
  openGroup(cls, id, attrs) {
    const group = this.create('g');
    this.groups.push(group);
    this.parent.appendChild(group);
    this.parent = group;
    if (cls) group.setAttribute('class', Vex.Prefix(cls));
    if (id) group.setAttribute('id', Vex.Prefix(id));

    if (attrs && attrs.pointerBBox) {
      group.setAttribute('pointer-events', 'bounding-box');
    }
    return group;
  }

  closeGroup() {
    this.groups.pop();
    this.parent = this.groups[this.groups.length - 1];
  }

  add(elem) {
    this.parent.appendChild(elem);
  }

  // Tests if the browser is Internet Explorer; if it is,
  // we do some tricks to improve text layout.  See the
  // note at ieMeasureTextFix() for details.
  iePolyfill() {
    if (typeof (navigator) !== 'undefined') {
      this.ie = (
        /MSIE 9/i.test(navigator.userAgent) ||
        /MSIE 10/i.test(navigator.userAgent) ||
        /rv:11\.0/i.test(navigator.userAgent) ||
        /Trident/i.test(navigator.userAgent)
      );
    }
  }

  // ### Styling & State Methods:

  setFont(family, size, weight) {
    // Unlike canvas, in SVG italic is handled by font-style,
    // not weight. So: we search the weight argument and
    // apply bold and italic to weight and style respectively.
    let bold = false;
    let italic = false;
    let style = 'normal';
    // Weight might also be a number (200, 400, etc...) so we
    // test its type to be sure we have access to String methods.
    if (typeof weight === 'string') {
      // look for "italic" in the weight:
      if (weight.indexOf('italic') !== -1) {
        weight = weight.replace(/italic/g, '');
        italic = true;
      }
      // look for "bold" in weight
      if (weight.indexOf('bold') !== -1) {
        weight = weight.replace(/bold/g, '');
        bold = true;
      }
      // remove any remaining spaces
      weight = weight.replace(/ /g, '');
    }
    weight = bold ? 'bold' : weight;
    weight = (typeof weight === 'undefined' || weight === '') ? 'normal' : weight;

    style = italic ? 'italic' : style;

    const fontAttributes = {
      'font-family': family,
      'font-size': size + 'pt',
      'font-weight': weight,
      'font-style': style,
    };

    // Store the font size so that if the browser is Internet
    // Explorer we can fix its calculations of text width.
    this.fontSize = Number(size);

    Vex.Merge(this.attributes, fontAttributes);
    Vex.Merge(this.state, fontAttributes);

    return this;
  }

  setRawFont(font) {
    font = font.trim();
    // Assumes size first, splits on space -- which is presently
    // how all existing modules are calling this.
    const fontArray = font.split(' ');

    this.attributes['font-family'] = fontArray[1];
    this.state['font-family'] = fontArray[1];

    this.attributes['font-size'] = fontArray[0];
    this.state['font-size'] = fontArray[0];

    // Saves fontSize for IE polyfill
    this.fontSize = Number(fontArray[0].match(/\d+/));
    return this;
  }

  setFillStyle(style) {
    this.attributes.fill = style;
    return this;
  }

  setBackgroundFillStyle(style) {
    this.background_attributes.fill = style;
    this.background_attributes.stroke = style;
    return this;
  }

  setStrokeStyle(style) {
    this.attributes.stroke = style;
    return this;
  }

  setShadowColor(style) {
    this.shadow_attributes.color = style;
    return this;
  }

  setShadowBlur(blur) {
    this.shadow_attributes.width = blur;
    return this;
  }

  setLineWidth(width) {
    this.attributes['stroke-width'] = width;
    this.lineWidth = width;
  }

  // @param array {lineDash} as [dashInt, spaceInt, dashInt, spaceInt, etc...]
  setLineDash(lineDash) {
    if (Object.prototype.toString.call(lineDash) === '[object Array]') {
      lineDash = lineDash.join(', ');
      this.attributes['stroke-dasharray'] = lineDash;
      return this;
    } else {
      throw new Vex.RERR('ArgumentError', 'lineDash must be an array of integers.');
    }
  }

  setLineCap(lineCap) {
    this.attributes['stroke-linecap'] = lineCap;
    return this;
  }

  // ### Sizing & Scaling Methods:

  // TODO (GCR): See note at scale() -- seperate 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, height) {
    this.width = width;
    this.height = height;
    this.element.style.width = width;
    const attributes = {
      width,
      height,
    };
    this.applyAttributes(this.svg, attributes);
    this.scale(this.state.scale.x, this.state.scale.y);
    return this;
  }

  scale(x, y) {
    // 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 or Raphael 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.scale = { x, y };
    const visibleWidth = this.width / x;
    const visibleHeight = this.height / y;
    this.setViewBox(0, 0, visibleWidth, visibleHeight);

    return this;
  }

  setViewBox(...args) {
    // Override for "x y w h" style:
    if (args.length === 1) {
      const [viewBox] = args;
      this.svg.setAttribute('viewBox', viewBox);
    } else {
      const [xMin, yMin, width, height] = args;
      const viewBoxString = xMin + ' ' + yMin + ' ' + width + ' ' + height;
      this.svg.setAttribute('viewBox', viewBoxString);
    }
  }

  // ### Drawing helper methods:

  applyAttributes(element, attributes) {
    const attrNamesToIgnore = attrNamesToIgnoreMap[element.nodeName];
    Object
      .keys(attributes)
      .forEach(propertyName => {
        if (attrNamesToIgnore && attrNamesToIgnore[propertyName]) {
          return;
        }
        element.setAttributeNS(null, propertyName, attributes[propertyName]);
      });

    return element;
  }

  // ### Shape & Path Methods:

  clear() {
    // 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.scale.x, this.state.scale.y);
  }

  // ## Rectangles:

  rect(x, y, width, height, attributes) {
    // Avoid invalid negative height attribs by
    // flipping the rectangle on its head:
    if (height < 0) {
      y += height;
      height *= -1;
    }

    // Create the rect & style it:
    const rectangle = this.create('rect');
    if (typeof attributes === 'undefined') {
      attributes = {
        fill: 'none',
        'stroke-width': this.lineWidth,
        stroke: this.attributes.stroke, // VexFlowPatch: fix hardcoded 'black' instead of attributes.stroke (ctx strokeStyle)
      };
    }

    Vex.Merge(attributes, {
      x,
      y,
      width,
      height,
    });

    this.applyAttributes(rectangle, attributes);

    this.add(rectangle);
    return this;
  }

  fillRect(x, y, width, height) {
    if (height < 0) {
      y += height;
      height *= -1;
    }

    this.rect(x, y, width, height, this.attributes);
    return this;
  }

  clearRect(x, y, width, height) {
    // TODO(GCR): Improve implementation of this...
    // Currently it draws a box of the background color, rather
    // than creating alpha through lower z-levels.
    //
    // See the implementation of this in SVGKit:
    // http://sourceforge.net/projects/svgkit/
    // as a starting point.
    //
    // Adding a large number of transform paths (as we would
    // have to do) could be a real performance hit.  Since
    // tabNote seems to be the only module that makes use of this
    // it may be worth creating a seperate tabStave that would
    // draw lines around locations of tablature fingering.
    //

    this.rect(x, y, width, height, this.background_attributes);
    return this;
  }

  // ## Paths:

  beginPath() {
    this.path = '';
    this.pen.x = NaN;
    this.pen.y = NaN;

    return this;
  }

  moveTo(x, y) {
    this.path += 'M' + x + ' ' + y;
    this.pen.x = x;
    this.pen.y = y;
    return this;
  }

  lineTo(x, y) {
    this.path += 'L' + x + ' ' + y;
    this.pen.x = x;
    this.pen.y = y;
    return this;
  }

  bezierCurveTo(x1, y1, x2, y2, x, y) {
    this.path += 'C' +
      x1 + ' ' +
      y1 + ',' +
      x2 + ' ' +
      y2 + ',' +
      x + ' ' +
      y;
    this.pen.x = x;
    this.pen.y = y;
    return this;
  }

  quadraticCurveTo(x1, y1, x, y) {
    this.path += 'Q' +
      x1 + ' ' +
      y1 + ',' +
      x + ' ' +
      y;
    this.pen.x = x;
    this.pen.y = y;
    return this;
  }

  // This is an attempt (hack) to simulate the HTML5 canvas
  // arc method.
  arc(x, y, radius, startAngle, endAngle, antiClockwise) {
    function normalizeAngle(angle) {
      while (angle < 0) {
        angle += Math.PI * 2;
      }

      while (angle > Math.PI * 2) {
        angle -= Math.PI * 2;
      }
      return angle;
    }

    startAngle = normalizeAngle(startAngle);
    endAngle = normalizeAngle(endAngle);

    if (startAngle > endAngle) {
      const tmp = startAngle;
      startAngle = endAngle;
      endAngle = tmp;
      antiClockwise = !antiClockwise;
    }

    const delta = endAngle - startAngle;

    if (delta > Math.PI) {
      this.arcHelper(x, y, radius, startAngle, startAngle + delta / 2, antiClockwise);
      this.arcHelper(x, y, radius, startAngle + delta / 2, endAngle, antiClockwise);
    } else {
      this.arcHelper(x, y, radius, startAngle, endAngle, antiClockwise);
    }
    return this;
  }

  arcHelper(x, y, radius, startAngle, endAngle, antiClockwise) {
    const x1 = x + radius * Math.cos(startAngle);
    const y1 = y + radius * Math.sin(startAngle);

    const x2 = x + radius * Math.cos(endAngle);
    const y2 = y + radius * Math.sin(endAngle);

    let largeArcFlag = 0;
    let sweepFlag = 0;
    if (antiClockwise) {
      sweepFlag = 1;
      if (endAngle - startAngle < Math.PI) {
        largeArcFlag = 1;
      }
    } else if (endAngle - startAngle > Math.PI) {
      largeArcFlag = 1;
    }

    this.path += 'M' + x1 + ' ' + y1 + ' A' +
      radius + ' ' + radius + ' 0 ' + largeArcFlag + ' ' + sweepFlag + ' ' +
      x2 + ' ' + y2;
    if (!isNaN(this.pen.x) && !isNaN(this.pen.y)) {
      this.peth += 'M' + this.pen.x + ' ' + this.pen.y;
    }
  }

  closePath() {
    this.path += 'Z';

    return this;
  }

  // Adapted from the source for Raphael's Element.glow
  glow() {
    // Calculate the width & paths of the glow:
    if (this.shadow_attributes.width > 0) {
      const sa = this.shadow_attributes;
      const num_paths = sa.width / 2;
      // Stroke at varying widths to create effect of gaussian blur:
      for (let i = 1; i <= num_paths; i++) {
        const attributes = {
          stroke: sa.color,
          'stroke-linejoin': 'round',
          'stroke-linecap': 'round',
          'stroke-width': +((sa.width * 0.4) / num_paths * i).toFixed(3),
          opacity: +((sa.opacity || 0.3) / num_paths).toFixed(3),
        };

        const path = this.create('path');
        attributes.d = this.path;
        this.applyAttributes(path, attributes);
        this.add(path);
      }
    }
    return this;
  }

  fill(attributes) {
    // If our current path is set to glow, make it glow
    this.glow();

    const path = this.create('path');
    let newAttributes = attributes;
    if (typeof attributes === 'undefined') {
        attributes = {};
        Vex.Merge(attributes, this.attributes);
        attributes.stroke = 'none';
        newAttributes = attributes;
    } else {
      newAttributes = attributes;
      Vex.Merge(newAttributes, this.attributes); // this overrides attributes either way
      if (attributes.class) {
        newAttributes.class = attributes.class;
      }
      if (attributes.id) {
        newAttributes.id = attributes.id;
      }
    }
    
    attributes.d = this.path;
    //attributes.class = "testbeam";
    
    this.applyAttributes(path, attributes);
    this.add(path);
    return this;
  }

  stroke(extraAttributes = undefined) {
    // If our current path is set to glow, make it glow.
    this.glow();

    const path = this.create('path');
    const attributes = {};
    Vex.Merge(attributes, this.attributes);
    if (extraAttributes) {
        Vex.Merge(attributes, extraAttributes);
    }
    attributes.fill = 'none';
    attributes['stroke-width'] = this.lineWidth;
    attributes.d = this.path;

    this.applyAttributes(path, attributes);
    this.add(path);
    return this;
  }

  // ## Text Methods:
  measureText(text) {
    const txt = this.create('text');
    if (typeof (txt.getBBox) !== 'function') {
      return { x: 0, y: 0, width: 0, height: 0 };
    }

    txt.textContent = text;
    this.applyAttributes(txt, this.attributes);

    // Temporarily add it to the document for measurement.
    this.svg.appendChild(txt);

    let bbox = txt.getBBox();
    if (this.ie && text !== '' && this.attributes['font-style'] === 'italic') {
      bbox = this.ieMeasureTextFix(bbox, text);
    }

    this.svg.removeChild(txt);
    return bbox;
  }

  ieMeasureTextFix(bbox) {
    // Internet Explorer over-pads text in italics,
    // resulting in giant width estimates for measureText.
    // To fix this, we use this formula, tested against
    // ie 11:
    // overestimate (in pixels) = FontSize(in pt) * 1.196 + 1.96
    // And then subtract the overestimate from calculated width.

    const fontSize = Number(this.fontSize);
    const m = 1.196;
    const b = 1.9598;
    const widthCorrection = (m * fontSize) + b;
    const width = bbox.width - widthCorrection;
    const height = bbox.height - 1.5;

    // Get non-protected copy:
    const box = {
      x: bbox.x,
      y: bbox.y,
      width,
      height,
    };

    return box;
  }

  fillText(text, x, y) {
    if (!text || text.length <= 0) {
      return;
    }
    const attributes = {};
    Vex.Merge(attributes, this.attributes);
    attributes.stroke = 'none';
    attributes.x = x;
    attributes.y = y;

    const txt = this.create('text');
    txt.textContent = text;
    this.applyAttributes(txt, attributes);
    this.add(txt);
  }

  save() {
    // TODO(mmuthanna): State needs to be deep-copied.
    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;
  }

  restore() {
    // TODO(0xfe): State needs to be deep-restored.
    const state = this.state_stack.pop();
    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;
  }
}