From c519cc821bc216ac7c24dc68448e4292b1f803a4 Mon Sep 17 00:00:00 2001
From: Calixte Denizet <calixte.denizet@gmail.com>
Date: Fri, 23 Jun 2023 15:47:59 +0200
Subject: [PATCH] Improve highlightments and popups in HCM (bug 1830850)

- Modify the text and background colors in popup to fit a11y requirements
- Add a backdrop filter on clickable areas in using a svg filter mapping
  canvas colors to Highlight and HighlightText ones.
---
 src/display/annotation_layer.js  |  40 +++++-
 src/display/base_factory.js      |   4 +
 src/display/display_utils.js     | 213 +++++++++++++++++++++----------
 web/annotation_layer_builder.css |  26 +++-
 web/pdf_page_view.js             |  19 +++
 web/pdf_viewer.js                |  15 +++
 6 files changed, 248 insertions(+), 69 deletions(-)

diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js
index 6a2b35147..2495d7a0d 100644
--- a/src/display/annotation_layer.js
+++ b/src/display/annotation_layer.js
@@ -210,7 +210,7 @@ class AnnotationElement {
     container.style.zIndex = this.parent.zIndex++;
 
     if (this.data.popupRef) {
-      container.setAttribute("aria-haspopup", true);
+      container.setAttribute("aria-haspopup", "dialog");
     }
 
     if (data.noRotate) {
@@ -474,7 +474,7 @@ class AnnotationElement {
    */
   _createPopup() {
     const { container, data } = this;
-    container.setAttribute("aria-haspopup", true);
+    container.setAttribute("aria-haspopup", "dialog");
 
     const popup = new PopupAnnotationElement({
       data: {
@@ -590,6 +590,17 @@ class AnnotationElement {
   getElementsToTriggerPopup() {
     return this.quadrilaterals || this.container;
   }
+
+  addHighlightArea() {
+    const triggers = this.getElementsToTriggerPopup();
+    if (Array.isArray(triggers)) {
+      for (const element of triggers) {
+        element.classList.add("highlightArea");
+      }
+    } else {
+      triggers.classList.add("highlightArea");
+    }
+  }
 }
 
 class LinkAnnotationElement extends AnnotationElement {
@@ -1881,6 +1892,7 @@ class PopupAnnotationElement extends AnnotationElement {
     for (const element of this.elements) {
       element.popup = popup;
       elementIds.push(element.data.id);
+      element.addHighlightArea();
     }
 
     this.container.setAttribute("aria-controls", elementIds.join(","));
@@ -2264,6 +2276,10 @@ class LineAnnotationElement extends AnnotationElement {
   getElementsToTriggerPopup() {
     return this.#line;
   }
+
+  addHighlightArea() {
+    this.container.classList.add("highlightArea");
+  }
 }
 
 class SquareAnnotationElement extends AnnotationElement {
@@ -2323,6 +2339,10 @@ class SquareAnnotationElement extends AnnotationElement {
   getElementsToTriggerPopup() {
     return this.#square;
   }
+
+  addHighlightArea() {
+    this.container.classList.add("highlightArea");
+  }
 }
 
 class CircleAnnotationElement extends AnnotationElement {
@@ -2383,6 +2403,10 @@ class CircleAnnotationElement extends AnnotationElement {
   getElementsToTriggerPopup() {
     return this.#circle;
   }
+
+  addHighlightArea() {
+    this.container.classList.add("highlightArea");
+  }
 }
 
 class PolylineAnnotationElement extends AnnotationElement {
@@ -2452,6 +2476,10 @@ class PolylineAnnotationElement extends AnnotationElement {
   getElementsToTriggerPopup() {
     return this.#polyline;
   }
+
+  addHighlightArea() {
+    this.container.classList.add("highlightArea");
+  }
 }
 
 class PolygonAnnotationElement extends PolylineAnnotationElement {
@@ -2556,6 +2584,10 @@ class InkAnnotationElement extends AnnotationElement {
   getElementsToTriggerPopup() {
     return this.#polylines;
   }
+
+  addHighlightArea() {
+    this.container.classList.add("highlightArea");
+  }
 }
 
 class HighlightAnnotationElement extends AnnotationElement {
@@ -2750,6 +2782,10 @@ class FileAttachmentAnnotationElement extends AnnotationElement {
     return this.#trigger;
   }
 
+  addHighlightArea() {
+    this.container.classList.add("highlightArea");
+  }
+
   /**
    * Download the file attachment associated with this annotation.
    *
diff --git a/src/display/base_factory.js b/src/display/base_factory.js
index 696cc15c9..c6da0791c 100644
--- a/src/display/base_factory.js
+++ b/src/display/base_factory.js
@@ -30,6 +30,10 @@ class BaseFilterFactory {
     return "none";
   }
 
+  addHighlightHCMFilter(fgColor, bgColor, newFgColor, newBgColor) {
+    return "none";
+  }
+
   destroy(keepHCM = false) {}
 }
 
diff --git a/src/display/display_utils.js b/src/display/display_utils.js
index fe51836ff..84b61c536 100644
--- a/src/display/display_utils.js
+++ b/src/display/display_utils.js
@@ -64,6 +64,12 @@ class DOMFilterFactory extends BaseFilterFactory {
 
   #hcmUrl;
 
+  #hcmHighlightFilter;
+
+  #hcmHighlightKey;
+
+  #hcmHighlightUrl;
+
   #id = 0;
 
   constructor({ docId, ownerDocument = globalThis.document } = {}) {
@@ -98,13 +104,6 @@ class DOMFilterFactory extends BaseFilterFactory {
     return this.#_defs;
   }
 
-  #appendFeFunc(feComponentTransfer, func, table) {
-    const feFunc = this.#document.createElementNS(SVG_NS, func);
-    feFunc.setAttribute("type", "discrete");
-    feFunc.setAttribute("tableValues", table);
-    feComponentTransfer.append(feFunc);
-  }
-
   addFilter(maps) {
     if (!maps) {
       return "none";
@@ -155,20 +154,8 @@ class DOMFilterFactory extends BaseFilterFactory {
     this.#cache.set(maps, url);
     this.#cache.set(key, url);
 
-    const filter = this.#document.createElementNS(SVG_NS, "filter", SVG_NS);
-    filter.setAttribute("id", id);
-    filter.setAttribute("color-interpolation-filters", "sRGB");
-    const feComponentTransfer = this.#document.createElementNS(
-      SVG_NS,
-      "feComponentTransfer"
-    );
-    filter.append(feComponentTransfer);
-
-    this.#appendFeFunc(feComponentTransfer, "feFuncR", tableR);
-    this.#appendFeFunc(feComponentTransfer, "feFuncG", tableG);
-    this.#appendFeFunc(feComponentTransfer, "feFuncB", tableB);
-
-    this.#defs.append(filter);
+    const filter = this.#createFilter(id);
+    this.#addTransferMapConversion(tableR, tableG, tableB, filter);
 
     return url;
   }
@@ -187,13 +174,9 @@ class DOMFilterFactory extends BaseFilterFactory {
       return this.#hcmUrl;
     }
 
-    this.#defs.style.color = fgColor;
-    fgColor = getComputedStyle(this.#defs).getPropertyValue("color");
-    const fgRGB = getRGB(fgColor);
+    const fgRGB = this.#getRGB(fgColor);
     fgColor = Util.makeHexColor(...fgRGB);
-    this.#defs.style.color = bgColor;
-    bgColor = getComputedStyle(this.#defs).getPropertyValue("color");
-    const bgRGB = getRGB(bgColor);
+    const bgRGB = this.#getRGB(bgColor);
     bgColor = Util.makeHexColor(...bgRGB);
     this.#defs.style.color = "";
 
@@ -221,39 +204,9 @@ class DOMFilterFactory extends BaseFilterFactory {
     const table = map.join(",");
 
     const id = `g_${this.#docId}_hcm_filter`;
-    const filter = (this.#hcmFilter = this.#document.createElementNS(
-      SVG_NS,
-      "filter",
-      SVG_NS
-    ));
-    filter.setAttribute("id", id);
-    filter.setAttribute("color-interpolation-filters", "sRGB");
-    let feComponentTransfer = this.#document.createElementNS(
-      SVG_NS,
-      "feComponentTransfer"
-    );
-    filter.append(feComponentTransfer);
-
-    this.#appendFeFunc(feComponentTransfer, "feFuncR", table);
-    this.#appendFeFunc(feComponentTransfer, "feFuncG", table);
-    this.#appendFeFunc(feComponentTransfer, "feFuncB", table);
-
-    const feColorMatrix = this.#document.createElementNS(
-      SVG_NS,
-      "feColorMatrix"
-    );
-    feColorMatrix.setAttribute("type", "matrix");
-    feColorMatrix.setAttribute(
-      "values",
-      "0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0"
-    );
-    filter.append(feColorMatrix);
-
-    feComponentTransfer = this.#document.createElementNS(
-      SVG_NS,
-      "feComponentTransfer"
-    );
-    filter.append(feComponentTransfer);
+    const filter = (this.#hcmHighlightFilter = this.#createFilter(id));
+    this.#addTransferMapConversion(table, table, table, filter);
+    this.#addGrayConversion(filter);
 
     const getSteps = (c, n) => {
       const start = fgRGB[c] / 255;
@@ -264,18 +217,101 @@ class DOMFilterFactory extends BaseFilterFactory {
       }
       return arr.join(",");
     };
-    this.#appendFeFunc(feComponentTransfer, "feFuncR", getSteps(0, 5));
-    this.#appendFeFunc(feComponentTransfer, "feFuncG", getSteps(1, 5));
-    this.#appendFeFunc(feComponentTransfer, "feFuncB", getSteps(2, 5));
-
-    this.#defs.append(filter);
+    this.#addTransferMapConversion(
+      getSteps(0, 5),
+      getSteps(1, 5),
+      getSteps(2, 5),
+      filter
+    );
 
     this.#hcmUrl = `url(#${id})`;
     return this.#hcmUrl;
   }
 
+  addHighlightHCMFilter(fgColor, bgColor, newFgColor, newBgColor) {
+    const key = `${fgColor}-${bgColor}-${newFgColor}-${newBgColor}`;
+    if (this.#hcmHighlightKey === key) {
+      return this.#hcmHighlightUrl;
+    }
+
+    this.#hcmHighlightKey = key;
+    this.#hcmHighlightUrl = "none";
+    this.#hcmHighlightFilter?.remove();
+
+    if (!fgColor || !bgColor) {
+      return this.#hcmHighlightUrl;
+    }
+
+    const [fgRGB, bgRGB] = [fgColor, bgColor].map(this.#getRGB.bind(this));
+    let fgGray = Math.round(
+      0.2126 * fgRGB[0] + 0.7152 * fgRGB[1] + 0.0722 * fgRGB[2]
+    );
+    let bgGray = Math.round(
+      0.2126 * bgRGB[0] + 0.7152 * bgRGB[1] + 0.0722 * bgRGB[2]
+    );
+    let [newFgRGB, newBgRGB] = [newFgColor, newBgColor].map(
+      this.#getRGB.bind(this)
+    );
+    if (bgGray < fgGray) {
+      [fgGray, bgGray, newFgRGB, newBgRGB] = [
+        bgGray,
+        fgGray,
+        newBgRGB,
+        newFgRGB,
+      ];
+    }
+    this.#defs.style.color = "";
+
+    // Now we can create the filters to highlight some canvas parts.
+    // The colors in the pdf will almost be Canvas and CanvasText, hence we
+    // want to filter them to finally get Highlight and HighlightText.
+    // Since we're in HCM the background color and the foreground color should
+    // be really different when converted to grayscale (if they're not then it
+    // means that we've a poor contrast). Once the canvas colors are converted
+    // to grayscale we can easily map them on their new colors.
+    // The grayscale step is important because if we've something like:
+    //   fgColor = #FF....
+    //   bgColor = #FF....
+    //   then we are enable to map the red component on the new red components
+    //   which can be different.
+
+    const getSteps = (fg, bg, n) => {
+      const arr = new Array(256);
+      const step = (bgGray - fgGray) / n;
+      const newStart = fg / 255;
+      const newStep = (bg - fg) / (255 * n);
+      let prev = 0;
+      for (let i = 0; i <= n; i++) {
+        const k = Math.round(fgGray + i * step);
+        const value = newStart + i * newStep;
+        for (let j = prev; j <= k; j++) {
+          arr[j] = value;
+        }
+        prev = k + 1;
+      }
+      for (let i = prev; i < 256; i++) {
+        arr[i] = arr[prev - 1];
+      }
+      return arr.join(",");
+    };
+
+    const id = `g_${this.#docId}_hcm_highlight_filter`;
+    const filter = (this.#hcmHighlightFilter = this.#createFilter(id));
+
+    this.#addGrayConversion(filter);
+    this.#addTransferMapConversion(
+      getSteps(newFgRGB[0], newBgRGB[0], 5),
+      getSteps(newFgRGB[1], newBgRGB[1], 5),
+      getSteps(newFgRGB[2], newBgRGB[2], 5),
+      filter
+    );
+
+    this.#hcmHighlightUrl = `url(#${id})`;
+    return this.#hcmHighlightUrl;
+  }
+
   destroy(keepHCM = false) {
-    if (keepHCM && this.#hcmUrl) {
+    if (keepHCM && (this.#hcmUrl || this.#hcmHighlightUrl)) {
       return;
     }
     if (this.#_defs) {
@@ -288,6 +324,51 @@ class DOMFilterFactory extends BaseFilterFactory {
     }
     this.#id = 0;
   }
+
+  #addGrayConversion(filter) {
+    const feColorMatrix = this.#document.createElementNS(
+      SVG_NS,
+      "feColorMatrix"
+    );
+    feColorMatrix.setAttribute("type", "matrix");
+    feColorMatrix.setAttribute(
+      "values",
+      "0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0"
+    );
+    filter.append(feColorMatrix);
+  }
+
+  #createFilter(id) {
+    const filter = this.#document.createElementNS(SVG_NS, "filter");
+    filter.setAttribute("color-interpolation-filters", "sRGB");
+    filter.setAttribute("id", id);
+    this.#defs.append(filter);
+
+    return filter;
+  }
+
+  #appendFeFunc(feComponentTransfer, func, table) {
+    const feFunc = this.#document.createElementNS(SVG_NS, func);
+    feFunc.setAttribute("type", "discrete");
+    feFunc.setAttribute("tableValues", table);
+    feComponentTransfer.append(feFunc);
+  }
+
+  #addTransferMapConversion(rTable, gTable, bTable, filter) {
+    const feComponentTransfer = this.#document.createElementNS(
+      SVG_NS,
+      "feComponentTransfer"
+    );
+    filter.append(feComponentTransfer);
+    this.#appendFeFunc(feComponentTransfer, "feFuncR", rTable);
+    this.#appendFeFunc(feComponentTransfer, "feFuncG", gTable);
+    this.#appendFeFunc(feComponentTransfer, "feFuncB", bTable);
+  }
+
+  #getRGB(color) {
+    this.#defs.style.color = color;
+    return getRGB(getComputedStyle(this.#defs).getPropertyValue("color"));
+  }
 }
 
 class DOMCanvasFactory extends BaseCanvasFactory {
diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css
index f22194d46..1219fd3af 100644
--- a/web/annotation_layer_builder.css
+++ b/web/annotation_layer_builder.css
@@ -30,6 +30,7 @@
     --input-disabled-border-color: GrayText;
     --input-hover-border-color: Highlight;
     --link-outline: 1.5px solid LinkText;
+    --hcm-highligh-filter: invert(100%);
   }
   .annotationLayer .textWidgetAnnotation :is(input, textarea):required,
   .annotationLayer .choiceWidgetAnnotation select:required,
@@ -40,7 +41,30 @@
   }
 
   .annotationLayer .linkAnnotation:hover {
-    backdrop-filter: invert(100%);
+    backdrop-filter: var(--hcm-highligh-filter);
+  }
+
+  .annotationLayer .linkAnnotation > a:hover {
+    opacity: 0 !important;
+    background: none !important;
+    box-shadow: none;
+  }
+
+  .annotationLayer .popupAnnotation .popup {
+    outline: calc(1.5px * var(--scale-factor)) solid CanvasText !important;
+    background-color: ButtonFace !important;
+    color: ButtonText !important;
+  }
+
+  .annotationLayer .highlightArea:hover::after {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    backdrop-filter: var(--hcm-highligh-filter);
+    content: "";
+    pointer-events: none;
   }
 
   .annotationLayer .popupAnnotation.focused .popup {
diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js
index 4825d1ef7..b31408767 100644
--- a/web/pdf_page_view.js
+++ b/web/pdf_page_view.js
@@ -13,6 +13,8 @@
  * limitations under the License.
  */
 
+// eslint-disable-next-line max-len
+/** @typedef {import("../src/display/base_factory.js").BaseFilterFactory} BaseFilterFactory */
 // eslint-disable-next-line max-len
 /** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
 // eslint-disable-next-line max-len
@@ -84,6 +86,8 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js";
  * @property {IL10n} [l10n] - Localization service.
  * @property {function} [layerProperties] - The function that is used to lookup
  *   the necessary layer-properties.
+ * @property {BaseFilterFactory} [filterFactory] - Factory to create some SVG
+ *   filters.
  */
 
 const MAX_CANVAS_PIXELS = compatibilityParams.maxCanvasPixels || 16777216;
@@ -203,6 +207,21 @@ class PDFPageView {
         "--scale-factor",
         this.scale * PixelsPerInch.PDF_TO_CSS_UNITS
       );
+      if (
+        options.filterFactory &&
+        (this.pageColors?.foreground === "CanvasText" ||
+          this.pageColors?.background === "Canvas")
+      ) {
+        container?.style.setProperty(
+          "--hcm-highligh-filter",
+          options.filterFactory.addHighlightHCMFilter(
+            "CanvasText",
+            "Canvas",
+            "HighlightText",
+            "Highlight"
+          )
+        );
+      }
 
       const { optionalContentConfigPromise } = options;
       if (optionalContentConfigPromise) {
diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js
index 103937a50..363000fd8 100644
--- a/web/pdf_viewer.js
+++ b/web/pdf_viewer.js
@@ -883,6 +883,20 @@ class PDFViewer {
         // Ensure that the various layers always get the correct initial size,
         // see issue 15795.
         this.viewer.style.setProperty("--scale-factor", viewport.scale);
+        if (
+          this.pageColors?.foreground === "CanvasText" ||
+          this.pageColors?.background === "Canvas"
+        ) {
+          this.viewer.style.setProperty(
+            "--hcm-highligh-filter",
+            pdfDocument.filterFactory.addHighlightHCMFilter(
+              "CanvasText",
+              "Canvas",
+              "HighlightText",
+              "Highlight"
+            )
+          );
+        }
 
         for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) {
           const pageView = new PDFPageView({
@@ -902,6 +916,7 @@ class PDFViewer {
             pageColors: this.pageColors,
             l10n: this.l10n,
             layerProperties,
+            filterFactory: pdfDocument.filterFactory,
           });
           this._pages.push(pageView);
         }