Skip to content

Commit 683e005

Browse files
authored
fix(label-content-name-mismatch): ignore ligature fonts (#1829)
* fix(label-content-name-mismatch): ignore ligature fonts * move to own function * finalize tests * increase time * use font api * use roboto to test text ligatures * ignore for windows * more time? * use woff files for google fonts * try hosting font * no ligature font * no integration * dont flag programming ligs * no Uint32Array * Revert "no Uint32Array" This reverts commit 8c8fdee. * dont use .some on Uint32Array * try fixing reduce * polyfill some and reduce
1 parent 50df70a commit 683e005

File tree

9 files changed

+640
-6
lines changed

9 files changed

+640
-6
lines changed

lib/checks/label/label-content-name-mismatch.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
const { text } = axe.commons;
2+
const { pixelThreshold, occuranceThreshold } = options || {};
23

34
const accText = text.accessibleText(node).toLowerCase();
45
if (text.isHumanInterpretable(accText) < 1) {
56
return undefined;
67
}
78

8-
const visibleText = text
9-
.sanitize(text.visibleVirtual(virtualNode))
10-
.toLowerCase();
9+
const textVNodes = text.visibleTextNodes(virtualNode);
10+
const nonLigatureText = textVNodes
11+
.filter(
12+
textVNode =>
13+
!text.isIconLigature(textVNode, pixelThreshold, occuranceThreshold)
14+
)
15+
.map(textVNode => textVNode.actualNode.nodeValue)
16+
.join('');
17+
const visibleText = text.sanitize(nonLigatureText).toLowerCase();
18+
if (!visibleText) {
19+
return true;
20+
}
1121
if (text.isHumanInterpretable(visibleText) < 1) {
1222
if (isStringContained(visibleText, accText)) {
1323
return true;

lib/checks/label/label-content-name-mismatch.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
{
22
"id": "label-content-name-mismatch",
33
"evaluate": "label-content-name-mismatch.js",
4+
"options": {
5+
"pixelThreshold": 0.1,
6+
"occuranceThreshold": 3
7+
},
48
"metadata": {
59
"impact": "serious",
610
"messages": {

lib/commons/text/is-icon-ligature.js

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

lib/core/imports/index.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ if (!('Promise' in window)) {
1414
require('es6-promise').polyfill();
1515
}
1616

17+
/**
18+
* Polyfill required TypedArray and functions
19+
* Reference https://github.com/zloirock/core-js/
20+
*/
21+
if (!('Uint32Array' in window)) {
22+
require('core-js/features/typed-array/uint32-array');
23+
}
24+
if (window.Uint32Array) {
25+
if (!('some' in window.Uint32Array.prototype)) {
26+
require('core-js/features/typed-array/some');
27+
}
28+
if (!('reduce' in window.Uint32Array.prototype)) {
29+
require('core-js/features/typed-array/reduce');
30+
}
31+
}
32+
1733
/**
1834
* Polyfill `WeakMap`
1935
* Reference: https://github.com/polygonplanet/weakmap-polyfill

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"browserify": "^16.2.3",
8888
"chai": "~4.2.0",
8989
"clone": "~2.1.1",
90+
"core-js": "^3.2.1",
9091
"css-selector-parser": "^1.3.0",
9192
"derequire": "^2.0.6",
9293
"emoji-regex": "8.0.0",

test/checks/label/label-content-name-mismatch.js

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
describe('label-content-name-mismatch tests', function() {
22
'use strict';
33

4-
var fixture = document.getElementById('fixture');
54
var queryFixture = axe.testUtils.queryFixture;
65
var check = checks['label-content-name-mismatch'];
76
var options = undefined;
87

9-
afterEach(function() {
10-
fixture.innerHTML = '';
8+
var fontApiSupport = !!document.fonts;
9+
10+
before(function(done) {
11+
if (!fontApiSupport) {
12+
done();
13+
}
14+
15+
var materialFont = new FontFace(
16+
'Material Icons',
17+
'url(https://fonts.gstatic.com/s/materialicons/v48/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2)'
18+
);
19+
materialFont.load().then(function() {
20+
document.fonts.add(materialFont);
21+
done();
22+
});
1123
});
1224

1325
it('returns true when visible text and accessible name (`aria-label`) matches (text sanitized)', function() {
@@ -84,6 +96,17 @@ describe('label-content-name-mismatch tests', function() {
8496
assert.isTrue(actual);
8597
});
8698

99+
(fontApiSupport ? it : it.skip)(
100+
'returns true when visible text excluding ligature icon is part of accessible name',
101+
function() {
102+
var vNode = queryFixture(
103+
'<button id="target" aria-label="next page">next page <span style="font-family: \'Material Icons\'">delete</span></button>'
104+
);
105+
var actual = check.evaluate(vNode.actualNode, options, vNode);
106+
assert.isTrue(actual);
107+
}
108+
);
109+
87110
it('returns true when visible text excluding private use unicode is part of accessible name', function() {
88111
var vNode = queryFixture(
89112
'<button id="target" aria-label="Favorites"> Favorites</button>'

0 commit comments

Comments
 (0)