theseus/bundled-addons/pdf-editor/lib/overlay.js
Local Dev d625bd25a1 feat(pdf-editor): replace the document's own text, on its own baseline
Until now "editing" a PDF here meant laying things over it. You could put a
word on top of a word, but the document underneath never changed, and the
result read like a sticker because it was one. This adds the thing the word
Edit actually promises: click a line of the document's text, type different
words, and they land where the old ones were, in the old size and the old
colour.

The position and size come from pdf.js's text layer, which has already placed
a span over every run and carries that run's size in unscaled PDF points — so
the size is right whatever the zoom, which reading it off the rendered box
would not be. The colours come from the rendered page, because nothing in the
text API reports them: the background is the average of the most common colour
bucket in the run's box, since type is a minority of the pixels even when it
is dense, and the ink is whatever sits furthest from that background. On the
test fixture it recovers the marker's red exactly.

Two things that look like details and are not. The bucket only chooses WHICH
pixels are background; the colour itself is their average, because rebuilding
it from the bucket index rounds white down to #f8f8f8 and a not-quite-white
patch on a white page is a visible seam. And the cover reaches below the
baseline by a quarter of the font size, because pdf.js sizes its spans to the
em box: cut the cover to the span and every descender in the original line
survives as a little hook under the replacement.

A replacement is a cover plus text, so it is a mark like any other — movable,
resizable, undoable, and rendered on screen from the same numbers the writer
uses, which is what makes the preview trustworthy.

Said plainly in the dialog and again in the save summary: this hides the
original, it does not remove it. The old glyphs are still in the content
stream underneath. Redact is the tool that takes text away, and it says so
too.
2026-09-22 21:49:37 +02:00

