theseus/bundled-addons/pdf-editor/lib/shape.js

209 lines
8.7 KiB
JavaScript
Raw Normal View History

feat(pdf-editor): a mark you placed is something you can still work on Everything the editor put on a page was final. A text stamp could not be corrected without deleting it and typing it again, nothing could be resized, and the only way to remove a mark was a Delete key nobody had been told about — the selection drew a dashed box and offered no action at all. Placing a stamp also left its tool armed, so the next click stamped a second copy. Marks are now editable objects. Selecting one gives it grab handles and a small bar pinned above it: delete and duplicate for anything, and for text an edit button, a size stepper and bold and italic. Double-clicking text reopens it for rewriting in place rather than adding a second one. Placing a text stamp or a signature drops straight back to the select tool with the new mark live, which is both what people expect and what puts it immediately within reach of a nudge. Resizing is one function over every mark type rather than a special case per kind: a handle drag produces a new bounding box, and the mark is mapped from its old box into that one. Text scales by font size instead of stretching its glyphs, signatures keep their aspect on a corner, and lines offer their two endpoints instead of a box that would let you stretch them in ways you never aimed at. A whole gesture lands on the undo stack as one step. Selecting a thin mark used to mean clicking its outline exactly — about one screen pixel. Each stroked mark now carries an invisible fat copy of itself purely to catch the pointer. New marks to go with it: underline and strike-through, which share the highlight's text-selection geometry and differ only in where the rule sits; a plain line; and a fill toggle for rectangles and ellipses. Bold and italic mean three more Helvetica variants embedded at save time, since a PDF treats them as separate fonts rather than as a style. Double-click is detected from the pointer stream rather than from a dblclick listener, because selecting a mark calls preventDefault() on the pointerdown and that suppresses the compatibility mouse events the browser would have synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
// 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 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"]);
/** 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]) };
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 };
}
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));
}