Reads the lines, curves and text of a PDF page into page points, and finds the end, middle, crossing or nearest point of those lines for snapping.
How the precision is kept
Path coordinates come from pdf.js's operator list and are multiplied by the page and content matrices in 64-bit. pdf.js 6.3.289, the build the app's manifest loads, hands each path over as a Float32Array, so every line end and curve point starts with about 7 significant digits. Curves are flattened to 8 chords for snapping. Snap decisions compare distances in page points with fixed tolerances.
Open item. Take path coordinates from the PDF in 64-bit instead of the Float32Array that pdf.js delivers, so imported and snapped points keep more than about 7 digits.
Calculations under this policy
8 calculations in 2 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
pdf-vector.jsCadCraft 0.8.2 32-bit in part5 calculationsReviewed
Tests bin/test-cadcraft.mjs
Page box and rotation matrix
64-bit
The matrix from PDF user space to the page as displayed: origin at the lower-left corner, y up, the page's rotation applied.
0°: [1 0 0 1 −x₀ −y₀]; 90°: [0 −1 1 0 −y₀ W + x₀]; 180°: [−1 0 0 −1 W + x₀ H + y₀]; 270°: [0 1 −1 0 H + y₀ −x₀]
The rotation entries are the whole numbers 0, 1 and −1, so no sine or cosine is rounded; the offsets are single 64-bit sums of the page box values. A rotation that is not 90, 180 or 270 falls to the unrotated case.
pdf-vector.js
lines 22 to 31
CadCraft 0.8.2
/** PDF user space → the page as displayed: lower-left origin, y up, the page's /Rotate (clockwise multiples of 90) applied. */
export function viewMatrix(view, rotate = 0) {
const [x0, y0, x1, y1] = view, W = x1 - x0, H = y1 - y0;
switch (((rotate % 360) + 360) % 360) {
case 90: return [0, -1, 1, 0, -y0, W + x0];
case 180: return [-1, 0, 0, -1, W + x0, H + y0];
case 270: return [0, 1, -1, 0, H + y0, -x0];
default: return [1, 0, 0, 1, -x0, -y0];
}
}
Matrix product
64-bit
Combines the current matrix with one the page's content applies.
[a b c d e f] = m · k for 2 × 3 affine matrices
Six 64-bit expressions of products and sums. Matrices are multiplied one after another as the content lists them; the product is never re-normalized.
The numbers d[k] are read from the path buffer of pdf.js 6.3.289, which is a Float32Array (build/pdf.worker.mjs: new Float32Array(pathBuffer)), so each coordinate has about 7 significant digits before the 64-bit matrix is applied. A quadratic curve is raised to a cubic with the factor 2 ÷ 3 in 64-bit. A non-finite number ends the path.
pdf-vector.js
lines 86 to 103
CadCraft 0.8.2
const m = ctm, X = (x, y) => m[0] * x + m[2] * y + m[4], Y = (x, y) => m[1] * x + m[3] * y + m[5];
const subs = [];
let cur = null, sx = 0, sy = 0, px = 0, py = 0;
const ok = (...v) => v.every(Number.isFinite);
for (let k = 0; k < d.length;) {
const c = d[k];
if (c === 0) { const x = X(d[k + 1], d[k + 2]), y = Y(d[k + 1], d[k + 2]); if (!ok(x, y)) break; cur = { closed: false, ops: [['M', x, y]] }; subs.push(cur); sx = px = x; sy = py = y; k += 3; }
else if (c === 1 && cur) { const x = X(d[k + 1], d[k + 2]), y = Y(d[k + 1], d[k + 2]); if (!ok(x, y)) break; cur.ops.push(['L', x, y]); px = x; py = y; k += 3; }
else if (c === 2 && cur) {
const c1x = X(d[k + 1], d[k + 2]), c1y = Y(d[k + 1], d[k + 2]), c2x = X(d[k + 3], d[k + 4]), c2y = Y(d[k + 3], d[k + 4]), x = X(d[k + 5], d[k + 6]), y = Y(d[k + 5], d[k + 6]);
if (!ok(c1x, c1y, c2x, c2y, x, y)) break;
cur.ops.push(['C', c1x, c1y, c2x, c2y, x, y]); px = x; py = y; k += 7;
} else if (c === 3 && cur) { // quadratic → cubic
const qx = X(d[k + 1], d[k + 2]), qy = Y(d[k + 1], d[k + 2]), x = X(d[k + 3], d[k + 4]), y = Y(d[k + 3], d[k + 4]);
if (!ok(qx, qy, x, y)) break;
cur.ops.push(['C', px + 2 / 3 * (qx - px), py + 2 / 3 * (qy - py), x + 2 / 3 * (qx - x), y + 2 / 3 * (qy - y), x, y]); px = x; py = y; k += 5;
} else if (c === 4 && cur) { cur.closed = true; px = sx; py = sy; k += 1; }
else break; // an operator this reader does not know: the rest of the path is dropped
Text position, height and angle
64-bit
Where each run of text starts, how tall it is, its angle and its width, in displayed-page points.
The six numbers of pdf.js's text transform go through the page matrix in 64-bit. Height is the length of the transformed y axis (hypot) and the angle is atan2 of the transformed x axis. Text with a non-finite value or a height that is not above zero is left out.
Turns every line and curve of the page into straight segments for snapping.
B(t) = u³p₀ + 3u²t·c₁ + 3u·t²·c₂ + t³p₃, u = 1 − t, t = 1⁄8 … 8⁄8
Each cubic becomes 8 chords at equal parameter steps, so a point snapped on a curve lies on a chord, not on the curve; the last chord ends exactly on the curve's stored end point. Segments are kept in a Float64Array, but their coordinates were 32-bit when pdf.js delivered the path.
pdf-vector.js
lines 129 to 158
CadCraft 0.8.2
/** The page's lines flattened for snapping: segments [x1,y1,x2,y2,…] and, per segment, 0 straight / 1 chord of a curve (2 first, 4 last). */
export function segmentsOf(pageVec, { maxSegs = 400000 } = {}) {
let segs = new Float64Array(4096), curve = new Uint8Array(1024), n = 0;
const push = (ax, ay, bx, by, fl) => {
if (n >= maxSegs || (ax === bx && ay === by)) return;
if (n * 4 >= segs.length) { const g = new Float64Array(segs.length * 2); g.set(segs); segs = g; const c2 = new Uint8Array(segs.length / 4); c2.set(curve); curve = c2; }
segs[n * 4] = ax; segs[n * 4 + 1] = ay; segs[n * 4 + 2] = bx; segs[n * 4 + 3] = by; curve[n] = fl; n++;
};
for (const sh of pageVec.shapes) {
for (const sub of sh.subs) {
let px = 0, py = 0, sx = 0, sy = 0;
for (const o of sub.ops) {
if (o[0] === 'M') { px = sx = o[1]; py = sy = o[2]; }
else if (o[0] === 'L') { push(px, py, o[1], o[2], 0); px = o[1]; py = o[2]; }
else {
let lx = px, ly = py;
for (let s = 1; s <= CURVE_STEPS; s++) {
const t = s / CURVE_STEPS, u = 1 - t;
const x = s === CURVE_STEPS ? o[5] : u * u * u * px + 3 * u * u * t * o[1] + 3 * u * t * t * o[3] + t * t * t * o[5];
const y = s === CURVE_STEPS ? o[6] : u * u * u * py + 3 * u * u * t * o[2] + 3 * u * t * t * o[4] + t * t * t * o[6];
push(lx, ly, x, y, 1 | (s === 1 ? 2 : 0) | (s === CURVE_STEPS ? 4 : 0)); lx = x; ly = y;
}
px = o[5]; py = o[6];
}
}
if (sub.closed) push(px, py, sx, sy, 0);
}
}
return { segs: segs.subarray(0, n * 4), curve: curve.subarray(0, n), n };
}
pdf-snap.jsCadCraft 0.8.2 32-bit in part3 calculationsReviewed
TestsNone found by the review
Nearest point on a segment
32-bit in part
The point of a segment closest to the pointer.
t = ((p − a)·(b − a)) ÷ |b − a|², held to [0, 1]; q = a + t(b − a)
64-bit arithmetic with one division, skipped for a zero-length segment (its first end is returned). The segment ends it reads were 32-bit when pdf.js delivered the path.
pdf-snap.js
lines 98 to 103
CadCraft 0.8.2
function closestOnSeg(p, g, s) {
const ax = g[s * 4], ay = g[s * 4 + 1], bx = g[s * 4 + 2], by = g[s * 4 + 3];
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
const t = L ? Math.max(0, Math.min(1, ((p.x - ax) * dx + (p.y - ay) * dy) / L)) : 0;
return { x: ax + dx * t, y: ay + dy * t };
}
Crossing of two segments
32-bit in part
The point where two segments of the page cross, or nothing when they are parallel, apart, or only meet end to end.
den = r × s; u = ((c − a) × s) ÷ den; v = ((c − a) × r) ÷ den; point = a + u·r
Parallel is decided by |den| < 1e-12 × max(1, |r|·|s|), which scales with the segment lengths once they exceed 1. u and v are accepted within 1e-9 of [0, 1]. For nearly parallel segments den is a difference of nearly equal products and the point loses digits. Inputs were 32-bit on arrival from pdf.js.
pdf-snap.js
lines 106 to 120
CadCraft 0.8.2
/** Where two segments cross (ends included), or null (parallel, or apart). */
export function intersect(g, s, t) {
const ax = g[s * 4], ay = g[s * 4 + 1], bx = g[s * 4 + 2], by = g[s * 4 + 3];
const cx = g[t * 4], cy = g[t * 4 + 1], dx = g[t * 4 + 2], dy = g[t * 4 + 3];
const rx = bx - ax, ry = by - ay, sx = dx - cx, sy = dy - cy;
const den = rx * sy - ry * sx;
if (Math.abs(den) < 1e-12 * Math.max(1, Math.hypot(rx, ry) * Math.hypot(sx, sy))) return null;
const u = ((cx - ax) * sy - (cy - ay) * sx) / den, v = ((cx - ax) * ry - (cy - ay) * rx) / den;
const e = 1e-9;
if (u < -e || u > 1 + e || v < -e || v > 1 + e) return null;
// two pieces that only meet end to end (a corner, a curve's chords) do not cross: that point is an end, or nothing
const atEnd = (w) => w < e || w > 1 - e;
if (atEnd(u) && atEnd(v)) return null;
return { x: ax + rx * u, y: ay + ry * u };
}
Choosing the snap point
32-bit in part
Picks the end, crossing, middle or nearest point within the snap distance of the pointer.
midpoint = (a + b) ÷ 2; candidate kept when |candidate − p| ≤ tol
Candidates are compared by distance from hypot; one within 1e-9 page points of the best so far (absolute) is treated as the same spot and the stronger kind wins (end, then crossing, then middle). The nearest-point fallback is used only when no end, crossing or middle is within the snap distance. Coordinates returned were 32-bit when pdf.js delivered the path.
pdf-snap.js
lines 59 to 95
CadCraft 0.8.2
snap(p, tol, kinds = {}) {
const want = { end: true, mid: true, int: true, near: true, ...kinds };
const g = this.segs, cur = this.curve;
const cand = this.near(p, tol).filter((s) => distSeg(p, g, s) <= tol);
if (!cand.length) return null;
let best = null;
const RANK = { end: 0, int: 1, mid: 2 }; // the same spot found twice (a crossing at a midpoint): the stronger name
const offer = (x, y, kind) => {
const d = Math.hypot(x - p.x, y - p.y);
if (d > tol) return;
if (!best || d < best.d - 1e-9 || (Math.abs(d - best.d) <= 1e-9 && RANK[kind] < RANK[best.kind])) best = { x, y, kind, d };
};
for (const s of cand) {
const ax = g[s * 4], ay = g[s * 4 + 1], bx = g[s * 4 + 2], by = g[s * 4 + 3];
if (want.end) {
// a flattened curve's inner chord points are not ends; its own two ends are
if (!cur[s] || cur[s] & 2) offer(ax, ay, 'end');
if (!cur[s] || cur[s] & 4) offer(bx, by, 'end');
}
if (want.mid && !cur[s]) offer((ax + bx) / 2, (ay + by) / 2, 'mid');
}
if (want.int) {
const list = cand.slice(0, 80);
for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
const q = intersect(g, list[i], list[j]);
if (q) offer(q.x, q.y, 'int');
}
}
if (best) return best;
if (!want.near) return null;
for (const s of cand) {
const q = closestOnSeg(p, g, s);
const d = Math.hypot(q.x - p.x, q.y - p.y);
if (!best || d < best.d) best = { x: q.x, y: q.y, kind: 'near', d };
}
return best && best.d <= tol ? best : null;
}
In detail
Method
2 × 3 affine matrices composed in the order the page lists them; cubic Bézier evaluation at equal parameter steps; segment crossing by the two-parameter cross-product form; nearest point by projection onto the segment.
Number types
64-bit numbers and a Float64Array of segments; the path input from pdf.js is a Float32Array.
Tolerances
Crossing: parallel when |den| < 1e-12 × max(1, |r|·|s|); parameters accepted within 1e-9 of [0, 1]; two segments that meet only at their ends (within 1e-9 of a parameter end) give no crossing. Snap: two candidates count as equally near within 1e-9 page points (absolute).
Limits
Points on a curve snap to one of 8 chords, not to the curve. A page /Rotate that is not a multiple of 90 is read as unrotated. Only the first 80 nearby segments are tested for crossings.
Approach: 32-bit step, flagged
One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.