Skip to content

Commit

Permalink
fix(label-content-name-mismatch): ignore ligature fonts (#1829)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
straker authored Oct 14, 2019
1 parent 50df70a commit 683e005
Show file tree
Hide file tree
Showing 9 changed files with 640 additions and 6 deletions.
16 changes: 13 additions & 3 deletions lib/checks/label/label-content-name-mismatch.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
const { text } = axe.commons;
const { pixelThreshold, occuranceThreshold } = options || {};

const accText = text.accessibleText(node).toLowerCase();
if (text.isHumanInterpretable(accText) < 1) {
return undefined;
}

const visibleText = text
.sanitize(text.visibleVirtual(virtualNode))
.toLowerCase();
const textVNodes = text.visibleTextNodes(virtualNode);
const nonLigatureText = textVNodes
.filter(
textVNode =>
!text.isIconLigature(textVNode, pixelThreshold, occuranceThreshold)
)
.map(textVNode => textVNode.actualNode.nodeValue)
.join('');
const visibleText = text.sanitize(nonLigatureText).toLowerCase();
if (!visibleText) {
return true;
}
if (text.isHumanInterpretable(visibleText) < 1) {
if (isStringContained(visibleText, accText)) {
return true;
Expand Down
4 changes: 4 additions & 0 deletions lib/checks/label/label-content-name-mismatch.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"id": "label-content-name-mismatch",
"evaluate": "label-content-name-mismatch.js",
"options": {
"pixelThreshold": 0.1,
"occuranceThreshold": 3
},
"metadata": {
"impact": "serious",
"messages": {
Expand Down
210 changes: 210 additions & 0 deletions lib/commons/text/is-icon-ligature.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/* global text */

/**
* 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}
*/
text.isIconLigature = function(
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;

// text with unicode or non-bmp letters cannot be ligature icons
if (
!text.sanitize(nodeValue) ||
text.hasUnicode(nodeValue, { emoji: true, nonBmp: true })
) {
return false;
}

if (!axe._cache.get('context')) {
axe._cache.set(
'context',
document.createElement('canvas').getContext('2d')
);
}
const context = axe._cache.get('context');
const canvas = context.canvas;

// keep track of each font encountered and the number of times it shows up
// as a ligature.
if (!axe._cache.get('fonts')) {
axe._cache.set('fonts', {});
}
const fonts = axe._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
context.font = fontStyle;
const firstChar = nodeValue.charAt(0);
let width = context.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
context.font = fontStyle;
context.textAlign = 'left';
context.textBaseline = 'top';
context.fillText(firstChar, 0, 0);
const compareData = new Uint32Array(
context.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;
}

context.clearRect(0, 0, width, fontSize);
context.fillText(nodeValue, 0, 0);
const compareWith = new Uint32Array(
context.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 + context.measureText(char).width;
}, 0);
const actualWidth = context.measureText(nodeValue).width;

const pixelDifference = differences / compareData.length;
const sizeDifference = 1 - actualWidth / expectedWidth;

if (
pixelDifference >= differenceThreshold &&
sizeDifference >= differenceThreshold
) {
font.numLigatures++;
return true;
}

return false;
};
25 changes: 25 additions & 0 deletions lib/commons/text/visible-text-nodes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* global text */

/**
* Returns an array of visible text virtual nodes
* @method visibleTextNodes
* @memberof axe.commons.text
* @instance
* @param {VirtualNode} vNode
* @return {VitrualNode[]}
*/
text.visibleTextNodes = function(vNode) {
const parentVisible = axe.commons.dom.isVisible(vNode.actualNode);
let nodes = [];
vNode.children.forEach(child => {
if (child.actualNode.nodeType === 3) {
if (parentVisible) {
nodes.push(child);
}
} else {
nodes = nodes.concat(text.visibleTextNodes(child));
}
});
return nodes;
};
16 changes: 16 additions & 0 deletions lib/core/imports/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ if (!('Promise' in window)) {
require('es6-promise').polyfill();
}

/**
* Polyfill required TypedArray and functions
* Reference https://github.com/zloirock/core-js/
*/
if (!('Uint32Array' in window)) {
require('core-js/features/typed-array/uint32-array');
}
if (window.Uint32Array) {
if (!('some' in window.Uint32Array.prototype)) {
require('core-js/features/typed-array/some');
}
if (!('reduce' in window.Uint32Array.prototype)) {
require('core-js/features/typed-array/reduce');
}
}

/**
* Polyfill `WeakMap`
* Reference: https://github.com/polygonplanet/weakmap-polyfill
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"browserify": "^16.2.3",
"chai": "~4.2.0",
"clone": "~2.1.1",
"core-js": "^3.2.1",
"css-selector-parser": "^1.3.0",
"derequire": "^2.0.6",
"emoji-regex": "8.0.0",
Expand Down
29 changes: 26 additions & 3 deletions test/checks/label/label-content-name-mismatch.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
describe('label-content-name-mismatch tests', function() {
'use strict';

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

afterEach(function() {
fixture.innerHTML = '';
var fontApiSupport = !!document.fonts;

before(function(done) {
if (!fontApiSupport) {
done();
}

var materialFont = new FontFace(
'Material Icons',
'url(https://fonts.gstatic.com/s/materialicons/v48/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2)'
);
materialFont.load().then(function() {
document.fonts.add(materialFont);
done();
});
});

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

(fontApiSupport ? it : it.skip)(
'returns true when visible text excluding ligature icon is part of accessible name',
function() {
var vNode = queryFixture(
'<button id="target" aria-label="next page">next page <span style="font-family: \'Material Icons\'">delete</span></button>'
);
var actual = check.evaluate(vNode.actualNode, options, vNode);
assert.isTrue(actual);
}
);

it('returns true when visible text excluding private use unicode is part of accessible name', function() {
var vNode = queryFixture(
'<button id="target" aria-label="Favorites"> Favorites</button>'
Expand Down
Loading

0 comments on commit 683e005

Please sign in to comment.