Resources Numerical accuracy Calculation policy

PDF page vectors and snapping to them

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.js CadCraft 0.8.2 32-bit in part 5 calculations Reviewed

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.

  • pdf-vector.js
  • line 20
  • CadCraft 0.8.2
const mul = (m, k) => [m[0] * k[0] + m[2] * k[1], m[1] * k[0] + m[3] * k[1], m[0] * k[2] + m[2] * k[3], m[1] * k[2] + m[3] * k[3], m[0] * k[4] + m[2] * k[5] + m[4], m[1] * k[4] + m[3] * k[5] + m[5]];

Path points

32-bit in part

Each move, line and curve point of a path, taken through the current matrix into displayed-page points.

x′ = a·x + c·y + e; y′ = b·x + d·y + f; quadratic to cubic: c₁ = p + ⅔(q − p), c₂ = x + ⅔(q − x)

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.

height = |M·(t₂, t₃)|; angle = atan2 of M·(t₀, t₁); width = |w| × |(m₀, m₁)|

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.

  • pdf-vector.js
  • lines 117 to 122
  • CadCraft 0.8.2
const t = it.transform, m = V;
const x = m[0] * t[4] + m[2] * t[5] + m[4], y = m[1] * t[4] + m[3] * t[5] + m[5];
const vx = m[0] * t[0] + m[2] * t[1], vy = m[1] * t[0] + m[3] * t[1];   // the text's x axis, displayed
const hh = Math.hypot(m[0] * t[2] + m[2] * t[3], m[1] * t[2] + m[3] * t[3]);
if (![x, y, vx, vy, hh].every(Number.isFinite) || !(hh > 0)) continue;
texts.push({ s: it.str.slice(0, 1000), x, y, h: hh, rot: Math.atan2(vy, vx), w: Number.isFinite(it.width) ? Math.abs(it.width) * Math.hypot(m[0], m[1]) : 0 });

Flattening curves to segments

32-bit in part

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.js CadCraft 0.8.2 32-bit in part 3 calculations Reviewed

Tests None 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.

Every policy that takes this approach