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 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); }
|
|
|
|
|
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
2026-09-27 13:16:07 +02:00
|
|
|
/**
|
|
|
|
|
* Put a saved signature back on the pad so it can be redrawn or added to.
|
|
|
|
|
*
|
|
|
|
|
* `result()` throws away where in the pad the signature was drawn — it
|
|
|
|
|
* keeps only the shape and its aspect — so this lays it out afresh:
|
|
|
|
|
* across the pad's width, centred, at the aspect it was saved with.
|
|
|
|
|
*/
|
|
|
|
|
load(strokes, aspect = 0.34) {
|
|
|
|
|
const margin = 24;
|
|
|
|
|
const w = this.canvas.width - margin * 2;
|
|
|
|
|
const h = Math.min(this.canvas.height - margin * 2, w * aspect);
|
|
|
|
|
const top = (this.canvas.height - h) / 2;
|
|
|
|
|
this.strokes = (strokes || [])
|
|
|
|
|
.filter((s) => Array.isArray(s) && s.length > 1)
|
|
|
|
|
.map((s) => s.map(([x, y]) => [margin + x * w, top + y * h]));
|
|
|
|
|
this.cur = null;
|
|
|
|
|
this._paint();
|
|
|
|
|
this._onChange?.(!this.isEmpty);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
_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)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|