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).
197 lines
7.3 KiB
JavaScript
197 lines
7.3 KiB
JavaScript
// The on-screen layer that draws marks over a rendered page.
|
|
//
|
|
// One SVG per page, sized to that page's current viewport in CSS pixels.
|
|
// Every mark is stored in PDF user space, so drawing means running each point
|
|
// through `viewport.convertToViewportPoint` — which already accounts for the
|
|
// page's scale AND its rotation. That is why rotating a page needs no special
|
|
// handling here: the viewport changes, we re-render, and the marks turn with
|
|
// the content they were drawn on.
|
|
//
|
|
// SVG rather than a canvas because marks have to be individually
|
|
// hit-testable, selectable and movable, and because it stays crisp when the
|
|
// user zooms without us re-rasterising anything.
|
|
|
|
import { arrowBarbs, boundsOf, signatureStrokes, textLayout } from "./shape.js";
|
|
|
|
const NS = "http://www.w3.org/2000/svg";
|
|
function el(name, attrs) {
|
|
const n = document.createElementNS(NS, name);
|
|
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
|
|
return n;
|
|
}
|
|
|
|
export class Overlay {
|
|
/** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
|
|
constructor(pageDiv) {
|
|
this.svg = el("svg", { class: "smOverlay", xmlns: NS });
|
|
this.marks = el("g", { class: "marks" });
|
|
this.chrome = el("g", { class: "chrome" }); // selection box + handles
|
|
this.svg.append(this.marks, this.chrome);
|
|
pageDiv.append(this.svg);
|
|
this.viewport = null;
|
|
}
|
|
|
|
destroy() { this.svg.remove(); }
|
|
|
|
/** Map a user-space point to CSS pixels inside the page div. */
|
|
toView(x, y) {
|
|
const [vx, vy] = this.viewport.convertToViewportPoint(x, y);
|
|
return [vx, vy];
|
|
}
|
|
/** Map a page-relative CSS pixel point back to user space. */
|
|
toPdf(vx, vy) {
|
|
const [x, y] = this.viewport.convertToPdfPoint(vx, vy);
|
|
return [x, y];
|
|
}
|
|
|
|
render(viewport, annots, selectedId) {
|
|
this.viewport = viewport;
|
|
this.svg.setAttribute("width", viewport.width);
|
|
this.svg.setAttribute("height", viewport.height);
|
|
this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
|
|
this.marks.textContent = "";
|
|
for (const a of annots) {
|
|
const node = this._draw(a);
|
|
if (!node) continue;
|
|
node.classList.add("mark");
|
|
node.dataset.id = a.id;
|
|
if (a.id === selectedId) node.classList.add("sel");
|
|
this.marks.append(node);
|
|
}
|
|
this._drawChrome(annots.find((a) => a.id === selectedId) || null);
|
|
}
|
|
|
|
// Scale a user-space length (a stroke width, a font size) into CSS pixels.
|
|
// The viewport's scale already carries zoom; rotation does not change length.
|
|
get k() { return this.viewport ? this.viewport.scale : 1; }
|
|
|
|
_draw(a) {
|
|
const g = el("g", {});
|
|
switch (a.kind) {
|
|
case "highlight": {
|
|
for (const r of a.rects || []) {
|
|
const [x0, y1] = this.toView(r.x, r.y + r.h);
|
|
const [x1, y0] = this.toView(r.x + r.w, r.y);
|
|
g.append(el("rect", {
|
|
x: Math.min(x0, x1), y: Math.min(y0, y1),
|
|
width: Math.abs(x1 - x0), height: Math.abs(y0 - y1),
|
|
fill: a.color, "fill-opacity": a.opacity ?? 0.38,
|
|
style: "mix-blend-mode:multiply",
|
|
}));
|
|
}
|
|
g.dataset.fill = "1";
|
|
return g;
|
|
}
|
|
case "pen": {
|
|
const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
|
|
if (pts.length < 2) return null;
|
|
g.append(el("polyline", {
|
|
points: pts.map((p) => `${p[0]},${p[1]}`).join(" "),
|
|
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
|
|
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
}));
|
|
return g;
|
|
}
|
|
case "rect": {
|
|
const b = this._box(a);
|
|
g.append(el("rect", { ...b, fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k }));
|
|
return g;
|
|
}
|
|
case "ellipse": {
|
|
const b = this._box(a);
|
|
g.append(el("ellipse", {
|
|
cx: b.x + b.width / 2, cy: b.y + b.height / 2,
|
|
rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
|
|
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
|
|
}));
|
|
return g;
|
|
}
|
|
case "arrow": {
|
|
const [x1, y1] = this.toView(a.x1, a.y1);
|
|
const [x2, y2] = this.toView(a.x2, a.y2);
|
|
const w = (a.width || 2) * this.k;
|
|
const barbs = arrowBarbs(a.x1, a.y1, a.x2, a.y2, a.width || 2).map((p) => this.toView(p.x, p.y));
|
|
g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": w, "stroke-linecap": "round" }));
|
|
g.append(el("polyline", {
|
|
points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`,
|
|
fill: "none", stroke: a.color, "stroke-width": w,
|
|
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
}));
|
|
return g;
|
|
}
|
|
case "text": {
|
|
const size = (a.size || 12) * this.k;
|
|
let widest = 0;
|
|
for (const line of textLayout(a)) {
|
|
const [vx, vy] = this.toView(line.x, line.y);
|
|
const t = el("text", {
|
|
x: vx, y: vy, fill: a.color,
|
|
"font-family": "Helvetica, Arial, sans-serif",
|
|
"font-size": size, "xml:space": "preserve",
|
|
});
|
|
t.textContent = line.text;
|
|
g.append(t);
|
|
}
|
|
g.dataset.fill = "1";
|
|
// Measure once attached so the selection box is tight. Cached on the
|
|
// mark in user-space units; boundsOf() reads it.
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
|
|
if (widest > 0) a._w = widest / this.k;
|
|
} catch {}
|
|
});
|
|
return g;
|
|
}
|
|
case "signature": {
|
|
const w = (a.width || 1.6) * this.k;
|
|
for (const stroke of signatureStrokes(a)) {
|
|
if (stroke.length < 2) continue;
|
|
g.append(el("polyline", {
|
|
points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "),
|
|
fill: "none", stroke: a.color, "stroke-width": w,
|
|
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
}));
|
|
}
|
|
return g;
|
|
}
|
|
case "redact": {
|
|
const b = this._box(a);
|
|
g.append(el("rect", { ...b, fill: "#000" }));
|
|
g.dataset.fill = "1";
|
|
return g;
|
|
}
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
// A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both
|
|
// corners so a rotated viewport lands the right way up.
|
|
_box(a) {
|
|
const [ax, ay] = this.toView(a.x, a.y);
|
|
const [bx, by] = this.toView(a.x + a.w, a.y + a.h);
|
|
return { x: Math.min(ax, bx), y: Math.min(ay, by),
|
|
width: Math.abs(bx - ax), height: Math.abs(by - ay) };
|
|
}
|
|
|
|
_drawChrome(sel) {
|
|
this.chrome.textContent = "";
|
|
if (!sel) return;
|
|
const b = boundsOf(sel);
|
|
const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h });
|
|
const pad = 3;
|
|
this.chrome.append(el("rect", {
|
|
class: "selbox", x: r.x - pad, y: r.y - pad,
|
|
width: r.width + pad * 2, height: r.height + pad * 2,
|
|
}));
|
|
}
|
|
|
|
/** The mark id under a page-relative CSS pixel point, or null. */
|
|
hitTest(vx, vy) {
|
|
const node = document.elementFromPoint(
|
|
vx + this.svg.getBoundingClientRect().left,
|
|
vy + this.svg.getBoundingClientRect().top);
|
|
const mark = node?.closest?.(".mark");
|
|
return mark ? mark.dataset.id : null;
|
|
}
|
|
}
|