From 820699fa9df80db65134f9946f552c8b139b1277 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Thu, 13 Nov 2025 13:28:41 +0100 Subject: [PATCH 01/13] Remove mix-blend-mode from fallback highlight --- navigator-html-injectables/src/modules/Decorator.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index f804aef9..570191a0 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -4,9 +4,7 @@ import { Module } from "./Module"; import { rangeFromLocator } from "../helpers/locator"; import { ModuleName } from "./ModuleLibrary"; import { Rect, getClientRectsNoOverlap } from "../helpers/rect"; -import { getProperty } from "../helpers/css"; import { ReadiumWindow } from "../helpers/dom"; -import { isDarkColor } from "../helpers/color"; export enum Width { Wrap = "wrap", // Smallest width fitting the CSS border box. @@ -249,18 +247,16 @@ class DecorationGroup { let template = this.wnd.document.createElement("template"); // template.innerHTML = item.decoration.element.trim(); - // TODO more styles logic - - const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || - isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")); + // Previously we tried to use CSS mix-blend-mode to guarantee contrast, but it was inconsistent + // with the native highlight, and was not good enough given the background-color can be completely + // arbitrary, and no longer just Readium CSS’ night mode, which was removed in V2 anyway. + // In the future, a set of color helpers will be added to help with this. template.innerHTML = `
Date: Thu, 13 Nov 2025 13:36:26 +0100 Subject: [PATCH 02/13] Rename class of templated div This is more consistent with the ids and classNames we use across the ts-toolkit packages. --- navigator-html-injectables/src/modules/Decorator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 570191a0..50503c05 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -254,7 +254,7 @@ class DecorationGroup { template.innerHTML = `
Date: Thu, 13 Nov 2025 13:44:40 +0100 Subject: [PATCH 03/13] Add data-readium to templated highlight Consistency with existing Readium-injected features. --- navigator-html-injectables/src/modules/Decorator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 50503c05..4151925d 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -254,6 +254,7 @@ class DecorationGroup { template.innerHTML = `
Date: Fri, 14 Nov 2025 10:28:47 +0100 Subject: [PATCH 04/13] Revert "Remove mix-blend-mode from fallback highlight" This reverts commit 820699fa9df80db65134f9946f552c8b139b1277. --- navigator-html-injectables/src/modules/Decorator.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 4151925d..49f6f5cb 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -4,7 +4,9 @@ import { Module } from "./Module"; import { rangeFromLocator } from "../helpers/locator"; import { ModuleName } from "./ModuleLibrary"; import { Rect, getClientRectsNoOverlap } from "../helpers/rect"; +import { getProperty } from "../helpers/css"; import { ReadiumWindow } from "../helpers/dom"; +import { isDarkColor } from "../helpers/color"; export enum Width { Wrap = "wrap", // Smallest width fitting the CSS border box. @@ -247,10 +249,10 @@ class DecorationGroup { let template = this.wnd.document.createElement("template"); // template.innerHTML = item.decoration.element.trim(); - // Previously we tried to use CSS mix-blend-mode to guarantee contrast, but it was inconsistent - // with the native highlight, and was not good enough given the background-color can be completely - // arbitrary, and no longer just Readium CSS’ night mode, which was removed in V2 anyway. - // In the future, a set of color helpers will be added to help with this. + // TODO more styles logic + + const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || + isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")); template.innerHTML = `
Date: Fri, 14 Nov 2025 11:56:10 +0100 Subject: [PATCH 05/13] Update color helpers Adjust sRGB, remove helpers that were not tested or do not handle features properly, check dark/light using actual contrast against black and white --- .../src/helpers/color.ts | 131 ++++++------------ 1 file changed, 44 insertions(+), 87 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 37af7186..4f139c5a 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,114 +1,71 @@ export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { if (color.startsWith("rgb")) { - const rgb = color.match(/rgb\((\d+),\s(\d+),\s(\d+)(?:,\s(\d+))?\)/); + const rgb = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i); if (rgb) { return { - r: parseInt(rgb[1], 10), - g: parseInt(rgb[2], 10), - b: parseInt(rgb[3], 10), - a: rgb[4] ? parseInt(rgb[4], 10) / 255 : 1, + r: parseInt(rgb[1], 10), // 0-255 + g: parseInt(rgb[2], 10), // 0-255 + b: parseInt(rgb[3], 10), // 0-255 + a: rgb[4] ? parseFloat(rgb[4]) : 1, // 0-1 }; } } else if (color.startsWith("#")) { const hex = color.slice(1); if (hex.length === 3 || hex.length === 4) { return { - r: parseInt(hex[0] + hex[0], 16) / 255, - g: parseInt(hex[1] + hex[1], 16) / 255, - b: parseInt(hex[2] + hex[2], 16) / 255, - a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, + r: parseInt(hex[0] + hex[0], 16), // 0-255 + g: parseInt(hex[1] + hex[1], 16), // 0-255 + b: parseInt(hex[2] + hex[2], 16), // 0-255 + a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, // 0-1 }; } else if (hex.length === 6 || hex.length === 8) { return { - r: parseInt(hex[0] + hex[1], 16) / 255, - g: parseInt(hex[2] + hex[3], 16) / 255, - b: parseInt(hex[4] + hex[5], 16) / 255, - a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, + r: parseInt(hex[0] + hex[1], 16), // 0-255 + g: parseInt(hex[2] + hex[3], 16), // 0-255 + b: parseInt(hex[4] + hex[5], 16), // 0-255 + a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, // 0-1 }; } } - return { r: 0, g: 0, b: 0, a: 1 }; + return { r: 255, g: 255, b: 255, a: 1 }; // Default to white (255, 255, 255, 1) }; -export const getLuminance = (color: { r: number; g: number; b: number; a: number }): number => { - return 0.2126 * color.r * color.a + 0.7152 * color.g * color.a + 0.0722 * color.b * color.a; -} - -export const isDarkColor = (color: string): boolean => { - const rgba = colorToRgba(color); - const luminance = getLuminance(rgba); - return luminance < 128; +const toLinear = (c: number): number => { + const normalized = c / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : Math.pow((normalized + 0.055) / 1.055, 2.4); }; -export const isLightColor = (color: string): boolean => !isDarkColor(color); +export const getLuminance = (color: { r: number; g: number; b: number; a?: number }): number => { + // Convert sRGB to linear RGB and apply WCAG 2.0 formula + const r = toLinear(color.r); + const g = toLinear(color.g); + const b = toLinear(color.b); -export const checkContrast = (color1: string, color2: string): number => { - const rgba1 = colorToRgba(color1); - const rgba2 = colorToRgba(color2); - const lum1 = getLuminance(rgba1); - const lum2 = getLuminance(rgba2); - const brightest = Math.max(lum1, lum2); - const darkest = Math.min(lum1, lum2); - return (brightest + 0.05) / (darkest + 0.05); -}; + // WCAG 2.0 relative luminance formula (returns 0-1) + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; -export const ensureContrast = (color1: string, color2: string, contrast: number = 4.5): string[] => { - const c1 = colorToRgba(color1); - const c2 = colorToRgba(color2); - - const lum1 = getLuminance(c1); - const lum2 = getLuminance(c2); - const [darkest, brightest] = lum1 < lum2 ? [lum1, lum2] : [lum2, lum1]; - - const contrastRatio = (brightest + 0.05) / (darkest + 0.05); - if (contrastRatio >= contrast) { - return [ - `rgba(${c1.r}, ${c1.g}, ${c1.b}, ${c1.a})`, - `rgba(${c2.r}, ${c2.g}, ${c2.b}, ${c2.a})` - ]; - } + // Apply alpha if provided (0-1 range) + return color.a !== undefined ? luminance * color.a : luminance; +}; - const adjustColor = (color: { r: number; g: number; b: number; a: number }, delta: number) => ({ - r: Math.max(0, Math.min(255, color.r + delta)), - g: Math.max(0, Math.min(255, color.g + delta)), - b: Math.max(0, Math.min(255, color.b + delta)), - a: color.a - }); - - const delta = ((contrast - contrastRatio) * 255) / (contrastRatio + 0.05); - let correctedColor: { r: number; g: number; b: number; a: number }; - let otherColor: { r: number; g: number; b: number; a: number }; - if (lum1 < lum2) { - correctedColor = c1; - otherColor = c2; - } else { - correctedColor = c2; - otherColor = c1; - } +export const checkContrast = (color1: string, color2: string): number => { + const luminance1 = getLuminance(colorToRgba(color1)); + const luminance2 = getLuminance(colorToRgba(color2)); - const correctedColorAdjusted = adjustColor(correctedColor, -delta); - const newLum = getLuminance(correctedColorAdjusted); - const newContrastRatio = (brightest + 0.05) / (newLum + 0.05); + // Ensure luminance1 is the lighter color + const l1 = Math.max(luminance1, luminance2); + const l2 = Math.min(luminance1, luminance2); - if (newContrastRatio < contrast) { - const updatedDelta = ((contrast - newContrastRatio) * 255) / (newContrastRatio + 0.05); - const otherColorAdjusted = adjustColor(otherColor, updatedDelta); - return [ - lum1 < lum2 - ? `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})` - : `rgba(${otherColorAdjusted.r}, ${otherColorAdjusted.g}, ${otherColorAdjusted.b}, ${otherColorAdjusted.a})`, - lum1 < lum2 - ? `rgba(${otherColorAdjusted.r}, ${otherColorAdjusted.g}, ${otherColorAdjusted.b}, ${otherColorAdjusted.a})` - : `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`, - ]; - } + // WCAG 2.0 contrast ratio formula + return (l1 + 0.05) / (l2 + 0.05); +}; - return [ - lum1 < lum2 - ? `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})` - : `rgba(${otherColor.r}, ${otherColor.g}, ${otherColor.b}, ${otherColor.a})`, - lum1 < lum2 - ? `rgba(${otherColor.r}, ${otherColor.g}, ${otherColor.b}, ${otherColor.a})` - : `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`, - ]; +export const isDarkColor = (color: string): boolean => { + const contrastWithWhite = checkContrast(color, "#FFFFFF"); + const contrastWithBlack = checkContrast(color, "#000000"); + return contrastWithWhite > contrastWithBlack; }; + +export const isLightColor = (color: string): boolean => !isDarkColor(color); \ No newline at end of file From 2241662af3125c4bb54c30d47d2adea5ebbaa98c Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 14 Nov 2025 12:04:32 +0100 Subject: [PATCH 06/13] Add check for computed style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In case we do not find a backgroundColor or we cannot convert it to rgba (e.g. color names, unsupported format…) --- navigator-html-injectables/src/modules/Decorator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 49f6f5cb..e09c888e 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -252,7 +252,8 @@ class DecorationGroup { // TODO more styles logic const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || - isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")); + isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")) || + isDarkColor(this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color")); template.innerHTML = `
Date: Sun, 16 Nov 2025 17:30:26 +0100 Subject: [PATCH 07/13] Observe background change for fallback highlights This makes things at least a little more consistent with the Highlight API behaviour, although you get some blending. And images are not handled that well. --- .../src/modules/Decorator.ts | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index e09c888e..88e814c6 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -251,9 +251,7 @@ class DecorationGroup { // template.innerHTML = item.decoration.element.trim(); // TODO more styles logic - const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || - isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")) || - isDarkColor(this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color")); + const isDarkMode = this.getCurrentDarkMode(); template.innerHTML = `
{ + (highlight as HTMLElement).style.setProperty("mix-blend-mode", isDarkMode ? "exclusion" : "multiply", "important"); + }); + } + + private extractCustomProperty(style: string | null, propertyName: string): string | null { + if (!style) return null; + + const match = style.match(new RegExp(`${propertyName}:\\s*([^;]+)`)); + return match ? match[1].trim() : null; + } + private handleResize() { this.wnd.clearTimeout(this.resizeFrame); this.resizeFrame = this.wnd.setTimeout(() => { @@ -444,6 +465,38 @@ export class Decorator extends Module { wnd.addEventListener("orientationchange", this.handleResizer); wnd.addEventListener("resize", this.handleResizer); + // Set up MutationObserver to watch for CSS custom property changes + this.backgroundObserver = new MutationObserver((mutations) => { + const shouldUpdate = mutations.some(mutation => { + if (mutation.type === "attributes" && mutation.attributeName === "style") { + const element = mutation.target as Element; + const oldStyle = mutation.oldValue; + const newStyle = element.getAttribute("style"); + + // Check if the relevant CSS custom properties actually changed + const oldAppearance = this.extractCustomProperty(oldStyle, "--USER__appearance"); + const newAppearance = this.extractCustomProperty(newStyle, "--USER__appearance"); + const oldBgColor = this.extractCustomProperty(oldStyle, "--USER__backgroundColor"); + const newBgColor = this.extractCustomProperty(newStyle, "--USER__backgroundColor"); + + return oldAppearance !== newAppearance || + oldBgColor !== newBgColor; + } + return false; + }); + + if (shouldUpdate) { + this.updateAllBlendModes(); + } + }); + + this.backgroundObserver.observe(wnd.document.documentElement, { + attributes: true, + attributeFilter: ["style"], + attributeOldValue: true, + subtree: true + }); + comms.log("Decorator Mounted"); return true; } @@ -454,6 +507,7 @@ export class Decorator extends Module { comms.unregisterAll(Decorator.moduleName); this.resizeObserver.disconnect(); + this.backgroundObserver.disconnect(); this.cleanup(); comms.log("Decorator Unmounted"); From 267aff543f0c0d36b9bebb2084df8d27441842a6 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Sun, 16 Nov 2025 21:40:18 +0100 Subject: [PATCH 08/13] Enforce contrast color for highlight And cover non HEX and RGB colors in conversion helper --- navigator-html-injectables/src/helpers/color.ts | 17 ++++++++++++++++- .../src/modules/Decorator.ts | 10 ++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 4f139c5a..7c602056 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,4 +1,15 @@ export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { + // Handle colors by using Canvas API's color conversion + if (!color.startsWith("#") && !color.startsWith("rgb")) { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = color; + const computedColor = ctx.fillStyle; + color = computedColor; + } + } + if (color.startsWith("rgb")) { const rgb = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i); if (rgb) { @@ -68,4 +79,8 @@ export const isDarkColor = (color: string): boolean => { return contrastWithWhite > contrastWithBlack; }; -export const isLightColor = (color: string): boolean => !isDarkColor(color); \ No newline at end of file +export const isLightColor = (color: string): boolean => !isDarkColor(color); + +export const getContrastingTextColor = (backgroundColor: string): "black" | "white" => { + return isDarkColor(backgroundColor) ? "white" : "black"; +}; \ No newline at end of file diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 88e814c6..bac75332 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -6,7 +6,9 @@ import { ModuleName } from "./ModuleLibrary"; import { Rect, getClientRectsNoOverlap } from "../helpers/rect"; import { getProperty } from "../helpers/css"; import { ReadiumWindow } from "../helpers/dom"; -import { isDarkColor } from "../helpers/color"; +import { isDarkColor, getContrastingTextColor } from "../helpers/color"; + +const DEFAULT_HIGHLIGHT_COLOR = "#FFFF00"; // Yellow in HEX export enum Width { Wrap = "wrap", // Smallest width fitting the CSS border box. @@ -183,8 +185,8 @@ class DecorationGroup { // TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time stylesheet.innerHTML = ` ::highlight(${this.id}) { - color: black; - background-color: ${item.decoration?.style?.tint ?? "yellow"}; + color: ${getContrastingTextColor(item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR)}; + background-color: ${item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR}; }`; } @@ -258,7 +260,7 @@ class DecorationGroup { data-readium="true" class="readium-highlight" style="${[ - `background-color: ${item.decoration?.style?.tint ?? "yellow"} !important`, + `background-color: ${item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR} !important`, //"opacity: 0.3 !important", `mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`, "opacity: 1 !important", From 23683cdb90ca852f9eacac43e399e9e6dfa44838 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 17 Nov 2025 10:50:41 +0100 Subject: [PATCH 09/13] Update color conversion Lazy instantiate canvas, keep a cache of parsed colors, warn about non-parseable ones. --- .../src/helpers/color.ts | 117 ++++++++++++++---- 1 file changed, 90 insertions(+), 27 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 7c602056..c753b9cf 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,44 +1,106 @@ +// Lazy canvas initialization +let canvas: HTMLCanvasElement | null = null; +let ctx: CanvasRenderingContext2D | null = null; + +// Default color for failed conversions +const DEFAULT_COLOR = { r: 255, g: 255, b: 255, a: 1 }; + +// Cache for computed color conversions +const colorCache = new Map(); + +const getCanvasContext = () => { + if (!canvas) { + canvas = document.createElement("canvas"); + ctx = canvas.getContext("2d"); + } + return ctx; +}; + export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { - // Handle colors by using Canvas API's color conversion - if (!color.startsWith("#") && !color.startsWith("rgb")) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.fillStyle = color; - const computedColor = ctx.fillStyle; - color = computedColor; + // Check cache first + const cached = colorCache.get(color); + if (cached !== undefined) { + if (cached === null) { + return DEFAULT_COLOR; // Return default white for previously failed colors } + return cached; } - if (color.startsWith("rgb")) { - const rgb = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i); - if (rgb) { - return { - r: parseInt(rgb[1], 10), // 0-255 - g: parseInt(rgb[2], 10), // 0-255 - b: parseInt(rgb[3], 10), // 0-255 - a: rgb[4] ? parseFloat(rgb[4]) : 1, // 0-1 + // Use Canvas API to convert any CSS color to “standardized” format + const context = getCanvasContext(); + let computedColor = color; + + if (context) { + context.fillStyle = color; + computedColor = context.fillStyle; + } + + // Parse the computed color value from canvas + if (computedColor.startsWith("rgb")) { + // Regex that handles both comma and space separators, slash for alpha + const rgba = computedColor.match(/rgba?\(([\d.]+%?)[,\s]+([\d.]+%?)[,\s]+([\d.]+%?)(?:[\/,]\s*([\d.]+%?))?\)/); + + if (rgba) { + const parseValue = (val: string): number => { + if (val.endsWith("%")) { + return Math.round(parseFloat(val) * 2.55); // Convert percentage to 0-255 + } + return parseFloat(val); }; + + const parseAlpha = (val: string): number => { + if (val.endsWith("%")) { + return parseFloat(val) / 100; // Convert percentage to 0-1 + } + return parseFloat(val); + }; + + const result = { + r: parseValue(rgba[1]), // 0-255 + g: parseValue(rgba[2]), // 0-255 + b: parseValue(rgba[3]), // 0-255 + a: rgba[4] ? parseAlpha(rgba[4]) : 1, // 0-1 + }; + + // Cache the result for future use + colorCache.set(color, result); + return result; } - } else if (color.startsWith("#")) { - const hex = color.slice(1); + } else if (computedColor.startsWith("#")) { + const hex = computedColor.slice(1); + let result; + if (hex.length === 3 || hex.length === 4) { - return { + result = { r: parseInt(hex[0] + hex[0], 16), // 0-255 g: parseInt(hex[1] + hex[1], 16), // 0-255 b: parseInt(hex[2] + hex[2], 16), // 0-255 a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, // 0-1 }; } else if (hex.length === 6 || hex.length === 8) { - return { + result = { r: parseInt(hex[0] + hex[1], 16), // 0-255 g: parseInt(hex[2] + hex[3], 16), // 0-255 b: parseInt(hex[4] + hex[5], 16), // 0-255 a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, // 0-1 }; + } else { + // Invalid hex length, cache null and return default + colorCache.set(color, null); + return DEFAULT_COLOR; } + + // Cache the result for future use + colorCache.set(color, result); + return result; } - return { r: 255, g: 255, b: 255, a: 1 }; // Default to white (255, 255, 255, 1) + + // If we couldn't parse the color, warn and return default + console.warn(`Could not parse color format: ${color}. Falling back to ${DEFAULT_COLOR} to check contrast. Please make sure your color value can be computed to HEX or RGB(A) format.`); + + // Cache null to avoid repeated warnings + entire conversion process + colorCache.set(color, null); + return DEFAULT_COLOR; }; const toLinear = (c: number): number => { @@ -49,16 +111,17 @@ const toLinear = (c: number): number => { }; export const getLuminance = (color: { r: number; g: number; b: number; a?: number }): number => { - // Convert sRGB to linear RGB and apply WCAG 2.0 formula + // Convert sRGB to linear RGB and apply WCAG 2.2 formula const r = toLinear(color.r); const g = toLinear(color.g); const b = toLinear(color.b); - // WCAG 2.0 relative luminance formula (returns 0-1) + // WCAG 2.2 relative luminance formula (returns 0-1) + // Note: Alpha is ignored for contrast calculations. WCAG 2.2 only defines contrast for opaque colors, + // and semi-transparent colors have a range of possible contrast ratios depending on background. + // For text readability decisions, we use the base color as the most conservative approach. const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; - - // Apply alpha if provided (0-1 range) - return color.a !== undefined ? luminance * color.a : luminance; + return luminance; }; export const checkContrast = (color1: string, color2: string): number => { @@ -69,7 +132,7 @@ export const checkContrast = (color1: string, color2: string): number => { const l1 = Math.max(luminance1, luminance2); const l2 = Math.min(luminance1, luminance2); - // WCAG 2.0 contrast ratio formula + // WCAG 2.2 contrast ratio formula return (l1 + 0.05) / (l2 + 0.05); }; From de7176dfd22e5dcaea8d1eee6b7e3bbd49f0d035 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 17 Nov 2025 11:31:27 +0100 Subject: [PATCH 10/13] Clarify warning for Decorator contrast check --- navigator-html-injectables/src/helpers/color.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index c753b9cf..dbaa1d6a 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -96,7 +96,8 @@ export const colorToRgba = (color: string): { r: number; g: number; b: number; a } // If we couldn't parse the color, warn and return default - console.warn(`Could not parse color format: ${color}. Falling back to ${DEFAULT_COLOR} to check contrast. Please make sure your color value can be computed to HEX or RGB(A) format.`); + // Decorator-specific ATM + console.warn(`Decorator: could not parse color format: ${color}. Falling back to ${DEFAULT_COLOR} to check contrast. Please make sure your color value can be computed to HEX or RGB(A) format.`); // Cache null to avoid repeated warnings + entire conversion process colorCache.set(color, null); From 28e63e0f5ca373151f129b88d3aa5ffba8207f2c Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 25 Nov 2025 12:59:37 +0100 Subject: [PATCH 11/13] Replace rgba conversion with canvas data Since we are already using a canvas anyway, get rgba from imageData, which also helps support a lot more color notations, although we need to filter some values --- .../src/helpers/color.ts | 154 ++++++++++-------- 1 file changed, 82 insertions(+), 72 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index dbaa1d6a..fee27be8 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,4 +1,4 @@ -// Lazy canvas initialization +// Lazy canvas initialization for color conversion let canvas: HTMLCanvasElement | null = null; let ctx: CanvasRenderingContext2D | null = null; @@ -8,100 +8,110 @@ const DEFAULT_COLOR = { r: 255, g: 255, b: 255, a: 1 }; // Cache for computed color conversions const colorCache = new Map(); -const getCanvasContext = () => { +const getCanvasContext = (): CanvasRenderingContext2D | null => { if (!canvas) { canvas = document.createElement("canvas"); - ctx = canvas.getContext("2d"); + canvas.width = 1; + canvas.height = 1; + ctx = canvas.getContext("2d", { willReadFrequently: true }); } return ctx; }; -export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { +const isSpecialColorValue = (color: string): boolean => { + if (!color) return true; + + const normalizedColor = color.trim().toLowerCase(); + + // Check for CSS variables + if (normalizedColor.startsWith("var(")) { + return true; + } + + // Check for CSS color keywords + const cssKeywords = [ + "transparent", + "currentcolor", + "inherit", + "initial", + "revert", + "unset", + "revert-layer" + ]; + + if (cssKeywords.includes(normalizedColor)) { + return true; + } + + // Check for gradients + const gradientTypes = [ + "linear-gradient", + "radial-gradient", + "conic-gradient", + "repeating-linear-gradient", + "repeating-radial-gradient", + "repeating-conic-gradient" + ]; + + return gradientTypes.some(grad => normalizedColor.includes(grad)); +}; + +const warnAboutInvalidColor = (color: string, reason: string): void => { + console.warn( + `[Decorator] Could not parse color: "${color}". ${reason} Falling back to ${JSON.stringify(DEFAULT_COLOR)} to compute contrast. Please use a CSS color value that can be computed to RGB(A).` + ); +}; + +export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number } => { // Check cache first const cached = colorCache.get(color); if (cached !== undefined) { - if (cached === null) { - return DEFAULT_COLOR; // Return default white for previously failed colors - } - return cached; + return cached ?? DEFAULT_COLOR; + } + + // Check for special color values + if (isSpecialColorValue(color)) { + warnAboutInvalidColor(color, "Unsupported color format or special value."); + colorCache.set(color, null); + return DEFAULT_COLOR; } - // Use Canvas API to convert any CSS color to “standardized” format const context = getCanvasContext(); - let computedColor = color; - - if (context) { - context.fillStyle = color; - computedColor = context.fillStyle; + if (!context) { + warnAboutInvalidColor(color, "Could not get canvas context."); + colorCache.set(color, null); + return DEFAULT_COLOR; } - // Parse the computed color value from canvas - if (computedColor.startsWith("rgb")) { - // Regex that handles both comma and space separators, slash for alpha - const rgba = computedColor.match(/rgba?\(([\d.]+%?)[,\s]+([\d.]+%?)[,\s]+([\d.]+%?)(?:[\/,]\s*([\d.]+%?))?\)/); - - if (rgba) { - const parseValue = (val: string): number => { - if (val.endsWith("%")) { - return Math.round(parseFloat(val) * 2.55); // Convert percentage to 0-255 - } - return parseFloat(val); - }; - - const parseAlpha = (val: string): number => { - if (val.endsWith("%")) { - return parseFloat(val) / 100; // Convert percentage to 0-1 - } - return parseFloat(val); - }; - - const result = { - r: parseValue(rgba[1]), // 0-255 - g: parseValue(rgba[2]), // 0-255 - b: parseValue(rgba[3]), // 0-255 - a: rgba[4] ? parseAlpha(rgba[4]) : 1, // 0-1 - }; - - // Cache the result for future use - colorCache.set(color, result); - return result; - } - } else if (computedColor.startsWith("#")) { - const hex = computedColor.slice(1); - let result; + // Clear the canvas + context.clearRect(0, 0, 1, 1); + + try { + // Set the color and draw a 1x1 pixel + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + + // Get the pixel data + const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; - if (hex.length === 3 || hex.length === 4) { - result = { - r: parseInt(hex[0] + hex[0], 16), // 0-255 - g: parseInt(hex[1] + hex[1], 16), // 0-255 - b: parseInt(hex[2] + hex[2], 16), // 0-255 - a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, // 0-1 - }; - } else if (hex.length === 6 || hex.length === 8) { - result = { - r: parseInt(hex[0] + hex[1], 16), // 0-255 - g: parseInt(hex[2] + hex[3], 16), // 0-255 - b: parseInt(hex[4] + hex[5], 16), // 0-255 - a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, // 0-1 - }; - } else { - // Invalid hex length, cache null and return default + // If the color is completely transparent, return default + if (a === 0) { + warnAboutInvalidColor(color, "Fully transparent color."); colorCache.set(color, null); return DEFAULT_COLOR; } + // Convert from 0-255 to 0-1 for alpha + const result = { r, g, b, a: a / 255 }; + // Cache the result for future use colorCache.set(color, result); return result; + } catch (error) { + warnAboutInvalidColor(color, `Error: ${error instanceof Error ? error.message : String(error)}`); + colorCache.set(color, null); + return DEFAULT_COLOR; } - - // If we couldn't parse the color, warn and return default - // Decorator-specific ATM - console.warn(`Decorator: could not parse color format: ${color}. Falling back to ${DEFAULT_COLOR} to check contrast. Please make sure your color value can be computed to HEX or RGB(A) format.`); - - // Cache null to avoid repeated warnings + entire conversion process - colorCache.set(color, null); - return DEFAULT_COLOR; }; const toLinear = (c: number): number => { From c686add19173db276146f51a04668ae580d74131 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 25 Nov 2025 18:50:52 +0100 Subject: [PATCH 12/13] Support for alpha in helpers Blending of tint with background in canvas to get the correct contrast ratio + Update contrasting text in Highlight API --- .../src/helpers/color.ts | 71 +++++++++++-------- .../src/modules/Decorator.ts | 19 ++--- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index fee27be8..79042e84 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -62,9 +62,13 @@ const warnAboutInvalidColor = (color: string, reason: string): void => { ); }; -export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number } => { - // Check cache first - const cached = colorCache.get(color); +export const colorToRgba = ( + color: string, + backgroundColor: string | null = null +): { r: number; g: number; b: number; a: number } => { + // Check cache with background key if provided + const cacheKey = backgroundColor ? `${color}|${backgroundColor}` : color; + const cached = colorCache.get(cacheKey); if (cached !== undefined) { return cached ?? DEFAULT_COLOR; } @@ -72,14 +76,14 @@ export const colorToRgba = (color: string): { r: number; g: number; b: number; a // Check for special color values if (isSpecialColorValue(color)) { warnAboutInvalidColor(color, "Unsupported color format or special value."); - colorCache.set(color, null); + colorCache.set(cacheKey, null); return DEFAULT_COLOR; } const context = getCanvasContext(); if (!context) { warnAboutInvalidColor(color, "Could not get canvas context."); - colorCache.set(color, null); + colorCache.set(cacheKey, null); return DEFAULT_COLOR; } @@ -87,29 +91,32 @@ export const colorToRgba = (color: string): { r: number; g: number; b: number; a context.clearRect(0, 0, 1, 1); try { - // Set the color and draw a 1x1 pixel + // Draw background if provided + if (backgroundColor) { + context.fillStyle = backgroundColor; + context.fillRect(0, 0, 1, 1); + } + + // Draw the color context.fillStyle = color; context.fillRect(0, 0, 1, 1); - // Get the pixel data + // Get the resulting pixel data const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; // If the color is completely transparent, return default if (a === 0) { warnAboutInvalidColor(color, "Fully transparent color."); - colorCache.set(color, null); + colorCache.set(cacheKey, null); return DEFAULT_COLOR; } - // Convert from 0-255 to 0-1 for alpha const result = { r, g, b, a: a / 255 }; - - // Cache the result for future use - colorCache.set(color, result); + colorCache.set(cacheKey, result); return result; } catch (error) { warnAboutInvalidColor(color, `Error: ${error instanceof Error ? error.message : String(error)}`); - colorCache.set(color, null); + colorCache.set(cacheKey, null); return DEFAULT_COLOR; } }; @@ -135,26 +142,32 @@ export const getLuminance = (color: { r: number; g: number; b: number; a?: numbe return luminance; }; -export const checkContrast = (color1: string, color2: string): number => { - const luminance1 = getLuminance(colorToRgba(color1)); - const luminance2 = getLuminance(colorToRgba(color2)); - - // Ensure luminance1 is the lighter color - const l1 = Math.max(luminance1, luminance2); - const l2 = Math.min(luminance1, luminance2); - - // WCAG 2.2 contrast ratio formula - return (l1 + 0.05) / (l2 + 0.05); +export const checkContrast = ( + color1: string | { r: number; g: number; b: number; a?: number }, + color2: string | { r: number; g: number; b: number; a?: number } +): number => { + const rgba1 = typeof color1 === "string" ? colorToRgba(color1) : color1; + const rgba2 = typeof color2 === "string" ? colorToRgba(color2) : color2; + + const l1 = getLuminance(rgba1); + const l2 = getLuminance(rgba2); + + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); }; -export const isDarkColor = (color: string): boolean => { - const contrastWithWhite = checkContrast(color, "#FFFFFF"); - const contrastWithBlack = checkContrast(color, "#000000"); +export const isDarkColor = (color: string, blendedWith: string | null = null): boolean => { + const blended = colorToRgba(color, blendedWith); + const contrastWithWhite = checkContrast(blended, { r: 255, g: 255, b: 255, a: 1 }); + const contrastWithBlack = checkContrast(blended, { r: 0, g: 0, b: 0, a: 1 }); return contrastWithWhite > contrastWithBlack; }; -export const isLightColor = (color: string): boolean => !isDarkColor(color); +export const isLightColor = (color: string, blendedWith: string | null = null): boolean => { + return !isDarkColor(color, blendedWith); +}; -export const getContrastingTextColor = (backgroundColor: string): "black" | "white" => { - return isDarkColor(backgroundColor) ? "white" : "black"; +export const getContrastingTextColor = (color: string, blendedWith: string | null = null): "black" | "white" => { + return isDarkColor(color, blendedWith) ? "white" : "black"; }; \ No newline at end of file diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index bac75332..0697a61d 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -182,11 +182,15 @@ class DecorationGroup { const [stylesheet, highlighter]: [HTMLStyleElement, any] = this.requireContainer(true) as [HTMLStyleElement, unknown]; highlighter.add(item.range); + const backgroundColor = getProperty(this.wnd, "--USER__backgroundColor") || + this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color"); + const tint = item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR; + // TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time stylesheet.innerHTML = ` ::highlight(${this.id}) { - color: ${getContrastingTextColor(item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR)}; - background-color: ${item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR}; + color: ${getContrastingTextColor(tint, backgroundColor)}; + background-color: ${tint}; }`; } @@ -401,12 +405,9 @@ export class Decorator extends Module { this.groups.clear(); } - private updateAllBlendModes() { - const highlights = this.wnd.document.querySelectorAll(".readium-highlight"); - const isDarkMode = this.groups.values().next().value?.getCurrentDarkMode() ?? false; - - highlights.forEach(highlight => { - (highlight as HTMLElement).style.setProperty("mix-blend-mode", isDarkMode ? "exclusion" : "multiply", "important"); + private updateHighlightStyles() { + this.groups.forEach(group => { + group.requestLayout(); }); } @@ -488,7 +489,7 @@ export class Decorator extends Module { }); if (shouldUpdate) { - this.updateAllBlendModes(); + this.updateHighlightStyles(); } }); From 1eac363ce760c8e0c400d3ee6b0afa3551b4e555 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Wed, 26 Nov 2025 09:43:43 +0100 Subject: [PATCH 13/13] Improvements to canvas in color helpers Per @chocolatkey notes --- .../src/helpers/color.ts | 66 ++++++++++++++----- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 79042e84..d7a01363 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,6 +1,9 @@ -// Lazy canvas initialization for color conversion -let canvas: HTMLCanvasElement | null = null; -let ctx: CanvasRenderingContext2D | null = null; +// Lazy canvas/offscreen canvas initialization for color conversion +let canvas: HTMLCanvasElement | OffscreenCanvas | null = null; +let ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null; + +// Track which pixel to use next for color sampling (0-24 for 5x5 grid) +let currentPixelIndex = 0; // Default color for failed conversions const DEFAULT_COLOR = { r: 255, g: 255, b: 255, a: 1 }; @@ -8,12 +11,26 @@ const DEFAULT_COLOR = { r: 255, g: 255, b: 255, a: 1 }; // Cache for computed color conversions const colorCache = new Map(); -const getCanvasContext = (): CanvasRenderingContext2D | null => { +const getCanvasContext = (): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null => { if (!canvas) { - canvas = document.createElement("canvas"); - canvas.width = 1; - canvas.height = 1; - ctx = canvas.getContext("2d", { willReadFrequently: true }); + // Try to use OffscreenCanvas if available + if (typeof OffscreenCanvas !== "undefined") { + canvas = new OffscreenCanvas(5, 5); + ctx = canvas.getContext("2d", { + willReadFrequently: true, + desynchronized: true + }); + } else { + // Fall back to regular canvas + const htmlCanvas = document.createElement("canvas"); + htmlCanvas.width = 5; + htmlCanvas.height = 5; + canvas = htmlCanvas; + ctx = htmlCanvas.getContext("2d", { + willReadFrequently: true, + desynchronized: true + }); + } } return ctx; }; @@ -87,22 +104,37 @@ export const colorToRgba = ( return DEFAULT_COLOR; } - // Clear the canvas - context.clearRect(0, 0, 1, 1); - try { - // Draw background if provided + // Clear and initialize canvas at the start of each cycle + if (currentPixelIndex === 0) { + context.clearRect(0, 0, 5, 5); + } + + // Calculate which pixel to use for this operation + const x = currentPixelIndex % 5; + const y = Math.floor(currentPixelIndex / 5); + + // Clear just this pixel to ensure clean state + context.clearRect(x, y, 1, 1); + + // Fill background color if provided if (backgroundColor) { context.fillStyle = backgroundColor; - context.fillRect(0, 0, 1, 1); + context.fillRect(x, y, 1, 1); } - // Draw the color + // Draw the color at the current pixel context.fillStyle = color; - context.fillRect(0, 0, 1, 1); + context.fillRect(x, y, 1, 1); - // Get the resulting pixel data - const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; + // Get the pixel data for this specific pixel + const imageData = context.getImageData(x, y, 1, 1); + + // Move to next pixel for next call + currentPixelIndex = (currentPixelIndex + 1) % 25; + + // Get the pixel data for the pixel we just sampled + const [r, g, b, a] = imageData.data; // If the color is completely transparent, return default if (a === 0) {