123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- 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;
|