From 7f85c00ee6faac9118e13e630c9a6b754217f90f Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Sun, 7 Sep 2025 22:00:30 +0200 Subject: [PATCH] Make sure that a good constrast ratio is respected when darkening/lightening a color --- src/display/annotation_layer.js | 9 +- src/display/display_utils.js | 200 ++++++++++++++++++++++++++------ src/display/editor/editor.js | 11 +- src/pdf.js | 9 +- test/unit/display_utils_spec.js | 23 +--- test/unit/pdf_spec.js | 6 +- web/comment_manager.css | 2 +- web/comment_manager.js | 9 +- web/pdfjs.js | 6 +- web/viewer.html | 2 +- 10 files changed, 201 insertions(+), 76 deletions(-) diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 0fefe94be..e13e9036f 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -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) { diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 097c83cfa..e3cc7452d 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -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} baseColor + * @param {Array} 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, diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 19d34cf97..acdf172aa 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -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 + ); } /** diff --git a/src/pdf.js b/src/pdf.js index 4c43c23f1..707add654 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -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, diff --git a/test/unit/display_utils_spec.js b/test/unit/display_utils_spec.js index 8f8c3d7e0..914724158 100644 --- a/test/unit/display_utils_spec.js +++ b/test/unit/display_utils_spec.js @@ -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" ); }); }); diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index 0a897bd70..35e92b9ae 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -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, diff --git a/web/comment_manager.css b/web/comment_manager.css index fb19a2b84..0dc4dad7b 100644 --- a/web/comment_manager.css +++ b/web/comment_manager.css @@ -358,7 +358,7 @@ } } -#editorCommentsSidebar { +.comment.sidebar { --comment-close-button-icon: url(images/comment-closeButton.svg); --comment-date-fg-color: light-dark( diff --git a/web/comment_manager.js b/web/comment_manager.js index 5d15247b5..e36ca532f 100644 --- a/web/comment_manager.js +++ b/web/comment_manager.js @@ -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) { diff --git a/web/pdfjs.js b/web/pdfjs.js index 820c2960a..e7a9fa3bc 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -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, diff --git a/web/viewer.html b/web/viewer.html index b931c7cd5..085fb7d42 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -250,7 +250,7 @@ See https://github.com/adobe-type-tools/cmap-resources