Skip to content

Commit

Permalink
fix!: shim an implementation of getSubStringLength (#6663) (CP: 24.…
Browse files Browse the repository at this point in the history
…4) (#6670)

* Shim an implementation of `getSubStringLength` (#6663)

* Shim an implementation of getSubStringLength

Estimate the rendered length of a substring of text in an SVG element. Supports
wrapping/truncation of long labels in charts.

* Improve shimmed measurement routines

Improve shims of getBBox and getSubStringLength:

The prior implementations were relying on specific DOM structures that
Highcharts used to create: if the measured element had children, all text
was expected to be within those children, and none in the top-level element.
Also, it did not handle arbitrary element nesting. The current version of
Highcharts does not always follow these rules, and there were cases of
structures like:

<text>Some text here<tspan>and some in a nested element</tspan></text>

In that example, 'Some text here' would be omitted from measurement.

Replace that strategy with one that processes each text node separately,
providing the context of the containing element, and recursing into all
nested elements.

Fix some bugs around new line management in getBBox that became apparent
after that change.

The previous method of computing string width was a rough approximation based
on average character width. This method produced suboptimal results in many
situtations, generating extra space or overlapping text.

Instead, use the string-pixel-width library to provide a better estimate. This
library has per-character widths for a number of font families and variants, and
results in widths much closer to the actual rendered style. As Lucida is used
heavily in at least the test charts, and it is not natively supported by the
library, add a custom mapping file including metrics for it.

Also, improve detection of font family and font size, walking up the element
parent chain (even potentially outside of the measured element) to find settings
of these attributes.

* Review cleanup

* Test getSubStringLength

Add test for getSubStringLength to ensure proper handling of sub-strings
crossing text nodes (and other cases).

To support this, change the way methods are added to jsdom SVG elements:
rather than adding them only for elements instantiated via createElementNS,
add them to the SVGElement prototype. This makes them available for elements
created by any method of instantiation (including by setting innerHTML, as used
in the test).

Use rewire to access private elements of the exporter during tests.

* spotless

* chore: pin npm dependencies

---------

Co-authored-by: Aron Nopanen <[email protected]>
Co-authored-by: Diego Cardoso <[email protected]>
  • Loading branch information
3 people authored Sep 26, 2024
1 parent d8d78cc commit 9c678b0
Show file tree
Hide file tree
Showing 14 changed files with 822 additions and 100 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
const jsdom = require('jsdom');
const fs = require('fs');
const path = require('path');
const customWidthsMap = require('./customWidthsMap.js');
const defaultWidthsMap = require('string-pixel-width/lib/widthsMap.js');
const pixelWidth = require('string-pixel-width');

// Combine the default font widths map from string-pixel-width with our additions
const widthsMap = { ...defaultWidthsMap, ...customWidthsMap };

const { JSDOM } = jsdom;

Expand All @@ -25,7 +31,7 @@ const doc = win.document;
global.Node = win.Node

// Require Highcharts with the window shim
const Highcharts = require('highcharts/highstock')(win);
const Highcharts = require('highcharts/highstock.src')(win);
require("highcharts/modules/accessibility")(Highcharts);
require("highcharts/highcharts-more")(Highcharts);
require("highcharts/highcharts-3d")(Highcharts);
Expand All @@ -45,101 +51,207 @@ require("highcharts/modules/bullet")(Highcharts);

win.Date = Date;

function processTextNodes(element, cb) {
for (var childNode of element.childNodes) {
if (childNode.nodeType === Node.ELEMENT_NODE) {
processTextNodes(childNode, cb);
} else if (childNode.nodeType === Node.TEXT_NODE) {
cb(childNode, element);
}
}
}

/**
* Search up parent chain for an element with the requested attribute set
*
* @param {*} ele
* @param {*} attr
* @returns Attribute, possibly inherited, or undefined if not found
*/
function findStyleAttr(ele, attr) {
while (ele) {
if (ele.style[attr]) {
return ele.style[attr];
}
ele = ele.parentElement;
}
}

const sizableFonts = Object.keys(widthsMap);

/**
* The string-pixel-width library supports a limited set of fonts. Search a font-family
* list for a usable font, or find the next-best fallback
*/
function findSizableFont(fontFamily = "") {
let fonts = fontFamily.split(",")
.map(s => s.trim())
.map(s => s.replace(/^"(.*)"$/, '$1')) // Un-quote any quoted entries
.map(s => s.toLowerCase());

// Search the font list for one in our list of sizable fonts
let usableFont = fonts.find(f => sizableFonts.includes(f));

if (!usableFont) {
// None of those are sizable. Go with Highcharts default font
usableFont = 'times new roman';
}

return usableFont;
}

function removeHighchartsTextOutlines(elem) {
let children = [].slice.call(
elem.children.length ? elem.children : [elem]
);
children.forEach(child => {
if (child.getAttribute('class') === 'highcharts-text-outline') {
child.parentNode.removeChild(child);
}
});

}

// Do some modifications to the jsdom document in order to get the SVG bounding
// boxes right.
let oldCreateElementNS = doc.createElementNS;
doc.createElementNS = (ns, tagName) => {
let elem = oldCreateElementNS.call(doc, ns, tagName);
if (ns !== 'http://www.w3.org/2000/svg') {
return elem;
}

/**
* Pass Highcharts' test for SVG capabilities
* @returns {undefined}
*/
elem.createSVGRect = () => { };
/**
* jsdom doesn't compute layout (see
* https://github.com/tmpvar/jsdom/issues/135). This getBBox implementation
* provides just enough information to get Highcharts to render text boxes
* correctly, and is not intended to work like a general getBBox
* implementation. The height of the boxes are computed from the sum of
* tspans and their font sizes. The width is based on an average width for
* each glyph. It could easily be improved to take font-weight into account.
* For a more exact result we could to create a map over glyph widths for
* several fonts and sizes, but it may not be necessary for the purpose.
* If the width for the element is zero, then the height is also
* set to zero, in order to not reserve any vertical space for elements
* without content.
* @returns {Object} The bounding box
*/
elem.getBBox = () => {
let lineWidth = 0,
width = 0,
height = 0;

let children = [].slice.call(
elem.children.length ? elem.children : [elem]
);
/**
* Pass Highcharts' test for SVG capabilities
* @returns {undefined}
*/
win.SVGElement.prototype.createSVGRect = function() { };
/**
* jsdom doesn't compute layout (see
* https://github.com/tmpvar/jsdom/issues/135). This getBBox implementation
* provides just enough information to get Highcharts to render text boxes
* correctly, and is not intended to work like a general getBBox
* implementation. The height of the boxes are computed from the sum of
* tspans and their font sizes. The width is based on an average width for
* each glyph. It could easily be improved to take font-weight into account.
* For a more exact result we could to create a map over glyph widths for
* several fonts and sizes, but it may not be necessary for the purpose.
* If the width for the element is zero, then the height is also
* set to zero, in order to not reserve any vertical space for elements
* without content.
* @returns {Object} The bounding box
*/
win.SVGElement.prototype.getBBox = function() {
let lineWidth = 0,
lineHeight = 0,
width = 0,
height = 0,
newLine = false;

children
.filter(child => {
if (child.getAttribute('class') === 'highcharts-text-outline') {
child.parentNode.removeChild(child);
return false;
}
return true;
})
.forEach(child => {
let fontSize = child.style.fontSize || elem.style.fontSize,
lineHeight,
textLength;

// The font size and lineHeight is based on empirical values,
// copied from the SVGRenderer.fontMetrics function in
// Highcharts.
if (/px/.test(fontSize)) {
fontSize = parseInt(fontSize, 10);
} else {
fontSize = /em/.test(fontSize) ?
parseFloat(fontSize) * 12 :
12;
}
lineHeight = fontSize < 24 ?
fontSize + 3 :
Math.round(fontSize * 1.2);
textLength = child.textContent.length * fontSize * 0.55;

// Tspans on the same line
if (child.getAttribute('dx') !== '0') {
height += lineHeight;
}
removeHighchartsTextOutlines(this);

// New line
if (child.getAttribute('dy') !== null) {
lineWidth = 0;
}
processTextNodes(this, (textNode, child) => {
if (child.tagName === 'title') {
return;
}
let fontSize = findStyleAttr(child, 'fontSize'),
fontFamily = findStyleAttr(child, 'fontFamily'),
textLength;

lineWidth += textLength;
width = Math.max(width, lineWidth);
// The font size and lineHeight is based on empirical values,
// copied from the SVGRenderer.fontMetrics function in
// Highcharts.
if (/px/.test(fontSize)) {
fontSize = parseInt(fontSize, 10);
} else {
fontSize = /em/.test(fontSize) ?
parseFloat(fontSize) * 12 :
12;
}
let nodeHeight = fontSize < 24 ?
fontSize + 3 :
Math.round(fontSize * 1.2);
lineHeight = Math.max(lineHeight, nodeHeight);
let fontToUse = findSizableFont(fontFamily);
textLength = pixelWidth(textNode.data, { size: fontSize, font: fontToUse, map: widthsMap });

}
);
// In practice, dy is used to trigger a new line
if (child.getAttribute('dy') !== null) {
lineWidth = 0;
newLine = true;
}

lineWidth += textLength;
width = Math.max(width, lineWidth);

if (newLine) {
height += lineHeight;
newLine = false;
lineHeight = 0;
}
});

// Add the height of the ongoing line
height += lineHeight;

// If the width of the text box is 0, always return a 0 height (since the element indeed consumes no space)
// Returning a non-zero height causes Highcharts to allocate vertical space in the chart for text that doesn't
// exist
let retHeight = width == 0 ? 0 : height;
return {
x: 0,
y: 0,
width: width,
height: retHeight
};
// If the width of the text box is 0, always return a 0 height (since the element indeed consumes no space)
// Returning a non-zero height causes Highcharts to allocate vertical space in the chart for text that doesn't
// exist
let retHeight = width == 0 ? 0 : height;
return {
x: 0,
y: 0,
width: width,
height: retHeight
};
return elem;
};
/**
* Estimate the rendered length of a substring of text. Uses a similar strategy to getBBox,
* above.
*
* @param {integer} charnum Starting character position
* @param {integer} numchars Number of characters to count
* @returns Rendered length of the substring (estimated)
*/
win.SVGElement.prototype.getSubStringLength = function(charnum, numchars) {
let offset = charnum,
remaining = numchars,
textLength = 0;

removeHighchartsTextOutlines(this);

processTextNodes(this, (textNode, child) => {
if (child.tagName === 'title') {
return;
}

if (remaining <= 0) {
return;
}
let childLength = textNode.length;

if (childLength <= offset) {
offset -= childLength;
} else {
let usedLength = Math.min(childLength - offset, remaining);
remaining -= usedLength;

let fontSize = findStyleAttr(child, 'fontSize'),
fontFamily = findStyleAttr(child, 'fontFamily');

// The font size is based on empirical values,
// copied from the SVGRenderer.fontMetrics function in
// Highcharts.
if (/px/.test(fontSize)) {
fontSize = parseInt(fontSize, 10);
} else {
fontSize = /em/.test(fontSize) ?
parseFloat(fontSize) * 12 :
12;
}
let textToSize = textNode.data.substring(offset, offset + usedLength);
let fontToUse = findSizableFont(fontFamily);
let measuredWidth = pixelWidth(textToSize, { size: fontSize, font: fontToUse, map: widthsMap });
textLength += measuredWidth;
}
});

return textLength;
}

const inflateFunctions = (jsonConfiguration) => {
Object.entries(jsonConfiguration).forEach(([attr, targetProperty]) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
"scripts": {
"test": "mocha",
"test:watch": "mocha --watch",
"build": "webpack --config webpack.config.js"
"build": "webpack --config webpack.config.js"
},
"dependencies": {
"highcharts": "9.2.2",
"jsdom": "16.5.3"
"jsdom": "16.5.3",
"string-pixel-width": "1.11.0"
},
"devDependencies": {
"chai": "4.3.4",
"mocha": "8.4.0",
"mock-fs": "5.0.0",
"rewire": "7.0.0",
"webpack": "5.76.0",
"webpack-cli": "4.9.2"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,9 @@ private Configuration createColumnWithoutTitle() {

XAxis x = new XAxis();
x.setCategories("January is a long month", "February is rather boring",
"Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec");
"Mar", "Apr", "May", "Jun",
"Jul is a month to enjoy really nice weather", "Aug", "Sep",
"Oct", "Nov", "Dec");
configuration.addxAxis(x);

YAxis y = new YAxis();
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 9c678b0

Please sign in to comment.