();
+
+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; } => {
- if (color.startsWith("rgb")) {
- const rgb = color.match(/rgb\((\d+),\s(\d+),\s(\d+)(?:,\s(\d+))?\)/);
- 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,
+ // 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;
+ }
+
+ // 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 {
- 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,
+ 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 {
- 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,
+ 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: 0, g: 0, b: 0, a: 1 };
+
+ // 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;
};
-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.2 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.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;
+ return luminance;
};
-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})`
- ];
- }
+export const checkContrast = (color1: string, color2: string): number => {
+ const luminance1 = getLuminance(colorToRgba(color1));
+ const luminance2 = getLuminance(colorToRgba(color2));
- 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;
- }
+ // Ensure luminance1 is the lighter color
+ const l1 = Math.max(luminance1, luminance2);
+ const l2 = Math.min(luminance1, luminance2);
- const correctedColorAdjusted = adjustColor(correctedColor, -delta);
- const newLum = getLuminance(correctedColorAdjusted);
- const newContrastRatio = (brightest + 0.05) / (newLum + 0.05);
-
- 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.2 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);
+
+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 f804aef9..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};
}`;
}
@@ -251,14 +253,14 @@ 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"));
+ 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(() => {
@@ -442,6 +467,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;
}
@@ -452,6 +509,7 @@ export class Decorator extends Module {
comms.unregisterAll(Decorator.moduleName);
this.resizeObserver.disconnect();
+ this.backgroundObserver.disconnect();
this.cleanup();
comms.log("Decorator Unmounted");