Resources Numerical accuracy Calculation policy

Carried, stamped and pasted markup geometry

Where markups land when they are carried onto a revised sheet, where a stamp or a picture is placed, and where copied page content is pasted.

How the precision is kept

All positions are 64-bit numbers in PDF user space (points). Carrying markups to a page of the same size copies their points unchanged; to another size it applies one scale factor about the two pages' view origins. Stamps and pictures are placed as a rectangle of center ± half size. Pasted content gets a transform built from two translations and a quarter-turn matrix of whole numbers, so no sine or cosine is evaluated.

Open item. Pass an area markup's holes through the same mapping as its outline when markups are carried to a page of another size.

Calculations under this policy

6 calculations in 3 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.

slip.js MarkupCraft 0.8.1 64-bit 2 calculations Reviewed

Tests bin/test-markupcraft.mjs

Markups carried onto a revised sheet

64-bit

The points and rectangle of each markup on the new sheet, scaled when the new page has another size.

k = min(w₂ ÷ w₁, h₂ ÷ h₁); p′ = origin₂ + (p − origin₁) × k

Pages within 1 point of each other in width and height are treated as the same and the points are copied unchanged. Otherwise one factor scales pts and rect about the view origins; the markup's holes are not mapped and stay at their old coordinates. The scaled line width is rounded to 0.01 and the font size to 0.1 with a floor of 4.

  • slip.js
  • lines 58 to 82
  • MarkupCraft 0.8.1
export function carryMarkups(markups, toKey, { from, to, skipClosed = false } = {}) {
  const items = [], sources = [];
  let left = 0, scaled = 0, closed = 0;
  const same = !from || !to || sameSize(from, to);
  const k = same ? 1 : Math.min(to.width / from.width, to.height / from.height);
  const fx = (p) => (same ? p : [to.view[0] + (p[0] - from.view[0]) * k, to.view[1] + (p[1] - from.view[1]) * k]);
  for (const m of markups) {
    if (m.type === 'foreign' || m.locked) { left++; continue; }
    if (skipClosed && CLOSED.has(m.status)) { closed++; continue; }
    const c = clone(m);
    delete c.id;
    c.sheet = toKey;
    c.src = null;
    if (c.pts) c.pts = c.pts.map(fx);
    if (c.rect) { const a = fx([c.rect[0], c.rect[1]]), b = fx([c.rect[2], c.rect[3]]); c.rect = [a[0], a[1], b[0], b[1]]; }
    if (!same) {
      scaled++;
      c.style = { ...c.style, width: Math.round(c.style.width * k * 100) / 100, fontSize: Math.max(4, Math.round(c.style.fontSize * k * 10) / 10) };
    }
    c.replies = (c.replies || []).map((r) => ({ ...r, id: undefined }));
    items.push(c);
    sources.push(m.id);
  }
  return { items, sources, left, scaled, closed };
}

Superseded stamp position

64-bit

The rectangle of the Superseded stamp across the top middle of the old sheet.

w = min(0.4 × page width as read, 432); h = max(36, 0.18 × w); rect = center ± half size

Sizes are fixed fractions of the page with limits in points; the margin is 4 % of the page's smaller side plus half the stamp. The rectangle is stored unrounded; only the font size is rounded to a whole number.

  • slip.js
  • lines 84 to 99
  • MarkupCraft 0.8.1
export function supersededStamp(page, { label, author = '', sheet }) {
  const [x0, y0, x1, y1] = page.view;
  const W = x1 - x0, H = y1 - y0;
  const rot = page.rotate || 0;
  const across = rot % 180 ? H : W;                       // the page's width as it reads
  const w = Math.min(across * 0.4, 432), h = Math.max(36, w * 0.18);
  const dx = rot % 180 ? h : w, dy = rot % 180 ? w : h;   // the rect in user space (y up)
  const m = Math.min(W, H) * 0.04 + (rot % 180 ? dx : dy) / 2;
  // "top middle" in user space for each turn of the page
  const c = rot === 90 ? [x0 + m, (y0 + y1) / 2] : rot === 180 ? [(x0 + x1) / 2, y0 + m] : rot === 270 ? [x1 - m, (y0 + y1) / 2] : [(x0 + x1) / 2, y1 - m];
  return {
    sheet, type: 'stamp', rot, author, label,
    rect: [c[0] - dx / 2, c[1] - dy / 2, c[0] + dx / 2, c[1] + dy / 2],
    style: { color: '#c4122f', width: 3, fill: null, fillOpacity: 0.25, opacity: 1, fontSize: Math.round(h * 0.45) },
  };
}
imagestamp.js MarkupCraft 0.8.1 64-bit 2 calculations Reviewed

Tests bin/test-markupcraft.mjs

