diff --git a/bundled-addons/pdf-editor/editor.css b/bundled-addons/pdf-editor/editor.css
index 8e65d2fb..79e1e570 100644
--- a/bundled-addons/pdf-editor/editor.css
+++ b/bundled-addons/pdf-editor/editor.css
@@ -271,3 +271,16 @@ body[data-dragging="1"] .selbar,
`visibleStroke`: the copy is transparent on purpose. */
body[data-mode="select"] .smOverlay .mark .hit { pointer-events: stroke; }
.smOverlay .mark .hit { pointer-events: none; }
+
+/* Picking a run of the document's own text to replace. The text layer stays
+ live so a click can land on a run; the form layer steps aside so a widget
+ does not swallow it. The hover outline is the only cue that the invisible
+ text layer is a target at all. */
+body[data-mode="pickrun"] .annotationLayer { pointer-events: none !important; }
+body[data-mode="pickrun"] .pdfViewer .page { cursor: text; }
+body[data-mode="pickrun"] .textLayer span:hover {
+ outline: 2px solid rgb(from var(--acid) r g b / .9);
+ outline-offset: 1px;
+ background: rgb(from var(--acid) r g b / .16);
+ border-radius: 2px;
+}
diff --git a/bundled-addons/pdf-editor/editor.html b/bundled-addons/pdf-editor/editor.html
index 7e23eb66..c0d5074a 100644
--- a/bundled-addons/pdf-editor/editor.html
+++ b/bundled-addons/pdf-editor/editor.html
@@ -62,6 +62,9 @@
+
@@ -191,7 +194,7 @@
Text stamp
-
+
diff --git a/bundled-addons/pdf-editor/editor.js b/bundled-addons/pdf-editor/editor.js
index 5548bf9d..9c2b4f58 100644
--- a/bundled-addons/pdf-editor/editor.js
+++ b/bundled-addons/pdf-editor/editor.js
@@ -19,7 +19,7 @@ import { PageStrip } from "./lib/view.js";
import { Rail } from "./lib/rail.js";
import { Tools } from "./lib/tools.js";
import { SignaturePad } from "./lib/signature.js";
-import { buildPdf, unencodableStamps } from "./lib/save.js";
+import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
import { boundsOf, clampFont } from "./lib/shape.js";
const $ = (id) => document.getElementById(id);
@@ -197,6 +197,7 @@ async function loadBytes(bytes, name, scratchId = null) {
selectTool,
onSelectionChange: paintSelectionBar,
editText: onTextModal,
+ editRun: onEditRun,
openSignaturePad: onSignaturePad,
signature: () => savedSignature,
},
@@ -374,6 +375,7 @@ const TOOL_HINTS = {
line: "Drag to draw a line.",
arrow: "Drag from tail to head.",
text: "Click where the text should start.",
+ edittext: "Click a line of the document's own text to replace it.",
signature: "Click to place your signature.",
redact: "Drag a box over what has to go. Redacted pages are rebuilt as images on save.",
};
@@ -447,6 +449,98 @@ window.addEventListener("resize", () => {
}, 140);
});
+// ---- replacing the document's own text --------------------------------
+//
+// The run's size and position come from pdf.js's text layer; its colours come
+// from the rendered page, because nothing in the text API reports them. Both
+// are guesses made from what is actually on screen, which is the same thing
+// the reader is looking at, so a wrong guess is visible immediately rather
+// than only after saving.
+
+function hex([r, g, b]) {
+ return "#" + [r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0")).join("");
+}
+
+/**
+ * The background and ink colours of one text run, read off the rendered page.
+ * Background is the most common colour in the run's box — type is a minority
+ * of the pixels even when it is dense. Ink is whatever sits furthest from it.
+ */
+function sampleRun(run) {
+ const fallback = { bg: "#ffffff", ink: "#000000", sampled: false };
+ try {
+ const view = session.strip?.views.get(run.uid);
+ const canvas = view?.pv?.div?.querySelector(".canvasWrapper canvas");
+ if (!canvas || !canvas.width) return fallback;
+ const cr = canvas.getBoundingClientRect();
+ if (!cr.width || !cr.height) return fallback;
+ const sx = canvas.width / cr.width, sy = canvas.height / cr.height;
+ const r = run.clientRect;
+ const x0 = Math.max(0, Math.floor((r.left - cr.left) * sx));
+ const y0 = Math.max(0, Math.floor((r.top - cr.top) * sy));
+ const w = Math.min(canvas.width - x0, Math.ceil(r.width * sx));
+ const h = Math.min(canvas.height - y0, Math.ceil(r.height * sy));
+ if (w < 2 || h < 2) return fallback;
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
+ const data = ctx.getImageData(x0, y0, w, h).data;
+
+ // Buckets of 8 per channel: exact equality would split an anti-aliased
+ // background into hundreds of near-identical shades and no single one
+ // would win. The bucket only decides WHICH pixels are the background —
+ // the colour itself is their average, because reconstructing it from the
+ // bucket index rounds white down to #f8f8f8, and a not-quite-white patch
+ // over a white page is a visible seam.
+ const buckets = new Map();
+ for (let i = 0; i < data.length; i += 4) {
+ const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3);
+ let acc = buckets.get(key);
+ if (!acc) buckets.set(key, (acc = { n: 0, r: 0, g: 0, b: 0 }));
+ acc.n++; acc.r += data[i]; acc.g += data[i + 1]; acc.b += data[i + 2];
+ }
+ let win = null;
+ for (const acc of buckets.values()) if (!win || acc.n > win.n) win = acc;
+ if (!win) return fallback;
+ const bg = [win.r / win.n, win.g / win.n, win.b / win.n];
+
+ let ink = [0, 0, 0], far = -1;
+ for (let i = 0; i < data.length; i += 4) {
+ const d = Math.abs(data[i] - bg[0]) + Math.abs(data[i + 1] - bg[1]) + Math.abs(data[i + 2] - bg[2]);
+ if (d > far) { far = d; ink = [data[i], data[i + 1], data[i + 2]]; }
+ }
+ // Nothing that reads as ink — a blank strip, or type too faint to trust.
+ if (far < 40) return { ...fallback, bg: hex(bg), sampled: true };
+ return { bg: hex(bg), ink: hex(ink), sampled: true };
+ } catch { return fallback; }
+}
+
+async function onEditRun(run) {
+ const look = sampleRun(run);
+ $("modal-text-title").textContent = "Replace text";
+ $("text-body").value = run.text;
+ $("text-size-field").hidden = true; // the size comes from the document
+ $("text-bold").classList.remove("active");
+ $("text-italic").classList.remove("active");
+ $("text-warn").hidden = true;
+ const note = $("text-note");
+ note.hidden = false;
+ note.textContent = `${run.size.toFixed(1)} pt from the document. The replacement is drawn over the original, `
+ + `which stays in the file underneath — use Redact if it has to be gone.`;
+ const ok = await openModal("modal-text");
+ $("text-size-field").hidden = false;
+ note.hidden = true;
+ if (!ok) return null;
+ const a = session.tools.replaceRun(run, {
+ text: $("text-body").value,
+ size: run.size,
+ color: look.ink,
+ cover: look.bg,
+ bold: $("text-bold").classList.contains("active"),
+ italic: $("text-italic").classList.contains("active"),
+ });
+ if (a && !look.sampled) toast("Could not read the page's colours; used black on white.", true);
+ return a;
+}
+
// ---- selection bar ----------------------------------------------------
//
// A floating strip pinned to whatever is selected, carrying the actions that
@@ -698,6 +792,8 @@ async function doSave() {
: `${red.size} redacted page${red.size === 1 ? "" : "s"} — the text under the boxes stays extractable`,
!flattenRedactions]);
}
+ const replaced = replacedRuns(m);
+ if (replaced) lines.push([`${replaced} text run${replaced === 1 ? "" : "s"} replaced in place — the original text stays in the file underneath`, false]);
const bad = unencodableStamps(m);
if (bad) lines.push([`${bad} text stamp${bad === 1 ? "" : "s"} contain characters the built-in font cannot write`, true]);
if (!lines.length) lines.push(["no changes — this saves a copy of the original", false]);
@@ -788,6 +884,7 @@ $("viewerContainer").addEventListener("click", (e) => {
const TOOL_KEYS = {
v: "select", h: "highlight", u: "underline", k: "strikeout", p: "pen",
r: "rect", o: "ellipse", l: "line", a: "arrow", t: "text", g: "signature",
+ e: "edittext",
};
window.addEventListener("keydown", (e) => {
// A modal owns the keyboard while it is up: Escape cancels, and Enter
diff --git a/bundled-addons/pdf-editor/lib/overlay.js b/bundled-addons/pdf-editor/lib/overlay.js
index 8261396c..5bcbfe4b 100644
--- a/bundled-addons/pdf-editor/lib/overlay.js
+++ b/bundled-addons/pdf-editor/lib/overlay.js
@@ -11,7 +11,7 @@
// hit-testable, selectable and movable, and because it stays crisp when the
// user zooms without us re-rasterising anything.
-import { arrowBarbs, boundsOf, handlesFor, signatureStrokes, textLayout, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js";
+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
@@ -207,6 +207,29 @@ export class Overlay {
}
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" }));
diff --git a/bundled-addons/pdf-editor/lib/save.js b/bundled-addons/pdf-editor/lib/save.js
index e4760693..3cba6361 100644
--- a/bundled-addons/pdf-editor/lib/save.js
+++ b/bundled-addons/pdf-editor/lib/save.js
@@ -27,7 +27,7 @@ import {
LineCapStyle, LineJoinStyle,
} from "../vendor/pdf-lib/pdf-lib.esm.min.js";
-import { arrowBarbs, signatureStrokes, textLayout, RULE_OFFSET } from "./shape.js";
+import { arrowBarbs, editLayout, signatureStrokes, textLayout, RULE_OFFSET } from "./shape.js";
/** #rrggbb -> pdf-lib rgb(). */
function colorOf(hex) {
@@ -54,7 +54,13 @@ export function winAnsiSafe(text) {
/** True when any text stamp carries characters Helvetica cannot write. */
export function unencodableStamps(model) {
- return model.annots.filter((a) => a.kind === "text" && winAnsiSafe(a.text).dropped > 0).length;
+ return model.annots.filter((a) => (a.kind === "text" || a.kind === "textedit")
+ && winAnsiSafe(a.text).dropped > 0).length;
+}
+
+/** How many runs were replaced in place, for the save summary. */
+export function replacedRuns(model) {
+ return model.annots.filter((a) => a.kind === "textedit").length;
}
// ---- mark drawing -----------------------------------------------------
@@ -140,6 +146,22 @@ function drawMark(page, a, font) {
}
return;
}
+ case "textedit": {
+ // Cover the original run in the page's own background colour, then set
+ // the replacement on the run's own baseline. The original glyphs are
+ // still in the content stream underneath — this hides them, it does not
+ // delete them, which is the same honesty the redact tool states out
+ // loud. Anyone who needs them gone should redact instead.
+ fillRect(page, a.x, a.y, a.w, a.h, a.cover || "#ffffff", 1);
+ const size = a.size || 12;
+ const f = typeof font === "function" ? font(a) : font;
+ for (const line of editLayout(a)) {
+ const { text } = winAnsiSafe(line.text);
+ if (!text) continue;
+ page.drawText(text, { x: line.x, y: line.y, size, font: f, color: colorOf(a.color || "#000000") });
+ }
+ return;
+ }
case "signature":
for (const s of signatureStrokes(a)) strokePolyline(page, s, a.color, a.width || 1.6);
return;
diff --git a/bundled-addons/pdf-editor/lib/shape.js b/bundled-addons/pdf-editor/lib/shape.js
index ece559e7..8b1eff30 100644
--- a/bundled-addons/pdf-editor/lib/shape.js
+++ b/bundled-addons/pdf-editor/lib/shape.js
@@ -13,6 +13,20 @@ export const ASCENT = 0.76;
/** Line advance as a fraction of font size. */
export const LINE_GAP = 1.2;
+/**
+ * Where each line of a replaced text run is drawn.
+ *
+ * A "textedit" is a cover rectangle over the original run plus replacement
+ * text sitting on that run's own baseline — so it is a text stamp whose origin
+ * came from the document rather than from a click. Sharing the line layout
+ * with textLayout keeps the two kinds rendering identically.
+ */
+export function editLayout(a) {
+ const lines = String(a.text ?? "").split("\n");
+ const size = a.size || 12;
+ return lines.map((text, i) => ({ text, x: a.x, y: a.baseline - i * size * LINE_GAP }));
+}
+
/**
* 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.
@@ -79,7 +93,7 @@ export const RULE_OFFSET = { underline: 0.06, strikeout: 0.42 };
/** Marks whose geometry is a list of text-line rectangles. */
export const RECT_LIST_KINDS = new Set(["highlight", "underline", "strikeout"]);
/** Marks whose geometry is a single box. */
-export const BOX_KINDS = new Set(["rect", "ellipse", "redact", "signature"]);
+export const BOX_KINDS = new Set(["rect", "ellipse", "redact", "signature", "textedit"]);
/** Marks defined by two endpoints. */
export const SEGMENT_KINDS = new Set(["arrow", "line"]);
@@ -119,6 +133,7 @@ export function translatePatch(a, dx, dy) {
return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
}
if (a.kind === "pen") return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
+ if (a.kind === "textedit") return { x: a.x + dx, y: a.y + dy, baseline: a.baseline + dy };
return { x: a.x + dx, y: a.y + dy };
}
@@ -200,6 +215,15 @@ export function scaleToBox(a, from, to) {
const size = clampFont((a.size || 12) * (sy || 1));
return { size, x: to.x, y: to.y + to.h };
}
+ if (a.kind === "textedit") {
+ // The cover follows the box; the baseline rides with it so the
+ // replacement keeps sitting where the original line sat.
+ return {
+ x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h),
+ size: clampFont((a.size || 12) * (sy || 1)),
+ baseline: fy(a.baseline),
+ };
+ }
return { x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h) };
}
diff --git a/bundled-addons/pdf-editor/lib/tools.js b/bundled-addons/pdf-editor/lib/tools.js
index 5125d4c0..2decfe93 100644
--- a/bundled-addons/pdf-editor/lib/tools.js
+++ b/bundled-addons/pdf-editor/lib/tools.js
@@ -18,16 +18,19 @@
// click stamps a second copy, which is almost never what was wanted, and it
// puts the thing just placed immediately within reach of a nudge or a resize.
-import { boundsOf, normBox, translatePatch, resizeBox, scaleToBox } from "./shape.js";
+import { ASCENT, boundsOf, normBox, translatePatch, resizeBox, scaleToBox } from "./shape.js";
const MIN_DRAG = 3; // CSS px before a press counts as a drag
const DOUBLE_CLICK_MS = 450; // window for a second click on the same mark
const SIG_WIDTH_PT = 170; // default placed width of a signature
+const DESCENDER = 0.26; // how far below the baseline a glyph can reach, as a fraction of size
/** Tools that mark up existing text rather than drawing free-hand. */
export const TEXT_MARKUP = new Set(["highlight", "underline", "strikeout"]);
/** Tools that place something with a single click. */
const STAMP_TOOLS = new Set(["text", "signature"]);
+/** Tools that act on a run of the document's OWN text. */
+const RUN_TOOLS = new Set(["edittext"]);
export class Tools {
constructor({ strip, model, host }) {
@@ -64,6 +67,7 @@ export class Tools {
get mode() {
if (this.tool === "select") return "select";
if (TEXT_MARKUP.has(this.tool)) return "highlight";
+ if (RUN_TOOLS.has(this.tool)) return "pickrun";
if (STAMP_TOOLS.has(this.tool)) return "stamp";
return "draw";
}
@@ -170,6 +174,21 @@ export class Tools {
if (TEXT_MARKUP.has(this.tool)) return; // let the browser select text
+ // Editing the document's own text: the click has to land on a run of it,
+ // which is exactly what a text-layer span is. Anywhere else is a miss, and
+ // saying so beats silently doing nothing.
+ if (this.tool === "edittext") {
+ e.preventDefault();
+ const span = e.target?.closest?.(".textLayer span");
+ if (!span || !span.textContent.trim()) {
+ this.host.toast?.("Click on a line of the document's text to replace it.", true);
+ return;
+ }
+ const run = this.describeRun(span, hit.uid);
+ if (run) this.host.editRun?.(run);
+ return;
+ }
+
const [x, y] = ov.toPdf(hit.vx, hit.vy);
if (this.tool === "select") {
@@ -329,6 +348,62 @@ export class Tools {
}
}
+ /**
+ * Everything needed to replace one run of the document's text, in the
+ * coordinates the rest of the editor speaks.
+ *
+ * The text layer is the source: pdf.js has already placed a span over every
+ * run, and its `--font-height` carries the run's size in unscaled PDF points
+ * — so the size survives zooming, which reading it off the rendered box
+ * would not.
+ */
+ describeRun(span, uid) {
+ const ov = this.strip.overlayFor(uid);
+ const view = this.strip.views.get(uid);
+ if (!ov?.viewport || !view) return null;
+ const pr = view.pv.div.getBoundingClientRect();
+ const r = span.getBoundingClientRect();
+ if (r.width < 1 || r.height < 1) return null;
+ const [ax, ay] = ov.toPdf(r.left - pr.left, r.bottom - pr.top);
+ const [bx, by] = ov.toPdf(r.right - pr.left, r.top - pr.top);
+ const box = normBox(ax, ay, bx, by);
+ const declared = parseFloat(getComputedStyle(span).getPropertyValue("--font-height"));
+ const size = Number.isFinite(declared) && declared > 0 ? declared : box.h * 0.8;
+ // PDF text sits on a baseline; the span's top is the top of the em box,
+ // so the baseline hangs one ascent below it.
+ const baseline = box.y + box.h - size * ASCENT;
+ // The span does NOT enclose the glyphs. pdf.js sizes it to the em box, so
+ // every descender — the tail of a y, a comma — falls below it, and a cover
+ // cut to the span leaves a row of little hooks showing under the
+ // replacement. Reach below the baseline far enough to take them, and a
+ // hair above and to the sides for accents and side bearings.
+ const bottom = Math.min(box.y, baseline - size * DESCENDER);
+ const top = box.y + box.h + size * 0.06;
+ return {
+ uid,
+ text: span.textContent,
+ box: { x: box.x - 0.6, y: bottom, w: box.w + 1.2, h: top - bottom },
+ size,
+ baseline,
+ clientRect: { left: r.left, top: r.top, width: r.width, height: r.height },
+ };
+ }
+
+ /** Commit a replaced run as a mark, selected and ready to nudge. */
+ replaceRun(run, props) {
+ const a = this.model.addAnnot({
+ kind: "textedit", page: run.uid,
+ x: run.box.x, y: run.box.y, w: run.box.w, h: run.box.h,
+ baseline: run.baseline,
+ text: props.text, size: props.size ?? run.size,
+ color: props.color || "#000000",
+ cover: props.cover || "#ffffff",
+ bold: !!props.bold, italic: !!props.italic,
+ });
+ this._afterStamp(a);
+ return a;
+ }
+
// ---- text markup --------------------------------------------------
// Take the live selection's client rects, drop them onto whichever page each
// one sits on, and convert to user space. A selection spanning a page break