Make sure that a good constrast ratio is respected when darkening/lightening a color

This commit is contained in:
Calixte Denizet 2025-09-07 22:00:30 +02:00
parent d946de904c
commit 7f85c00ee6
10 changed files with 201 additions and 76 deletions

View File

@ -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) {

View File

@ -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,

View File

@ -24,7 +24,8 @@ import {
} from "./tools.js";
import {
applyOpacity,
changeLightness,
CSSConstants,
findContrastColor,
noContextMenu,
stopEvent,
} from "../display_utils.js";
@ -1866,11 +1867,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
);
}
/**

View File

@ -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,

View File

@ -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"
);
});
});

View File

@ -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,

View File

@ -358,7 +358,7 @@
}
}
#editorCommentsSidebar {
.comment.sidebar {
--comment-close-button-icon: url(images/comment-closeButton.svg);
--comment-date-fg-color: light-dark(

View File

@ -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) {

View File

@ -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,

View File

@ -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>