/**
* Functionality for color contrast accessibility checks.
* @module contrast
*/
const walk = (node, fn, context) => {
const passdown = fn(node, context);
if (passdown === 'skipchildren') {
return;
}
if (node.children) {
node.children.forEach((child) => walk(child, fn, passdown));
}
};
const rgbToCssColor = ({ r, g, b }) => `rgb(${r * 255},${g * 255},${b * 255})`;
/**
* Blends the given colors (RGBA dicts) using the "over" paint operation.
*/
const flattenColors = (fg, bg) => {
// https://en.wikipedia.org/wiki/Alpha_compositing
const a = fg.a + bg.a * (1 - fg.a);
if (a === 0) {
return { r: 0, g: 0, b: 0, a: 0 };
}
return {
r: (fg.r * fg.a + bg.r * bg.a * (1 - fg.a)) / a,
g: (fg.g * fg.a + bg.g * bg.a * (1 - fg.a)) / a,
b: (fg.b * fg.a + bg.b * bg.a * (1 - fg.a)) / a,
a
};
};
const formatContrastRatio = (contrastRatio) => {
return isNaN(contrastRatio) ? 'NA' : `${contrastRatio.toFixed(2)}:1`;
};
/**
* Mixes the given colors (RGBA dicts) at the given amount (0 to 1).
*/
const mixColors = (c1, c2, amount) => {
// from tinycolor
// https://github.com/bgrins/TinyColor/blob/master/tinycolor.js#L701
amount = amount === 0 ? 0 : amount || 50;
return {
r: (c2.r - c1.r) * amount + c1.r,
g: (c2.g - c1.g) * amount + c1.g,
b: (c2.b - c1.b) * amount + c1.b,
a: (c2.a - c1.a) * amount + c1.a
};
};
const detailFor = (numFail, numPass, minCR, maxCR) => {
const note =
minCR === maxCR
? formatContrastRatio(minCR)
: `${formatContrastRatio(minCR)} – ${formatContrastRatio(maxCR)}`;
if (numFail > 0 && numPass > 0) {
return {
status: 'mixed',
contrastRatio: minCR,
note
};
}
if (numFail > 0) {
return {
status: 'fail',
contrastRatio: minCR,
note
};
}
if (numPass > 0) {
return {
status: 'pass',
contrastRatio: minCR,
note
};
}
return { status: 'unknown', contrastRatio: 0 };
};
/**
* Calculates the luminance of the given RGB color.
*/
const srgbLuminance = ({ r, g, b }) => {
// from tinycolor
// https://github.com/bgrins/TinyColor/blob/master/tinycolor.js#L75
// http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
let R;
let G;
let B;
if (r <= 0.03928) {
R = r / 12.92;
} else {
R = Math.pow((r + 0.055) / 1.055, 2.4);
}
if (g <= 0.03928) {
G = g / 12.92;
} else {
G = Math.pow((g + 0.055) / 1.055, 2.4);
}
if (b <= 0.03928) {
B = b / 12.92;
} else {
B = Math.pow((b + 0.055) / 1.055, 2.4);
}
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
};
const decodeToImageData = async (bytes) => {
const url = URL.createObjectURL(new Blob([bytes]));
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject();
img.src = url;
});
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.canvas.width = image.width;
ctx.canvas.height = image.height;
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, image.width, image.height);
return imageData;
};
const getImageDataPixel = (imageData, x, y, constrain = true) => {
x = Math.round(x);
y = Math.round(y);
if (constrain) {
x = Math.max(0, Math.min(x, imageData.width - 1));
y = Math.max(0, Math.min(y, imageData.height - 1));
}
if (x < 0 || x >= imageData.width || y < 0 || y >= imageData.height) {
return null;
}
return {
r: imageData.data[(y * imageData.width + x) * 4] / 255,
g: imageData.data[(y * imageData.width + x) * 4 + 1] / 255,
b: imageData.data[(y * imageData.width + x) * 4 + 2] / 255,
a: imageData.data[(y * imageData.width + x) * 4 + 3] / 255
};
};
const pageContainingNode = (node) => {
while (node?.type !== 'PAGE') {
node = node.parent;
}
return node.type === 'PAGE' ? node : null;
};
const mapNodeIds = (original, dup) => {
const map = new Map();
const fromIds = [];
const toIds = [];
// walking the original and the duplicate should
// always return corresponding nodes in the same order
walk(original, (node) => fromIds.push(node.id));
walk(dup, (node) => toIds.push(node.id));
if (fromIds.length !== toIds.length) {
// TODO: can this ever happen?
return map;
}
for (const [idx, id] of toIds.entries()) {
map.set(id, fromIds[idx]);
}
return map;
};
const computeTypeContrast = (textNodeInfo, bgImageData) => {
const { x, y, w, h, textStyleSamples, effectiveOpacity } = textNodeInfo;
if (!textStyleSamples.length) {
// show error
return {
aa: { status: 'unknown', contrastRatio: 0 },
aaa: { status: 'unknown', contrastRatio: 0 }
};
}
const samplePoints = [
// TODO: adaptive sampling?
[x, y],
// as of last testing, runtime diff. sampling 4 vs. 1 points only took ~5% longer
[x + w - 1, y],
[x, y + h - 1],
[x + h - 1, y + h - 1]
];
const stats = {
aaFail: 0,
aaPass: 0,
aaaFail: 0,
aaaPass: 0,
minCR: Infinity,
maxCR: 0
};
textStyleSamples.map(({ textSize, isBold, color }) => {
const pointSize = textSize / 1.333333333; // CSS px -> pt
const isLargeText = pointSize >= 18 || (isBold && pointSize >= 14);
const passingAAContrastForLayer = isLargeText ? 3 : 4.5;
const passingAAAContrastForLayer = isLargeText ? 4.5 : 7;
samplePoints.map(([x_, y_]) => {
let bgColor = getImageDataPixel(bgImageData, x_, y_);
if (!bgColor) {
// likely this sample point is out of bounds
} else {
bgColor = flattenColors(bgColor, { r: 1, g: 1, b: 1, a: 1 }); // flatten bgColor on a white matte
const blendedTextColor = mixColors(
bgColor,
color,
color.a * effectiveOpacity
);
const lum1 = srgbLuminance(blendedTextColor);
const lum2 = srgbLuminance(bgColor);
const contrastRatio =
(Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05);
stats.minCR = Math.min(stats.minCR, contrastRatio);
stats.maxCR = Math.max(stats.maxCR, contrastRatio);
if (contrastRatio < passingAAContrastForLayer) {
stats.aaFail += 1;
} else {
stats.aaPass += 1;
}
if (contrastRatio < passingAAAContrastForLayer) {
stats.aaaFail += 1;
} else {
stats.aaaPass += 1;
}
}
return null;
});
return null;
});
return {
aa: detailFor(stats.aaFail, stats.aaPass, stats.minCR, stats.maxCR),
aaa: detailFor(stats.aaaFail, stats.aaaPass, stats.minCR, stats.maxCR)
};
};
const urlForImageBytes = (ui8arr) => {
return URL.createObjectURL(new Blob([ui8arr]));
};
export default {
computeTypeContrast,
decodeToImageData,
formatContrastRatio,
mapNodeIds,
pageContainingNode,
rgbToCssColor,
urlForImageBytes,
walk
};