theseus/bundled-addons/pdf-editor/lib/signature.js
Local Dev 6b2c4b25c0 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

114 lines
3.7 KiB
JavaScript

// The signature pad.
//
// What comes out is a list of strokes, each a list of points normalised to
// 0..1 inside the pad's box, y measured downwards. Not a picture. That matters
// for three reasons: the same signature can be dropped at any size on any page
// and stay sharp, it survives into the saved PDF as vector line art rather
// than a fuzzy PNG, and it is a few hundred bytes in storage instead of tens
// of kilobytes of base64.
//
// The pad also trims the drawing to its own bounding box before normalising,
// so a signature scrawled in the top-left corner of the pad still fills the
// box it gets placed in.
export class SignaturePad {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.strokes = []; // in canvas pixel space while drawing
this.cur = null;
this._onChange = null;
canvas.addEventListener("pointerdown", (e) => this._down(e));
canvas.addEventListener("pointermove", (e) => this._move(e));
window.addEventListener("pointerup", () => this._up());
this.clear();
}
onChange(fn) { this._onChange = fn; }
clear() {
this.strokes = [];
this.cur = null;
this._paint();
this._onChange?.(false);
}
get isEmpty() { return !this.strokes.some((s) => s.length > 1); }
_pt(e) {
const r = this.canvas.getBoundingClientRect();
// The canvas is CSS-scaled to the modal's width, so a client point has to
// come back through that ratio to land in the backing store's pixels.
return [
(e.clientX - r.left) * (this.canvas.width / r.width),
(e.clientY - r.top) * (this.canvas.height / r.height),
];
}
_down(e) {
if (e.button !== 0) return;
this.cur = [this._pt(e)];
this.strokes.push(this.cur);
this.canvas.setPointerCapture?.(e.pointerId);
e.preventDefault();
}
_move(e) {
if (!this.cur) return;
this.cur.push(this._pt(e));
this._paint();
}
_up() {
if (!this.cur) return;
if (this.cur.length < 2) this.strokes.pop();
this.cur = null;
this._paint();
this._onChange?.(!this.isEmpty);
}
_paint() {
const { ctx, canvas } = this;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// A baseline, so people sign at a sensible height rather than in a corner.
ctx.strokeStyle = "rgba(20,30,50,.18)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(24, canvas.height * 0.72);
ctx.lineTo(canvas.width - 24, canvas.height * 0.72);
ctx.stroke();
ctx.strokeStyle = "#101418";
ctx.lineWidth = 2.6;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const s of this.strokes) {
if (s.length < 2) continue;
ctx.beginPath();
ctx.moveTo(s[0][0], s[0][1]);
for (let i = 1; i < s.length; i++) ctx.lineTo(s[i][0], s[i][1]);
ctx.stroke();
}
ctx.restore();
}
/**
* @returns {{strokes:[number,number][][], aspect:number}|null}
* strokes normalised into 0..1 over the drawing's own bounds; `aspect` is
* height/width of those bounds, so the caller can place it undistorted.
*/
result() {
const live = this.strokes.filter((s) => s.length > 1);
if (!live.length) return null;
const xs = live.flat().map((p) => p[0]);
const ys = live.flat().map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs);
const y0 = Math.min(...ys), y1 = Math.max(...ys);
// A single horizontal or vertical line has zero extent on one axis; floor
// it so the division below cannot produce Infinity.
const w = Math.max(1, x1 - x0), h = Math.max(1, y1 - y0);
return {
strokes: live.map((s) => s.map(([x, y]) => [(x - x0) / w, (y - y0) / h])),
aspect: Math.min(1.2, Math.max(0.12, h / w)),
};
}
}