From 49f780c18eecf7447ec852c702cfc884bcad4003 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 27 Mar 2025 16:02:16 +0100 Subject: [PATCH 1/9] [Annotations] Avoid TypeError in redrawHighlights Recently I started noticing that pages can have AnnotationEditorLayers *even* when their canvas is discarded, so... that's that. #pdfjsQuirks --- lib/annots.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/annots.js b/lib/annots.js index 317fa09..02a44cb 100644 --- a/lib/annots.js +++ b/lib/annots.js @@ -34,8 +34,10 @@ function redrawHighlights(e) { return; } const canvasWrapper = e.source.div.querySelector(".canvasWrapper"); - canvasWrapper.querySelectorAll("svg.highlight").forEach(recolorHighlight); - monitorHighlights.observe(canvasWrapper, { childList: true }); + if (canvasWrapper) { + canvasWrapper.querySelectorAll("svg.highlight").forEach(recolorHighlight); + monitorHighlights.observe(canvasWrapper, { childList: true }); + } } function handleInput(e) { From 57339453ba957b721bb9f2f9c30eaa01b2346b8d Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 27 Mar 2025 23:12:48 +0100 Subject: [PATCH 2/9] [Annots] Improve free highlight blending in dark Those highlights can be a bit brighter when there is no text underneath. --- addon/doq.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/addon/doq.css b/addon/doq.css index 633c932..39adfa3 100644 --- a/addon/doq.css +++ b/addon/doq.css @@ -10,9 +10,12 @@ --free-text-color: #000000; color: var(--free-text-color) !important; } -.reader.dark .canvasWrapper > .highlight { +.reader.dark .canvasWrapper > .highlight:not(.free) { --blend-mode: overlay; } +.reader.dark .canvasWrapper > .highlight.free { + --blend-mode: hard-light; +} .filter :is(.page, .thumbnailImage), .colorSwatch.filter { filter: var(--filter-css); } From e4c47ec383c9c1b0583e30808c5d66eefbf3e224 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 28 Mar 2025 00:26:56 +0100 Subject: [PATCH 3/9] [Config] Update legacy comparison for PDF.js 5.x --- addon/app/config.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/addon/app/config.js b/addon/app/config.js index 5923315..193fdd2 100644 --- a/addon/app/config.js +++ b/addon/app/config.js @@ -30,7 +30,7 @@ function initConfig() { /* Legacy PDF.js support */ const pdfjsVer = pdfjsLib.version.split(".").map(Number); - if (pdfjsVer[0] < 4 || pdfjsVer[1] < 7) { + if (isLegacy(pdfjsVer, "4.7")) { if (pdfjsVer[0] < 3) { console.warn("doq: unsupported PDF.js version " + pdfjsLib.version); } @@ -40,6 +40,14 @@ function initConfig() { DOQ.config = config; } +function isLegacy(libVer, minVer) { + minVer = minVer.split(".").map(Number); + if (libVer[0] === minVer[0]) { + return libVer[1] < minVer[1]; + } + return libVer[0] < minVer[0]; +} + function getAddonConfig() { return { sysTheme: window.matchMedia("(prefers-color-scheme: light)"), From 81bfa240c84e8d3fdbd17fd6f88b4c4db4b92f9e Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Sat, 29 Mar 2025 19:11:31 +0100 Subject: [PATCH 4/9] [Annots] Rewrite and refactor annotation handling This is a major overhaul of both SVG and non-SVG annotation handling. The patch achieves the following things: * fully seperate add-on logic from core by moving the remaining viewer related code from `lib/` to `app/`, completing the work of 7b0c8de; * generalise SVG annotation handling and correctly recolor ink (draw) annotations in PDF.js 4.9+, as they have now been made SVG as well; * better reassign various parts of annotations event handling to the different modules; and * rename, reorder and clean up the `annots.js` functions and exports. This refactor should also finally enable *doq* lib users to theme PDF.js annotations in non-viewer environments, as they can pass the appropriate container elements to the exported functions of `annots.js`. --- addon/app/reader.js | 12 +++-- {lib => addon/app}/utils.js | 1 + addon/doq.js | 15 ++++--- lib/annots.js | 90 +++++++++++++++++-------------------- 4 files changed, 58 insertions(+), 60 deletions(-) rename {lib => addon/app}/utils.js (99%) diff --git a/addon/app/reader.js b/addon/app/reader.js index 4fb9b53..d312106 100644 --- a/addon/app/reader.js +++ b/addon/app/reader.js @@ -2,7 +2,7 @@ import { DOQ } from "./config.js"; import { updatePreference } from "./prefs.js"; import { wrapCanvas, setCanvasTheme } from "../lib/engine.js"; -import { redrawAnnotation } from "../lib/annots.js"; +import * as Annots from "../lib/annots.js"; function initReader() { const cvsp = HTMLCanvasElement.prototype; @@ -84,15 +84,21 @@ function toggleFlags(e) { } } +function handleAnnotations(e) { + const { canvas, annotationEditorLayer, eventBus } = e.source; + Annots.monitorAnnotations(canvas?.parentElement); + Annots.monitorEditorEvents(annotationEditorLayer.div, eventBus); +} + function forceRedraw() { const { pdfViewer, pdfThumbnailViewer } = window.PDFViewerApplication; const annotations = pdfViewer.pdfDocument?.annotationStorage.getAll(); - Object.values(annotations || {}).forEach(redrawAnnotation); + Object.values(annotations || {}).forEach(Annots.redrawAnnotation); pdfViewer._pages.filter(e => e.renderingState).forEach(e => e.reset()); pdfThumbnailViewer._thumbnails.filter(e => e.renderingState) .forEach(e => e.reset()); window.PDFViewerApplication.forceRendering(); } -export { initReader, updateReaderColors, toggleFlags }; +export { initReader, updateReaderColors, toggleFlags, handleAnnotations }; diff --git a/lib/utils.js b/addon/app/utils.js similarity index 99% rename from lib/utils.js rename to addon/app/utils.js index 3e23598..4673003 100644 --- a/lib/utils.js +++ b/addon/app/utils.js @@ -1,3 +1,4 @@ + function getViewerEventBus(app) { app = app ?? window.PDFViewerApplication; const task = (resolve, reject) => { diff --git a/addon/doq.js b/addon/doq.js index d45f836..1b24739 100644 --- a/addon/doq.js +++ b/addon/doq.js @@ -1,12 +1,12 @@ import * as doqAPI from "./lib/api.js"; import { addColorScheme } from "./lib/engine.js"; -import { monitorAnnotationParams, handleInput } from "./lib/annots.js"; import { DOQ, initConfig } from "./app/config.js"; import { migratePrefs } from "./app/prefs.js"; import { updateReaderState, updateColorScheme } from "./app/theme.js"; -import { initReader, updateReaderColors, toggleFlags } from "./app/reader.js"; +import { getViewerEventBus } from "./app/utils.js"; +import * as Reader from "./app/reader.js"; import * as Toolbar from "./app/toolbar.js"; /* Initialisation */ @@ -49,7 +49,7 @@ function installUI(html) { function load(colorSchemes) { colorSchemes.forEach(addColorScheme); - initReader(); + Reader.initReader(); initConfig(); migratePrefs(); /* TEMPORARY */ updateReaderState(); @@ -61,16 +61,17 @@ function bindEvents() { const { config, flags } = DOQ; config.sysTheme.onchange = updateReaderState; config.schemeSelector.onchange = updateColorScheme; - config.tonePicker.onchange = updateReaderColors; - config.shapeToggle.onchange = config.imageToggle.onchange = toggleFlags; - monitorAnnotationParams(); + config.tonePicker.onchange = Reader.updateReaderColors; + config.shapeToggle.onchange = config.imageToggle.onchange = Reader.toggleFlags; + getViewerEventBus().then(eventBus => { + eventBus.on("annotationeditorlayerrendered", Reader.handleAnnotations); + }); config.viewReader.onclick = Toolbar.toggleToolbar; config.optionsToggle.onchange = e => Toolbar.toggleOptions(); config.schemeSelector.onclick = e => { config.readerToolbar.classList.remove("tabMode"); }; - config.viewer.addEventListener("input", handleInput); window.addEventListener("beforeprint", e => flags.isPrinting = true); window.addEventListener("afterprint", e => flags.isPrinting = false); diff --git a/lib/annots.js b/lib/annots.js index 02a44cb..bf3c966 100644 --- a/lib/annots.js +++ b/lib/annots.js @@ -1,43 +1,53 @@ import { checkFlags, getCanvasStyle } from "./engine.js"; -import { getViewerEventBus } from "./utils.js"; -function monitorAnnotationParams() { - getViewerEventBus().then(eventBus => { - eventBus.on("annotationeditorlayerrendered", redrawHighlights); - eventBus.on("switchannotationeditorparams", recolorSelectedAnnots); - }) +/* Monitor and recolor SVG annotations when they are added/modified */ +const svgAnnotation = "svg.draw, svg.highlight"; +const annotsMonitor = new MutationObserver(recolorNewAnnots); + +function monitorAnnotations(annotsContainer) { + if (!checkFlags() || !annotsContainer) { + return; + } + annotsContainer.querySelectorAll(svgAnnotation).forEach(recolorSvgAnnot); + annotsMonitor.observe(annotsContainer, { childList: true }); +} + +function recolorNewAnnots(mutationRecords) { + const isSvgAnnot = node => node.matches(svgAnnotation); + mutationRecords.forEach(record => { + const { target, addedNodes } = record; + [target, ...addedNodes].filter(isSvgAnnot).forEach(recolorSvgAnnot); + }); } -const monitorHighlights = new MutationObserver((records, _) => { - records.forEach(recolorNewHighlights); -}); +function recolorSvgAnnot(annot) { + const attr = annot.matches(".highlight") ? "fill" : "stroke"; + const newColor = getCanvasStyle(annot.getAttribute(attr)); + const alreadyObserved = annot.style[attr] !== ""; + annot.style.setProperty(attr, newColor); + if (!alreadyObserved) { + annotsMonitor.observe(annot, { attributeFilter: [attr] }); + } +} + +/* Recolor/rebuild non-SVG annotations (call before a forced page redraw) */ function redrawAnnotation(annot) { - if (annot.name === "highlightEditor") { - /* pass; highlights are rendered as SVGs _inside_ the canvasWrapper, - so they are better handled _after_ the page is rendered (see below). */ - } else if (annot.name === "freeTextEditor") { + if (annot.name === "freeTextEditor") { recolorFreeTextAnnot(annot.editorDiv); - } else { - if (annot.name === "stampEditor") { - /* There is no public API to force repaint of a stamp annotation; - nullifying its parent _tricks_ PDF.js into recreating its canvas. */ - annot.parent = null; - annot.div.querySelector("canvas")?.remove(); - } + } else if (annot.name === "stampEditor") { + /* There is no public API to force repaint of a stamp annotation; + nullifying its parent tricks PDF.js into recreating its canvas. */ + annot.parent = null; + annot.div.querySelector("canvas")?.remove(); annot.rebuild(); } } -function redrawHighlights(e) { - if (!checkFlags()) { - return; - } - const canvasWrapper = e.source.div.querySelector(".canvasWrapper"); - if (canvasWrapper) { - canvasWrapper.querySelectorAll("svg.highlight").forEach(recolorHighlight); - monitorHighlights.observe(canvasWrapper, { childList: true }); - } +/* Monitor/recolor new non-SVG annotations when they are created/modified */ +function monitorEditorEvents(editorLayer, eventBus) { + editorLayer.addEventListener("input", handleInput); + eventBus.on("switchannotationeditorparams", recolorSelectedAnnots); } function handleInput(e) { @@ -70,24 +80,4 @@ function recolorFreeTextAnnot(editor) { } } -function recolorNewHighlights(mutationRecord) { - const { target } = mutationRecord; - const recolor = node => { - if (node.matches("svg.highlight")) { - recolorHighlight(node); - } - }; - recolor(target); - mutationRecord.addedNodes.forEach(recolor); -} - -function recolorHighlight(annot) { - const newColor = getCanvasStyle(annot.getAttribute("fill")); - const alreadyObserved = annot.style.fill !== ""; - annot.style.setProperty("fill", newColor); - if (!alreadyObserved) { - monitorHighlights.observe(annot, { attributeFilter: ["fill"] }); - } -} - -export { monitorAnnotationParams, redrawAnnotation, handleInput }; +export { monitorAnnotations, redrawAnnotation, monitorEditorEvents }; From 8afe31a3ebe76e6d5d0c903b65d77e498d3002fe Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Tue, 1 Apr 2025 08:59:48 +0200 Subject: [PATCH 5/9] [Core] Simplify image blending in drawImage handler Instead of drawing the mask on the canvas and waiting for PDF.js, we can directly blend the image with the mask and return the result for drawing with the normal source-over. This is logically simpler and less prone to breaking with complicated multi-image arrangements (sometimes found for title decorations in books). This also calls for some apt renamings. --- lib/engine.js | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/engine.js b/lib/engine.js index 8785fba..dd2a948 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -51,7 +51,7 @@ function wrapCanvas() { }); wrapSet(ctxp, f + "Style", getCanvasStyle, checks); }); - ctxp.drawImage = wrapAPI(ctxp.drawImage, setCanvasCompOp, checkFlags); + ctxp.drawImage = wrapAPI(ctxp.drawImage, blendImage, checkFlags); } /* Method and setter wrapper closures */ @@ -158,7 +158,7 @@ function resetShapeStyle(ctx, method, args, prop) { markContext(ctx) } -function updateTextStyle(ctx, method, args, prop) { +function updateTextStyle(ctx, _method, args, prop) { const style = ctx[prop]; if (!ctx._hasBackgrounds && !isAccent(style)) { @@ -227,36 +227,35 @@ function markContext(ctx) { canvasCache.set("dataMap", null); } -/* Set the image composite operation, drawing the mask to blend with */ -function setCanvasCompOp(ctx, drawImage, args) { +/* Replace the image to draw with one blended with the theme */ +function blendImage(ctx, _method, drawArgs) { markContext(ctx); - const image = args[0]; + const image = drawArgs[0]; if (!DOQ.flags.imagesOn || image instanceof HTMLCanvasElement) { return; } - args = [...args]; + const args = [...drawArgs]; if (args.length < 5) { args.push(image.width, image.height); } const { colors, foreground, background } = activeTone; - const maskColor = colors.bg.lightness < 50 ? foreground : background; - const mask = createMask(maskColor, args.slice(0, 5)); - args.splice(0, 1, mask); - drawImage.apply(ctx, args); - - ctx.globalCompositeOperation = "multiply"; + const blendColor = colors.bg.lightness < 50 ? foreground : background; + drawArgs[0] = drawBlendedImage(blendColor, args.slice(0, 5)); } -function createMask(color, args) { +function drawBlendedImage(color, args) { const cvs = document.createElement("canvas"); const dim = [cvs.width, cvs.height] = args.slice(3); const ctx = cvs.getContext("2d"); ctx.setfillStyle(color); ctx.origFillRect(0, 0, ...dim); - ctx.globalCompositeOperation = "destination-in"; + ctx.globalCompositeOperation = "destination-in"; /* clip the mask */ + ctx.origDrawImage(...args, 0, 0, ...dim); + + ctx.globalCompositeOperation = "multiply"; ctx.origDrawImage(...args, 0, 0, ...dim); return cvs; } From 977ecf72631748019a890240a79b17d0ed820467 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Tue, 1 Apr 2025 21:12:32 +0200 Subject: [PATCH 6/9] [Core] Update text when gradients/patterns present Like 89a6f95; since canvas gradients and patterns are currently left off from interpolation, we need to test against backgrounds containing them. --- lib/engine.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/engine.js b/lib/engine.js index dd2a948..f31e683 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -42,7 +42,7 @@ function wrapCanvas() { const ctxp = CanvasRenderingContext2D.prototype; ctxp.origFillRect = ctxp.fillRect; ctxp.origDrawImage = ctxp.drawImage; - const checks = style => checkFlags() && checkStyle(style); + const checks = (...args) => checkFlags() && checkStyle(...args); ["fill", "stroke"].forEach(f => { ["", "Rect", "Text"].forEach(e => { @@ -57,7 +57,7 @@ function wrapCanvas() { /* Method and setter wrapper closures */ function wrapAPI(method, callHandler, test, prop) { return function() { - if (!test?.(this[prop])) { + if (!test?.(this[prop], this)) { return method.apply(this, arguments); } this.save(); @@ -94,8 +94,17 @@ function checkFlags() { return flags.engineOn && !flags.isPrinting; } -function checkStyle(style) { - return typeof(style) === "string"; /* is not gradient/pattern */ +function checkStyle(style, ctx) { + const isPlain = typeof style === "string"; + if (!isPlain && ctx !== undefined) { /* is a gradient/pattern */ + markContext(ctx); + } + return isPlain; +} + +function markContext(ctx) { + ctx._hasBackgrounds = true; + canvasCache.set("dataMap", null); } /* Get style from cache, calculate if not present */ @@ -222,11 +231,6 @@ function isAccent(style) { return accents?.some(isStyle) || scheme.accents?.some(isStyle); } -function markContext(ctx) { - ctx._hasBackgrounds = true; - canvasCache.set("dataMap", null); -} - /* Replace the image to draw with one blended with the theme */ function blendImage(ctx, _method, drawArgs) { markContext(ctx); From 40da973c6d879aefb89063af428ddff0d72b6add Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Sun, 13 Apr 2025 11:22:44 +0200 Subject: [PATCH 7/9] [Core] Ignore textBg if text is outside the canvas Apparently, this can happen in normal PDFs and PDF.js still proceeds to render them, so... that's that. #pdfjsQuirks --- lib/engine.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/engine.js b/lib/engine.js index f31e683..8fd83e4 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -219,6 +219,9 @@ function readCanvasColor(ctx, text, tx, ty, canvasData) { let {x, y} = tfm.transformPoint({ x: tx + dx, y: ty - dy }); [x, y] = [x, y].map(Math.round); + if (x < 0 || x >= canvasData.width || y < 0 || y >= canvasData.height) { + return null; + } const i = (y * canvasData.width + x) * 4; const rgb = Array.from(canvasData.data.slice(i, i + 3)); return new Color(rgb.map(e => e / 255)); From d3300e9dfc203a7ec90f224b449760d64087f513 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Sat, 26 Apr 2025 13:37:56 +0530 Subject: [PATCH 8/9] [Addon] Make find highlights more visible in dark --- addon/doq.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/addon/doq.css b/addon/doq.css index 39adfa3..f9bb974 100644 --- a/addon/doq.css +++ b/addon/doq.css @@ -16,6 +16,11 @@ .reader.dark .canvasWrapper > .highlight.free { --blend-mode: hard-light; } +.reader.dark .textLayer .highlight { + --highlight-backdrop-filter: invert() hue-rotate(180deg); + --highlight-selected-backdrop-filter: invert() hue-rotate(180deg); + display: inline-block; /* needed for WebKit/Blink */ +} .filter :is(.page, .thumbnailImage), .colorSwatch.filter { filter: var(--filter-css); } From b271b063ca62a2e3248a9c10851f29c3c7a68572 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Sat, 19 Jul 2025 22:14:42 +0200 Subject: [PATCH 9/9] [Annots] Update annotation handling for PDF.js 5.2 --- addon/app/reader.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/addon/app/reader.js b/addon/app/reader.js index d312106..7c1c997 100644 --- a/addon/app/reader.js +++ b/addon/app/reader.js @@ -92,9 +92,15 @@ function handleAnnotations(e) { function forceRedraw() { const { pdfViewer, pdfThumbnailViewer } = window.PDFViewerApplication; - const annotations = pdfViewer.pdfDocument?.annotationStorage.getAll(); + const annotStore = pdfViewer.pdfDocument?.annotationStorage; + let annotations; - Object.values(annotations || {}).forEach(Annots.redrawAnnotation); + try { + annotations = Object.values(annotStore.getAll() || {}); /* PDF.js < 5.2 */ + } catch (e) { + annotations = [...annotStore].map(e => e[1]); + } + annotations.forEach(Annots.redrawAnnotation); pdfViewer._pages.filter(e => e.renderingState).forEach(e => e.reset()); pdfThumbnailViewer._thumbnails.filter(e => e.renderingState) .forEach(e => e.reset());