Skip to content
10 changes: 9 additions & 1 deletion addon/app/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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)"),
Expand Down
20 changes: 16 additions & 4 deletions addon/app/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
1 change: 1 addition & 0 deletions lib/utils.js → addon/app/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

function getViewerEventBus(app) {
app = app ?? window.PDFViewerApplication;
const task = (resolve, reject) => {
Expand Down
10 changes: 9 additions & 1 deletion addon/doq.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
15 changes: 8 additions & 7 deletions addon/doq.js
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -49,7 +49,7 @@ function installUI(html) {

function load(colorSchemes) {
colorSchemes.forEach(addColorScheme);
initReader();
Reader.initReader();
initConfig();
migratePrefs(); /* TEMPORARY */
updateReaderState();
Expand All @@ -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);
Expand Down
88 changes: 40 additions & 48 deletions lib/annots.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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 };
52 changes: 29 additions & 23 deletions lib/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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();
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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));
Expand All @@ -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;
}
Expand Down