theseus/bundled-addons/pdf-editor/index.js

233 lines
11 KiB
JavaScript
Raw Normal View History

feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
// PDF Editor — the Node half. Small on purpose: everything interesting
// happens in editor.html, which is a full Theseus tab (the "open-tab"
// capability) rendering with pdf.js and writing with pdf-lib.
//
// This file exists for the three things a file:// page cannot do itself:
//
// 1. Launch. The dock's "Open a PDF…" item opens the editor tab. Nothing
// is handed over; the editor shows its own drop zone.
// 2. Fetch a PDF the user right-clicked. A file:// page has an opaque
// origin, so its cross-origin fetch of https://host/doc.pdf is refused
// by CORS on essentially every real server. electron's `net.fetch`
// runs on the app session instead, so it follows whatever proxy the
// user configured, and the bytes land in a scratch file.
// 3. Serve those bytes back. The editor pulls them with
// invoke("getBytes", {id}).
//
// Handoff is a scratch FILE plus an id in the query string, not a
// storage.__pending entry. The screenshot add-on shipped __pending first
// and had to abandon it: the receiving page drains the entry on first
// paint, so a reload — or Theseus restoring the tab on next launch — finds
// an empty slot and an editor with no document. An id in the URL survives
// both, because the bytes are still on disk. It also keeps a 20 MB PDF out
// of a kv store whose every write rewrites the whole JSON file.
const fs = require("node:fs");
const path = require("node:path");
const SCRATCH_DIR = "pdf-editor-scratch";
const MAX_RECENT = 8; // scratch files kept before the oldest is reaped
const MAX_BYTES = 200 * 1024 * 1024; // refuse anything absurd before we buffer it
module.exports = {
activate(api) {
// Per-add-on scratch dir under <userData>/extensions-data/, a sibling of the
feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
// kv JSON. We never write inside the add-on folder — a user reading the
// shipped source should see only what shipped.
const dataParent = path.dirname(path.join(api.folder, ".."));
// api.dataDir is the host-provided per-extension data folder (<userData>/extensions-data);
// the fallback rebuilds it for hosts that predate that field.
const scratchDir = path.join(api.dataDir || path.join(dataParent, "extensions-data"), SCRATCH_DIR);
feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
try { fs.mkdirSync(scratchDir, { recursive: true }); }
catch (e) { api.log("scratch mkdir failed:", e?.message); }
// ---- scratch bookkeeping ------------------------------------------
// The ring lives in api.storage so it survives restarts, and every read
// prunes entries whose file has gone missing.
function readRing() {
const r = api.storage.get("recent", []);
return Array.isArray(r) ? r : [];
}
function pruneRing(ring) {
const alive = ring.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } });
const keep = alive.slice(0, MAX_RECENT);
for (const r of alive.slice(MAX_RECENT)) { try { fs.unlinkSync(r.path); } catch {} }
return keep;
}
function remember(entry) {
let ring = readRing();
ring.unshift(entry);
ring = pruneRing(ring);
api.storage.set("recent", ring);
}
function findEntry(id) {
return pruneRing(readRing()).find((r) => r.id === id) || null;
}
function newId() {
return "doc-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
}
// A filename we are willing to put on disk and later show as the
// document's name. Anything path-ish or non-printable is discarded, not
// escaped — the fallback name is always acceptable.
function safeName(raw) {
const base = String(raw || "").split(/[\\/]/).pop().trim();
const clean = base.replace(/[\x00-\x1f<>:"|?*]/g, "").slice(0, 120);
if (!clean || clean === "." || clean === "..") return "document.pdf";
return /\.pdf$/i.test(clean) ? clean : clean + ".pdf";
}
// The last path segment of a URL is the best filename guess we have.
function nameFromUrl(url) {
try {
const u = new URL(url);
const seg = decodeURIComponent(u.pathname.split("/").filter(Boolean).pop() || "");
return safeName(seg || u.hostname + ".pdf");
} catch { return "document.pdf"; }
}
function writeScratch(buf, name) {
const id = newId();
const file = path.join(scratchDir, id + ".pdf");
fs.writeFileSync(file, buf);
const entry = { id, name, path: file, size: buf.length, at: Date.now() };
remember(entry);
return entry;
}
// ---- launch paths -------------------------------------------------
// Dock → the file picker, straight away.
//
// This used to open an empty editor whose own drop zone then asked for a
// file, which meant three clicks and two screens before the user reached
// their own documents: dock, menu item, Open, and only then the file
// browser. Asking here collapses it — the dock click IS the "open a file"
// gesture, so answer it with a file browser.
//
// The dialog is native, raised from the add-on's Node side, because a
// file:// page cannot open one without a user gesture of its own and a
// freshly-opened tab has no gesture to spend.
//
// Cancelling still lands you in the editor, empty. Someone who changed
// their mind about which file probably still wants the editor, and it is
// where drag-and-drop works.
api.onMessage("menu-select", async (payload) => {
feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
const item = String(payload && payload.id || "");
if (item !== "open") throw new Error(`unknown menu item: ${item}`);
const entry = await pickPdf();
if (!entry) { api.openTab("editor.html"); api.log("picker cancelled — opened an empty editor"); return { ok: true, cancelled: true }; }
api.openTab("editor.html", { query: { doc: entry.id } });
api.log(`picked ${entry.name} (${entry.size} bytes) → ${entry.id}`);
return { ok: true, id: entry.id };
feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
});
/**
* Native open dialog, then the bytes in scratch. Returns null when the
* user cancels or picks something that is not a PDF.
*/
async function pickPdf() {
const { dialog, BrowserWindow } = api.require("electron");
// Parent it to Theseus so the dialog is attached to the window rather
// than floating loose behind it.
const parent = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] || null;
const opts = {
title: "Open a PDF",
properties: ["openFile"],
filters: [{ name: "PDF documents", extensions: ["pdf"] }, { name: "All files", extensions: ["*"] }],
};
const res = parent ? await dialog.showOpenDialog(parent, opts) : await dialog.showOpenDialog(opts);
const file = !res.canceled && res.filePaths && res.filePaths[0];
if (!file) return null;
const stat = await fs.promises.stat(file);
if (stat.size > MAX_BYTES) throw new Error(`${path.basename(file)} is ${(stat.size / 1048576).toFixed(0)} MB — over the ${MAX_BYTES / 1048576} MB cap`);
const buf = await fs.promises.readFile(file);
if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
throw new Error(`${path.basename(file)} does not start with a PDF header`);
}
return writeScratch(buf, safeName(path.basename(file)));
}
feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
// Right-click a link → fetch it here, hand the editor an id.
api.onMessage("context-menu", async (payload) => {
if (String(payload && payload.itemId || "") !== "open-link") return;
const url = String(payload && payload.linkURL || "").trim();
if (!url) { api.log("context-menu fired with no link"); return { ok: false }; }
let parsed;
try { parsed = new URL(url); } catch { throw new Error(`not a URL: ${url.slice(0, 80)}`); }
if (!/^https?:$/.test(parsed.protocol)) {
throw new Error(`refusing to fetch a ${parsed.protocol} link`);
}
const { net } = api.require("electron");
api.log(`fetching ${parsed.origin}${parsed.pathname.slice(0, 60)}`);
const res = await net.fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText || "fetch failed"}`);
const len = Number(res.headers.get("content-length") || 0);
if (len > MAX_BYTES) throw new Error(`refusing ${(len / 1048576).toFixed(0)} MB — over the ${MAX_BYTES / 1048576} MB cap`);
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > MAX_BYTES) throw new Error(`refusing ${(buf.length / 1048576).toFixed(0)} MB — over the cap`);
// %PDF is the only header a PDF can start with. A server that answered
// an HTML error page with 200 would otherwise reach the editor and
// fail there with a much less useful message.
if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
throw new Error("that link did not answer with a PDF");
}
const entry = writeScratch(buf, nameFromUrl(url));
api.openTab("editor.html", { query: { doc: entry.id } });
api.log(`fetched ${entry.name} (${entry.size} bytes) → ${entry.id}`);
return { ok: true, id: entry.id };
});
// ---- editor-facing handlers ---------------------------------------
// Bytes go across as a Buffer, which structured-clones into a
// Uint8Array on the page side. Base64 would inflate a 20 MB file to 27
// MB of string for no benefit.
api.onMessage("getBytes", (payload) => {
const id = String(payload && payload.id || "");
const hit = findEntry(id);
if (!hit) throw new Error(`no scratch document "${id}"`);
return { id: hit.id, name: hit.name, size: hit.size, at: hit.at, data: fs.readFileSync(hit.path) };
});
// The editor calls this when the user closes a document it had been
// handed, so a fetched PDF does not sit in scratch forever. Missing ids
// are fine — the ring may already have reaped it.
api.onMessage("release", (payload) => {
const id = String(payload && payload.id || "");
const hit = findEntry(id);
if (hit) { try { fs.unlinkSync(hit.path); } catch {} }
api.storage.set("recent", readRing().filter((r) => r.id !== id));
return { ok: true };
});
// A link inside a PDF, clicked. It must NOT navigate the editor's own tab —
// that would throw away an unsaved editing session — so the editor hands
// the URL here and we open it as a new tab instead.
api.onMessage("openExternal", (payload) => {
const url = String(payload && payload.url || "").trim();
let parsed;
try { parsed = new URL(url); } catch { throw new Error("not a URL"); }
if (!/^https?:$/.test(parsed.protocol)) throw new Error(`refusing to open a ${parsed.protocol} link`);
api.openTab(parsed.href);
return { ok: true };
});
// Reveal the scratch dir. Useful when a save went somewhere the user
// cannot find, and for support questions about what is cached.
api.onMessage("openScratch", () => {
const { shell } = api.require("electron");
shell.openPath(scratchDir);
return { ok: true, path: scratchDir };
});
// Reap on activation: scratch is a cache, and a crash mid-session can
// leave files with no ring entry pointing at them.
try {
const known = new Set(readRing().map((r) => path.basename(r.path)));
for (const f of fs.readdirSync(scratchDir)) {
if (!known.has(f)) fs.unlinkSync(path.join(scratchDir, f));
}
} catch (e) { api.log("scratch reap skipped:", e?.message); }
api.log("registered pdf-editor toolbar menu + link context-menu item");
},
};