Works out the points and boxes that are stored when a markup is drawn, snapped, typed to scale, resized, moved, simplified or lined up.
How the precision is kept
A markup's geometry is a list of points or a box in PDF user space, kept as 64-bit numbers. Every edit computes the new coordinates directly from the old ones in 64-bit; nothing is rounded to a grid. Directions that come from an angle go through cos and sin, so axis-aligned results can differ from the exact axis in the last bits.
Calculations under this policy
12 calculations in 4 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.
Moves a point onto the nearest 45° direction from the point before it, keeping its distance.
a = round(atan2(dy, dx) ÷ (π ÷ 4)) × (π ÷ 4); p = from + (cos a, sin a) × hypot(dx, dy)
The direction is computed with cos and sin of the rounded angle, not taken from a table. In 64-bit cos(π ÷ 2) and sin(π) are tiny non-zero numbers, so a snapped vertical or leftward segment can differ from exactly vertical or horizontal in the last bits of the coordinate. The distance is kept from Math.hypot.
geom.js
lines 43 to 50
MarkupCraft 0.8.1
/** Snap p to the nearest 45° direction from `from`. */
export function snap45(from, p) {
const dx = p[0] - from[0], dy = p[1] - from[1];
const len = Math.hypot(dx, dy);
if (!len) return [p[0], p[1]];
const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
return [from[0] + Math.cos(a) * len, from[1] + Math.sin(a) * len];
}
Square from a corner
64-bit
Makes the box from a fixed corner to the pointer square.
s = max(|dx|, |dy|); p = a + (sign dx, sign dy) × s
One subtraction and one addition per coordinate; both sides use the same s, so the box is square to the rounding of the two additions.
geom.js
lines 52 to 57
MarkupCraft 0.8.1
/** Make the box from a fixed corner to p square (Shift while drawing boxes). */
export function squareFrom(a, p) {
const dx = p[0] - a[0], dy = p[1] - a[1];
const s = Math.max(Math.abs(dx), Math.abs(dy));
return [a[0] + Math.sign(dx || 1) * s, a[1] + Math.sign(dy || 1) * s];
}
Resize by a grip
64-bit
The new box or points when a grip of a markup is dragged.
The result is computed from the markup as it was when the drag began, so rounding does not build up during one drag. A box is kept at least 2 points wide and high; that minimum is applied to boxes only, and a freehand stroke is rescaled to whatever width the drag gives. With Shift a corner keeps the start proportions using the larger of the two scale factors.
geom.js
lines 59 to 102
MarkupCraft 0.8.1
/**
* Dragging a grip of the markup as it was when the drag began (`m0`) to point p → a patch
* ({rect} / {pts}). opts.shift keeps proportions (box) or snaps to 45° (vertex).
*/
export function applyGrip(m0, grip, p, opts = {}) {
if (grip.kind === 'vertex' || grip.kind === 'tip' || grip.kind === 'knee') {
const pts = m0.pts.map((q) => [q[0], q[1]]);
let q = p;
if (opts.shift && pts.length > 1) {
const nb = grip.i > 0 ? pts[grip.i - 1] : pts[1];
q = snap45(nb, p);
}
pts[grip.i] = [q[0], q[1]];
return { pts };
}
// box grips
const box = BOX_TYPES.has(m0.type) ? m0.rect : pointsBounds(m0.pts);
let [x0, y0, x1, y1] = box;
const k = grip.key;
if (k.includes('w')) x0 = p[0];
if (k.includes('e')) x1 = p[0];
if (k.includes('s')) y0 = p[1];
if (k.includes('n')) y1 = p[1];
if (opts.shift && k.length === 2) {
// keep the aspect ratio from the opposite corner
const w0 = box[2] - box[0], h0 = box[3] - box[1];
if (w0 > 0 && h0 > 0) {
const sx = Math.abs(x1 - x0) / w0, sy = Math.abs(y1 - y0) / h0, sc = Math.max(sx, sy);
if (k.includes('w')) x0 = x1 - w0 * sc * Math.sign(x1 - x0 || 1); else x1 = x0 + w0 * sc * Math.sign(x1 - x0 || 1);
if (k.includes('s')) y0 = y1 - h0 * sc * Math.sign(y1 - y0 || 1); else y1 = y0 + h0 * sc * Math.sign(y1 - y0 || 1);
}
}
const nb = normRect([x0, y0, x1, y1]);
const min = 2;
if (nb[2] - nb[0] < min) nb[2] = nb[0] + min;
if (nb[3] - nb[1] < min) nb[3] = nb[1] + min;
if (BOX_TYPES.has(m0.type)) return { rect: nb };
// freehand: scale every point from the old box to the new one
const bw = box[2] - box[0] || 1, bh = box[3] - box[1] || 1;
// a flipped drag mirrors the stroke
const fx = (x) => (k.includes('w') || k.includes('e') ? x0 + ((x - box[0]) / bw) * (x1 - x0) : x);
const fy = (y) => (k.includes('n') || k.includes('s') ? y0 + ((y - box[1]) / bh) * (y1 - y0) : y);
return { pts: m0.pts.map(([x, y]) => [fx(x), fy(y)]) };
}
Move a markup
64-bit
Shifts a markup's points, box and cut-outs by a distance.
x′ = x + dx; y′ = y + dy
One 64-bit addition per stored coordinate. Each move rounds once, so moving there and back does not always restore the exact starting bits.
geom.js
lines 104 to 111
MarkupCraft 0.8.1
/** Patch that moves a markup by (dx, dy). */
export function moveBy(m, dx, dy) {
const patch = {};
if (m.pts) patch.pts = m.pts.map(([x, y]) => [x + dx, y + dy]);
if (m.rect) patch.rect = [m.rect[0] + dx, m.rect[1] + dy, m.rect[2] + dx, m.rect[3] + dy];
if (m.holes?.length) patch.holes = m.holes.map((h) => h.map(([x, y]) => [x + dx, y + dy]));
return patch;
}
Simplify a freehand stroke
64-bit
Drops the points of a pen or highlighter stroke that lie within a tolerance of the line through their neighbors.
d = |dy·pₓ − dx·p_y + bₓ·a_y − b_y·aₓ| ÷ L
Douglas–Peucker with an explicit stack. Kept points are the original points, unchanged; none is moved or averaged. The distance formula uses absolute coordinates, and the tolerance is whatever the caller passes.
geom.js
lines 113 to 132
MarkupCraft 0.8.1
/** Douglas–Peucker: drop points closer than tol to the line through their neighbours. */
export function simplify(pts, tol) {
if (pts.length < 3) return pts.slice();
const keep = new Uint8Array(pts.length);
keep[0] = keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
while (stack.length) {
const [a, b] = stack.pop();
let best = -1, bd = tol;
const [ax, ay] = pts[a], [bx, by] = pts[b];
const dx = bx - ax, dy = by - ay, L = Math.hypot(dx, dy);
for (let i = a + 1; i < b; i++) {
const [px, py] = pts[i];
const d = L ? Math.abs(dy * px - dx * py + bx * ay - by * ax) / L : Math.hypot(px - ax, py - ay);
if (d > bd) { bd = d; best = i; }
}
if (best > 0) { keep[best] = 1; stack.push([a, best], [best, b]); }
}
return pts.filter((_, i) => keep[i]);
}
Inverse of a transform
64-bit
The inverse of a 2-D affine transform.
det = a·d − b·c; inverse = adjugate ÷ det
Each entry is divided by the determinant. Only a determinant of exactly zero is refused (the identity is returned), so a nearly singular transform is inverted as it is.
geom.js
lines 191 to 195
MarkupCraft 0.8.1
export function inv(m) {
const det = m[0] * m[3] - m[1] * m[2];
if (!det) return I.slice();
return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det, (m[2] * m[5] - m[3] * m[4]) / det, (m[1] * m[4] - m[0] * m[5]) / det];
}
Line-up through two pairs of points
64-bit
The move, turn and uniform scale that carries two picked points on one sheet onto two picked points on another.
s = |b| ÷ |a|; θ = atan2(b) − atan2(a); M = [s·cos θ, s·sin θ, −s·sin θ, s·cos θ, e, f]
The turn goes through two atan2 calls and back through cos and sin, rather than through the dot and cross products of the two vectors. The translation is fixed so the first pair maps exactly; the second pair maps to rounding. Refused when either pair is closer than 1e-9 points, an absolute tolerance.
geom.js
lines 198 to 211
MarkupCraft 0.8.1
/**
* The similarity (move + turn + uniform scale) that takes old points a1, a2 onto new points b1, b2.
* Used to line up two sheets by picking the same two features on each.
*/
export function similarity(a1, a2, b1, b2) {
const ax = a2[0] - a1[0], ay = a2[1] - a1[1], bx = b2[0] - b1[0], by = b2[1] - b1[1];
const la = Math.hypot(ax, ay), lb = Math.hypot(bx, by);
if (la < 1e-9 || lb < 1e-9) return null;
const s = lb / la;
const ang = Math.atan2(by, bx) - Math.atan2(ay, ax);
const c = Math.cos(ang) * s, sn = Math.sin(ang) * s;
const e = b1[0] - (c * a1[0] - sn * a1[1]), f = b1[1] - (sn * a1[0] + c * a1[1]);
return [c, sn, -sn, c, e, f];
}
Places the next point of a line or outline at a typed real length, along a typed angle or toward the pointer.
len = mm ÷ mmPerPt; a = (deg + page turn) × π ÷ 180; p = from + (cos a, sin a) × len
The length is one 64-bit division of the parsed millimeters by the sheet's mmPerPt. A typed angle is converted to radians and passed to cos and sin, so 90° or 180° gives a direction that is off the axis by the rounding of cos(π ÷ 2) or sin(π). Without an angle the direction is the unit vector to the pointer, refused when the pointer is closer than 1e-9 points.
sketch.js
lines 15 to 35
MarkupCraft 0.8.1
export function sketchPoint(from, toward, text, cal, rot = 0) {
if (!cal || !(cal.mmPerPt > 0)) return { error: 'sketch.err.scale' };
const [lenText, angText] = String(text || '').split(/[<@]/);
const mm = parseLength(lenText, cal.unit);
if (!(mm > 0)) return { error: 'sketch.err.length' };
const len = mm / cal.mmPerPt;
let dx, dy;
if (angText !== undefined && angText.trim() !== '') {
const deg = Number(angText.trim().replace(/°$/, ''));
if (!Number.isFinite(deg)) return { error: 'sketch.err.angle' };
// as seen: right = 0°, up = 90°; turn back into user space by the page's rotation
const a = ((deg + (rot || 0)) * Math.PI) / 180; // a page turned 90° clockwise shows user +x pointing down (seen −90°)
dx = Math.cos(a); dy = Math.sin(a);
} else {
const vx = toward[0] - from[0], vy = toward[1] - from[1];
const d = Math.hypot(vx, vy);
if (d < 1e-9) return { error: 'sketch.err.direction' };
dx = vx / d; dy = vy / d;
}
return { pt: [from[0] + dx * len, from[1] + dy * len] };
}
Turns what the stroke cleaner recognized (line, circle, arc, outline) into the points or box of a line, ellipse, arc, rectangle, polyline or polygon markup.
circle → box (c − r, c + r); arc → points at a₀, a₀ + sweep ÷ 2, a₁ on the circle
The recognized shape comes from cleanSketch in stroke-clean.js; this function only converts it. An arc's three points are rebuilt with cos and sin from its center, radius and angles, and the arc markup later recomputes the circle from those three points. A closed four-point stroke becomes a rectangle when every corner is within 1e-6 points (absolute) of the bounding box.
pen-clean.js
lines 17 to 37
MarkupCraft 0.8.1
/** The fields of the markup that says what this cleaned entity is, or null (keep the pen). */
export function fieldsFor(e) {
if (e.type === 'line') return { type: 'line', pts: [[e.x1, e.y1], [e.x2, e.y2]] };
if (e.type === 'circle') return { type: 'ellipse', rect: [e.cx - e.r, e.cy - e.r, e.cx + e.r, e.cy + e.r] };
if (e.type === 'arc') {
let sweep = e.a1 - e.a0; if (sweep <= 0) sweep += Math.PI * 2;
const at = (a) => [e.cx + e.r * Math.cos(a), e.cy + e.r * Math.sin(a)];
return { type: 'arc', pts: [at(e.a0), at(e.a0 + sweep / 2), at(e.a1)] };
}
if (e.type === 'poly') {
const P = e.pts.map((p) => [p.x, p.y]);
if (e.closed && P.length === 4) { // a right-angled four-sided closed stroke that is level: a rectangle
const xs = P.map((p) => p[0]), ys = P.map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
const level = P.every((p) => (Math.abs(p[0] - x0) < 1e-6 || Math.abs(p[0] - x1) < 1e-6) && (Math.abs(p[1] - y0) < 1e-6 || Math.abs(p[1] - y1) < 1e-6));
if (level) return { type: 'rect', rect: [x0, y0, x1, y1] };
}
if (P.length >= (e.closed ? 3 : 2)) return { type: e.closed ? 'polygon' : 'polyline', pts: P };
}
return null;
}
What is stored when a pen or highlighter stroke is finished.
tolerance = 0.6 ÷ zoom (points)
The simplify tolerance is 0.6 screen pixels converted to points by the current zoom, so the same hand movement stores more points when drawn zoomed in. A stroke of one point gets a second point 0.01 points to its right.
The first two picks are taken back through the inverse of the alignment then in force, then similarity() gives the new fit, all in 64-bit. The status line shows the scale in percent and the turn in degrees with 2 decimals; the fit itself is kept unrounded.
markupcraft.js
lines 4099 to 4122
MarkupCraft 0.8.1
function alignClick(p) {
const a = cmp.aligning;
a.pts.push({ screenUser: p });
const n = a.pts.length;
if (n === 1) ui.cmpStatus.textContent = t('compare.align.2');
if (n === 2) { cmp.balance = 1; applyCompare(); ui.cmpStatus.textContent = t('compare.align.3'); }
if (n === 3) ui.cmpStatus.textContent = t('compare.align.4');
if (n === 4) {
// the old sheet was shown moved by T: undo that to get points in the old sheet's own space
const T = alignment(cmp), Ti = inv(T);
const o1 = applyT(Ti, a.pts[0].screenUser), o2 = applyT(Ti, a.pts[1].screenUser);
const fit = similarity(o1, o2, a.pts[2].screenUser, a.pts[3].screenUser);
cmp.aligning = null;
cmp.balance = +ui.cmpBalance.value / 100;
if (!fit) { ui.cmpStatus.textContent = t('compare.align.same'); applyCompare(); return; }
cmp.fit = fit; cmp.dx = 0; cmp.dy = 0;
const sc = Math.hypot(fit[0], fit[1]), ang = Math.atan2(fit[1], fit[0]) * 180 / Math.PI;
ui.cmpStatus.textContent = t('compare.align.done', { scale: (sc * 100).toFixed(2), angle: ang.toFixed(2) });
status(t('compare.aligned'));
applyCompare();
updateCursor();
}
view.invalidate();
}
Line up a picture by two points
64-bit
The new box of a picture markup so two points on it land on two points on the sheet.
k = √(rw·rh ÷ (W·H)) × fit scale; box = center ± (w·k ÷ 2, h·k ÷ 2)
The fit is computed in screen coordinates and the center mapped back to user space, in 64-bit. A turn of 0.1° or less is ignored; a larger turn redraws the picture's pixels on a canvas whose size is rounded to whole pixels, so the turn is in the pixels and the stored box stays upright. The status line rounds the scale to whole percent and the turn to 0.1°.
markupcraft.js
lines 1823 to 1855
MarkupCraft 0.8.1
/** Carry the picture so its two points land on theirs: scaled, moved and, when the turn is more than a hair, turned in its pixels. */
async function applyLineUp(id, [a1, b1, a2, b2]) {
const m = store.get(id);
if (!m || m.type !== 'image') return;
const scr = (q) => view.toScreen(q);
const fit = fitTwoPoints(scr(a1), scr(a2), scr(b1), scr(b2));
if (!fit) { status(t('lineup.same')); return; }
const info = imageInfo(m.img);
if (!info) return;
const [W, H] = [info.width, info.height];
const rw = Math.abs(m.rect[2] - m.rect[0]), rh = Math.abs(m.rect[3] - m.rect[1]);
const swapped = Math.abs(rw / rh - W / H) > Math.abs(rw / rh - H / W);
const k = Math.sqrt((rw * rh) / (W * H)) * fit.scale; // points per pixel after the line-up
const turn = Math.abs(fit.angle) > (0.1 * Math.PI) / 180;
let img = m.img, w2 = W, h2 = H;
if (turn) {
[w2, h2] = turnedSize(W, H, fit.angle).map((v) => Math.max(1, Math.round(v)));
const im = await new Promise((ok, bad) => { const i = new Image(); i.onload = () => ok(i); i.onerror = bad; i.src = m.img; });
const c = el('canvas', { width: w2, height: h2 });
const g = c.getContext('2d');
g.translate(w2 / 2, h2 / 2); g.rotate(fit.angle); g.drawImage(im, -W / 2, -H / 2);
img = cleanImage(c.toDataURL('image/png'));
if (!img) { status(t('lineup.too_big')); return; }
}
const c0 = scr([(m.rect[0] + m.rect[2]) / 2, (m.rect[1] + m.rect[3]) / 2]);
const c1 = fit.map(c0);
const center = view.toUser(c1[0], c1[1]);
const [ww, hh] = swapped ? [h2 * k, w2 * k] : [w2 * k, h2 * k];
const rect = [center[0] - ww / 2, center[1] - hh / 2, center[0] + ww / 2, center[1] + hh / 2];
store.change(t('lineup.undo'), (tx) => tx.update(id, { rect, img }));
refreshAll();
status(t('lineup.done', { pct: Math.round(fit.scale * 100), deg: Math.round((fit.angle * 180 / Math.PI) * 10) / 10 }));
}
In detail
Method
45° snap: angle rounded to a multiple of π ÷ 4, point rebuilt at the same distance. Typed point: length = mm ÷ mmPerPt along a typed angle or toward the pointer. Resize: edges set to the pointer, strokes rescaled linearly. Simplify: Douglas–Peucker. Line-up of two sheets or of a picture: a similarity (move, turn, uniform scale) through two pairs of points.
Number types
64-bit numbers throughout. Affine transforms are six-number arrays [a, b, c, d, e, f].
Tolerances
Freehand strokes are simplified with a tolerance of 0.6 screen pixels, that is 0.6 ÷ zoom in points. A resized box is kept at least 2 points wide and high. A similarity is refused when either pair of points is closer than 1e-9 points. A typed point needs a pointer direction longer than 1e-9 points. A cleaned pen stroke becomes a rectangle only when each corner is within 1e-6 points of its bounding box. A picture is turned in its pixels only when the turn is more than 0.1°.
Limits
The simplified pen stroke depends on the zoom it was drawn at. A matrix is inverted unless its determinant is exactly zero. A fit through two pairs of points has no redundancy, so it carries the full error of the picked points.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.