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.
232 lines
9.7 KiB
JavaScript
232 lines
9.7 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 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.
|
|
* @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) };
|
|
}
|
|
|
|
/**
|
|
* Where a rule sits inside a line's rectangle, as a fraction of its height
|
|
* from the baseline side. Shared by the renderer and the writer so that what
|
|
* is on screen and what lands in the file are the same picture — two copies of
|
|
* this number is two pictures the first time either is touched.
|
|
*/
|
|
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", "textedit"]);
|
|
/** Marks defined by two endpoints. */
|
|
export const SEGMENT_KINDS = new Set(["arrow", "line"]);
|
|
|
|
export function boundsOf(a) {
|
|
if (RECT_LIST_KINDS.has(a.kind)) {
|
|
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 };
|
|
}
|
|
if (SEGMENT_KINDS.has(a.kind)) return normBox(a.x1, a.y1, a.x2, a.y2);
|
|
switch (a.kind) {
|
|
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 "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) {
|
|
if (RECT_LIST_KINDS.has(a.kind)) {
|
|
return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) };
|
|
}
|
|
if (SEGMENT_KINDS.has(a.kind)) {
|
|
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 };
|
|
}
|
|
|
|
// ---- resizing ---------------------------------------------------------
|
|
//
|
|
// Handles are named by the corner or edge they sit on, in screen terms: "nw"
|
|
// is top-left as the reader sees it. User space has y running the other way,
|
|
// so "n" moves the box's y + h and "s" moves its y. Keeping the names in
|
|
// reader terms means the cursor CSS and the drag maths agree with what the
|
|
// user is doing with the mouse.
|
|
|
|
export const BOX_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
|
export const CORNER_HANDLES = ["nw", "ne", "se", "sw"];
|
|
|
|
/** Smallest box a resize may produce, in points. */
|
|
export const MIN_EXTENT = 4;
|
|
export const MIN_FONT = 4;
|
|
export const MAX_FONT = 288;
|
|
|
|
/** Which handles a mark offers. Segments get their two endpoints instead. */
|
|
export function handlesFor(a) {
|
|
if (SEGMENT_KINDS.has(a.kind)) return ["p1", "p2"];
|
|
if (a.kind === "text") return CORNER_HANDLES; // uniform scale only
|
|
return BOX_HANDLES;
|
|
}
|
|
|
|
/**
|
|
* The box a drag of `handle` produces, given the mark's current bounds and a
|
|
* user-space delta. Anchors the opposite corner or edge, and never inverts:
|
|
* dragging the top edge past the bottom stops at the minimum rather than
|
|
* flipping the mark over, which is disorienting mid-gesture.
|
|
*/
|
|
export function resizeBox(b, handle, dx, dy, { uniform = false } = {}) {
|
|
let { x, y, w, h } = b;
|
|
const east = handle.includes("e"), west = handle.includes("w");
|
|
const north = handle.includes("n"), south = handle.includes("s");
|
|
if (uniform && (east || west) && (north || south)) {
|
|
// Corner drag on a uniform mark: one scale factor, taken from whichever
|
|
// axis moved further, so the mark never distorts.
|
|
const sx = (w + (east ? dx : -dx)) / (w || 1);
|
|
const sy = (h + (north ? dy : -dy)) / (h || 1);
|
|
const k = Math.max(MIN_EXTENT / Math.max(w, h, 1), Math.abs(sx) > Math.abs(sy) ? sx : sy);
|
|
const nw = Math.max(MIN_EXTENT, w * k), nh = Math.max(MIN_EXTENT, h * k);
|
|
return {
|
|
x: west ? x + w - nw : x,
|
|
y: south ? y + h - nh : y,
|
|
w: nw, h: nh,
|
|
};
|
|
}
|
|
if (east) w = Math.max(MIN_EXTENT, w + dx);
|
|
if (west) { const nw2 = Math.max(MIN_EXTENT, w - dx); x = x + w - nw2; w = nw2; }
|
|
if (north) h = Math.max(MIN_EXTENT, h + dy);
|
|
if (south) { const nh2 = Math.max(MIN_EXTENT, h - dy); y = y + h - nh2; h = nh2; }
|
|
return { x, y, w, h };
|
|
}
|
|
|
|
/**
|
|
* Map a mark from one bounding box to another. One function for every kind,
|
|
* so a new mark type gets resizing for free as long as boundsOf knows it.
|
|
*/
|
|
export function scaleToBox(a, from, to) {
|
|
const sx = from.w > 0.001 ? to.w / from.w : 1;
|
|
const sy = from.h > 0.001 ? to.h / from.h : 1;
|
|
const fx = (x) => to.x + (x - from.x) * sx;
|
|
const fy = (y) => to.y + (y - from.y) * sy;
|
|
|
|
if (RECT_LIST_KINDS.has(a.kind)) {
|
|
return { rects: (a.rects || []).map((r) => ({ x: fx(r.x), y: fy(r.y), w: r.w * sx, h: r.h * sy })) };
|
|
}
|
|
if (SEGMENT_KINDS.has(a.kind)) {
|
|
return { x1: fx(a.x1), y1: fy(a.y1), x2: fx(a.x2), y2: fy(a.y2) };
|
|
}
|
|
if (a.kind === "pen") {
|
|
return { pts: (a.pts || []).map(([x, y]) => [fx(x), fy(y)]) };
|
|
}
|
|
if (a.kind === "text") {
|
|
// Text scales by font size, not by stretching glyphs. `y` is the top of
|
|
// the block, which is where the new box's top is.
|
|
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) };
|
|
}
|
|
|
|
export function clampFont(n) {
|
|
return Math.min(MAX_FONT, Math.max(MIN_FONT, Math.round(n * 10) / 10));
|
|
}
|