Skip to content

Commit

Permalink
feat: optimize functions for getting text dimension (apache#199)
Browse files Browse the repository at this point in the history
* feat: add function for getting multiple text dimensions

* feat: lazy deletion

* feat: use lazy factory

* fix: comments

* fix: rename variable
  • Loading branch information
kristw authored and zhaoyongjie committed Nov 25, 2021
1 parent b9046a7 commit f1d497e
Show file tree
Hide file tree
Showing 17 changed files with 646 additions and 80 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { TextStyle, Dimension } from './types';
import getBBoxCeil from './svg/getBBoxCeil';
import { hiddenSvgFactory, textFactory } from './svg/factories';
import updateTextNode from './svg/updateTextNode';

/**
* get dimensions of multiple texts with same style
* @param input
* @param defaultDimension
*/
export default function getMultipleTextDimensions(
input: {
className?: string;
container?: HTMLElement;
style?: TextStyle;
texts: string[];
},
defaultDimension?: Dimension,
): Dimension[] {
const { texts, className, style, container } = input;

const cache = new Map<string, Dimension>();
// for empty string
cache.set('', { height: 0, width: 0 });
let textNode: SVGTextElement | undefined;
let svgNode: SVGSVGElement | undefined;

const dimensions = texts.map(text => {
// Check if this string has been computed already
if (cache.has(text)) {
return cache.get(text) as Dimension;
}

// Lazy creation of text and svg nodes
if (!textNode) {
svgNode = hiddenSvgFactory.createInContainer(container);
textNode = textFactory.createInContainer(svgNode);
}

// Update text and get dimension
updateTextNode(textNode, { className, style, text });
const dimension = getBBoxCeil(textNode, defaultDimension);
// Store result to cache
cache.set(text, dimension);

return dimension;
});

// Remove svg node, if any
if (svgNode && textNode) {
// The nodes are added to the DOM briefly only to make getBBox works.
// (If not added to DOM getBBox will always return 0x0.)
// After that the svg nodes are not needed.
// We delay its removal in case there are subsequent calls to this function
// that can reuse the svg nodes.
// Experiments have shown that reusing existing nodes
// instead of deleting and adding new ones can save lot of time.
setTimeout(() => {
textFactory.removeFromContainer(svgNode);
hiddenSvgFactory.removeFromContainer(container);
// eslint-disable-next-line no-magic-numbers
}, 500);
}

return dimensions;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { TextStyle, Dimension } from './types';

const SVG_NS = 'http://www.w3.org/2000/svg';
const STYLE_FIELDS: (keyof TextStyle)[] = [
'font',
'fontWeight',
'fontStyle',
'fontSize',
'fontFamily',
'letterSpacing',
];
import updateTextNode from './svg/updateTextNode';
import getBBoxCeil from './svg/getBBoxCeil';
import { hiddenSvgFactory, textFactory } from './svg/factories';

export interface GetTextDimensionInput {
className?: string;
Expand All @@ -17,39 +10,34 @@ export interface GetTextDimensionInput {
text: string;
}

const DEFAULT_DIMENSION = { height: 20, width: 100 };

export default function getTextDimension(
input: GetTextDimensionInput,
defaultDimension: Dimension = DEFAULT_DIMENSION,
defaultDimension?: Dimension,
): Dimension {
const { text, className, style = {}, container = document.body } = input;

const textNode = document.createElementNS(SVG_NS, 'text');
textNode.textContent = text;
const { text, className, style, container } = input;

if (className !== undefined && className !== null) {
textNode.setAttribute('class', className);
// Empty string
if (text.length === 0) {
return { height: 0, width: 0 };
}

STYLE_FIELDS.filter(
(field: keyof TextStyle) => style[field] !== undefined && style[field] !== null,
).forEach((field: keyof TextStyle) => {
textNode.style[field] = `${style[field]}`;
});

const svg = document.createElementNS(SVG_NS, 'svg');
svg.style.position = 'absolute'; // so it won't disrupt page layout
svg.style.opacity = '0'; // and not visible
svg.style.pointerEvents = 'none'; // and not capturing mouse events
svg.appendChild(textNode);
container.appendChild(svg);

const bbox = textNode.getBBox ? textNode.getBBox() : defaultDimension;
container.removeChild(svg);

return {
height: Math.ceil(bbox.height),
width: Math.ceil(bbox.width),
};
const svgNode = hiddenSvgFactory.createInContainer(container);
const textNode = textFactory.createInContainer(svgNode);
updateTextNode(textNode, { className, style, text });
const dimension = getBBoxCeil(textNode, defaultDimension);

// The nodes are added to the DOM briefly only to make getBBox works.
// (If not added to DOM getBBox will always return 0x0.)
// After that the svg nodes are not needed.
// We delay its removal in case there are subsequent calls to this function
// that can reuse the svg nodes.
// Experiments have shown that reusing existing nodes
// instead of deleting and adding new ones can save lot of time.
setTimeout(() => {
textFactory.removeFromContainer(svgNode);
hiddenSvgFactory.removeFromContainer(container);
// eslint-disable-next-line no-magic-numbers
}, 500);

return dimension;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as getTextDimension } from './getTextDimension';
export { default as getMultipleTextDimensions } from './getMultipleTextDimensions';
export { default as computeMaxFontSize } from './computeMaxFontSize';
export { default as mergeMargin } from './mergeMargin';
export { default as parseLength } from './parseLength';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export default class LazyFactory<T extends HTMLElement | SVGElement> {
private activeNodes = new Map<
HTMLElement | SVGElement,
{
counter: number;
node: T;
}
>();

private factoryFn: () => T;

constructor(factoryFn: () => T) {
this.factoryFn = factoryFn;
}

createInContainer(container: HTMLElement | SVGElement = document.body) {
if (this.activeNodes.has(container)) {
const entry = this.activeNodes.get(container)!;
entry.counter += 1;

return entry.node;
}

const node = this.factoryFn();
container.appendChild(node);
this.activeNodes.set(container, { counter: 1, node });

return node;
}

removeFromContainer(container: HTMLElement | SVGElement = document.body) {
if (this.activeNodes.has(container)) {
const entry = this.activeNodes.get(container)!;
entry.counter -= 1;
if (entry.counter === 0) {
container.removeChild(entry.node);
this.activeNodes.delete(container);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const SVG_NS = 'http://www.w3.org/2000/svg';
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { SVG_NS } from './constants';

export default function createHiddenSvgNode() {
const svgNode = document.createElementNS(SVG_NS, 'svg');
svgNode.style.position = 'absolute'; // so it won't disrupt page layout
svgNode.style.opacity = '0'; // and not visible
svgNode.style.pointerEvents = 'none'; // and not capturing mouse events

return svgNode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SVG_NS } from './constants';

export default function createTextNode() {
return document.createElementNS(SVG_NS, 'text');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import LazyFactory from './LazyFactory';
import createHiddenSvgNode from './createHiddenSvgNode';
import createTextNode from './createTextNode';

export const hiddenSvgFactory = new LazyFactory(createHiddenSvgNode);
export const textFactory = new LazyFactory(createTextNode);
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Dimension } from '../types';

const DEFAULT_DIMENSION = { height: 20, width: 100 };

export default function getBBoxCeil(
node: SVGGraphicsElement,
defaultDimension: Dimension = DEFAULT_DIMENSION,
) {
const { width, height } = node.getBBox ? node.getBBox() : defaultDimension;

return {
height: Math.ceil(height),
width: Math.ceil(width),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { TextStyle } from '../types';

const STYLE_FIELDS: (keyof TextStyle)[] = [
'font',
'fontWeight',
'fontStyle',
'fontSize',
'fontFamily',
'letterSpacing',
];

export default function updateTextNode(
node: SVGTextElement,
{
className,
style = {},
text,
}: {
className?: string;
style?: TextStyle;
text?: string;
} = {},
) {
const textNode = node;

if (textNode.textContent !== text) {
textNode.textContent = typeof text === 'undefined' ? null : text;
}
if (textNode.getAttribute('class') !== className) {
textNode.setAttribute('class', className || '');
}

// clear style
STYLE_FIELDS.forEach((field: keyof TextStyle) => {
textNode.style[field] = null;
});

// apply new style
// Note that the font field will auto-populate other font fields when applicable.
STYLE_FIELDS.filter(
(field: keyof TextStyle) => typeof style[field] !== 'undefined' && style[field] !== null,
).forEach((field: keyof TextStyle) => {
textNode.style[field] = `${style[field]}`;
});

return textNode;
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
import { computeMaxFontSize } from '../src/index';
import addDummyFill from './addDummyFill';
import { addDummyFill, removeDummyFill } from './getBBoxDummyFill';

describe('computeMaxFontSize(input)', () => {
describe('returns dimension of the given text', () => {
let originalFn: () => DOMRect;

beforeEach(() => {
// @ts-ignore - fix jsdom
originalFn = SVGElement.prototype.getBBox;
addDummyFill();
});

afterEach(() => {
// @ts-ignore - fix jsdom
SVGElement.prototype.getBBox = originalFn;
});
beforeEach(addDummyFill);
afterEach(removeDummyFill);

it('requires either idealFontSize or maxHeight', () => {
expect(() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
export const SAMPLE_TEXT = 'dummy text. does not really matter';
let originalFn: () => DOMRect;

const textToWidth = {
paris: 200,
tokyo: 300,
beijing: 400,
};

export const SAMPLE_TEXT = Object.keys(textToWidth);

export function addDummyFill() {
// @ts-ignore - fix jsdom
originalFn = SVGElement.prototype.getBBox;

export default function addDummyFill() {
// @ts-ignore - fix jsdom
SVGElement.prototype.getBBox = function getBBox() {
let width = 200;
let width = textToWidth[this.textContent] || 200;
let height = 20;

if (this.getAttribute('class') === 'test-class') {
width = 100;
width /= 2;
}

if (this.style.fontFamily === 'Lobster') {
width = 250;
width *= 1.25;
}

if (this.style.fontSize) {
Expand Down Expand Up @@ -45,3 +56,8 @@ export default function addDummyFill() {
};
};
}

export function removeDummyFill() {
// @ts-ignore - fix jsdom
SVGElement.prototype.getBBox = originalFn;
}

0 comments on commit f1d497e

Please sign in to comment.