// 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) }; } /** Axis-aligned bounds of a mark, in user space. Used for selection and hit-testing. */ export function boundsOf(a) { switch (a.kind) { case "highlight": { 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 }; } 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 "arrow": return normBox(a.x1, a.y1, a.x2, a.y2); 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) { switch (a.kind) { case "highlight": return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) }; case "pen": return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) }; case "arrow": return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy }; default: return { x: a.x + dx, y: a.y + dy }; } }