diff --git a/README.md b/README.md index 62dffa2..9076550 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ To automatically create or reuse a personal fork using an authenticated GitHub C The installer first reuses a sibling `../enhancer` checkout when one exists. Otherwise it creates a managed checkout under `~/Library/Application Support/PDF Magic/Enhancer` on macOS or `~/.local/share/pdf-magic/enhancer` on Linux. It registers the native bridge under `~/.config/pdf-magic/native` for both Chrome and Brave. When setup runs from a viewer checkout, this stable launcher loads that checkout's current helper on every enhancement, so helper updates take effect automatically on the next request without reinstalling or reloading the extension. Running jobs finish with the version they started with. If the checkout is moved or unavailable, the launcher uses its installed fallback; rerun setup from the new location to relink it. Download-only setup installs a standalone copy and must be rerun to update it. -After setup completes, click Enhance again. The host locates Homebrew tools even when the browser is launched from Finder and keeps OCR logs separate from native-messaging responses. +After setup completes, click Enhance again. When the selected Enhancer checkout supports `setup-python-env.sh`, the installer creates or repairs `enhancer/.venv` and installs its Python dependencies there. The OCR scripts and native host prefer this isolated Python for tagging, AI review, and PDF metadata stamping; Homebrew's OCRmyPDF and qpdf remain separate executables. Existing older forks without the setup script continue to work with their previous interpreter discovery. The host locates Homebrew tools even when the browser is launched from Finder and keeps OCR logs separate from native-messaging responses. For web PDFs, the viewer sends the already-loaded document to the host in acknowledged chunks. Enhancement does not make another HTTP download, so it can use PDFs that the browser loaded from sites that reject standalone requests. Local PDFs are read directly from disk. Both enhancement modes use `--force-ocr --ai-review` to re-read every page and review recognized text against page images with the local AI models, including when the PDF already contains text. This rasterizes pages in the enhanced copy and takes longer than preserving existing text. The source remains unchanged. The tagger builds heading structure and section bookmarks from the reviewed text. Cached results from the previous pipeline are regenerated on the next enhancement. diff --git a/native/install-host.sh b/native/install-host.sh old mode 100755 new mode 100644 index 9ebd1fc..26f12db --- a/native/install-host.sh +++ b/native/install-host.sh @@ -197,6 +197,13 @@ if [[ ! -f "$enhancer_dir/ocr-scanned-pdf.sh" ]]; then exit 1 fi +# The user's explicit installer run also provisions isolated Python packages. +# Older forks without the setup script retain the legacy interpreter fallback. +if [[ -f "$enhancer_dir/setup-python-env.sh" ]]; then + printf 'Setting up the PDF Enhancer Python virtual environment...\n' + /bin/bash "$enhancer_dir/setup-python-env.sh" +fi + config_dir="$HOME/.config/pdf-magic" config_path="$config_dir/enhancer-host.json" host_install_dir="$config_dir/native" diff --git a/native/pdfmagic-enhancer-host.py b/native/pdfmagic-enhancer-host.py index 3e4cf1c..94dbf2e 100644 --- a/native/pdfmagic-enhancer-host.py +++ b/native/pdfmagic-enhancer-host.py @@ -367,7 +367,20 @@ def input_from_url(source_url: str, temporary_directory: Path) -> tuple[Path, bo def pikepdf_python() -> list[str]: - candidates: list[list[str]] = [[sys.executable], ["python3"]] + candidates: list[list[str]] = [] + # Use the same Enhancer-owned environment for metadata stamping as OCR. + # The host itself may run on an OS Python without the PDF dependencies. + if HOST_CONFIG.is_file(): + try: + config = json.loads(HOST_CONFIG.read_text()) + enhancer_dir = config.get("enhancer_dir") + if isinstance(enhancer_dir, str) and enhancer_dir.strip(): + venv_python = Path(enhancer_dir).expanduser() / ".venv" / "bin" / "python" + if venv_python.is_file(): + candidates.append([str(venv_python)]) + except (OSError, ValueError, TypeError): + pass + candidates.extend([[sys.executable], ["python3"]]) ocrmypdf = shutil.which("ocrmypdf") if ocrmypdf: first_line = Path(ocrmypdf).read_text(errors="ignore").splitlines()[0] diff --git a/package.json b/package.json index 814a18e..c97c216 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node scripts/build.mjs", "check": "node scripts/check-extension.mjs", - "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs" + "test": "node --test tests/enhance-failure.test.mjs tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs" }, "dependencies": { "pdfjs-dist": "6.3.289", diff --git a/src/viewer.html b/src/viewer.html index eeb4de1..4f4f2fa 100644 --- a/src/viewer.html +++ b/src/viewer.html @@ -189,6 +189,15 @@ + +
Loading PDF…
diff --git a/src/viewer/enhance-pdf.js b/src/viewer/enhance-pdf.js index fd8d147..5a63455 100644 --- a/src/viewer/enhance-pdf.js +++ b/src/viewer/enhance-pdf.js @@ -24,9 +24,14 @@ const progressContainer = document.querySelector("#enhance-progress"); const progressBar = document.querySelector("#enhance-progress-bar"); const progressLabel = document.querySelector("#enhance-progress-label"); const progressPercentage = document.querySelector("#enhance-progress-percentage"); +const errorNotice = document.querySelector("#enhance-error-notice"); +const errorMessage = document.querySelector("#enhance-error-message"); +const copyErrorButton = document.querySelector("#enhance-copy-error"); +const dismissErrorButton = document.querySelector("#enhance-dismiss-error"); const defaultTitle = enhanceButton.title; let titleResetTimer; let toastTimer; +let lastEnhancementStage = "preparing"; function isMissingNativeHost(message) { return /native.*(?:host|messag)|host.*(?:not found|not configured)|enhancer script not found|enhancer-host[.]json|native.*exited/i.test(message); @@ -72,6 +77,7 @@ setupForm.addEventListener("submit", async (event) => { function updateEnhancementProgress(stage, completed, total) { + lastEnhancementStage = stage; progressContainer.hidden = false; document.documentElement.classList.add("enhancement-active"); const labels = { @@ -176,6 +182,33 @@ function showToast(message) { toastTimer = setTimeout(() => toast.classList.remove("visible"), 2600); } +function showEnhancementFailure(message) { + const stageLabels = { + preparing: "Preparing the document", + queued: "Waiting for the enhancer", + upload: "Sending PDF bytes", + ocr: "Running OCR", + review: "Reviewing text", + finalizing: "Saving the enhanced PDF", + }; + const stage = stageLabels[lastEnhancementStage] || "Enhancing PDF"; + errorMessage.textContent = `${stage}: ${message}`; + errorNotice.hidden = false; +} + +copyErrorButton.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(errorMessage.textContent || "PDF enhancement failed"); + showToast("Enhancement error copied"); + } catch { + showToast("Could not copy enhancement error"); + } +}); + +dismissErrorButton.addEventListener("click", () => { + errorNotice.hidden = true; +}); + function setEnhanceTitle(title) { for (const button of enhanceButtons) { button.title = title; @@ -190,9 +223,9 @@ function showTemporaryTitle(title) { } async function replaceCurrentTab(outputUrl) { - const destination = new URL(window.location.href); - destination.search = ""; - destination.hash = ""; + // MIME-handler tabs retain the original HTTPS URL in the address bar; + // appending ?url= to that URL would re-request the source from the site. + const destination = new URL(chrome.runtime.getURL("src/viewer.html")); destination.searchParams.set("url", outputUrl); const pageNumber = Number.parseInt(pageNumberInput?.value || "", 10); if (Number.isFinite(pageNumber) && pageNumber > 1) { @@ -209,6 +242,7 @@ async function replaceCurrentTab(outputUrl) { } async function enhanceCurrentPdf() { + errorNotice.hidden = true; for (const button of enhanceButtons) button.disabled = true; setEnhanceTitle("Enhancing PDF…"); showToast("Enhancing PDF…"); @@ -235,7 +269,8 @@ async function enhanceCurrentPdf() { await replaceCurrentTab(response.outputUrl); } catch (error) { const message = error?.message || "Could not enhance PDF"; - showToast(message); + showEnhancementFailure(message); + showToast("PDF enhancement failed"); if (isMissingNativeHost(message)) { await offerEnhancerSetup(); } else { diff --git a/src/viewer/viewer.css b/src/viewer/viewer.css index ddbb68f..0a6851d 100644 --- a/src/viewer/viewer.css +++ b/src/viewer/viewer.css @@ -1077,6 +1077,39 @@ input { opacity: 1; } +/* Keep native-host and OCR errors available after the progress bar closes. */ +.enhance-error-notice { + position: fixed; + left: 50%; + bottom: 24px; + z-index: 21; + transform: translateX(-50%); + width: min(680px, calc(100vw - 32px)); + max-height: min(45vh, 300px); + overflow-y: auto; + padding: 12px 14px; + border: 1px solid #c43f3f; + border-radius: 8px; + background: var(--toast-bg); + color: var(--text); + box-shadow: 0 8px 28px rgb(0 0 0 / 20%); +} +.enhance-error-notice[hidden] { display: none; } +.enhance-error-notice p { + margin: 8px 0 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.enhance-error-actions { display: flex; justify-content: flex-end; gap: 8px; } +.enhance-error-actions button { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + background: var(--control-bg); + cursor: pointer; +} + /* Split panes can place a floating host control over the lower-right corner. Leave the footnote scrubber's interactive track clear of that corner. */ @media (max-width: 1200px) { diff --git a/tests/enhance-failure.test.mjs b/tests/enhance-failure.test.mjs new file mode 100644 index 0000000..441caa7 --- /dev/null +++ b/tests/enhance-failure.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = readFileSync(new URL("../src/viewer/enhance-pdf.js", import.meta.url), "utf8") + .replace(/^import .*;\n/gm, ""); + +function fixture(responseForAction) { + const elements = new Map(); + const clicks = new Map(); + const identifiers = [ + "enhance-nav", "enhance-pdf", "section-enhance-pdf", "section-popover", + "section-toggle", "enhancer-setup-dialog", "enhancer-setup-form", + "enhancer-repository-url", "enhancer-checkout-path", "enhancer-setup-status", + "enhancer-setup-close", "open-enhancer-setup", "tools-menu", "tools-button", + "section-nav", "page-number", "toast", + "enhance-progress", "enhance-progress-bar", "enhance-progress-label", + "enhance-progress-percentage", "enhance-error-notice", + "enhance-error-message", "enhance-copy-error", "enhance-dismiss-error", + ]; + for (const id of identifiers) { + elements.set(`#${id}`, { + hidden: id === "enhance-error-notice", + title: "Enhance PDF", + value: id === "page-number" ? "7" : "", + classList: { add() {}, remove() {} }, + style: { setProperty() {} }, + setAttribute() {}, removeAttribute() {}, + addEventListener(type, handler) { + if (type === "click") clicks.set(id, handler); + }, + showModal() { this.open = true; }, close() { this.open = false; }, + }); + } + const updates = []; + const copies = []; + const messages = []; + let onMessage; + let onDisconnect; + const runtime = { + id: "abcdefghijklmnopabcdefghijklmnop", + getURL: (path) => `chrome-extension://viewer/${path}`, + connectNative() { + return { + onMessage: { addListener(listener) { onMessage = listener; } }, + onDisconnect: { addListener(listener) { onDisconnect = listener; } }, + postMessage(message) { + messages.push(message); + Promise.resolve().then(async () => { + try { + onMessage(await responseForAction(message.action)); + } catch (error) { + runtime.lastError = { message: error.message }; + onDisconnect(); + } + }); + }, + disconnect() {}, + }; + }, + }; + const context = vm.createContext({ + URL, MutationObserver: class { observe() {} }, + setTimeout() {}, clearTimeout() {}, + btoa: (value) => Buffer.from(value, "binary").toString("base64"), + navigator: { clipboard: { writeText: async (value) => { copies.push(value); } } }, + chrome: { + runtime, + storage: { local: { get: async () => ({}), set: async () => {} } }, + tabs: { getCurrent: async () => ({ id: 4 }), update: async (_, value) => updates.push(value.url) }, + }, + window: { + location: { href: "https://www.sec.gov/files/rules/sro/dtc/2009/dtcresponse101509.pdf" }, + }, + document: { + querySelector: (selector) => elements.get(selector), + documentElement: { classList: { add() {}, remove() {} } }, + }, + resolvePdfSource: async () => ({ + originalUrl: new URL("https://www.sec.gov/files/rules/sro/dtc/2009/dtcresponse101509.pdf"), + }), + resolveDocumentReferenceUrl: async () => + "https://www.sec.gov/files/rules/sro/dtc/2009/dtcresponse101509.pdf", + pdfDocumentSessionReady: Promise.resolve({ document: { + getData: async () => new Uint8Array([37, 80, 68, 70, 45, 49, 46, 55]), + } }), + downloadSetupZip() {}, + }); + vm.runInContext(source, context); + return { run: () => vm.runInContext("enhanceCurrentPdf()", context), + elements, clicks, updates, copies, messages }; +} + +test("MIME-handler success navigates to the extension viewer, not an SEC query URL", async () => { + const scenario = fixture(async (action) => action === "enhance-pdf-finish" + ? { ok: true, outputUrl: "file:///tmp/scan-enhanced.pdf" } + : { ok: true }); + await scenario.run(); + assert.deepEqual(scenario.updates, [ + "chrome-extension://viewer/src/viewer.html?url=file%3A%2F%2F%2Ftmp%2Fscan-enhanced.pdf#page=7", + ]); + assert.equal(scenario.elements.get("#enhance-error-notice").hidden, true); +}); + +test("OCR failure remains readable and can be copied or dismissed", async () => { + const scenario = fixture(async (action) => action === "enhance-pdf-finish" + ? { ok: false, error: "OCRmyPDF: Tesseract returned exit 7" } + : { ok: true }); + await scenario.run(); + assert.equal(scenario.elements.get("#enhance-progress").hidden, true); + assert.equal(scenario.elements.get("#enhance-error-notice").hidden, false); + assert.match(scenario.elements.get("#enhance-error-message").textContent, /Running OCR: OCRmyPDF: Tesseract returned exit 7/); + assert.deepEqual(scenario.updates, []); + await scenario.clicks.get("enhance-copy-error")(); + assert.match(scenario.copies[0], /Tesseract returned exit 7/); + scenario.clicks.get("enhance-dismiss-error")(); + assert.equal(scenario.elements.get("#enhance-error-notice").hidden, true); +}); + +test("upload rejection preserves the stage at which the 0% progress failed", async () => { + const scenario = fixture(async (action) => action === "enhance-pdf-start" + ? { ok: false, error: "native host rejected the request" } + : { ok: true }); + await scenario.run(); + assert.equal(scenario.elements.get("#enhance-error-notice").hidden, false); + assert.match(scenario.elements.get("#enhance-error-message").textContent, + /^Sending PDF bytes: native host rejected the request$/); +}); diff --git a/tests/test_enhancer_python_venv.py b/tests/test_enhancer_python_venv.py new file mode 100644 index 0000000..a578392 --- /dev/null +++ b/tests/test_enhancer_python_venv.py @@ -0,0 +1,45 @@ +"""Verify native-host metadata stamping prefers the Enhancer's Python venv.""" + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +HOST = Path(__file__).resolve().parents[1] / "native" / "pdfmagic-enhancer-host.py" +spec = importlib.util.spec_from_file_location("pdfmagic_enhancer_host", HOST) +host = importlib.util.module_from_spec(spec) +spec.loader.exec_module(host) + + +class PythonVirtualEnvironmentTests(unittest.TestCase): + def test_host_uses_enhancer_venv_for_pikepdf_before_global_interpreters(self): + with tempfile.TemporaryDirectory() as folder: + directory = Path(folder) + python = directory / "enhancer" / ".venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\nexit 0\n") + config = directory / "enhancer-host.json" + config.write_text(json.dumps({"enhancer_dir": str(directory / "enhancer")})) + with patch.object(host, "HOST_CONFIG", config), \ + patch.object(host.subprocess, "run") as run, \ + patch.object(host.shutil, "which", return_value=None): + selected = host.pikepdf_python() + self.assertEqual(selected, [str(python)]) + self.assertEqual(run.call_count, 1) + self.assertEqual(run.call_args.args[0], [str(python), "-c", "import pikepdf"]) + + def test_unconfigured_host_keeps_the_prior_interpreter_fallback(self): + with tempfile.TemporaryDirectory() as folder: + config = Path(folder) / "missing-config.json" + with patch.object(host, "HOST_CONFIG", config), \ + patch.object(host.subprocess, "run") as run, \ + patch.object(host.shutil, "which", return_value=None): + selected = host.pikepdf_python() + self.assertEqual(selected, [host.sys.executable]) + self.assertEqual(run.call_count, 1) + + +if __name__ == "__main__": + unittest.main()