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