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.
This commit is contained in:
Local Dev 2026-09-22 21:49:37 +02:00
parent 77e32430da
commit a6e5f8874e
7 changed files with 265 additions and 7 deletions

View file

@ -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;
}

View file

@ -62,6 +62,9 @@
<button class="tool" data-tool="text" title="Text stamp (T)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
</button>
<button class="tool" data-tool="edittext" title="Replace the document's own text (E)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7M5.5 4v7"/><path d="M11.5 3.5l2 2L9 10l-2.5.5L7 8z"/><path d="M2 14h12"/></svg>
</button>
<button class="tool" data-tool="signature" title="Signature stamp (G)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 11c2 0 3-7 4.5-7S8 10 9.5 10 12 6 13.5 6"/><path d="M2 14h12"/></svg>
</button>
@ -191,7 +194,7 @@
<h2 id="modal-text-title">Text stamp</h2>
<textarea id="text-body" rows="4" placeholder="Type the text to place…"></textarea>
<div class="mrow">
<label class="mfield">Size
<label class="mfield" id="text-size-field">Size
<select id="text-size">
<option value="8">8</option><option value="10">10</option>
<option value="12" selected>12</option><option value="14">14</option>
@ -205,6 +208,7 @@
<button class="btn wide" id="text-cancel">Cancel</button>
<button class="btn wide primary" id="text-ok">Place</button>
</div>
<p class="mhint" id="text-note" hidden></p>
<p class="mhint" id="text-warn" hidden></p>
</div>

View file

@ -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

View file

@ -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" }));

View file

@ -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;

View file

@ -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) };
}

View file

@ -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