theseus/bundled-addons/pdf-editor/lib/shape.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

111 lines
4.4 KiB
JavaScript

// Geometry shared by the on-screen overlay and the pdf-lib writer.
//
// Both need to agree exactly on where an arrowhead's barbs land, where each
// line of a text stamp sits, and how a normalised signature stroke maps into
// its placed box. Keeping that arithmetic in one place is what makes "what I
// drew" and "what got saved" the same picture; two copies of it drift the
// first time either side is touched.
//
// Everything here works in PDF user space: points, origin bottom-left, y up.
/** Approximate ascent as a fraction of font size, for Helvetica. */
export const ASCENT = 0.76;
/** Line advance as a fraction of font size. */
export const LINE_GAP = 1.2;
/**
* Where each line of a text stamp is drawn. `a.y` is the TOP of the block —
* that is what the user clicked — so every baseline hangs below it.
* @returns {{text:string, x:number, y:number}[]} y is the baseline.
*/
export function textLayout(a) {
const lines = String(a.text ?? "").split("\n");
const size = a.size || 12;
return lines.map((text, i) => ({
text,
x: a.x,
y: a.y - size * ASCENT - i * size * LINE_GAP,
}));
}
/** Height of a text stamp's block, for hit-testing and the selection box. */
export function textBlockHeight(a) {
const lines = String(a.text ?? "").split("\n").length;
const size = a.size || 12;
return size * ASCENT + (lines - 1) * size * LINE_GAP + size * 0.24;
}
/**
* The two barb points of an arrowhead at (x2,y2) coming from (x1,y1).
* Head length scales with stroke width but is floored so a thin arrow still
* reads as an arrow.
*/
export function arrowBarbs(x1, y1, x2, y2, width) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const head = Math.max(6, Math.min(len * 0.34, (width || 2) * 4.5));
const ux = dx / len, uy = dy / len;
// 24° either side of the shaft.
const cos = Math.cos(0.42), sin = Math.sin(0.42);
return [
{ x: x2 - head * (ux * cos - uy * sin), y: y2 - head * (uy * cos + ux * sin) },
{ x: x2 - head * (ux * cos + uy * sin), y: y2 - head * (uy * cos - ux * sin) },
];
}
/**
* A placed signature's strokes in user space. Strokes are stored normalised
* (0..1, y measured DOWN from the top of the pad) so the same drawing can be
* dropped at any size, on any page, any number of times.
*/
export function signatureStrokes(a) {
const { x, y, w, h } = a;
return (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
}
/** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
export function normBox(x0, y0, x1, y1) {
return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
}
/** Axis-aligned bounds of a mark, in user space. Used for selection and hit-testing. */
export function boundsOf(a) {
switch (a.kind) {
case "highlight": {
const rs = a.rects || [];
if (!rs.length) return { x: 0, y: 0, w: 0, h: 0 };
const x0 = Math.min(...rs.map((r) => r.x)), y0 = Math.min(...rs.map((r) => r.y));
const x1 = Math.max(...rs.map((r) => r.x + r.w)), y1 = Math.max(...rs.map((r) => r.y + r.h));
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
case "pen": {
const pts = a.pts || [];
if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
const pad = (a.width || 2) / 2;
return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad,
w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 };
}
case "arrow": return normBox(a.x1, a.y1, a.x2, a.y2);
case "text": {
// Width is unknown without measuring the font; the renderer measures it
// and caches it on the mark as `_w` so selection boxes are tight.
return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
}
default: return { x: a.x, y: a.y, w: a.w, h: a.h };
}
}
/** Shift a mark by (dx,dy) in user space. Returns the patch, does not mutate. */
export function translatePatch(a, dx, dy) {
switch (a.kind) {
case "highlight":
return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) };
case "pen":
return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
case "arrow":
return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
default:
return { x: a.x + dx, y: a.y + dy };
}
}