Merge pull request #20240 from calixteman/fix_contrast
Make sure that a good constrast ratio is respected when darkening/lightening a color
This commit is contained in:
commit
d77f89026f
@ -39,7 +39,8 @@ import {
|
||||
} from "../shared/util.js";
|
||||
import {
|
||||
applyOpacity,
|
||||
changeLightness,
|
||||
CSSConstants,
|
||||
findContrastColor,
|
||||
PDFDateString,
|
||||
renderRichText,
|
||||
setLayerDimensions,
|
||||
@ -233,8 +234,10 @@ class AnnotationElement {
|
||||
if (!this.data.color) {
|
||||
return null;
|
||||
}
|
||||
const [r, g, b] = applyOpacity(...this.data.color, this.data.opacity);
|
||||
return changeLightness(r, g, b);
|
||||
return findContrastColor(
|
||||
applyOpacity(...this.data.color, this.data.opacity),
|
||||
CSSConstants.commentForegroundColor
|
||||
);
|
||||
}
|
||||
|
||||
_normalizePoint(point) {
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
import {
|
||||
BaseException,
|
||||
FeatureTest,
|
||||
MathClamp,
|
||||
shadow,
|
||||
Util,
|
||||
warn,
|
||||
@ -781,44 +782,19 @@ class ColorScheme {
|
||||
}
|
||||
}
|
||||
|
||||
function changeLightness(
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
lumCallback = ColorScheme.isDarkMode
|
||||
? l => (1 - Math.sqrt(1 - l)) / 2
|
||||
: l => (1 + Math.sqrt(l)) / 2
|
||||
) {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
const newL = (lumCallback(l) * 100).toFixed(2);
|
||||
|
||||
if (max === min) {
|
||||
// gray
|
||||
return `hsl(0, 0%, ${newL}%)`;
|
||||
class CSSConstants {
|
||||
static get commentForegroundColor() {
|
||||
const element = document.createElement("span");
|
||||
element.classList.add("comment", "sidebar");
|
||||
const { style } = element;
|
||||
style.width = style.height = "0";
|
||||
style.display = "none";
|
||||
style.color = "var(--comment-fg-color)";
|
||||
document.body.append(element);
|
||||
const { color } = window.getComputedStyle(element);
|
||||
element.remove();
|
||||
return shadow(this, "commentForegroundColor", getRGB(color));
|
||||
}
|
||||
|
||||
const d = max - min;
|
||||
|
||||
// hue (branch on max only; avoids mod)
|
||||
let h;
|
||||
if (max === r) {
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
} else if (max === g) {
|
||||
h = (b - r) / d + 2;
|
||||
} else {
|
||||
// max === b
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
h = (h * 60).toFixed(2);
|
||||
const s = ((d / (1 - Math.abs(2 * l - 1))) * 100).toFixed(2);
|
||||
|
||||
return `hsl(${h}, ${s}%, ${newL}%)`;
|
||||
}
|
||||
|
||||
function applyOpacity(r, g, b, opacity) {
|
||||
@ -830,6 +806,153 @@ function applyOpacity(r, g, b, opacity) {
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
function RGBToHSL(rgb, output) {
|
||||
const r = rgb[0] / 255;
|
||||
const g = rgb[1] / 255;
|
||||
const b = rgb[2] / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
// achromatic
|
||||
output[0] = output[1] = 0; // hue and saturation are 0
|
||||
} else {
|
||||
const d = max - min;
|
||||
output[1] = l < 0.5 ? d / (max + min) : d / (2 - max - min);
|
||||
// hue
|
||||
switch (max) {
|
||||
case r:
|
||||
output[0] = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||
break;
|
||||
case g:
|
||||
output[0] = ((b - r) / d + 2) * 60;
|
||||
break;
|
||||
case b:
|
||||
output[0] = ((r - g) / d + 4) * 60;
|
||||
break;
|
||||
}
|
||||
}
|
||||
output[2] = l;
|
||||
}
|
||||
|
||||
function HSLToRGB(hsl, output) {
|
||||
const h = hsl[0];
|
||||
const s = hsl[1];
|
||||
const l = hsl[2];
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s; // chroma
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
|
||||
switch (Math.floor(h / 60)) {
|
||||
case 0:
|
||||
output[0] = c + m;
|
||||
output[1] = x + m;
|
||||
output[2] = m;
|
||||
break;
|
||||
case 1:
|
||||
output[0] = x + m;
|
||||
output[1] = c + m;
|
||||
output[2] = m;
|
||||
break;
|
||||
case 2:
|
||||
output[0] = m;
|
||||
output[1] = c + m;
|
||||
output[2] = x + m;
|
||||
break;
|
||||
case 3:
|
||||
output[0] = m;
|
||||
output[1] = x + m;
|
||||
output[2] = c + m;
|
||||
break;
|
||||
case 4:
|
||||
output[0] = x + m;
|
||||
output[1] = m;
|
||||
output[2] = c + m;
|
||||
break;
|
||||
case 5:
|
||||
case 6:
|
||||
output[0] = c + m;
|
||||
output[1] = m;
|
||||
output[2] = x + m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function computeLuminance(x) {
|
||||
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function contrastRatio(hsl1, hsl2, output) {
|
||||
HSLToRGB(hsl1, output);
|
||||
output.map(computeLuminance);
|
||||
const lum1 = 0.2126 * output[0] + 0.7152 * output[1] + 0.0722 * output[2];
|
||||
HSLToRGB(hsl2, output);
|
||||
output.map(computeLuminance);
|
||||
const lum2 = 0.2126 * output[0] + 0.7152 * output[1] + 0.0722 * output[2];
|
||||
return lum1 > lum2
|
||||
? (lum1 + 0.05) / (lum2 + 0.05)
|
||||
: (lum2 + 0.05) / (lum1 + 0.05);
|
||||
}
|
||||
|
||||
// Cache for the findContrastColor function, to improve performance.
|
||||
const contrastCache = new Map();
|
||||
|
||||
/**
|
||||
* Find a color that has sufficient contrast against a fixed color.
|
||||
* The luminance (in HSL color space) of the base color is adjusted
|
||||
* until the contrast ratio between the base color and the fixed color
|
||||
* is at least the minimum contrast ratio required by WCAG 2.1.
|
||||
* @param {Array<number>} baseColor
|
||||
* @param {Array<number>} fixedColor
|
||||
* @returns {string}
|
||||
*/
|
||||
function findContrastColor(baseColor, fixedColor) {
|
||||
const key =
|
||||
baseColor[0] +
|
||||
baseColor[1] * 0x100 +
|
||||
baseColor[2] * 0x10000 +
|
||||
fixedColor[0] * 0x1000000 +
|
||||
fixedColor[1] * 0x100000000 +
|
||||
fixedColor[2] * 0x10000000000;
|
||||
let cachedValue = contrastCache.get(key);
|
||||
if (cachedValue) {
|
||||
return cachedValue;
|
||||
}
|
||||
const array = new Float32Array(9);
|
||||
const output = array.subarray(0, 3);
|
||||
const baseHSL = array.subarray(3, 6);
|
||||
RGBToHSL(baseColor, baseHSL);
|
||||
const fixedHSL = array.subarray(6, 9);
|
||||
RGBToHSL(fixedColor, fixedHSL);
|
||||
const isFixedColorDark = fixedHSL[2] < 0.5;
|
||||
|
||||
// Use the contrast ratio requirements from WCAG 2.1.
|
||||
// https://www.w3.org/TR/WCAG21/#contrast-minimum
|
||||
// https://www.w3.org/TR/WCAG21/#contrast-enhanced
|
||||
const minContrast = isFixedColorDark ? 7 : 4.5;
|
||||
|
||||
baseHSL[2] = isFixedColorDark
|
||||
? Math.sqrt(baseHSL[2])
|
||||
: 1 - Math.sqrt(1 - baseHSL[2]);
|
||||
const increment = isFixedColorDark ? 0.01 : -0.01;
|
||||
let contrast = contrastRatio(baseHSL, fixedHSL, output);
|
||||
while (baseHSL[2] >= 0 && baseHSL[2] <= 1 && contrast < minContrast) {
|
||||
baseHSL[2] += increment;
|
||||
contrast = contrastRatio(baseHSL, fixedHSL, output);
|
||||
}
|
||||
baseHSL[2] = MathClamp(baseHSL[2], 0, 1);
|
||||
HSLToRGB(baseHSL, output);
|
||||
cachedValue = Util.makeHexColor(
|
||||
Math.round(output[0] * 255),
|
||||
Math.round(output[1] * 255),
|
||||
Math.round(output[2] * 255)
|
||||
);
|
||||
contrastCache.set(key, cachedValue);
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
function renderRichText({ html, dir, className }, container) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
if (typeof html === "string") {
|
||||
@ -857,10 +980,11 @@ function renderRichText({ html, dir, className }, container) {
|
||||
|
||||
export {
|
||||
applyOpacity,
|
||||
changeLightness,
|
||||
ColorScheme,
|
||||
CSSConstants,
|
||||
deprecated,
|
||||
fetchData,
|
||||
findContrastColor,
|
||||
getColorValues,
|
||||
getCurrentTransform,
|
||||
getCurrentTransformInverse,
|
||||
|
||||
@ -24,7 +24,8 @@ import {
|
||||
} from "./tools.js";
|
||||
import {
|
||||
applyOpacity,
|
||||
changeLightness,
|
||||
CSSConstants,
|
||||
findContrastColor,
|
||||
noContextMenu,
|
||||
stopEvent,
|
||||
} from "../display_utils.js";
|
||||
@ -1905,11 +1906,13 @@ class AnnotationEditor {
|
||||
if (!this.color) {
|
||||
return null;
|
||||
}
|
||||
let [r, g, b] = AnnotationEditor._colorManager.convert(
|
||||
const [r, g, b] = AnnotationEditor._colorManager.convert(
|
||||
this._uiManager.getNonHCMColor(this.color)
|
||||
);
|
||||
[r, g, b] = applyOpacity(r, g, b, this.opacity);
|
||||
return changeLightness(r, g, b);
|
||||
return findContrastColor(
|
||||
applyOpacity(r, g, b, this.opacity),
|
||||
CSSConstants.commentForegroundColor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -46,8 +46,9 @@ import {
|
||||
} from "./shared/util.js";
|
||||
import {
|
||||
applyOpacity,
|
||||
changeLightness,
|
||||
CSSConstants,
|
||||
fetchData,
|
||||
findContrastColor,
|
||||
getFilenameFromUrl,
|
||||
getPdfFilenameFromUrl,
|
||||
getRGB,
|
||||
@ -102,13 +103,14 @@ globalThis.pdfjsLib = {
|
||||
AnnotationType,
|
||||
applyOpacity,
|
||||
build,
|
||||
changeLightness,
|
||||
ColorPicker,
|
||||
createValidAbsoluteUrl,
|
||||
CSSConstants,
|
||||
DOMSVGFactory,
|
||||
DrawLayer,
|
||||
FeatureTest,
|
||||
fetchData,
|
||||
findContrastColor,
|
||||
getDocument,
|
||||
getFilenameFromUrl,
|
||||
getPdfFilenameFromUrl,
|
||||
@ -160,13 +162,14 @@ export {
|
||||
AnnotationType,
|
||||
applyOpacity,
|
||||
build,
|
||||
changeLightness,
|
||||
ColorPicker,
|
||||
createValidAbsoluteUrl,
|
||||
CSSConstants,
|
||||
DOMSVGFactory,
|
||||
DrawLayer,
|
||||
FeatureTest,
|
||||
fetchData,
|
||||
findContrastColor,
|
||||
getDocument,
|
||||
getFilenameFromUrl,
|
||||
getPdfFilenameFromUrl,
|
||||
|
||||
@ -15,10 +15,9 @@
|
||||
|
||||
import {
|
||||
applyOpacity,
|
||||
changeLightness,
|
||||
findContrastColor,
|
||||
getFilenameFromUrl,
|
||||
getPdfFilenameFromUrl,
|
||||
getRGB,
|
||||
isValidFetchUrl,
|
||||
PDFDateString,
|
||||
renderRichText,
|
||||
@ -305,24 +304,10 @@ describe("display_utils", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeLightness", function () {
|
||||
describe("findContrastColor", function () {
|
||||
it("Check that the lightness is changed correctly", function () {
|
||||
if (isNodeJS) {
|
||||
pending("DOM is not supported in Node.js.");
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
const { style } = div;
|
||||
style.width = style.height = "0";
|
||||
style.backgroundColor = "hsl(123, 45%, 67%)";
|
||||
document.body.append(div);
|
||||
const [r, g, b] = getRGB(getComputedStyle(div).backgroundColor);
|
||||
div.remove();
|
||||
expect([r, g, b]).toEqual([133, 209, 137]);
|
||||
expect(changeLightness(r, g, b, l => l)).toEqual(
|
||||
"hsl(123.16, 45.24%, 67.06%)"
|
||||
);
|
||||
expect(changeLightness(r, g, b, l => l / 2)).toEqual(
|
||||
"hsl(123.16, 45.24%, 33.53%)"
|
||||
expect(findContrastColor([210, 98, 76], [197, 113, 89])).toEqual(
|
||||
"#240d09"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,8 +37,9 @@ import {
|
||||
} from "../../src/shared/util.js";
|
||||
import {
|
||||
applyOpacity,
|
||||
changeLightness,
|
||||
CSSConstants,
|
||||
fetchData,
|
||||
findContrastColor,
|
||||
getFilenameFromUrl,
|
||||
getPdfFilenameFromUrl,
|
||||
getRGB,
|
||||
@ -86,13 +87,14 @@ const expectedAPI = Object.freeze({
|
||||
AnnotationType,
|
||||
applyOpacity,
|
||||
build,
|
||||
changeLightness,
|
||||
ColorPicker,
|
||||
createValidAbsoluteUrl,
|
||||
CSSConstants,
|
||||
DOMSVGFactory,
|
||||
DrawLayer,
|
||||
FeatureTest,
|
||||
fetchData,
|
||||
findContrastColor,
|
||||
getDocument,
|
||||
getFilenameFromUrl,
|
||||
getPdfFilenameFromUrl,
|
||||
|
||||
@ -358,7 +358,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
#editorCommentsSidebar {
|
||||
.comment.sidebar {
|
||||
--comment-close-button-icon: url(images/comment-closeButton.svg);
|
||||
|
||||
--comment-date-fg-color: light-dark(
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
|
||||
import {
|
||||
AnnotationEditorType,
|
||||
changeLightness,
|
||||
CSSConstants,
|
||||
findContrastColor,
|
||||
getRGB,
|
||||
noContextMenu,
|
||||
PDFDateString,
|
||||
@ -359,8 +360,10 @@ class CommentManager {
|
||||
if (!color) {
|
||||
return null; // No color provided.
|
||||
}
|
||||
const [r, g, b] = getRGB(color);
|
||||
return changeLightness(r, g, b);
|
||||
return findContrastColor(
|
||||
getRGB(color),
|
||||
CSSConstants.commentForegroundColor
|
||||
);
|
||||
}
|
||||
|
||||
#setText(text) {
|
||||
|
||||
@ -24,13 +24,14 @@ const {
|
||||
AnnotationType,
|
||||
applyOpacity,
|
||||
build,
|
||||
changeLightness,
|
||||
ColorPicker,
|
||||
createValidAbsoluteUrl,
|
||||
CSSConstants,
|
||||
DOMSVGFactory,
|
||||
DrawLayer,
|
||||
FeatureTest,
|
||||
fetchData,
|
||||
findContrastColor,
|
||||
getDocument,
|
||||
getFilenameFromUrl,
|
||||
getPdfFilenameFromUrl,
|
||||
@ -82,13 +83,14 @@ export {
|
||||
AnnotationType,
|
||||
applyOpacity,
|
||||
build,
|
||||
changeLightness,
|
||||
ColorPicker,
|
||||
createValidAbsoluteUrl,
|
||||
CSSConstants,
|
||||
DOMSVGFactory,
|
||||
DrawLayer,
|
||||
FeatureTest,
|
||||
fetchData,
|
||||
findContrastColor,
|
||||
getDocument,
|
||||
getFilenameFromUrl,
|
||||
getPdfFilenameFromUrl,
|
||||
|
||||
@ -250,7 +250,7 @@ See https://github.com/adobe-type-tools/cmap-resources
|
||||
<span data-l10n-id="pdfjs-editor-comment-button-label"></span>
|
||||
</button>
|
||||
<div class="editorParamsToolbar sidebar hidden menu" id="editorCommentParamsToolbar">
|
||||
<div id="editorCommentsSidebar" class="menuContainer" role="landmark" aria-labelledby="editorCommentsSidebarHeader">
|
||||
<div id="editorCommentsSidebar" class="menuContainer comment sidebar" role="landmark" aria-labelledby="editorCommentsSidebarHeader">
|
||||
<div id="editorCommentsSidebarHeader" role="heading" aria-level="2">
|
||||
<span class="commentCount">
|
||||
<span id="editorCommentsSidebarTitle" data-l10n-id="pdfjs-editor-comments-sidebar-title" data-l10n-args='{ "count": 0 }'></span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user