theseus/bundled-addons/pdf-editor/lib/model.js

189 lines
7.2 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
// The editable state of one open document, and the undo stack over it.
//
// Two rules hold this together:
//
// 1. Every mark is stored in PDF user space — points, origin bottom-left,
// the page's own unrotated coordinate system. Not screen pixels. That
// is the one space shared by pdf.js (viewport.convertToPdfPoint) and
// pdf-lib (page.drawX), so a mark survives zooming, rotating and
// reordering without a conversion table, and save-time needs no
// knowledge of how the page happened to be displayed.
//
// 2. Marks are keyed to a page's UID, never to its position. Reordering
// rewrites `order`; it does not touch a single annotation. A UID also
// stays valid while a page is deleted, so undeleting restores that
// page's marks with it.
//
// Undo is snapshot-based, like the screenshot editor's. The alternative —
// paired do/undo commands — needs a correct inverse for each of nine mark
// types plus reorder, rotate and delete, and one wrong inverse corrupts the
// document silently. A snapshot of this state is a few KB even with long pen
// paths, so there is nothing to win by being clever.
const UNDO_MAX = 60;
let seq = 0;
export function nextId(prefix = "m") { return `${prefix}${++seq}-${Math.random().toString(36).slice(2, 7)}`; }
export class DocModel {
constructor({ name, pageCount, pageInfo }) {
this.name = name;
// pageInfo[i] = { width, height, rotate } straight from pdf.js, in the
// source document's order. Immutable — it describes the file, not our
// edits.
this.pageInfo = pageInfo;
this.pages = new Map();
this.order = [];
for (let i = 0; i < pageCount; i++) {
const uid = `p${i}`;
this.pages.set(uid, { uid, src: i, rotate: 0, deleted: false });
this.order.push(uid);
}
this.annots = [];
this._undo = [];
this._redo = [];
this._onChange = [];
this.savedMark = this._key(); // snapshot identity at last save, for the dirty flag
}
onChange(fn) { this._onChange.push(fn); }
_emit(what) { for (const fn of this._onChange) { try { fn(what); } catch (e) { console.warn("model listener threw:", e); } } }
// ---- derived views ------------------------------------------------
/** UIDs in display order, deleted pages left out. */
visible() { return this.order.filter((uid) => !this.pages.get(uid).deleted); }
page(uid) { return this.pages.get(uid); }
/** Total rotation to display a page at: the file's own /Rotate plus ours. */
displayRotation(uid) {
const p = this.pages.get(uid);
return (((this.pageInfo[p.src].rotate + p.rotate) % 360) + 360) % 360;
}
/** Our added rotation only — what PDFPageView.update({rotation}) wants. */
extraRotation(uid) { return ((this.pages.get(uid).rotate % 360) + 360) % 360; }
size(uid) { const p = this.pages.get(uid); return this.pageInfo[p.src]; }
annotsFor(uid) { return this.annots.filter((a) => a.page === uid); }
annot(id) { return this.annots.find((a) => a.id === id) || null; }
deletedCount() { return this.order.filter((uid) => this.pages.get(uid).deleted).length; }
hasRedactions() { return this.annots.some((a) => a.kind === "redact"); }
redactedPages() {
return new Set(this.annots.filter((a) => a.kind === "redact").map((a) => a.page));
}
/** True when the page list differs from the file's, so save must rebuild it. */
pagesChanged() {
if (this.deletedCount() > 0) return true;
return this.order.some((uid, i) => this.pages.get(uid).src !== i);
}
rotationsChanged() { return this.order.some((uid) => this.pages.get(uid).rotate % 360 !== 0); }
// ---- history ------------------------------------------------------
_snapshot() {
return {
annots: structuredClone(this.annots),
order: this.order.slice(),
pages: this.order.map((uid) => { const p = this.pages.get(uid); return [uid, p.rotate, p.deleted]; }),
};
}
_restore(s) {
this.annots = structuredClone(s.annots);
this.order = s.order.slice();
for (const [uid, rotate, deleted] of s.pages) {
const p = this.pages.get(uid);
if (p) { p.rotate = rotate; p.deleted = deleted; }
}
}
// A cheap identity for "has anything changed since the last save".
_key() {
return JSON.stringify([this.order, this.order.map((u) => [this.pages.get(u).rotate, this.pages.get(u).deleted]), this.annots.length,
this.annots.map((a) => a.id)]);
}
/** Wrap a mutation so it lands on the undo stack as one step. */
edit(label, fn) {
const before = this._snapshot();
const r = fn();
this._undo.push({ label, before });
if (this._undo.length > UNDO_MAX) this._undo.shift();
this._redo = [];
this._emit(label);
return r;
}
canUndo() { return this._undo.length > 0; }
canRedo() { return this._redo.length > 0; }
undo() {
const step = this._undo.pop();
if (!step) return null;
this._redo.push({ label: step.label, before: this._snapshot() });
this._restore(step.before);
this._emit("undo:" + step.label);
return step.label;
}
redo() {
const step = this._redo.pop();
if (!step) return null;
this._undo.push({ label: step.label, before: this._snapshot() });
this._restore(step.before);
this._emit("redo:" + step.label);
return step.label;
}
markSaved() { this.savedMark = this._key(); this._emit("saved"); }
isDirty() { return this._key() !== this.savedMark; }
// ---- mutations ----------------------------------------------------
addAnnot(a) {
return this.edit(a.kind, () => {
const full = { id: nextId("a"), ...a };
this.annots.push(full);
return full;
});
}
removeAnnot(id) {
const a = this.annot(id);
if (!a) return false;
return this.edit("delete " + a.kind, () => {
this.annots = this.annots.filter((x) => x.id !== id);
return true;
});
}
/** Shift a mark by a user-space delta. `patch` is applied to the mark. */
moveAnnot(id, patch) {
const a = this.annot(id);
if (!a) return false;
return this.edit("move " + a.kind, () => { Object.assign(a, patch); return true; });
}
rotatePage(uid, deltaDeg) {
return this.edit("rotate", () => {
const p = this.pages.get(uid);
p.rotate = (((p.rotate + deltaDeg) % 360) + 360) % 360;
return p.rotate;
});
}
setDeleted(uid, deleted) {
return this.edit(deleted ? "delete page" : "restore page", () => {
this.pages.get(uid).deleted = !!deleted;
// Never leave nothing behind — a zero-page PDF cannot be written.
if (this.visible().length === 0) { this.pages.get(uid).deleted = false; return false; }
return true;
});
}
restoreAllPages() {
return this.edit("restore pages", () => {
for (const uid of this.order) this.pages.get(uid).deleted = false;
return true;
});
}
/** Move `uid` so it sits immediately before `beforeUid` (or last if null). */
reorder(uid, beforeUid) {
if (uid === beforeUid) return false;
return this.edit("reorder", () => {
const from = this.order.indexOf(uid);
if (from < 0) return false;
this.order.splice(from, 1);
const at = beforeUid == null ? this.order.length : this.order.indexOf(beforeUid);
this.order.splice(at < 0 ? this.order.length : at, 0, uid);
return true;
});
}
}