axe-core
Version:
Accessibility engine for automated Web UI testing
215 lines (194 loc) • 9.32 kB
JavaScript
import sanitize from './sanitize';
import hasUnicode from './has-unicode';
import cache from '../../core/base/cache';
/**
* Determines if a given text node is an icon ligature
*
* @method isIconLigature
* @memberof axe.commons.text
* @instance
* @param {VirtualNode} textVNode The virtual text node
* @param {Number} occuranceThreshold Number of times the font is encountered before auto-assigning the font as a ligature or not
* @param {Number} differenceThreshold Percent of differences in pixel data or pixel width needed to determine if a font is a ligature font
* @return {Boolean}
*/
function isIconLigature(
textVNode,
differenceThreshold = 0.15,
occuranceThreshold = 3
) {
/**
* Determine if the visible text is a ligature by comparing the
* first letters image data to the entire strings image data.
* If the two images are significantly different (typical set to 5%
* statistical significance, but we'll be using a slightly higher value
* of 15% to help keep the size of the canvas down) then we know the text
* has been replaced by a ligature.
*
* Example:
* If a text node was the string "File", looking at just the first
* letter "F" would produce the following image:
*
* ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
* │ │ │█│█│█│█│█│█│█│█│█│█│█│ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │ │█│█│█│█│█│█│█│█│█│█│█│ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │ │█│█│█│█│█│█│█│ │ │ │ │ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │ │█│█│█│█│█│█│█│ │ │ │ │ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
* └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
*
* But if the entire string "File" produced an image which had at least
* a 15% difference in pixels, we would know that the string was replaced
* by a ligature:
*
* ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
* │ │█│█│█│█│█│█│█│█│█│█│ │ │ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │█│ │ │ │ │ │ │ │ │█│█│ │ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │█│ │█│█│█│█│█│█│ │█│ │█│ │ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │█│ │ │ │ │ │ │ │ │█│█│█│█│ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │█│ │█│█│█│█│█│█│ │ │ │ │█│ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │█│ │ │ │ │ │ │ │ │ │ │ │█│ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │█│ │█│█│█│█│█│█│█│█│█│ │█│ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │█│ │ │ │ │ │ │ │ │ │ │ │█│ │
* ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
* │ │█│█│█│█│█│█│█│█│█│█│█│█│█│ │
* └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
*/
const nodeValue = textVNode.actualNode.nodeValue.trim();
// text with unicode or non-bmp letters cannot be ligature icons
if (
!sanitize(nodeValue) ||
hasUnicode(nodeValue, { emoji: true, nonBmp: true })
) {
return false;
}
if (!cache.get('canvasContext')) {
cache.set(
'canvasContext',
document.createElement('canvas').getContext('2d')
);
}
const canvasContext = cache.get('canvasContext');
const canvas = canvasContext.canvas;
// keep track of each font encountered and the number of times it shows up
// as a ligature.
if (!cache.get('fonts')) {
cache.set('fonts', {});
}
const fonts = cache.get('fonts');
const style = window.getComputedStyle(textVNode.parent.actualNode);
const fontFamily = style.getPropertyValue('font-family');
if (!fonts[fontFamily]) {
fonts[fontFamily] = {
occurances: 0,
numLigatures: 0
};
}
const font = fonts[fontFamily];
// improve the performance by only comparing the image data of a font
// a certain number of times
if (font.occurances >= occuranceThreshold) {
// if the font has always been a ligature assume it's a ligature font
if (font.numLigatures / font.occurances === 1) {
return true;
}
// inversely, if it's never been a ligature assume it's not a ligature font
else if (font.numLigatures === 0) {
return false;
}
// we could theoretically get into an odd middle ground scenario in which
// the font family is being used as normal text sometimes and as icons
// other times. in these cases we would need to always check the text
// to know if it's an icon or not
}
font.occurances++;
// 30px was chosen to account for common ligatures in normal fonts
// such as fi, ff, ffi. If a ligature would add a single column of
// pixels to a 30x30 grid, it would not meet the statistical significance
// threshold of 15% (30x30 = 900; 30/900 = 3.333%). this also allows for
// more than 1 column differences (60/900 = 6.666%) and things like
// extending the top of the f in the fi ligature.
let fontSize = 30;
let fontStyle = `${fontSize}px ${fontFamily}`;
// set the size of the canvas to the width of the first letter
canvasContext.font = fontStyle;
const firstChar = nodeValue.charAt(0);
let width = canvasContext.measureText(firstChar).width;
// ensure font meets the 30px width requirement (30px font-size doesn't
// necessarily mean its 30px wide when drawn)
if (width < 30) {
const diff = 30 / width;
width *= diff;
fontSize *= diff;
fontStyle = `${fontSize}px ${fontFamily}`;
}
canvas.width = width;
canvas.height = fontSize;
// changing the dimensions of a canvas resets all properties (include font)
// and clears it
canvasContext.font = fontStyle;
canvasContext.textAlign = 'left';
canvasContext.textBaseline = 'top';
canvasContext.fillText(firstChar, 0, 0);
const compareData = new Uint32Array(
canvasContext.getImageData(0, 0, width, fontSize).data.buffer
);
// if the font doesn't even have character data for a single char then
// it has to be an icon ligature (e.g. Material Icon)
if (!compareData.some(pixel => pixel)) {
font.numLigatures++;
return true;
}
canvasContext.clearRect(0, 0, width, fontSize);
canvasContext.fillText(nodeValue, 0, 0);
const compareWith = new Uint32Array(
canvasContext.getImageData(0, 0, width, fontSize).data.buffer
);
// calculate the number of differences between the first letter and the
// entire string, ignoring color differences
const differences = compareData.reduce((diff, pixel, i) => {
if (pixel === 0 && compareWith[i] === 0) {
return diff;
}
if (pixel !== 0 && compareWith[i] !== 0) {
return diff;
}
return ++diff;
}, 0);
// calculate the difference between the width of each character and the
// combined with of all characters
const expectedWidth = nodeValue.split('').reduce((width, char) => {
return width + canvasContext.measureText(char).width;
}, 0);
const actualWidth = canvasContext.measureText(nodeValue).width;
const pixelDifference = differences / compareData.length;
const sizeDifference = 1 - actualWidth / expectedWidth;
if (
pixelDifference >= differenceThreshold &&
sizeDifference >= differenceThreshold
) {
font.numLigatures++;
return true;
}
return false;
}
export default isIconLigature;