345 lines
14 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, editLayout, handlesFor, signatureStrokes, textLayout, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js";
const NS = "http://www.w3.org/2000/svg";
const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
let clipSeq = 0; // clipPath ids must be unique across every page in the document
const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
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 });
// Marks are clipped to the page, because a PDF reader shows nothing
// outside the crop box and a mark that spilled over the edge on screen
// would vanish on save. The selection chrome is NOT clipped: a mark
// flush against the edge still needs grabbable handles, and those sit
// just outside its bounds.
this.clipId = `smclip-${++clipSeq}`;
const defs = el("defs", {});
this.clipRect = el("rect", { x: 0, y: 0, width: 0, height: 0 });
const clip = el("clipPath", { id: this.clipId });
clip.append(this.clipRect);
defs.append(clip);
this.marks = el("g", { class: "marks", "clip-path": `url(#${this.clipId})` });
this.chrome = el("g", { class: "chrome" }); // selection box + handles
this.svg.append(defs, 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._selId = selectedId;
this.svg.setAttribute("width", viewport.width);
this.svg.setAttribute("height", viewport.height);
this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
this.clipRect.setAttribute("width", viewport.width);
this.clipRect.setAttribute("height", viewport.height);
this.marks.textContent = "";
for (const a of annots) {
const node = this._draw(a);
if (!node) continue;
this._addHitArea(node, a);
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 b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h });
g.append(el("rect", {
...b, fill: a.color, "fill-opacity": a.opacity ?? 0.38,
style: "mix-blend-mode:multiply",
}));
}
g.dataset.fill = "1";
return g;
}
// Underline and strikeout share the highlight's geometry — one rect per
// line of selected text — and differ only in where the rule sits inside
// that rect. Keeping them on the same shape means a selection spanning a
// page break splits the same way for all three.
case "underline": case "strikeout": {
const frac = RULE_OFFSET[a.kind];
for (const r of a.rects || []) {
const yy = r.y + r.h * frac;
const [x1, y1] = this.toView(r.x, yy);
const [x2, y2] = this.toView(r.x + r.w, yy);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": Math.max(0.6, (a.width || 1.4)) * this.k, "stroke-linecap": "butt",
}));
}
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, ...this._paint(a) }));
if (a.fill) g.dataset.fill = "1";
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),
...this._paint(a),
}));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "line": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round",
}));
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",
"font-weight": a.bold ? "bold" : "normal",
"font-style": a.italic ? "italic" : "normal",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
// A string's width is only knowable once the browser has laid it out,
// so it is measured a frame later and cached on the mark in user-space
// units for boundsOf(). The selection box is drawn from that, and was
// drawn before the measurement existed — so redraw it once the real
// width is in, or the box and its handles sit at the fallback guess.
requestAnimationFrame(() => {
try {
for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
if (widest <= 0) return;
const w = widest / this.k;
const changed = Math.abs((a._w ?? 0) - w) > 0.5;
a._w = w;
if (changed && a.id === this._selId) this._drawChrome(a);
} 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;
}
// A replaced text run: the original covered in the page's own background
// colour, the replacement drawn on the original's baseline. Rendering it
// here from the same numbers the writer uses is what makes the preview
// honest — the cover is the part people need to trust.
case "textedit": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: a.cover || "#ffffff" }));
const size = (a.size || 12) * this.k;
for (const line of editLayout(a)) {
const [vx, vy] = this.toView(line.x, line.y);
const t = el("text", {
x: vx, y: vy, fill: a.color || "#000000",
"font-family": a.family || "Helvetica, Arial, sans-serif",
"font-size": size, "xml:space": "preserve",
"font-weight": a.bold ? "bold" : "normal",
"font-style": a.italic ? "italic" : "normal",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
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;
}
}
/**
* Give a mark something to grab.
*
* A 2 pt outline is about one screen pixel: selecting it means hitting a
* hairline exactly, which is miserable with a mouse and impossible with a
* trackpad. Each stroked shape gets an invisible copy of itself with a fat
* stroke underneath, purely to catch the pointer. `pointer-events: stroke`
* hit-tests the stroke area whatever the paint is, so a transparent one
* still counts.
*/
_addHitArea(g, a) {
if (g.dataset.fill === "1") return; // already a solid target
const wide = Math.max(HIT_TOLERANCE, (a.width || 2) * this.k * 1.8);
const clones = [];
for (const child of g.children) {
if (child.tagName === "text") continue;
const c = child.cloneNode(false);
c.setAttribute("stroke", "transparent");
c.setAttribute("stroke-width", wide);
c.setAttribute("fill", "none");
c.removeAttribute("style");
c.setAttribute("class", "hit");
clones.push(c);
}
for (const c of clones) g.insertBefore(c, g.firstChild);
}
// 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) };
}
// The selection outline and its grab handles.
//
// Handles are drawn at a fixed pixel size rather than scaled with the page,
// so they stay grabbable at 50% zoom and do not swell into the artwork at
// 400%. Each carries data-handle, which is what tools.js hit-tests to tell a
// resize from a move.
_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,
}));
const names = handlesFor(sel);
if (SEGMENT_KINDS.has(sel.kind)) {
// A line's ends are the only meaningful grips; a bounding box would let
// you stretch it in ways that never match what you were aiming at.
const ends = { p1: this.toView(sel.x1, sel.y1), p2: this.toView(sel.x2, sel.y2) };
for (const n of names) this._handle(n, ends[n][0], ends[n][1], "move");
return;
}
const L = r.x - pad, R = r.x + r.width + pad;
const T = r.y - pad, B = r.y + r.height + pad;
const MX = (L + R) / 2, MY = (T + B) / 2;
const at = { nw: [L, T], n: [MX, T], ne: [R, T], e: [R, MY],
se: [R, B], s: [MX, B], sw: [L, B], w: [L, MY] };
const cursor = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize",
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
for (const n of names) {
const p = at[n];
if (p) this._handle(n, p[0], p[1], cursor[n]);
}
}
_handle(name, cx, cy, cursor) {
const h = el("rect", {
class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2,
width: HANDLE, height: HANDLE, rx: 1.5,
style: `cursor:${cursor}`,
});
h.dataset.handle = name;
this.chrome.append(h);
}
// Stroke and fill for a shape, honouring its fill and opacity.
_paint(a) {
return {
fill: a.fill ? a.color : "none",
"fill-opacity": a.fill ? (a.opacity ?? 0.25) : 0,
stroke: a.color,
"stroke-width": (a.width || 2) * this.k,
};
}
/** 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;
}
}