Picture size fitted to a box

64-bit

A picture's width and height scaled to fit a maximum size, keeping its proportions.

k = min(maxW ÷ w, maxH ÷ h, 1e9); size = (w × k, h × k)

One factor for both sides, so the proportions are kept to 64-bit accuracy. The factor is capped only at 1e9, so a small picture is enlarged to fill the box; the cap also keeps a zero width or height from giving an infinite factor.

  • imagestamp.js
  • lines 59 to 62
  • MarkupCraft 0.8.1
export function fitBox(w, h, maxW, maxH) {
  const k = Math.min(maxW / w, maxH / h, 1e9);
  return [w * k, h * k];
}

Rectangle of a placed picture

64-bit

The rectangle of a picture of a given size centered on a point, turned with the page.

rect = (cx − w ÷ 2, cy − h ÷ 2, cx + w ÷ 2, cy + h ÷ 2), with w and h swapped on a page turned 90° or 270°

Center plus and minus half the size, in 64-bit numbers with no rounding.

  • imagestamp.js
  • lines 64 to 67
  • MarkupCraft 0.8.1
export function placeRect(c, w, h, rot = 0) {
  const [dx, dy] = rot % 180 ? [h, w] : [w, h];
  return [c[0] - dx / 2, c[1] - dy / 2, c[0] + dx / 2, c[1] + dy / 2];
}
content-clip.js MarkupCraft 0.8.1 64-bit 2 calculations Reviewed

Tests bin/test-markupcraft.mjs

Box of copied page content

64-bit

The box around the selected objects that becomes the copied page's size.

box = union of the objects' boxes, grown by max(line width ÷ 2 + 0.5) on every side

Minimum and maximum of the objects' boxes, then one padding for all: the largest half line width plus 0.5 point. No rounding.

  • content-clip.js
  • lines 60 to 64
  • MarkupCraft 0.8.1
const box = [Infinity, Infinity, -Infinity, -Infinity];
for (const it of items) { box[0] = Math.min(box[0], it.box[0]); box[1] = Math.min(box[1], it.box[1]); box[2] = Math.max(box[2], it.box[2]); box[3] = Math.max(box[3], it.box[3]); }
const pad = Math.max(...items.map((it) => (it.lw || 0) / 2 + 0.5));   // a stroke's half width sits outside its path's box
box[0] -= pad; box[1] -= pad; box[2] += pad; box[3] += pad;
if (!(box[2] > box[0] && box[3] > box[1])) return { refused: 'empty' };

Transform of pasted content

64-bit

The matrix that moves copied content to where it is pasted and turns it to read the same way on the target page.

T = translate(−cx, −cy) · R(turn) · translate(target); turn = (target rotation − source rotation) mod 360

The turn is a multiple of 90° and its matrix is taken from a table of 0, 1 and −1, so the rotation part is exact. The translations use the 64-bit center of the copied box and the target point; the product is formed with mul from base-content.js.

  • content-clip.js
  • lines 95 to 108
  • MarkupCraft 0.8.1
/**
 * The `add` item that pastes a clip. `delta` moves it from where it was copied (paste in place = [0, 0]); `center` puts
 * the middle of the copied box there. `rotate` = the target page's /Rotate: the content is turned by the difference, so
 * it reads the same way on screen as where it was copied.
 */
export function pasteContent(clip, { rotate = null, delta = null, center = null } = {}, id = 'p' + Date.now().toString(36)) {
  const [x0, y0, x1, y1] = clip.box;
  const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
  const turn = rotate === null ? 0 : (norm(rotate) - clip.rotate + 360) % 360;   // degrees, counter-clockwise in page space
  const R = { 0: [1, 0, 0, 1], 90: [0, 1, -1, 0], 180: [-1, 0, 0, -1], 270: [0, -1, 1, 0] }[turn];
  const to = center || [cx + (delta?.[0] || 0), cy + (delta?.[1] || 0)];
  const T = mul(mul(translate(-cx, -cy), [...R, 0, 0]), translate(to[0], to[1]));
  return { id, kind: 'pdf', pdf: clip.pdf, box: clip.box.slice(), T };
}

In detail

Method
Carry: k = min(new width ÷ old width, new height ÷ old height); p′ = new origin + (p − old origin) × k. Paste: T = translate(−center) · quarter turn · translate(target).
Number types
64-bit numbers. Quarter-turn matrices hold 0, 1 and −1 only.
Tolerances
Two pages count as the same size when width and height each differ by at most 1 point (absolute).
Limits
Carrying to a page of another size maps a markup's points and rectangle only; an area markup's cut-outs (holes) are copied where they were. Line width is rounded to 0.01 and font size to 0.1 when scaled.

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.

Every policy that takes this approach