Skip to content

Commit

Permalink
Merge pull request #1137 from tommadams/textMeasure
Browse files Browse the repository at this point in the history
Clean up text measuring & add a text measurement cache to SVGContext
  • Loading branch information
0xfe authored Sep 18, 2021
2 parents b5bfca9 + 1fef9eb commit ad31634
Show file tree
Hide file tree
Showing 6 changed files with 112 additions and 149 deletions.
33 changes: 18 additions & 15 deletions src/canvascontext.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// [VexFlow](http://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
// MIT License
import { RenderContext } from './types/common';
import { RenderContext, TextMeasure } from './types/common';
import { warn } from './util';

/**
Expand All @@ -10,6 +10,7 @@ export class CanvasContext implements RenderContext {
vexFlowCanvasContext: CanvasRenderingContext2D;
canvas: HTMLCanvasElement | { width: number; height: number };
background_fillStyle?: string;
textHeight: number = 0;

static get WIDTH(): number {
return 600;
Expand Down Expand Up @@ -38,9 +39,6 @@ export class CanvasContext implements RenderContext {
}

/**
* This constructor is only called if Renderer.USE_CANVAS_PROXY is true.
* In most instances, we do not need to create a CanvasContext object.
* See Renderer.bolsterCanvasContext().
* @param context
*/
constructor(context: CanvasRenderingContext2D) {
Expand Down Expand Up @@ -76,11 +74,19 @@ export class CanvasContext implements RenderContext {

setFont(family: string, size: number, weight: string): this {
this.vexFlowCanvasContext.font = (weight || '') + ' ' + size + 'pt ' + family;
this.textHeight = (size * 4) / 3;
return this;
}

setRawFont(font: string): this {
this.vexFlowCanvasContext.font = font;

const fontArray = font.split(' ');
const size = Number(fontArray[0].match(/\d+/));
// The font size is specified in points, scale it to canvas units.
// CSS specifies dpi to be 96 and there are 72 points to an inch: 96/72 == 4/3.
this.textHeight = (size * 4) / 3;

return this;
}

Expand Down Expand Up @@ -135,15 +141,8 @@ export class CanvasContext implements RenderContext {
return this;
}

// setLineDash is the one native method in a canvas context
// that begins with set, therefore we don't bolster the method
// if it already exists (see Renderer.bolsterCanvasContext).
// If it doesn't exist, we bolster it and assume it's looking for
// a ctx.lineDash method, as previous versions of VexFlow
// expected.
setLineDash(dash: number[]): this {
// eslint-disable-next-line
(this.vexFlowCanvasContext as any).lineDash = dash;
this.vexFlowCanvasContext.setLineDash(dash);
return this;
}

Expand Down Expand Up @@ -222,8 +221,12 @@ export class CanvasContext implements RenderContext {
return this;
}

measureText(text: string): TextMetrics {
return this.vexFlowCanvasContext.measureText(text);
measureText(text: string): TextMeasure {
const metrics = this.vexFlowCanvasContext.measureText(text);
return {
width: metrics.width,
height: this.textHeight,
};
}

fillText(text: string, x: number, y: number): this {
Expand All @@ -242,7 +245,7 @@ export class CanvasContext implements RenderContext {
}

set font(value: string) {
this.vexFlowCanvasContext.font = value;
this.setRawFont(value);
}

get font(): string {
Expand Down
46 changes: 1 addition & 45 deletions src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,6 @@ export class Renderer {
DOWN: 3, // Downward leg
};

/**
* Set this to true if you're using VexFlow inside a runtime
* that does not allow modifying canvas objects. There is a small
* performance degradation due to the extra indirection.
*/
static readonly USE_CANVAS_PROXY = false;

static lastContext?: RenderContext = undefined;

static buildContext(
Expand Down Expand Up @@ -69,43 +62,6 @@ export class Renderer {
return Renderer.buildContext(elementId, Renderer.Backends.SVG, width, height, background);
}

// eslint-disable-next-line
static bolsterCanvasContext(ctx: any): RenderContext {
if (Renderer.USE_CANVAS_PROXY) {
return new CanvasContext(ctx);
}

// Modify the CanvasRenderingContext2D to include the following methods, if they do not already exist.
// TODO: Is a Proxy object appropriate here?
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
const methodNames = [
'clear',
'setFont',
'setRawFont',
'setFillStyle',
'setBackgroundFillStyle',
'setStrokeStyle',
'setShadowColor',
'setShadowBlur',
'setLineWidth',
'setLineCap',
'openGroup',
'closeGroup',
'getGroup',
];

ctx.vexFlowCanvasContext = ctx;

methodNames.forEach((methodName) => {
if (!(methodName in ctx)) {
// eslint-disable-next-line
ctx[methodName] = (CanvasContext.prototype as any)[methodName];
}
});

return ctx;
}

// Draw a dashed line (horizontal, vertical or diagonal
// dashPattern = [3,3] draws a 3 pixel dash followed by a three pixel space.
// setting the second number to 0 draws a solid line.
Expand Down Expand Up @@ -172,7 +128,7 @@ export class Renderer {
if (!canvasElement.getContext) {
throw new RuntimeError('BadElement', `Can't get canvas context from element: ${canvasId}`);
}
this.ctx = Renderer.bolsterCanvasContext(canvasElement.getContext('2d'));
this.ctx = new CanvasContext(canvasElement.getContext('2d')!);
} else if (this.backend === Renderer.Backends.SVG) {
this.ctx = new SVGContext(this.element);
} else {
Expand Down
149 changes: 76 additions & 73 deletions src/svgcontext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// @author Gregory Ristow (2015)

import { RuntimeError, prefix } from './util';
import { RenderContext } from './types/common';
import { RenderContext, TextMeasure } from './types/common';

// eslint-disable-next-line
type Attributes = { [key: string]: any };
Expand Down Expand Up @@ -41,10 +41,68 @@ interface State {
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>> = {};

constructor() {
// Create the SVG elements that will be used to measure the text in the event
// of a cache miss.
this.txt = document.createElementNS(SVG_NS, 'text');
}

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 style = attributes['font-style'];
const weight = attributes['font-weight'];

const key = `${family}%${size}%${style}%${weight}`;
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 {
this.txt.textContent = text;
this.txt.setAttributeNS(null, 'font-family', attributes['font-family']);
this.txt.setAttributeNS(null, 'font-size', attributes['font-size']);
this.txt.setAttributeNS(null, 'font-style', attributes['font-style']);
this.txt.setAttributeNS(null, 'font-weight', attributes['font-weight']);
svg.appendChild(this.txt);
const bbox = this.txt.getBBox();
svg.removeChild(this.txt);

// Remove the trailing 'pt' from the font size and scale to convert from points
// to canvas units.
// CSS specifies dpi to be 96 and there are 72 points to an inch: 96/72 == 4/3.
const fontSize = attributes['font-size'];
const height = (fontSize.substring(0, fontSize.length - 2) * 4) / 3;
return {
width: bbox.width,
height: height,
};
}
}

/**
* SVG rendering context with an API similar to CanvasRenderingContext2D.
*/
export class SVGContext implements RenderContext {
protected static measureTextCache = new MeasureTextCache();

element: HTMLElement; // the parent DOM object
svg: SVGSVGElement;
width: number = 0;
Expand All @@ -60,13 +118,11 @@ export class SVGContext implements RenderContext {
parent: SVGGElement;
groups: SVGGElement[];
fontString: string = '';
fontSize: number = 0;
ie: boolean = false; // true if the browser is Internet Explorer.

constructor(element: HTMLElement) {
this.element = element;

const svg = this.create('svg') as SVGSVGElement;
const svg = this.create('svg');
// Add it to the canvas:
this.element.appendChild(svg);

Expand Down Expand Up @@ -113,18 +169,27 @@ export class SVGContext implements RenderContext {
};

this.state_stack = [];

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

/**
* 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?: { pointerBBox: boolean }): SVGGElement {
const group: SVGGElement = this.create('g') as SVGGElement;
const group = this.create('g');
this.groups.push(group);
this.parent.appendChild(group);
this.parent = group;
Expand All @@ -146,19 +211,6 @@ export class SVGContext implements RenderContext {
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(): void {
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: string, size: number, weight: string): this {
Expand Down Expand Up @@ -195,9 +247,6 @@ export class SVGContext implements RenderContext {
'font-style': foundItalic ? 'italic' : 'normal',
};

// Store the font size so that if the browser is Internet
// Explorer we can fix its calculations of text width.
this.fontSize = Number(size);
// Currently this.fontString only supports size & family. See setRawFont().
this.fontString = `${size}pt ${family}`;
this.attributes = { ...this.attributes, ...fontAttributes };
Expand All @@ -220,9 +269,6 @@ export class SVGContext implements RenderContext {
this.attributes['font-family'] = family;
this.state['font-family'] = family;

// Saves fontSize for IE polyfill.
// Use the Number() function to parse the array returned by String.prototype.match()!
this.fontSize = Number(size.match(/\d+/));
return this;
}

Expand Down Expand Up @@ -394,7 +440,7 @@ export class SVGContext implements RenderContext {
}

// Create the rect & style it:
const rectangle: SVGRectElement = this.create('rect') as SVGRectElement;
const rectangle = this.create('rect');
if (typeof attributes === 'undefined') {
attributes = {
fill: 'none',
Expand Down Expand Up @@ -582,51 +628,8 @@ export class SVGContext implements RenderContext {
}

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

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

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

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

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

ieMeasureTextFix(bbox: DOMRect): SVGRect {
// 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 as SVGRect;
measureText(text: string): TextMeasure {
return SVGContext.measureTextCache.lookup(text, this.svg, this.attributes);
}

fillText(text: string, x: number, y: number): this {
Expand Down
Loading

0 comments on commit ad31634

Please sign in to comment.