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"; } from "../shared/util.js";
import { import {
applyOpacity, applyOpacity,
changeLightness, CSSConstants,
findContrastColor,
PDFDateString, PDFDateString,
renderRichText, renderRichText,
setLayerDimensions, setLayerDimensions,
@ -233,8 +234,10 @@ class AnnotationElement {
if (!this.data.color) { if (!this.data.color) {
return null; return null;
} }
const [r, g, b] = applyOpacity(...this.data.color, this.data.opacity); return findContrastColor(
return changeLightness(r, g, b); applyOpacity(...this.data.color, this.data.opacity),
CSSConstants.commentForegroundColor
);
} }
_normalizePoint(point) { _normalizePoint(point) {

View File

@ -16,6 +16,7 @@
import { import {
BaseException, BaseException,
FeatureTest, FeatureTest,
MathClamp,
shadow, shadow,
Util, Util,
warn, warn,
@ -781,44 +782,19 @@ class ColorScheme {
} }
} }
function changeLightness( class CSSConstants {
r, static get commentForegroundColor() {
g, const element = document.createElement("span");
b, element.classList.add("comment", "sidebar");
lumCallback = ColorScheme.isDarkMode const { style } = element;
? l => (1 - Math.sqrt(1 - l)) / 2 style.width = style.height = "0";
: l => (1 + Math.sqrt(l)) / 2 style.display = "none";
) { style.color = "var(--comment-fg-color)";
r /= 255; document.body.append(element);
g /= 255; const { color } = window.getComputedStyle(element);
b /= 255; element.remove();
return shadow(this, "commentForegroundColor", getRGB(color));
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}%)`;
} }
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) { function applyOpacity(r, g, b, opacity) {
@ -830,6 +806,153 @@ function applyOpacity(r, g, b, opacity) {
return [r, g, b]; 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) { function renderRichText({ html, dir, className }, container) {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
if (typeof html === "string") { if (typeof html === "string") {
@ -857,10 +980,11 @@ function renderRichText({ html, dir, className }, container) {
export { export {
applyOpacity, applyOpacity,
changeLightness,
ColorScheme, ColorScheme,
CSSConstants,
deprecated, deprecated,
fetchData, fetchData,
findContrastColor,
getColorValues, getColorValues,
getCurrentTransform, getCurrentTransform,
getCurrentTransformInverse, getCurrentTransformInverse,

View File

@ -24,7 +24,8 @@ import {
} from "./tools.js"; } from "./tools.js";
import { import {
applyOpacity, applyOpacity,
changeLightness, CSSConstants,
findContrastColor,
noContextMenu, noContextMenu,
stopEvent, stopEvent,
} from "../display_utils.js"; } from "../display_utils.js";
@ -1866,11 +1867,13 @@ class AnnotationEditor {
if (!this.color) { if (!this.color) {
return null; return null;
} }
let [r, g, b] = AnnotationEditor._colorManager.convert( const [r, g, b] = AnnotationEditor._colorManager.convert(
this._uiManager.getNonHCMColor(this.color) this._uiManager.getNonHCMColor(this.color)
); );
[r, g, b] = applyOpacity(r, g, b, this.opacity); return findContrastColor(
return changeLightness(r, g, b); applyOpacity(r, g, b, this.opacity),
CSSConstants.commentForegroundColor
);
} }
/** /**

View File

@ -46,8 +46,9 @@ import {
} from "./shared/util.js"; } from "./shared/util.js";
import { import {
applyOpacity, applyOpacity,
changeLightness, CSSConstants,
fetchData, fetchData,
findContrastColor,
getFilenameFromUrl, getFilenameFromUrl,
getPdfFilenameFromUrl, getPdfFilenameFromUrl,
getRGB, getRGB,
@ -102,13 +103,14 @@ globalThis.pdfjsLib = {
AnnotationType, AnnotationType,
applyOpacity, applyOpacity,
build, build,
changeLightness,
ColorPicker, ColorPicker,
createValidAbsoluteUrl, createValidAbsoluteUrl,
CSSConstants,
DOMSVGFactory, DOMSVGFactory,
DrawLayer, DrawLayer,
FeatureTest, FeatureTest,
fetchData, fetchData,
findContrastColor,
getDocument, getDocument,
getFilenameFromUrl, getFilenameFromUrl,
getPdfFilenameFromUrl, getPdfFilenameFromUrl,
@ -160,13 +162,14 @@ export {
AnnotationType, AnnotationType,
applyOpacity, applyOpacity,
build, build,
changeLightness,
ColorPicker, ColorPicker,
createValidAbsoluteUrl, createValidAbsoluteUrl,
CSSConstants,
DOMSVGFactory, DOMSVGFactory,
DrawLayer, DrawLayer,
FeatureTest, FeatureTest,
fetchData, fetchData,
findContrastColor,
getDocument, getDocument,
getFilenameFromUrl, getFilenameFromUrl,
getPdfFilenameFromUrl, getPdfFilenameFromUrl,

View File

@ -15,10 +15,9 @@
import { import {
applyOpacity, applyOpacity,
changeLightness, findContrastColor,
getFilenameFromUrl, getFilenameFromUrl,
getPdfFilenameFromUrl, getPdfFilenameFromUrl,
getRGB,
isValidFetchUrl, isValidFetchUrl,
PDFDateString, PDFDateString,
renderRichText, renderRichText,
@ -305,24 +304,10 @@ describe("display_utils", function () {
}); });
}); });
describe("changeLightness", function () { describe("findContrastColor", function () {
it("Check that the lightness is changed correctly", function () { it("Check that the lightness is changed correctly", function () {
if (isNodeJS) { expect(findContrastColor([210, 98, 76], [197, 113, 89])).toEqual(
pending("DOM is not supported in Node.js."); "#240d09"
}
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%)"
); );
}); });
}); });

View File

@ -37,8 +37,9 @@ import {
} from "../../src/shared/util.js"; } from "../../src/shared/util.js";
import { import {
applyOpacity, applyOpacity,
changeLightness, CSSConstants,
fetchData, fetchData,
findContrastColor,
getFilenameFromUrl, getFilenameFromUrl,
getPdfFilenameFromUrl, getPdfFilenameFromUrl,
getRGB, getRGB,
@ -86,13 +87,14 @@ const expectedAPI = Object.freeze({
AnnotationType, AnnotationType,
applyOpacity, applyOpacity,
build, build,
changeLightness,
ColorPicker, ColorPicker,
createValidAbsoluteUrl, createValidAbsoluteUrl,
CSSConstants,
DOMSVGFactory, DOMSVGFactory,
DrawLayer, DrawLayer,
FeatureTest, FeatureTest,
fetchData, fetchData,
findContrastColor,
getDocument, getDocument,
getFilenameFromUrl, getFilenameFromUrl,
getPdfFilenameFromUrl, getPdfFilenameFromUrl,

View File

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

View File

@ -15,7 +15,8 @@
import { import {
AnnotationEditorType, AnnotationEditorType,
changeLightness, CSSConstants,
findContrastColor,
getRGB, getRGB,
noContextMenu, noContextMenu,
PDFDateString, PDFDateString,
@ -359,8 +360,10 @@ class CommentManager {
if (!color) { if (!color) {
return null; // No color provided. return null; // No color provided.
} }
const [r, g, b] = getRGB(color); return findContrastColor(
return changeLightness(r, g, b); getRGB(color),
CSSConstants.commentForegroundColor
);
} }
#setText(text) { #setText(text) {

View File

@ -24,13 +24,14 @@ const {
AnnotationType, AnnotationType,
applyOpacity, applyOpacity,
build, build,
changeLightness,
ColorPicker, ColorPicker,
createValidAbsoluteUrl, createValidAbsoluteUrl,
CSSConstants,
DOMSVGFactory, DOMSVGFactory,
DrawLayer, DrawLayer,
FeatureTest, FeatureTest,
fetchData, fetchData,
findContrastColor,
getDocument, getDocument,
getFilenameFromUrl, getFilenameFromUrl,
getPdfFilenameFromUrl, getPdfFilenameFromUrl,
@ -82,13 +83,14 @@ export {
AnnotationType, AnnotationType,
applyOpacity, applyOpacity,
build, build,
changeLightness,
ColorPicker, ColorPicker,
createValidAbsoluteUrl, createValidAbsoluteUrl,
CSSConstants,
DOMSVGFactory, DOMSVGFactory,
DrawLayer, DrawLayer,
FeatureTest, FeatureTest,
fetchData, fetchData,
findContrastColor,
getDocument, getDocument,
getFilenameFromUrl, getFilenameFromUrl,
getPdfFilenameFromUrl, 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> <span data-l10n-id="pdfjs-editor-comment-button-label"></span>
</button> </button>
<div class="editorParamsToolbar sidebar hidden menu" id="editorCommentParamsToolbar"> <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"> <div id="editorCommentsSidebarHeader" role="heading" aria-level="2">
<span class="commentCount"> <span class="commentCount">
<span id="editorCommentsSidebarTitle" data-l10n-id="pdfjs-editor-comments-sidebar-title" data-l10n-args='{ "count": 0 }'></span> <span id="editorCommentsSidebarTitle" data-l10n-id="pdfjs-editor-comments-sidebar-title" data-l10n-args='{ "count": 0 }'></span>