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)"), diff --git a/addon/app/reader.js b/addon/app/reader.js index 4fb9b53..7c1c997 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,27 @@ 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(); + const annotStore = pdfViewer.pdfDocument?.annotationStorage; + let annotations; - Object.values(annotations || {}).forEach(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()); 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.css b/addon/doq.css index 633c932..f9bb974 100644 --- a/addon/doq.css +++ b/addon/doq.css @@ -10,9 +10,17 @@ --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; +} +.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); } 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 317fa09..bf3c966 100644 --- a/lib/annots.js +++ b/lib/annots.js @@ -1,41 +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); + }); +} + +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] }); + } } -const monitorHighlights = new MutationObserver((records, _) => { - records.forEach(recolorNewHighlights); -}); +/* 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"); - 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) { @@ -68,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 }; diff --git a/lib/engine.js b/lib/engine.js index 8785fba..8fd83e4 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 => { @@ -51,13 +51,13 @@ 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 */ 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 */ @@ -158,7 +167,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)) { @@ -210,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)); @@ -222,41 +234,35 @@ function isAccent(style) { return accents?.some(isStyle) || scheme.accents?.some(isStyle); } -function markContext(ctx) { - ctx._hasBackgrounds = true; - 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; }