├── addon ├── lib ├── images │ ├── checkMark.svg │ ├── readerIcon.svg │ └── optionsIcon.svg ├── options.json ├── app │ ├── toolbar.js │ ├── config.js │ ├── prefs.js │ ├── theme.js │ └── reader.js ├── doq.html ├── doq.js └── doq.css ├── lib ├── doq.js ├── utils.js ├── api.js ├── colors.json ├── annots.js ├── color.js └── engine.js ├── docs ├── logo │ ├── logo.png │ ├── logo-plain.svg │ └── logo.svg └── screenshots │ ├── reader-dark.png │ ├── addon-toolbar.png │ ├── reader-light.png │ ├── toolbar-light.png │ └── toolbar-collapsed.png ├── package.json ├── LICENSE.txt ├── CHANGELOG.md └── README.md /addon/lib: -------------------------------------------------------------------------------- 1 | ../lib -------------------------------------------------------------------------------- /lib/doq.js: -------------------------------------------------------------------------------- 1 | import * as DOQ from "./api.js"; 2 | export default DOQ; 3 | -------------------------------------------------------------------------------- /docs/logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivaprsd/doq/HEAD/docs/logo/logo.png -------------------------------------------------------------------------------- /docs/screenshots/reader-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivaprsd/doq/HEAD/docs/screenshots/reader-dark.png -------------------------------------------------------------------------------- /docs/screenshots/addon-toolbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivaprsd/doq/HEAD/docs/screenshots/addon-toolbar.png -------------------------------------------------------------------------------- /docs/screenshots/reader-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivaprsd/doq/HEAD/docs/screenshots/reader-light.png -------------------------------------------------------------------------------- /docs/screenshots/toolbar-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivaprsd/doq/HEAD/docs/screenshots/toolbar-light.png -------------------------------------------------------------------------------- /docs/screenshots/toolbar-collapsed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shivaprsd/doq/HEAD/docs/screenshots/toolbar-collapsed.png -------------------------------------------------------------------------------- /addon/images/checkMark.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | function getViewerEventBus(app) { 2 | app = app ?? window.PDFViewerApplication; 3 | const task = (resolve, reject) => { 4 | if (!app) { 5 | reject("No PDF.js viewer found in the current exectution context!"); 6 | return; 7 | } 8 | const passEventBus = () => resolve(app.eventBus); 9 | if (app.initializedPromise) { 10 | passEventBus(); 11 | } else { 12 | document.addEventListener("webviewerloaded", passEventBus); 13 | } 14 | } 15 | return new Promise(task); 16 | } 17 | 18 | export { getViewerEventBus }; 19 | -------------------------------------------------------------------------------- /docs/logo/logo-plain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doq", 3 | "description": "The missing reader mode/color schemes tool for PDFs.", 4 | "homepage": "https://github.com/shivaprsd/doq", 5 | "keywords": [ "pdf", "pdf.js", "reader mode", "color schemes" ], 6 | "license": "MIT", 7 | "author": "Shiva Prasad ", 8 | "contributors": [], 9 | "dependencies": {}, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/shivaprsd/doq.git" 13 | }, 14 | "type": "module", 15 | "target": "@mozilla/pdf.js", 16 | "main": "lib/doq.js", 17 | "version": "2.4", 18 | "pdfjs_version": { 19 | "minimum": "3.2.146", 20 | "target": "4.8.69" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /addon/images/readerIcon.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /addon/images/optionsIcon.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /docs/logo/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addon/options.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "schema", 3 | "properties": { 4 | "autoReader": { 5 | "title": "Auto reader mode", 6 | "description": "Whether to automatically apply the last-used reader theme at launch.", 7 | "type": "boolean", 8 | "default": true 9 | }, 10 | "dyanamicTheme": { 11 | "title": "Dynamic theme", 12 | "description": "Whether to save separate last-used preferences for OS light/dark themes.", 13 | "type": "boolean", 14 | "default": true 15 | }, 16 | "filterCSS": { 17 | "title": "Filter mode CSS", 18 | "description": "CSS property value to use for the Filter mode. Accepted filters: 'brightness', 'contrast', 'grayscale', 'hue-rotate', 'invert', 'saturate', and 'sepia'.", 19 | "type": "string", 20 | "pattern": "^((brightness|contrast|grayscale|hue-rotate|invert|saturate|sepia)\\([^\\)]+\\)(\\s+|$))+$", 21 | "default": "invert(86%) hue-rotate(180deg)" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 Shiva Prasad 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /addon/app/toolbar.js: -------------------------------------------------------------------------------- 1 | 2 | import { DOQ } from "./config.js"; 3 | 4 | export function handleKeyDown(e) { 5 | if (e.code === "Tab") { 6 | DOQ.config.readerToolbar.classList.add("tabMode"); 7 | } else if (e.code === "Escape") { 8 | closeToolbar(); 9 | e.target.blur(); 10 | e.preventDefault(); 11 | } 12 | } 13 | 14 | export function closeToolbar(e) { 15 | const { config } = DOQ; 16 | const toolbar = config.readerToolbar; 17 | 18 | if (toolbar.contains(e?.target) || e?.target === config.viewReader) { 19 | return; 20 | } 21 | if (!toolbar.classList.contains("hidden")) { 22 | toggleToolbar(); 23 | } 24 | } 25 | 26 | export function toggleToolbar() { 27 | const { config } = DOQ; 28 | const hidden = config.readerToolbar.classList.toggle("hidden"); 29 | 30 | config.viewReader.classList.toggle("toggled"); 31 | config.viewReader.setAttribute("aria-expanded", !hidden); 32 | if (hidden) { 33 | toggleOptions(/*collapse = */true); 34 | } else { 35 | const activeEditor = "#editorModeButtons .toggled"; 36 | document.querySelector(activeEditor)?.dispatchEvent(new Event("click")); 37 | } 38 | } 39 | 40 | export function toggleOptions(collapse) { 41 | const { config } = DOQ; 42 | const panel = config.readerToolbar.querySelector(".optionsPanel"); 43 | const collapsed = panel.classList.toggle("collapsed", collapse); 44 | config.optionsToggle.checked = !collapsed; 45 | } 46 | -------------------------------------------------------------------------------- /addon/app/config.js: -------------------------------------------------------------------------------- 1 | 2 | import { DOQ } from "../lib/engine.js"; 3 | 4 | Object.assign(DOQ, { 5 | config: {}, 6 | preferences: {}, 7 | options: { 8 | autoReader: true, 9 | dynamicTheme: true, 10 | filterCSS: "" 11 | } 12 | }); 13 | 14 | /* CSS filter syntax: BOL [()]+ EOL */ 15 | const filterRegEx = 16 | /^((brightness|contrast|grayscale|hue-rotate|invert|saturate|sepia)\([^\)]+\)(\s+|$))+$/; 17 | 18 | function getDefaultPrefs() { 19 | return { 20 | scheme: 0, tone: "0", 21 | flags: { shapesOn: true, imagesOn: true } 22 | }; 23 | } 24 | 25 | function initConfig() { 26 | const config = getAddonConfig(); 27 | DOQ.colorSchemes.forEach(scheme => { 28 | config.schemeSelector.appendChild(new Option(scheme.name)); 29 | }); 30 | 31 | /* Legacy PDF.js support */ 32 | const pdfjsVer = pdfjsLib.version.split(".").map(Number); 33 | if (pdfjsVer[0] < 4 || pdfjsVer[1] < 7) { 34 | if (pdfjsVer[0] < 3) { 35 | console.warn("doq: unsupported PDF.js version " + pdfjsLib.version); 36 | } 37 | config.viewReader.classList.add("pdfjsLegacy"); 38 | config.readerToolbar.classList.add("pdfjsLegacy"); 39 | } 40 | DOQ.config = config; 41 | } 42 | 43 | function getAddonConfig() { 44 | return { 45 | sysTheme: window.matchMedia("(prefers-color-scheme: light)"), 46 | docStyle: document.documentElement.style, 47 | viewReader: document.getElementById("viewReader"), 48 | readerToolbar: document.getElementById("readerToolbar"), 49 | schemeSelector: document.getElementById("schemeSelect"), 50 | tonePicker: document.getElementById("tonePicker"), 51 | shapeToggle: document.getElementById("shapeEnable"), 52 | imageToggle: document.getElementById("imageEnable"), 53 | optionsToggle: document.getElementById("optionsToggle"), 54 | viewerClassList: document.getElementById("outerContainer").classList, 55 | viewer: document.getElementById("viewerContainer") 56 | }; 57 | } 58 | 59 | export { DOQ, initConfig, getDefaultPrefs, filterRegEx }; 60 | -------------------------------------------------------------------------------- /addon/app/prefs.js: -------------------------------------------------------------------------------- 1 | 2 | import { DOQ, getDefaultPrefs } from "./config.js"; 3 | 4 | /* Preferences */ 5 | function readPreferences() { 6 | const prefs = getDefaultPrefs(); 7 | const theme = getSysTheme(); 8 | const store = JSON.parse(localStorage.getItem(`doq.preferences.${theme}`)); 9 | 10 | for (const key in store) { 11 | const value = store[key]; 12 | if (key in prefs && typeof value === typeof prefs[key]) { 13 | prefs[key] = value; 14 | } 15 | } 16 | DOQ.preferences = prefs; 17 | return prefs; 18 | } 19 | 20 | function updatePreference(key, value) { 21 | const prefs = DOQ.preferences; 22 | const theme = getSysTheme(); 23 | 24 | if (key in prefs.flags) { 25 | prefs.flags[key] = DOQ.flags[key]; 26 | } else if (key in prefs && typeof value === typeof prefs[key]) { 27 | prefs[key] = value; 28 | } 29 | localStorage.setItem(`doq.preferences.${theme}`, JSON.stringify(prefs)); 30 | } 31 | 32 | function getSysTheme() { 33 | const { options, config } = DOQ; 34 | const light = !options.dynamicTheme || config.sysTheme.matches; 35 | return light ? "light" : "dark"; 36 | } 37 | 38 | /* TEMPORARY: keep user prefs while adding Chromium theme */ 39 | function migratePrefs() { 40 | if (localStorage.getItem("doq.migrated-chromium-theme")) { 41 | return; 42 | } 43 | for (const theme of ["light", "dark"]) { 44 | const store = JSON.parse(localStorage.getItem(`doq.preferences.${theme}`)); 45 | if (store?.scheme !== undefined) { 46 | ++store.scheme; 47 | localStorage.setItem(`doq.preferences.${theme}`, JSON.stringify(store)); 48 | } 49 | } 50 | localStorage.setItem("doq.migrated-chromium-theme", "true"); 51 | } 52 | 53 | function readOptions() { 54 | const store = JSON.parse(localStorage.getItem("doq.options")); 55 | const { options } = DOQ; 56 | 57 | for (const key in options) { 58 | if (typeof options[key] === typeof store?.[key]) { 59 | options[key] = store[key]; 60 | } 61 | } 62 | return options; 63 | } 64 | 65 | export { readOptions, readPreferences, updatePreference, migratePrefs }; 66 | -------------------------------------------------------------------------------- /addon/doq.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | Reader Mode 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 28 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 41 | Apply to Shapes 43 | 44 | 45 | 47 | Blend Images 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | 2 | import { DOQ, wrapCanvas, addColorScheme, setCanvasTheme } from "./engine.js"; 3 | 4 | async function init(themes) { 5 | if (DOQ.initialized) 6 | return; 7 | 8 | if (themes === undefined) { 9 | themes = await loadThemes("colors.json"); 10 | } else if (!Array.isArray(themes)) { 11 | throw new Error("doq: argument 'themes' must be an array"); 12 | } 13 | themes.forEach(addTheme); 14 | 15 | try { 16 | wrapCanvas(); 17 | } catch (e) { 18 | throw new Error(`doq: unable to modify Canvas API: ${e.message}`); 19 | } 20 | DOQ.initialized = true; 21 | } 22 | 23 | async function loadThemes(path) { 24 | let themes = []; 25 | try { 26 | const url = new URL(path, import.meta.url); 27 | themes = await fetch(url).then(response => response.json()); 28 | } catch (e) { 29 | console.error(`doq: failed to load default themes: ${e.message}`); 30 | } 31 | return themes; 32 | } 33 | 34 | function addTheme(theme) { 35 | if (!theme.tones?.length) { 36 | throw new Error("doq: a theme should have at least one tone!"); 37 | } 38 | try { 39 | addColorScheme(theme); 40 | } catch (e) { 41 | throw new Error(`doq: failed to add theme: ${e.message}`); 42 | } 43 | } 44 | 45 | function setTheme(arg) { 46 | let scheme, tone; 47 | if (Array.isArray(arg)) { 48 | arg = arg.splice(0, 2); 49 | [scheme, tone] = arg; 50 | 51 | if (!DOQ.colorSchemes[scheme]?.tones[tone]) { 52 | throw new Error(`doq: no theme at index (${arg})`); 53 | } 54 | } else if (typeof arg === "string") { 55 | const args = arg.trim().split(/\s+/); 56 | [scheme, tone] = getIndex(...args); 57 | 58 | if (scheme < 0 || tone < 0) { 59 | throw new Error(`doq: no such theme: "${arg}"`); 60 | } 61 | } else { 62 | throw new Error("doq: argument must be array or string"); 63 | } 64 | setCanvasTheme(scheme, tone); 65 | DOQ.flags.engineOn = true; 66 | } 67 | 68 | function getIndex(schemeName, toneName) { 69 | const { colorSchemes } = DOQ; 70 | let scheme, tone = 0; 71 | 72 | scheme = colorSchemes.find(e => e.name === schemeName); 73 | if (scheme && toneName) { 74 | tone = scheme.tones.findIndex(e => e.name === toneName); 75 | } 76 | scheme = colorSchemes.indexOf(scheme); 77 | return [scheme, tone]; 78 | } 79 | 80 | function enable() { 81 | if (!DOQ.initialized) { 82 | throw new Error("doq: initialize with DOQ.init() before enabling"); 83 | } 84 | if (!DOQ.colorSchemes.length) { 85 | throw new Error("doq: cannot start theme engine: no themes found!"); 86 | } 87 | DOQ.flags.engineOn = true; 88 | } 89 | 90 | function disable() { 91 | DOQ.flags.engineOn = false; 92 | } 93 | 94 | export { init, addTheme, setTheme, enable, disable }; 95 | -------------------------------------------------------------------------------- /lib/colors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Chrome", 4 | "tones": [ 5 | { 6 | "name": "Yellow", 7 | "background": "#FEEFC3", 8 | "foreground": "#1F1F1F", 9 | "accents": ["#174EA6", "#681DA8"] 10 | }, 11 | { 12 | "name": "Blue", 13 | "background": "#D2E3FC", 14 | "foreground": "#1F1F1F", 15 | "accents": ["#174EA6", "#681DA8"] 16 | }, 17 | { 18 | "name": "Dark", 19 | "background": "#202124", 20 | "foreground": "#E3E3E3", 21 | "accents": ["#8AB4F8", "#D7AEFB"] 22 | } 23 | ] 24 | }, 25 | 26 | { 27 | "name": "Firefox", 28 | "tones": [ 29 | { 30 | "name": "Sepia", 31 | "background": "#F4ECD8", 32 | "foreground": "#5B4636", 33 | "accents": ["#0060DF", "#B5007F"] 34 | }, 35 | { 36 | "name": "Dark", 37 | "background": "#1C1B22", 38 | "foreground": "#EEEEEE", 39 | "accents": ["#45A1FF", "#E675FD"] 40 | } 41 | ] 42 | }, 43 | 44 | { 45 | "name": "Safari", 46 | "tones": [ 47 | { 48 | "name": "Sepia", 49 | "background": "#F8F1E3", 50 | "foreground": "#4F321C", 51 | "accents": ["#D19600"] 52 | }, 53 | { 54 | "name": "Gray", 55 | "background": "#4A4A4D", 56 | "foreground": "#D7D7D8", 57 | "accents": ["#5AC8FA"] 58 | }, 59 | { 60 | "name": "Night", 61 | "background": "#121212", 62 | "foreground": "#B0B0B0", 63 | "accents": ["#5AC8FA"] 64 | } 65 | ] 66 | }, 67 | 68 | { 69 | "name": "Nord", 70 | "tones": [ 71 | { 72 | "name": "Snow Storm", 73 | "background": "#ECEFF4", 74 | "foreground": "#3B4252" 75 | }, 76 | { 77 | "name": "Polar Night", 78 | "background": "#2E3440", 79 | "foreground": "#D8DEE9", 80 | "accents": ["#3B4252"] 81 | } 82 | ], 83 | "accents": [ 84 | "#434C5E", "#4C566A", "#E5E9F0", 85 | "#8FBCBB", "#88C0D0", "#81A1C1", "#5E81AC", 86 | "#BF616A", "#D08770", "#EBCB8B", "#A3BE8C", "#B48EAD" 87 | ] 88 | }, 89 | 90 | { 91 | "name": "Solarized", 92 | "tones": [ 93 | { 94 | "name": "Light", 95 | "background": "#FDF6E3", 96 | "foreground": "#657B83" 97 | }, 98 | { 99 | "name": "Dark", 100 | "background": "#002B36", 101 | "foreground": "#839496" 102 | } 103 | ], 104 | "accents": [ 105 | "#B58900", "#CB4B16", "#DC322F", "#D33682", 106 | "#6C71C4", "#268BD2", "#2AA198", "#859900" 107 | ] 108 | } 109 | ] 110 | -------------------------------------------------------------------------------- /addon/app/theme.js: -------------------------------------------------------------------------------- 1 | 2 | import { DOQ, filterRegEx } from "./config.js"; 3 | import { updateReaderColors } from "./reader.js"; 4 | import { readOptions, readPreferences, updatePreference } from "./prefs.js"; 5 | 6 | function updateReaderState(e) { 7 | const { config } = DOQ; 8 | const options = readOptions(); 9 | const prefs = readPreferences(); 10 | 11 | Object.assign(DOQ.flags, prefs.flags); 12 | config.imageToggle.checked = prefs.flags.imagesOn; 13 | config.shapeToggle.checked = prefs.flags.shapesOn; 14 | config.schemeSelector.selectedIndex = prefs.scheme; 15 | 16 | const { dynamicTheme, filterCSS } = options; 17 | if (filterRegEx.test(filterCSS) && CSS.supports("filter", filterCSS)) { 18 | config.docStyle.setProperty("--filter-css", filterCSS); 19 | } else if (filterCSS !== "") { 20 | console.warn(`doq: unsupported filter property: "${filterCSS}"`); 21 | } 22 | if (!e || dynamicTheme) { 23 | updateColorScheme(e); 24 | } 25 | } 26 | 27 | function updateColorScheme(e) { 28 | const { config, options, preferences, colorSchemes } = DOQ; 29 | const index = config.schemeSelector.selectedIndex; 30 | const scheme = colorSchemes[index]; 31 | 32 | if (!scheme.tones || !scheme.tones.length) { 33 | return; 34 | } 35 | if (scheme.tones.length > 3) { 36 | console.warn("doq: can show up to three tones only; ignoring the rest."); 37 | } 38 | const picker = config.tonePicker; 39 | refreshTonePicker(picker, scheme); 40 | 41 | if (index !== preferences.scheme) { 42 | updatePreference("scheme", index); 43 | updatePreference("tone", "1"); 44 | } 45 | const prefTone = (e || options.autoReader) ? preferences.tone : 0; 46 | picker.elements[prefTone].checked = true; 47 | updateReaderColors(e); 48 | } 49 | 50 | function refreshTonePicker(picker, scheme) { 51 | const toneWgt = picker.querySelector("template"); 52 | picker.innerHTML = toneWgt.outerHTML; 53 | 54 | let i = 0; 55 | picker.appendChild(cloneWidget(toneWgt, "origTone", "Original", i++)); 56 | scheme.tones.slice(0, 3).forEach(tone => { 57 | picker.appendChild(cloneWidget(toneWgt, null, null, i++, tone)); 58 | }); 59 | picker.appendChild(cloneWidget(toneWgt, "filterTone", "Filter", i)); 60 | picker.lastElementChild.classList.add("filter"); 61 | } 62 | 63 | function cloneWidget(template, id, title, value, tone) { 64 | const widget = template.content.cloneNode(true); 65 | const [input, label] = widget.children; 66 | 67 | title = title ?? tone.name; 68 | input.value = value; 69 | input.id = label.htmlFor = id ?? "tone" + title; 70 | input.setAttribute("aria-label", title); 71 | label.title = title; 72 | label.style.color = tone?.foreground; 73 | label.style.backgroundColor = tone?.background; 74 | return widget; 75 | } 76 | 77 | export { updateReaderState, updateColorScheme }; 78 | -------------------------------------------------------------------------------- /addon/doq.js: -------------------------------------------------------------------------------- 1 | 2 | import * as doqAPI from "./lib/api.js"; 3 | import { addColorScheme } from "./lib/engine.js"; 4 | import { monitorAnnotationParams, handleInput } from "./lib/annots.js"; 5 | 6 | import { DOQ, initConfig } from "./app/config.js"; 7 | import { migratePrefs } from "./app/prefs.js"; 8 | import { updateReaderState, updateColorScheme } from "./app/theme.js"; 9 | import { initReader, updateReaderColors, toggleFlags } from "./app/reader.js"; 10 | import * as Toolbar from "./app/toolbar.js"; 11 | 12 | /* Initialisation */ 13 | if (typeof window !== "undefined" && globalThis === window) { 14 | if (window.PDFViewerApplication) { 15 | const { readyState } = document; 16 | 17 | if (readyState === "interactive" || readyState === "complete") { 18 | installAddon(); 19 | } else { 20 | document.addEventListener("DOMContentLoaded", installAddon, true); 21 | } 22 | } 23 | window.DOQ = doqAPI; 24 | } else { 25 | console.error("doq: this script should be run in a browser environment"); 26 | } 27 | 28 | async function installAddon() { 29 | const getURL = path => new URL(path, import.meta.url); 30 | const colors = await fetch(getURL("lib/colors.json")).then(resp => resp.json()); 31 | linkCSS(getURL("doq.css")); 32 | fetch(getURL("doq.html")) 33 | .then(response => response.text()).then(installUI) 34 | .then(() => load(colors)); 35 | } 36 | 37 | function linkCSS(href) { 38 | const link = document.createElement("link"); 39 | link.rel = "stylesheet"; 40 | link.href = href; 41 | document.head.appendChild(link); 42 | } 43 | 44 | function installUI(html) { 45 | const docFrag = document.createRange().createContextualFragment(html); 46 | const toolbar = document.getElementById("toolbarViewerRight"); 47 | toolbar.prepend(docFrag.getElementById("toolbarAddon").content); 48 | } 49 | 50 | function load(colorSchemes) { 51 | colorSchemes.forEach(addColorScheme); 52 | initReader(); 53 | initConfig(); 54 | migratePrefs(); /* TEMPORARY */ 55 | updateReaderState(); 56 | bindEvents(); 57 | } 58 | 59 | /* Event listeners */ 60 | function bindEvents() { 61 | const { config, flags } = DOQ; 62 | config.sysTheme.onchange = updateReaderState; 63 | config.schemeSelector.onchange = updateColorScheme; 64 | config.tonePicker.onchange = updateReaderColors; 65 | config.shapeToggle.onchange = config.imageToggle.onchange = toggleFlags; 66 | monitorAnnotationParams(); 67 | 68 | config.viewReader.onclick = Toolbar.toggleToolbar; 69 | config.optionsToggle.onchange = e => Toolbar.toggleOptions(); 70 | config.schemeSelector.onclick = e => { 71 | config.readerToolbar.classList.remove("tabMode"); 72 | }; 73 | config.viewer.addEventListener("input", handleInput); 74 | 75 | window.addEventListener("beforeprint", e => flags.isPrinting = true); 76 | window.addEventListener("afterprint", e => flags.isPrinting = false); 77 | window.addEventListener("click", Toolbar.closeToolbar); 78 | window.addEventListener("keydown", Toolbar.handleKeyDown); 79 | } 80 | -------------------------------------------------------------------------------- /addon/app/reader.js: -------------------------------------------------------------------------------- 1 | 2 | import { DOQ } from "./config.js"; 3 | import { updatePreference } from "./prefs.js"; 4 | import { wrapCanvas, setCanvasTheme } from "../lib/engine.js"; 5 | import { redrawAnnotation } from "../lib/annots.js"; 6 | 7 | function initReader() { 8 | const cvsp = HTMLCanvasElement.prototype; 9 | const origGetContext = cvsp.getContext; 10 | cvsp.getContext = function() { 11 | const pageNum = this.closest(".page")?.dataset.pageNumber; 12 | if (pageNum) { 13 | this.setAttribute("data-cache-id", "page" + pageNum); 14 | } 15 | return origGetContext.apply(this, arguments); 16 | }; 17 | wrapCanvas(); 18 | DOQ.initialized = true; 19 | } 20 | 21 | function updateReaderColors(e) { 22 | const { config } = DOQ; 23 | const picker = config.tonePicker; 24 | const pick = picker.readerTone.value; 25 | const sel = config.schemeSelector.selectedIndex; 26 | const redraw = e?.isTrusted; 27 | 28 | if (pick == 0) { 29 | disableReader(redraw); 30 | disableFilter(); 31 | } else if (pick == picker.elements.length - 1) { 32 | enableFilter(redraw); 33 | } else { 34 | const readerTone = setCanvasTheme(sel, +pick - 1); 35 | const isDarkTone = readerTone.colors.bg.lightness < 50; 36 | config.docStyle.setProperty("--reader-bg", readerTone.background); 37 | disableFilter(); 38 | enableReader(redraw, isDarkTone); 39 | } 40 | updatePreference("tone", pick); 41 | } 42 | 43 | function enableReader(redraw, isDarkTheme) { 44 | const { viewerClassList } = DOQ.config; 45 | viewerClassList.add("reader"); 46 | viewerClassList.toggle("dark", isDarkTheme); 47 | DOQ.flags.engineOn = true; 48 | if (redraw) { 49 | forceRedraw(); 50 | } 51 | } 52 | 53 | function disableReader(redraw) { 54 | const { config, flags } = DOQ; 55 | if (!flags.engineOn) { 56 | return; 57 | } 58 | config.viewerClassList.remove("reader", "dark"); 59 | flags.engineOn = false; 60 | if (redraw) { 61 | forceRedraw(); 62 | } 63 | } 64 | 65 | function enableFilter(redraw) { 66 | if (DOQ.flags.engineOn) { 67 | disableReader(redraw); 68 | } 69 | DOQ.config.viewerClassList.add("filter"); 70 | } 71 | 72 | function disableFilter() { 73 | DOQ.config.viewerClassList.remove("filter"); 74 | } 75 | 76 | function toggleFlags(e) { 77 | const { flags } = DOQ; 78 | const flag = e.target.id.replace("Enable", "sOn"); 79 | 80 | flags[flag] = e.target.checked; 81 | updatePreference(flag); 82 | if (flags.engineOn) { 83 | forceRedraw(); 84 | } 85 | } 86 | 87 | function forceRedraw() { 88 | const { pdfViewer, pdfThumbnailViewer } = window.PDFViewerApplication; 89 | const annotations = pdfViewer.pdfDocument?.annotationStorage.getAll(); 90 | 91 | Object.values(annotations || {}).forEach(redrawAnnotation); 92 | pdfViewer._pages.filter(e => e.renderingState).forEach(e => e.reset()); 93 | pdfThumbnailViewer._thumbnails.filter(e => e.renderingState) 94 | .forEach(e => e.reset()); 95 | window.PDFViewerApplication.forceRendering(); 96 | } 97 | 98 | export { initReader, updateReaderColors, toggleFlags }; 99 | -------------------------------------------------------------------------------- /lib/annots.js: -------------------------------------------------------------------------------- 1 | 2 | import { checkFlags, getCanvasStyle } from "./engine.js"; 3 | import { getViewerEventBus } from "./utils.js"; 4 | 5 | function monitorAnnotationParams() { 6 | getViewerEventBus().then(eventBus => { 7 | eventBus.on("annotationeditorlayerrendered", redrawHighlights); 8 | eventBus.on("switchannotationeditorparams", recolorSelectedAnnots); 9 | }) 10 | } 11 | const monitorHighlights = new MutationObserver((records, _) => { 12 | records.forEach(recolorNewHighlights); 13 | }); 14 | 15 | function redrawAnnotation(annot) { 16 | if (annot.name === "highlightEditor") { 17 | /* pass; highlights are rendered as SVGs _inside_ the canvasWrapper, 18 | so they are better handled _after_ the page is rendered (see below). */ 19 | } else if (annot.name === "freeTextEditor") { 20 | recolorFreeTextAnnot(annot.editorDiv); 21 | } else { 22 | if (annot.name === "stampEditor") { 23 | /* There is no public API to force repaint of a stamp annotation; 24 | nullifying its parent _tricks_ PDF.js into recreating its canvas. */ 25 | annot.parent = null; 26 | annot.div.querySelector("canvas")?.remove(); 27 | } 28 | annot.rebuild(); 29 | } 30 | } 31 | 32 | function redrawHighlights(e) { 33 | if (!checkFlags()) { 34 | return; 35 | } 36 | const canvasWrapper = e.source.div.querySelector(".canvasWrapper"); 37 | canvasWrapper.querySelectorAll("svg.highlight").forEach(recolorHighlight); 38 | monitorHighlights.observe(canvasWrapper, { childList: true }); 39 | } 40 | 41 | function handleInput(e) { 42 | if (!checkFlags()) { 43 | return; 44 | } 45 | const { target } = e; 46 | const isFreeText = target.matches?.(".freeTextEditor > .internal"); 47 | 48 | if (isFreeText && !target.style.getPropertyValue("--free-text-color")) { 49 | recolorFreeTextAnnot(target); 50 | } 51 | } 52 | 53 | function recolorSelectedAnnots(e) { 54 | if (!checkFlags()) { 55 | return; 56 | } 57 | if (e.type === pdfjsLib.AnnotationEditorParamsType.FREETEXT_COLOR) { 58 | document.querySelectorAll(".freeTextEditor.selectedEditor > .internal") 59 | .forEach(recolorFreeTextAnnot); 60 | } 61 | } 62 | 63 | function recolorFreeTextAnnot(editor) { 64 | const newColor = getCanvasStyle(editor.style.color); 65 | 66 | if (editor.style.getPropertyValue("--free-text-color") !== newColor) { 67 | editor.style.setProperty("--free-text-color", newColor); 68 | } 69 | } 70 | 71 | function recolorNewHighlights(mutationRecord) { 72 | const { target } = mutationRecord; 73 | const recolor = node => { 74 | if (node.matches("svg.highlight")) { 75 | recolorHighlight(node); 76 | } 77 | }; 78 | recolor(target); 79 | mutationRecord.addedNodes.forEach(recolor); 80 | } 81 | 82 | function recolorHighlight(annot) { 83 | const newColor = getCanvasStyle(annot.getAttribute("fill")); 84 | const alreadyObserved = annot.style.fill !== ""; 85 | annot.style.setProperty("fill", newColor); 86 | if (!alreadyObserved) { 87 | monitorHighlights.observe(annot, { attributeFilter: ["fill"] }); 88 | } 89 | } 90 | 91 | export { monitorAnnotationParams, redrawAnnotation, handleInput }; 92 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v2.4 (2024-07-07) 2 | 3 | - Update: target PDF.js v4.1: support Stamps and Highlights 4 | - Feature: enable non-viewer integration and general usage 5 | - Refactor: separate the theme engine, components into modules 6 | - Refactor: make awaiting PDF.js eventBus reusable 7 | - Improvement: wrap setters to proactively calculate styles 8 | - Improvement: drop storing canvas, minimize checking background 9 | - Fix: map colors to gradient if no accents were provided 10 | 11 | 12 | # v2.3 (2023-02-08) 13 | 14 | ### Release version 2.3 15 | 16 | - Update: target PDF.js 3.2, but retain legacy compatibility 17 | - Update: new reader icon to match the revamped PDF.js UI 18 | - Improvement: optimize saving canvas for faster rendering 19 | - Feature: theme support for added text annotations 20 | - Fix: avoid redraw when simply disabling invert mode 21 | - Improvement: refactor method wrapping 22 | 23 | 24 | # v2.2 (2022-10-30) 25 | 26 | ### Release version 2.2 27 | 28 | - Update: target PDF.js version 2.16 29 | - Improvement: handle transparent canvas styles 30 | - Improvement: color calc based on BG of the text centre 31 | - Feature: support for PDF.js annotation editor 32 | - Fix: separate canvas data for different pages 33 | - Fix: apply invert filter to individual pages 34 | 35 | 36 | # v2.1 (2022-05-21) 37 | 38 | ### Release version 2.1 39 | 40 | - Update: target PDF.js version 2.13 41 | - Add themes: Nord and Firefox reader view 42 | - Revamp UI: neat, minimal & simplified toolbar 43 | - Feature: separate preferences based on OS theme 44 | - Feature: configurable options to control behaviour 45 | - Improvement: accessibility and color schemes 46 | - Improvement: better performance when reader is off 47 | - Fix: printing, thumbnails & some minor errors 48 | 49 | 50 | # v2.0 (2021-11-05) 51 | 52 | ### Release version 2.0 53 | 54 | Major update, almost a complete rewrite. See c941877, 4b42993. 55 | 56 | #### Changes 57 | 58 | - Name, logo, core functionality: convert to Reader mode 59 | - Stop tweaking the text layer; act directly on the canvas 60 | - Properly apply a color scheme: preserve monotones and accents 61 | - Accurate color transformations based on color science 62 | - Process/leave images instead of show/hiding them 63 | - Move the UI to its own separate (accessible) toolbar 64 | - Remove the PDF.js wrapper; this is now addon-only 65 | 66 | 67 | # v1.2 (2021-01-25) 68 | 69 | ### Release version 1.2 70 | 71 | - Feature: support multiple color schemes 72 | - Feature: gestures for toggling toolbar and scrolling 73 | - Improvement: change font resize buttons to html slider 74 | - Fix: random text inflation in iOS Safari 75 | - Fix: URL encode the path to the PDF file 76 | - Refactor: linking CSS 77 | 78 | 79 | # v1.1 (2020-12-19) 80 | 81 | ### Release version 1.1 82 | 83 | - Fix: make font resize persistent across page loads 84 | - Feature: make Terminal Mode and Lights Off mutually exclusive 85 | - Feature: plugin buttons are now highlighted when toggled On 86 | - Feature: toolbar buttons now disappear only in small views 87 | - Add: all plugin controls can now be accessed using Tab key 88 | - Improvement: minor code refactor 89 | 90 | 91 | # v1.0 (2020-12-17) 92 | 93 | ### Release version 1.0 94 | -------------------------------------------------------------------------------- /addon/doq.css: -------------------------------------------------------------------------------- 1 | /* Viewer customizations */ 2 | :root { 3 | --reader-bg: #F8F1E3; 4 | --filter-css: invert(86%) hue-rotate(180deg); 5 | } 6 | .reader .pdfViewer .page, .reader .thumbnailImage { 7 | background-color: var(--reader-bg) !important; 8 | } 9 | .reader .freeTextEditor > .internal { 10 | --free-text-color: #000000; 11 | color: var(--free-text-color) !important; 12 | } 13 | .reader.dark .canvasWrapper > .highlight { 14 | --blend-mode: overlay; 15 | } 16 | .filter :is(.page, .thumbnailImage), .colorSwatch.filter { 17 | filter: var(--filter-css); 18 | } 19 | 20 | /* Widgets */ 21 | .colorSwatch { 22 | display: inline-block; 23 | vertical-align: middle; 24 | text-align: center; 25 | box-sizing: border-box; 26 | height: 26px; 27 | width: 26px; 28 | border-radius: 50%; 29 | border: 0.5px solid var(--field-border-color); 30 | background-color: #ffffff; 31 | color: #000000; 32 | margin: 4px; 33 | cursor: pointer; 34 | } 35 | .maskIcon { 36 | display: block; 37 | width: 20px; 38 | height: 20px; 39 | text-align: center; 40 | color: var(--main-color); 41 | opacity: var(--toolbar-icon-opacity); 42 | cursor: pointer; 43 | } 44 | 45 | .maskIcon::before, :checked + .colorSwatch::before { 46 | display: inline-block; 47 | content: ""; 48 | width: 16px; 49 | height: 16px; 50 | margin-top: 4px; 51 | vertical-align: top; 52 | background-color: currentcolor; 53 | -webkit-mask-size: cover; 54 | mask-size: cover; 55 | } 56 | .maskIcon::before { 57 | margin-top: 2px; 58 | } 59 | 60 | /* Toolbar layout */ 61 | #readerToolbar { 62 | width: max-content; 63 | font-size: 0; 64 | background-color: var(--toolbar-bg-color); 65 | -webkit-user-select: none; 66 | user-select: none; 67 | } 68 | #readerToolbar::after { 69 | border-bottom-color: var(--toolbar-bg-color); 70 | } 71 | #readerToolbar > div { 72 | padding: 6px; 73 | } 74 | 75 | /* Panel layouts */ 76 | .mainPanel { 77 | max-width: 170px; 78 | padding-bottom: 4px; 79 | } 80 | .mainPanel > :first-child { 81 | margin-bottom: 6px; 82 | } 83 | .mainPanel > :last-child { 84 | display: flex; 85 | align-items: center; 86 | padding-left: 4px; 87 | padding-right: 2px; 88 | } 89 | .optionsPanel { 90 | padding: 0 4px; 91 | height: 74px; 92 | overflow-y: hidden; 93 | transition-property: height, opacity, visibility; 94 | transition-duration: var(--sidebar-transition-duration); 95 | } 96 | .optionsPanel.collapsed { 97 | height: 0; 98 | opacity: 0; 99 | visibility: hidden; 100 | } 101 | .optionsPanel > div { 102 | display: block; 103 | position: relative; 104 | } 105 | .optionsPanel > :first-child { 106 | margin-top: 8px; 107 | } 108 | .optionsPanel > :last-child { 109 | margin-top: 6px; 110 | margin-bottom: 4px; 111 | } 112 | 113 | /* Main panel */ 114 | #tonePicker { 115 | position: relative; 116 | text-align: center; 117 | } 118 | #schemeSelectContainer { 119 | position: relative; 120 | min-width: 94px; 121 | width: auto; 122 | flex-grow: 1; 123 | margin: 0 !important; 124 | } 125 | #schemeSelect { 126 | width: 100%; 127 | } 128 | #optionsToggleContainer { 129 | --button-hover-color: transparent; 130 | --toggled-btn-bg-color: transparent; 131 | position: relative; 132 | flex-shrink: 0; 133 | margin-inline-start: 8px; 134 | } 135 | #optionsToggle { 136 | margin: 3px; 137 | } 138 | #optionsToggleContainer .maskIcon { 139 | transition-property: transform; 140 | transition-duration: var(--sidebar-transition-duration); 141 | } 142 | #optionsToggle:checked + div > .maskIcon { 143 | transform: rotate(-22.5deg); 144 | } 145 | /* Options Panel */ 146 | .optionsPanel .toolbarLabel { 147 | display: flex; 148 | width: 100%; 149 | padding: 7px; 150 | } 151 | .optionsPanel .toggleButton { 152 | border-radius: 2px; 153 | } 154 | 155 | /* Icons */ 156 | #viewReader.toolbarButton::before { 157 | -webkit-mask-image: url(images/readerIcon.svg); 158 | mask-image: url(images/readerIcon.svg); 159 | } 160 | #tonePicker > :checked + .colorSwatch::before { 161 | -webkit-mask-image: url(images/checkMark.svg); 162 | mask-image: url(images/checkMark.svg); 163 | } 164 | #optionsToggleContainer .maskIcon::before { 165 | -webkit-mask-image: url(images/optionsIcon.svg); 166 | mask-image: url(images/optionsIcon.svg); 167 | } 168 | 169 | /* Form controls */ 170 | #readerToolbar input[type="radio"] { 171 | margin: 10px 0 3px 7px; 172 | position: absolute !important; 173 | top: 0; 174 | opacity: 0; 175 | pointer-events: none; 176 | } 177 | #readerToolbar input[type="checkbox"] { 178 | pointer-events: none; 179 | } 180 | 181 | /* Accessibility */ 182 | .tabMode #schemeSelectContainer:focus-within, 183 | #readerToolbar input:focus-visible + :is(label, div) { 184 | outline: 5px auto; 185 | } 186 | 187 | /* For PDF.js legacy (< 4.7) versions */ 188 | .pdfjsLegacy#viewReader::before { 189 | top: unset; 190 | } 191 | .pdfjsLegacy .editorParamsToolbarContainer { 192 | width: max-content; 193 | } 194 | .pdfjsLegacy input[type="checkbox"] { 195 | position: absolute; 196 | opacity: 0; 197 | } 198 | .pdfjsLegacy .toolbarLabel { 199 | box-sizing: border-box; 200 | justify-content: center; 201 | } 202 | .pdfjsLegacy :is(.toolbarLabel:hover, :focus-visible + label) { 203 | background-color: var(--button-hover-color); 204 | } 205 | .pdfjsLegacy :checked + .toolbarLabel { 206 | background-color: var(--toggled-btn-bg-color); 207 | } 208 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |  2 | 3 | # doq 4 | 5 | *doq* (pronounced as *doc-HUE*) is an add-on for Mozilla's excellent 6 | [PDF.js][1] PDF viewer. 7 | 8 | It adds a **reader mode** where you can change the *colors* of the rendered PDF 9 | files, to make it more comfortable to the eyes while reading. It works similar 10 | to the reader mode in web browsers and theme options in eBook readers (except 11 | that it cannot change the fonts or reflow text). 12 | 13 | *doq* was inspired by Safari's Reader View, and many terminal color schemes. 14 | 15 |  16 | 17 |  18 | 19 | ## Usage 20 | 21 | If you simply want to use *doq* to read PDFs, try [*doqment*][6]. It is a 22 | browser extension that bundles *doq* with PDF.js and opens all your PDF links. 23 | You can skip the rest of this section. 24 | 25 | *doq* is written as a native [ES6 module][2]; there is no bundled version. 26 | Hence it runs only in modern browsers that supports `import` and `export`. 27 | 28 | ### Installation 29 | 30 | For your own deployment of the PDF.js viewer: 31 | 32 | 1. [Download][3] the latest version (or clone this repo) 33 | 2. Copy the `addon/` and `lib/` directories to some directory on your server\ 34 | (e.g. `/pdfjs-dist/addons/doq/`) 35 | 3. Include `addon/doq.js` as a module in the `viewer.html` of your deployment: 36 | 37 | ```js 38 | 39 | ``` 40 | 4. The global add-on object can be accessed later as `window.DOQ`. 41 | 42 | Alternatively, to simply import as an ES6 module: 43 | `import doq from "path/to/lib/doq.js"`. 44 | 45 | Please check the exported functions of `lib/api.js` for the API for both the 46 | global add-on object and the module import. 47 | 48 | The add-on targets the default generic viewer of PDF.js. It should also work in 49 | custom viewers built on top of that. Feel free to open an issue if it breaks in 50 | your viewer. 51 | 52 | ### Defining colors 53 | 54 | Color schemes are defined in `lib/colors.json`, which you can extend. Only 55 | 6-digit RGB hex codes are currently supported. 56 | 57 | Each color scheme can have up to *three* tones. `background` and `foreground` 58 | will replace the white and black colors in the document respectively; they also 59 | define the gradient to which the rest of the greyscale gets interpolated. Other 60 | colors map to their nearest color among `accents`, which can be specified per 61 | tone and/or per scheme. They too get mapped to the gradient if no `accents` are 62 | supplied. 63 | 64 | Included by default are the Firefox, Safari Reader View themes and the 65 | [Solarized][4] color scheme. 66 | 67 | ### Reader options 68 | 69 | Deployments can configure the following options by writing key-value pairs 70 | directly to `doq.options` in Local storage (example follows): 71 | 72 | - `autoReader` [Boolean]: Whether to automatically apply the last-used reader 73 | theme at launch. Default `true`. 74 | 75 | - `dynamicTheme` [Boolean]: Whether to save separate last-used preferences for 76 | OS light/dark themes. Default `true`. 77 | 78 | - `filterCSS` [String]: CSS property value to use for the Filter mode. Allowed 79 | filter functions are `brightness`, `contrast`, `grayscale`, `hue-rotate`, 80 | `invert`, `saturate` and `sepia`. Default `"invert(86%) hue-rotate(180deg)"`. 81 | 82 | #### Example: 83 | 84 | ```js 85 | /* Options have to be set before loading doq */ 86 | const doqOptions = { dynamicTheme: false }; 87 | localStorage.setItem("doq.options", JSON.stringify(doqOptions)); 88 | ``` 89 | 90 | ## Features 91 | 92 |  93 | 94 | - **Reader mode**: applies the selected theme to the document's background, 95 | text and (optionally) to lines and other shapes. 96 | 97 | - **Blend images**: make images in the document blend with the new background 98 | (or text color in the case of dark themes). 99 | 100 | - **Filter mode**: simply apply a CSS filter to the PDF if that is all you want 101 | (faster but less pretty, especially with images). 102 | 103 | - **Intelligent application**: *doq* does not blindly change text color, but 104 | tries to ensure the legibility of the text against the background in which it 105 | is rendered. 106 | 107 | - **Color-science aware**: *doq* does color transformations in the 108 | perceptually-uniform [CIELAB color space][5]. 109 | 110 | - **Accessibility**: the add-on toolbar is designed, following WCAG guidelines, 111 | to be well accessible to keyboard/screen-reader users. 112 | 113 | - **Remember preferences**: *doq* loads the last used settings at launch, and 114 | also updates them dynamically, based on the OS theme in use. 115 | 116 | ### Performance 117 | 118 | *doq* recalculates the colors when the page is being rendered by PDF.js. This 119 | incurs a small overhead, slightly reducing the renderer's performance (this 120 | does not apply to the Filter mode as no processing is done there). *doq* tries 121 | to minimize this overhead with many optimizations (like caching the calculation 122 | results) so that speed improves after the initial render. 123 | 124 | (I guess this can be avoided altogether by implementing the logic directly 125 | within the PDF.js library by modifying the source. But that requires digging 126 | into PDF.js internals and also building and testing the entire thing, which I 127 | have zero experience with. Hence I chose the add-on route.) 128 | 129 | ## Why *doq*? 130 | 131 | Same reason that led Ethan Schoonover in developing the Solarized color scheme: 132 | **ergonomics for eyes**. It is best summarized by this quote from the Solarized 133 | Readme: 134 | 135 | > Black text on white from a computer display is akin to reading a book in 136 | > direct sunlight and tires the eye. 137 | 138 | PDFs are perhaps the single largest source of "black text on white" out there 139 | *that are not amenable to modification*. Designed to look the same everywhere, 140 | the PDF format, unlike EPUB or plain text, does not offer the flexibility of 141 | choosing its appearance while viewing. Yet it is the most popular document 142 | format in existence. Not much talk about ergonomics. 143 | 144 | Another point is **accessibility**. The ability to adjust the colors of 145 | documents can be immensly helpful to people with color vision deficiencies or 146 | other low-vision conditions. Document creators are usually *blind* to such 147 | concerns. 148 | 149 | The Web is heeding the call, with major browsers now having reader modes, and 150 | more and more websites providing dark/night/low-contrast versions on their own. 151 | But I could find scarcely any efforts in that direction in the domain of PDF 152 | viewing. None of the viewers I tried offered any simple way to change the PDF's 153 | appearance. In the end I decided to create a tool on my own. 154 | 155 | ### OK, but why *PDF.js*? 156 | 157 | Perhaps a web app is still not the best tool to view a PDF document; but they 158 | seem to be getting there. With modern browsers, PDF.js does a decent job, and 159 | is FireFox's built-in PDF viewer. Being familiar with web and JS, I saw it as 160 | the tool that I could quickly extend and develop my solution for, without 161 | needing to pore over thousands of lines of code of a low-level PDF library. It 162 | requires no additional software and is automatically cross-platform, meaning I 163 | could have my solution immediately available on my smartphone also, without 164 | much additional coding. 165 | 166 | The limitations do bug me sometimes. I would be delighted to see a *doq*-like 167 | feature added to other popular PDF viewers also. I plan to work towards that 168 | goal in future if time permits.\ 169 | *Eye ergonomics matter.* 170 | 171 | Suggestions and contributions are welcome! 172 | 173 | --- 174 | 175 | This project started out slightly differently; versions 1.x are now legacy. If 176 | interested, see v2.0 release notes for an overview of what changed, and why. 177 | 178 | [1]: https://mozilla.github.io/pdf.js/ 179 | [2]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules 180 | [3]: https://github.com/shivaprsd/doq/releases/latest 181 | [4]: https://ethanschoonover.com/solarized/ 182 | [5]: https://en.wikipedia.org/wiki/CIELAB_color_space 183 | [6]: https://github.com/shivaprsd/doqment 184 | -------------------------------------------------------------------------------- /lib/color.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface for storing and processing sRGB colors in CIELAB 3 | * Adapted from => https://github.com/LeaVerou/color.js 4 | */ 5 | export default class Color { 6 | constructor(...args) { 7 | if (Array.isArray(args[0])) { 8 | const [coords, space] = args; 9 | 10 | if (space === "lab") { 11 | this._lab = coords; 12 | } else { 13 | this._rgb = coords; 14 | } 15 | } else if (typeof args[0] === "string") { 16 | const str = args[0]; 17 | 18 | if (str[0] === "#" && str.length === 7) { 19 | this._hex = str; 20 | this._rgb = Color.parseHex(str); 21 | } else if (str.startsWith("rgb(")) { 22 | this._rgb = Color.parseRGB(str); 23 | } else if (str.startsWith("rgba(")) { 24 | [this._rgb, this._alpha] = Color.parseRGBA(str); 25 | } else { 26 | throw new Error(`Unsupported color format: "${str}"`); 27 | } 28 | } else { 29 | this._rgb = [0, 0, 0]; 30 | } 31 | } 32 | 33 | get hex() { 34 | this._hex = this._hex || this.toHex(); 35 | return this._hex; 36 | } 37 | 38 | get rgb() { 39 | this._rgb = this._rgb || sRGB.fromLab(this._lab); 40 | return this._rgb; 41 | } 42 | 43 | get lab() { 44 | this._lab = this._lab || sRGB.toLab(this._rgb); 45 | return this._lab; 46 | } 47 | 48 | get lightness() { 49 | return this.lab[0]; 50 | } 51 | 52 | get chroma() { 53 | const [L, a, b] = this.lab; 54 | return Math.sqrt(a ** 2 + b ** 2); 55 | } 56 | 57 | get alpha() { 58 | return this._alpha ?? 1; 59 | } 60 | 61 | deltaE(color) { 62 | return Math.sqrt(this.lab.reduce((a, c, i) => { 63 | if (isNaN(c) || isNaN(color.lab[i])) { 64 | return a; 65 | } 66 | return a + (color.lab[i] - c) ** 2; 67 | }, 0)); 68 | } 69 | 70 | range(color) { 71 | function interpolate(start, end, p) { 72 | if (isNaN(start)) { 73 | return end; 74 | } 75 | if (isNaN(end)) { 76 | return start; 77 | } 78 | return start + (end - start) * p; 79 | } 80 | return p => { 81 | const coords = this.lab.map((start, i) => { 82 | const end = color.lab[i]; 83 | return interpolate(start, end, p); 84 | }); 85 | return new Color(coords, "lab"); 86 | }; 87 | } 88 | 89 | toHex(alpha = 1) { 90 | let hex = this.rgb.map(Color.compToHex).join(""); 91 | if (alpha !== 1) { 92 | hex += Color.compToHex(alpha); 93 | } 94 | return "#" + hex; 95 | } 96 | 97 | static parseHex(str) { 98 | let rgba = []; 99 | str.replace(/[a-f0-9]{2}/gi, component => { 100 | rgba.push(parseInt(component, 16) / 255); 101 | }); 102 | return rgba.slice(0, 3); 103 | } 104 | 105 | static parseRGB(str) { 106 | return Color.parseRGBA(str.replace("rgb", "rgba"))[0]; 107 | } 108 | 109 | static parseRGBA(str) { 110 | const rgba = str.slice(5, -1).split(","); 111 | return [ 112 | rgba.slice(0, 3).map(c => parseInt(c) / 255), /* [r, g, b] */ 113 | parseFloat(rgba.pop()) /* alpha */ 114 | ]; 115 | } 116 | 117 | static compToHex(c) { 118 | c = Math.round(Math.min(Math.max(c * 255, 0), 255)); 119 | return c.toString(16).padStart(2, "0"); 120 | } 121 | } 122 | Color.white = new Color([1, 1, 1]); 123 | 124 | /** 125 | * Matrices and functions for sRGB <--> CIELAB conversion 126 | * https://drafts.csswg.org/css-color-4/conversions.js 127 | */ 128 | const Matrices = { 129 | lin_sRGB_to_XYZ: [ 130 | [ 0.41239079926595934, 0.357584339383878, 0.1804807884018343 ], 131 | [ 0.21263900587151027, 0.715168678767756, 0.07219231536073371 ], 132 | [ 0.01933081871559182, 0.11919477979462598, 0.9505321522496607 ] 133 | ], 134 | XYZ_to_lin_sRGB: [ /* inverse of above */ 135 | [ 3.2409699419045226, -1.537383177570094, -0.4986107602930034 ], 136 | [ -0.9692436362808796, 1.8759675015077202, 0.04155505740717559 ], 137 | [ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786 ] 138 | ], 139 | /* Bradford CAT */ 140 | D65_to_D50: [ 141 | [ 1.0479298208405488, 0.022946793341019088, -0.05019222954313557 ], 142 | [ 0.029627815688159344, 0.990434484573249, -0.01707382502938514 ], 143 | [ -0.009243058152591178, 0.015055144896577895, 0.7518742899580008 ] 144 | ], 145 | D50_to_D65: [ /* inverse of above */ 146 | [ 0.9554734527042182, -0.023098536874261423, 0.0632593086610217 ], 147 | [ -0.028369706963208136, 1.0099954580058226, 0.021041398966943008 ], 148 | [ 0.012314001688319899, -0.020507696433477912, 1.3303659366080753 ] 149 | ], 150 | 151 | /** 152 | * Simple matrix (and vector) multiplication 153 | * https://drafts.csswg.org/css-color-4/multiply-matrices.js 154 | * @author Lea Verou 2020 MIT License 155 | */ 156 | multiply(A, B) { 157 | const m = A.length; 158 | /* if A is vector, convert to [[a, b, c, ...]] */ 159 | if (!Array.isArray(A[0])) { 160 | A = [A]; 161 | } 162 | /* if B is vector, convert to [[a], [b], [c], ...]] */ 163 | if (!Array.isArray(B[0])) { 164 | B = B.map(x => [x]); 165 | } 166 | const p = B[0].length; 167 | const B_cols = B[0].map((_, i) => B.map(x => x[i])); /* transpose B */ 168 | 169 | let product = A.map(row => B_cols.map(col => { 170 | if (!Array.isArray(row)) { 171 | return col.reduce((a, c) => a + c * row, 0); 172 | } 173 | return row.reduce((a, c, i) => a + c * (col[i] || 0), 0); 174 | })); 175 | if (m === 1) { 176 | product = product[0]; /* Avoid [[a, b, c, ...]] */ 177 | } 178 | if (p === 1) { 179 | return product.map(x => x[0]); /* Avoid [[a], [b], [c], ...]] */ 180 | } 181 | return product; 182 | } 183 | } 184 | 185 | /* Gamma-corrected sRGB to CIE Lab and back */ 186 | const sRGB = { 187 | toXYZ_M: Matrices.multiply(Matrices.D65_to_D50, Matrices.lin_sRGB_to_XYZ), 188 | fromXYZ_M: Matrices.multiply(Matrices.XYZ_to_lin_sRGB, Matrices.D50_to_D65), 189 | whites: { 190 | /* ASTM E308-01: [0.96422, 1.00000, 0.82521] */ 191 | D50: [0.3457 / 0.3585, 1.00000, (1.0 - 0.3457 - 0.3585) / 0.3585] 192 | }, 193 | CIE_fracs: { 194 | ε: 216/24389, /* 6^3/29^3 */ 195 | ε3: 24/116, /* 6 / 29 */ 196 | κ: 24389/27 /* 29^3/3^3 */ 197 | }, 198 | 199 | toLab(RGB) { 200 | return this.XYZtoLab(this.toXYZ(this.toLinear(RGB))); 201 | }, 202 | 203 | toLinear(RGB) { /* sRGB values [0 - 1] */ 204 | return RGB.map(function (val) { 205 | const sign = val < 0? -1 : 1; 206 | const abs = Math.abs(val); 207 | if (abs < 0.04045) { 208 | return val / 12.92; 209 | } 210 | return sign * (Math.pow((abs + 0.055) / 1.055, 2.4)); 211 | }); 212 | }, 213 | 214 | toXYZ(linRGB) { 215 | return Matrices.multiply(this.toXYZ_M, linRGB); 216 | }, 217 | 218 | XYZtoLab(XYZ) { 219 | const white = this.whites.D50; 220 | const {κ, ε} = this.CIE_fracs; 221 | const xyz = XYZ.map((value, i) => value / white[i]); 222 | 223 | const f = xyz.map(value => { 224 | return value > ε ? Math.cbrt(value) : (κ * value + 16)/116; 225 | }); 226 | return [ 227 | (116 * f[1]) - 16, /* L */ 228 | 500 * (f[0] - f[1]), /* a */ 229 | 200 * (f[1] - f[2]) /* b */ 230 | ]; 231 | }, 232 | 233 | fromLab(Lab) { 234 | return this.toGamma(this.fromXYZ(this.LabToXYZ(Lab))); 235 | }, 236 | 237 | LabToXYZ(Lab) { 238 | const white = this.whites.D50; 239 | const {κ, ε3} = this.CIE_fracs; 240 | let f = []; 241 | 242 | f[1] = (Lab[0] + 16)/116; 243 | f[0] = Lab[1]/500 + f[1]; 244 | f[2] = f[1] - Lab[2]/200; 245 | /* κ * ε = 2^3 = 8 */ 246 | const xyz = [ 247 | f[0] > ε3 ? Math.pow(f[0],3) : (116*f[0]-16)/κ, 248 | Lab[0] > 8 ? Math.pow((Lab[0]+16)/116,3) : Lab[0]/κ, 249 | f[2] > ε3 ? Math.pow(f[2],3) : (116*f[2]-16)/κ 250 | ]; 251 | return xyz.map((value, i) => value * white[i]); 252 | }, 253 | 254 | fromXYZ(XYZ) { 255 | return Matrices.multiply(this.fromXYZ_M, XYZ); 256 | }, 257 | 258 | toGamma(RGB) { /* linear-light sRGB values [0 - 1] */ 259 | return RGB.map(function (val) { 260 | const sign = val < 0? -1 : 1; 261 | const abs = Math.abs(val); 262 | if (abs > 0.0031308) { 263 | return sign * (1.055 * Math.pow(abs, 1/2.4) - 0.055); 264 | } 265 | return 12.92 * val; 266 | }); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /lib/engine.js: -------------------------------------------------------------------------------- 1 | 2 | import Color from "./color.js"; 3 | 4 | const DOQ = { 5 | colorSchemes: [], 6 | flags: { 7 | engineOn: false, isPrinting: false, 8 | shapesOn: true, imagesOn: true 9 | } 10 | } 11 | let activeTone = {}; 12 | let styleCache = new Map(); 13 | let canvasCache = new Map(); 14 | 15 | function setCanvasTheme(scheme, tone) { 16 | const newTone = DOQ.colorSchemes[scheme].tones[tone]; 17 | if (newTone !== activeTone) { 18 | styleCache.clear(); 19 | canvasCache.clear(); 20 | activeTone = newTone; 21 | } 22 | return activeTone; 23 | } 24 | 25 | function addColorScheme(scheme) { 26 | const newColor = arg => new Color(arg); 27 | scheme.colors = (scheme.accents || []).map(newColor); 28 | 29 | scheme.tones.forEach(tone => { 30 | const [b, f] = [tone.background, tone.foreground].map(newColor); 31 | tone.colors = { 32 | bg: b, fg: f, grad: b.range(f), 33 | acc: (tone.accents || []).map(newColor).concat(scheme.colors) 34 | }; 35 | tone.scheme = scheme; 36 | }); 37 | DOQ.colorSchemes.push(scheme); 38 | } 39 | 40 | /* Wrap canvas drawing */ 41 | function wrapCanvas() { 42 | const ctxp = CanvasRenderingContext2D.prototype; 43 | ctxp.origFillRect = ctxp.fillRect; 44 | ctxp.origDrawImage = ctxp.drawImage; 45 | const checks = style => checkFlags() && checkStyle(style); 46 | 47 | ["fill", "stroke"].forEach(f => { 48 | ["", "Rect", "Text"].forEach(e => { 49 | const handler = (e === "Text") ? updateTextStyle : resetShapeStyle; 50 | ctxp[f + e] = wrapAPI(ctxp[f + e], handler, checks, f + "Style"); 51 | }); 52 | wrapSet(ctxp, f + "Style", getCanvasStyle, checks); 53 | }); 54 | ctxp.drawImage = wrapAPI(ctxp.drawImage, setCanvasCompOp, checkFlags); 55 | } 56 | 57 | /* Method and setter wrapper closures */ 58 | function wrapAPI(method, callHandler, test, prop) { 59 | return function() { 60 | if (!test?.(this[prop])) { 61 | return method.apply(this, arguments); 62 | } 63 | this.save(); 64 | callHandler(this, method, arguments, prop); 65 | const retVal = method.apply(this, arguments); 66 | this.restore(); 67 | return retVal; 68 | } 69 | } 70 | 71 | function wrapSet(obj, prop, getNewVal, test) { 72 | const descriptor = Object.getOwnPropertyDescriptor(obj, prop); 73 | const { set: ownSet, get: ownGet } = descriptor; 74 | 75 | Object.defineProperty(obj, prop, { 76 | get() { 77 | return ownGet.call(this); 78 | }, 79 | set(arg) { 80 | ownSet.call(this, arg); 81 | if (!test?.(arg)) { 82 | return; 83 | } 84 | const value = ownGet.call(this); 85 | ownSet.call(this, getNewVal(value)); 86 | obj["orig" + prop] = value; 87 | } 88 | }); 89 | obj["set" + prop] = ownSet; 90 | } 91 | 92 | function checkFlags() { 93 | const { flags } = DOQ; 94 | return flags.engineOn && !flags.isPrinting; 95 | } 96 | 97 | function checkStyle(style) { 98 | return typeof(style) === "string"; /* is not gradient/pattern */ 99 | } 100 | 101 | /* Get style from cache, calculate if not present */ 102 | function getCanvasStyle(style, bg) { 103 | style = new Color(style); 104 | const key = style.hex + (bg?.hex || ""); 105 | let newStyle = styleCache.get(key); 106 | 107 | if (!newStyle) { 108 | newStyle = bg ? getTextStyle(style, bg) : calcStyle(style); 109 | styleCache.set(key, newStyle); 110 | } 111 | return newStyle.toHex(style.alpha); 112 | } 113 | 114 | /* Calculate a new style for given colorscheme and tone */ 115 | function calcStyle(color) { 116 | const { grad, acc } = activeTone.colors; 117 | let style; 118 | 119 | if (color.chroma > 10 && acc.length) { 120 | style = findMatch(acc, e => e.deltaE(color), Math.min); 121 | } else { 122 | const whiteL = Color.white.lightness; 123 | style = grad(1 - color.lightness / whiteL); 124 | } 125 | return style; 126 | } 127 | 128 | function getTextStyle(color, textBg, minContrast = 30) { 129 | const { bg, fg } = activeTone.colors; 130 | const diffL = clr => Math.abs(clr.lightness - textBg.lightness); 131 | 132 | if (bg.deltaE(textBg) > 2.3 && diffL(color) < minContrast) { 133 | return findMatch([color, bg, fg], diffL, Math.max); 134 | } 135 | return color; 136 | } 137 | 138 | function findMatch(array, mapFun, condFun) { 139 | const newArr = array.map(mapFun); 140 | return array[newArr.indexOf(condFun(...newArr))]; 141 | } 142 | 143 | /* Alter fill and stroke styles */ 144 | function resetShapeStyle(ctx, method, args, prop) { 145 | if (isAccent(ctx[prop])) { 146 | markContext(ctx); 147 | } 148 | if (DOQ.flags.shapesOn) { 149 | return; 150 | } 151 | const { width, height } = ctx.canvas; 152 | 153 | if (method.name === "fillRect" && args[2] == width && args[3] == height) { 154 | return; 155 | } 156 | const setStyle = ctx["set" + prop]; 157 | setStyle.call(ctx, ctx["orig" + prop]); 158 | markContext(ctx) 159 | } 160 | 161 | function updateTextStyle(ctx, method, args, prop) { 162 | const style = ctx[prop]; 163 | 164 | if (!ctx._hasBackgrounds && !isAccent(style)) { 165 | return; 166 | } 167 | const bg = getCanvasColor(ctx, args); 168 | const newStyle = getCanvasStyle(style, bg); 169 | 170 | if (newStyle !== style) { 171 | const setStyle = ctx["set" + prop]; 172 | setStyle.call(ctx, newStyle); 173 | } 174 | } 175 | 176 | /* Get canvas color from cache, read form canvas if not present. 177 | * Also use a singleton WeakMap to cache the current canvas data. */ 178 | function getCanvasColor(ctx, args) { 179 | const cvs = ctx.canvas; 180 | const cacheId = cvs.dataset.cacheId; 181 | let colorMap = canvasCache.get(cacheId); 182 | 183 | if (cacheId && !colorMap) { 184 | colorMap = []; 185 | canvasCache.set(cacheId, colorMap); 186 | } 187 | ctx._currentTextId ??= 0; 188 | const textId = ctx._currentTextId++; 189 | if (textId < colorMap?.length) { 190 | return colorMap[textId]; 191 | } 192 | 193 | let canvasData = canvasCache.get("dataMap")?.get(ctx); 194 | if (!canvasData) { 195 | canvasData = ctx.getImageData(0, 0, cvs.width, cvs.height); 196 | canvasCache.set("dataMap", new WeakMap([[ctx, canvasData]])); 197 | } 198 | const color = readCanvasColor(ctx, ...args, canvasData); 199 | colorMap?.push(color); 200 | return color; 201 | } 202 | 203 | /* Read canvas color at text position from canvas data */ 204 | function readCanvasColor(ctx, text, tx, ty, canvasData) { 205 | const mtr = ctx.measureText(text); 206 | const dx = mtr.width / 2; 207 | const dy = (mtr.actualBoundingBoxAscent - mtr.actualBoundingBoxDescent) / 2; 208 | 209 | const tfm = ctx.getTransform(); 210 | let {x, y} = tfm.transformPoint({ x: tx + dx, y: ty - dy }); 211 | [x, y] = [x, y].map(Math.round); 212 | 213 | const i = (y * canvasData.width + x) * 4; 214 | const rgb = Array.from(canvasData.data.slice(i, i + 3)); 215 | return new Color(rgb.map(e => e / 255)); 216 | } 217 | 218 | function isAccent(style) { 219 | const { accents, scheme } = activeTone; 220 | const isStyle = s => s.toLowerCase() === style; 221 | style = style.toLowerCase(); 222 | return accents?.some(isStyle) || scheme.accents?.some(isStyle); 223 | } 224 | 225 | function markContext(ctx) { 226 | ctx._hasBackgrounds = true; 227 | canvasCache.set("dataMap", null); 228 | } 229 | 230 | /* Set the image composite operation, drawing the mask to blend with */ 231 | function setCanvasCompOp(ctx, drawImage, args) { 232 | markContext(ctx); 233 | const image = args[0]; 234 | 235 | if (!DOQ.flags.imagesOn || image instanceof HTMLCanvasElement) { 236 | return; 237 | } 238 | args = [...args]; 239 | if (args.length < 5) { 240 | args.push(image.width, image.height); 241 | } 242 | 243 | const { colors, foreground, background } = activeTone; 244 | const maskColor = colors.bg.lightness < 50 ? foreground : background; 245 | const mask = createMask(maskColor, args.slice(0, 5)); 246 | args.splice(0, 1, mask); 247 | drawImage.apply(ctx, args); 248 | 249 | ctx.globalCompositeOperation = "multiply"; 250 | } 251 | 252 | function createMask(color, args) { 253 | const cvs = document.createElement("canvas"); 254 | const dim = [cvs.width, cvs.height] = args.slice(3); 255 | const ctx = cvs.getContext("2d"); 256 | 257 | ctx.setfillStyle(color); 258 | ctx.origFillRect(0, 0, ...dim); 259 | ctx.globalCompositeOperation = "destination-in"; 260 | ctx.origDrawImage(...args, 0, 0, ...dim); 261 | return cvs; 262 | } 263 | 264 | export { 265 | DOQ, setCanvasTheme, addColorScheme, wrapCanvas, getCanvasStyle, checkFlags 266 | }; 267 | --------------------------------------------------------------------------------