is-icon-ligature.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import sanitize from './sanitize';
  2. import hasUnicode from './has-unicode';
  3. import cache from '../../core/base/cache';
  4. /**
  5. * Determines if a given text node is an icon ligature
  6. *
  7. * @method isIconLigature
  8. * @memberof axe.commons.text
  9. * @instance
  10. * @param {VirtualNode} textVNode The virtual text node
  11. * @param {Number} occuranceThreshold Number of times the font is encountered before auto-assigning the font as a ligature or not
  12. * @param {Number} differenceThreshold Percent of differences in pixel data or pixel width needed to determine if a font is a ligature font
  13. * @return {Boolean}
  14. */
  15. function isIconLigature(
  16. textVNode,
  17. differenceThreshold = 0.15,
  18. occuranceThreshold = 3
  19. ) {
  20. /**
  21. * Determine if the visible text is a ligature by comparing the
  22. * first letters image data to the entire strings image data.
  23. * If the two images are significantly different (typical set to 5%
  24. * statistical significance, but we'll be using a slightly higher value
  25. * of 15% to help keep the size of the canvas down) then we know the text
  26. * has been replaced by a ligature.
  27. *
  28. * Example:
  29. * If a text node was the string "File", looking at just the first
  30. * letter "F" would produce the following image:
  31. *
  32. * ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
  33. * │ │ │█│█│█│█│█│█│█│█│█│█│█│ │ │
  34. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  35. * │ │ │█│█│█│█│█│█│█│█│█│█│█│ │ │
  36. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  37. * │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
  38. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  39. * │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
  40. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  41. * │ │ │█│█│█│█│█│█│█│ │ │ │ │ │ │
  42. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  43. * │ │ │█│█│█│█│█│█│█│ │ │ │ │ │ │
  44. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  45. * │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
  46. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  47. * │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
  48. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  49. * │ │ │█│█│ │ │ │ │ │ │ │ │ │ │ │
  50. * └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
  51. *
  52. * But if the entire string "File" produced an image which had at least
  53. * a 15% difference in pixels, we would know that the string was replaced
  54. * by a ligature:
  55. *
  56. * ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
  57. * │ │█│█│█│█│█│█│█│█│█│█│ │ │ │ │
  58. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  59. * │ │█│ │ │ │ │ │ │ │ │█│█│ │ │ │
  60. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  61. * │ │█│ │█│█│█│█│█│█│ │█│ │█│ │ │
  62. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  63. * │ │█│ │ │ │ │ │ │ │ │█│█│█│█│ │
  64. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  65. * │ │█│ │█│█│█│█│█│█│ │ │ │ │█│ │
  66. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  67. * │ │█│ │ │ │ │ │ │ │ │ │ │ │█│ │
  68. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  69. * │ │█│ │█│█│█│█│█│█│█│█│█│ │█│ │
  70. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  71. * │ │█│ │ │ │ │ │ │ │ │ │ │ │█│ │
  72. * ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
  73. * │ │█│█│█│█│█│█│█│█│█│█│█│█│█│ │
  74. * └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
  75. */
  76. const nodeValue = textVNode.actualNode.nodeValue.trim();
  77. // text with unicode or non-bmp letters cannot be ligature icons
  78. if (
  79. !sanitize(nodeValue) ||
  80. hasUnicode(nodeValue, { emoji: true, nonBmp: true })
  81. ) {
  82. return false;
  83. }
  84. if (!cache.get('canvasContext')) {
  85. cache.set(
  86. 'canvasContext',
  87. document.createElement('canvas').getContext('2d')
  88. );
  89. }
  90. const canvasContext = cache.get('canvasContext');
  91. const canvas = canvasContext.canvas;
  92. // keep track of each font encountered and the number of times it shows up
  93. // as a ligature.
  94. if (!cache.get('fonts')) {
  95. cache.set('fonts', {});
  96. }
  97. const fonts = cache.get('fonts');
  98. const style = window.getComputedStyle(textVNode.parent.actualNode);
  99. const fontFamily = style.getPropertyValue('font-family');
  100. if (!fonts[fontFamily]) {
  101. fonts[fontFamily] = {
  102. occurances: 0,
  103. numLigatures: 0
  104. };
  105. }
  106. const font = fonts[fontFamily];
  107. // improve the performance by only comparing the image data of a font
  108. // a certain number of times
  109. if (font.occurances >= occuranceThreshold) {
  110. // if the font has always been a ligature assume it's a ligature font
  111. if (font.numLigatures / font.occurances === 1) {
  112. return true;
  113. }
  114. // inversely, if it's never been a ligature assume it's not a ligature font
  115. else if (font.numLigatures === 0) {
  116. return false;
  117. }
  118. // we could theoretically get into an odd middle ground scenario in which
  119. // the font family is being used as normal text sometimes and as icons
  120. // other times. in these cases we would need to always check the text
  121. // to know if it's an icon or not
  122. }
  123. font.occurances++;
  124. // 30px was chosen to account for common ligatures in normal fonts
  125. // such as fi, ff, ffi. If a ligature would add a single column of
  126. // pixels to a 30x30 grid, it would not meet the statistical significance
  127. // threshold of 15% (30x30 = 900; 30/900 = 3.333%). this also allows for
  128. // more than 1 column differences (60/900 = 6.666%) and things like
  129. // extending the top of the f in the fi ligature.
  130. let fontSize = 30;
  131. let fontStyle = `${fontSize}px ${fontFamily}`;
  132. // set the size of the canvas to the width of the first letter
  133. canvasContext.font = fontStyle;
  134. const firstChar = nodeValue.charAt(0);
  135. let width = canvasContext.measureText(firstChar).width;
  136. // ensure font meets the 30px width requirement (30px font-size doesn't
  137. // necessarily mean its 30px wide when drawn)
  138. if (width < 30) {
  139. const diff = 30 / width;
  140. width *= diff;
  141. fontSize *= diff;
  142. fontStyle = `${fontSize}px ${fontFamily}`;
  143. }
  144. canvas.width = width;
  145. canvas.height = fontSize;
  146. // changing the dimensions of a canvas resets all properties (include font)
  147. // and clears it
  148. canvasContext.font = fontStyle;
  149. canvasContext.textAlign = 'left';
  150. canvasContext.textBaseline = 'top';
  151. canvasContext.fillText(firstChar, 0, 0);
  152. const compareData = new Uint32Array(
  153. canvasContext.getImageData(0, 0, width, fontSize).data.buffer
  154. );
  155. // if the font doesn't even have character data for a single char then
  156. // it has to be an icon ligature (e.g. Material Icon)
  157. if (!compareData.some(pixel => pixel)) {
  158. font.numLigatures++;
  159. return true;
  160. }
  161. canvasContext.clearRect(0, 0, width, fontSize);
  162. canvasContext.fillText(nodeValue, 0, 0);
  163. const compareWith = new Uint32Array(
  164. canvasContext.getImageData(0, 0, width, fontSize).data.buffer
  165. );
  166. // calculate the number of differences between the first letter and the
  167. // entire string, ignoring color differences
  168. const differences = compareData.reduce((diff, pixel, i) => {
  169. if (pixel === 0 && compareWith[i] === 0) {
  170. return diff;
  171. }
  172. if (pixel !== 0 && compareWith[i] !== 0) {
  173. return diff;
  174. }
  175. return ++diff;
  176. }, 0);
  177. // calculate the difference between the width of each character and the
  178. // combined with of all characters
  179. const expectedWidth = nodeValue.split('').reduce((width, char) => {
  180. return width + canvasContext.measureText(char).width;
  181. }, 0);
  182. const actualWidth = canvasContext.measureText(nodeValue).width;
  183. const pixelDifference = differences / compareData.length;
  184. const sizeDifference = 1 - actualWidth / expectedWidth;
  185. if (
  186. pixelDifference >= differenceThreshold &&
  187. sizeDifference >= differenceThreshold
  188. ) {
  189. font.numLigatures++;
  190. return true;
  191. }
  192. return false;
  193. }
  194. export default isIconLigature;