112 lines
4.4 KiB
JavaScript
112 lines
4.4 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 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 };
|
||
|
|
}
|
||
|
|
}
|