Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions native/install-host.sh
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 14 additions & 1 deletion native/pdfmagic-enhancer-host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@
</div>
</div>

<div id="enhance-error-notice" class="enhance-error-notice" role="alert" hidden>
<strong>Could not enhance PDF</strong>
<p id="enhance-error-message"></p>
<div class="enhance-error-actions">
<button id="enhance-copy-error" type="button">Copy error</button>
<button id="enhance-dismiss-error" type="button">Dismiss</button>
</div>
</div>

<main id="viewer" class="viewer" aria-live="polite">
<div id="status" class="status">Loading PDF…</div>
</main>
Expand Down
43 changes: 39 additions & 4 deletions src/viewer/enhance-pdf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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…");
Expand All @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions src/viewer/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
129 changes: 129 additions & 0 deletions tests/enhance-failure.test.mjs
Original file line number Diff line number Diff line change
@@ -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$/);
});
45 changes: 45 additions & 0 deletions tests/test_enhancer_python_venv.py
Original file line number Diff line number Diff line change
@@ -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()
Loading