Resources Numerical accuracy Calculation policy

Edits to a page's own content

Moves, scales, turns, reshapes and rewords the drawing's own lines, pictures and text in the PDF (the Page content tool), and writes the result back into the file.

How the precision is kept

An edit is kept as data: a page-space matrix, moved points, a color or new words, all 64-bit and always applied to the content as the file was opened, so repeated edits do not pile up rounding in the file. The limit is in the writing: every number put back into the content stream goes through num() in base-lex.js, which writes at most 4 decimals and writes 0 for a value below 5e-5. Coordinates and matrix entries in the saved file therefore sit on a 0.0001 grid in the object's own space.

Open item. Write the coordinates and matrices of edited page content with enough digits to read back the 64-bit value, instead of at most 4 decimals.

Calculations under this policy

16 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.

base-content.js MarkupCraft 0.8.1 64-bit 12 calculations Reviewed

Tests bin/test-markupcraft.mjs

Product of two transforms

64-bit

Combines two affine transforms into one.

p′ = p × M (row vectors); (m × n) applies m first

Each of the six entries is a sum of two or three 64-bit products, computed directly. Nothing is normalized or rounded.

  • base-content.js
  • lines 36 to 42
  • MarkupCraft 0.8.1
export function mul(m, n) {
  return [
    m[0] * n[0] + m[1] * n[2], m[0] * n[1] + m[1] * n[3],
    m[2] * n[0] + m[3] * n[2], m[2] * n[1] + m[3] * n[3],
    m[4] * n[0] + m[5] * n[2] + n[4], m[4] * n[1] + m[5] * n[3] + n[5],
  ];
}

Inverse of a transform

64-bit

The inverse of the matrix an object is painted with.

det = a·d − b·c; inverse = adjugate ÷ det

Returns null only for a determinant of exactly zero; the callers then refuse the edit. A nearly singular matrix is inverted as it is.

  • base-content.js
  • lines 43 to 47
  • MarkupCraft 0.8.1
export function inv(m) {
  const det = m[0] * m[3] - m[1] * m[2];
  if (!det) return null;
  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];
}

Path coordinates rewritten

64-bit

The new numbers of one path-building operator after a move, scale or turn.

U = C × T × C⁻¹; (x′, y′) = (x, y) × U, written with at most 4 decimals

Each pair is carried through U in 64-bit and written with num(), which keeps at most 4 decimals. The decimals are in the path's own user space, so their size on the page depends on the matrix the path is painted with. A rectangle operator is kept only when |U₁| and |U₂| are below 1e-12 (absolute); otherwise it becomes four corner points.

  • base-content.js
  • lines 505 to 519
  • MarkupCraft 0.8.1
/** One path-building op, its numbers taken through U (a rectangle that U turns becomes m l l l h). */
function pathOpBytes(op, v, U) {
  if (op === 're') {
    const [x, y, w, h] = v;
    if (Math.abs(U[1]) < 1e-12 && Math.abs(U[2]) < 1e-12) {
      const [nx, ny] = apply(U, x, y);
      return opBytes([num(nx), num(ny), num(w * U[0]), num(h * U[3])], 're');
    }
    const pts = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]].map(([px, py]) => apply(U, px, py));
    return ascii(`${num(pts[0][0])} ${num(pts[0][1])} m ${pts.slice(1).map((p) => `${num(p[0])} ${num(p[1])} l`).join(' ')} h`);
  }
  const nv = [];
  for (let k = 0; k + 1 < v.length; k += 2) { const [nx, ny] = apply(U, v[k], v[k + 1]); nv.push(num(nx), num(ny)); }
  return opBytes(nv, op);
}

Matrix round a moved picture or form

64-bit

The cm matrix written before a picture or form XObject that was moved, scaled or turned.

U = C × T × C⁻¹, six entries with at most 4 decimals

U is computed in 64-bit and each entry is written with num(). The scale and turn entries keep 4 decimals and an entry smaller than 5e-5 is written as 0, so a point at coordinate x in the object's own space can be off by up to 5e-5 × x per entry.

  • base-content.js
  • lines 471 to 474
  • MarkupCraft 0.8.1
const Ci = inv(obj.ctm);
if (!Ci) return { edits: [], refused: 'flat', missing: [] };
const U = mul(mul(obj.ctm, T), Ci);
return { edits: tag([{ key: c.key, s: first.s, e: first.s, bytes: ascii(`q ${U.map(num).join(' ')} cm `) }, ...edits, { key: c.key, s: last.e, e: last.e, bytes: ascii(' Q') }]), missing: [] };

Is this a move at all

64-bit

Decides whether an edit's matrix changes anything.

|M − identity| < 1e-9 on all six entries

An absolute tolerance on each entry, the two translations included, so a move of less than 1e-9 points is treated as none.

  • base-content.js
  • line 503
  • MarkupCraft 0.8.1
const isIdentity = (m) => Math.abs(m[0] - 1) < 1e-9 && Math.abs(m[1]) < 1e-9 && Math.abs(m[2]) < 1e-9 && Math.abs(m[3] - 1) < 1e-9 && Math.abs(m[4]) < 1e-9 && Math.abs(m[5]) < 1e-9;

Line width in the path's own units

64-bit

Converts a line width given in page units to the units of the path's own space.

w = page width ÷ √|a·d − b·c|

√|det| is the geometric mean of the matrix's two scales, so for a matrix that scales x and y differently the width is right on average, not in each direction. A zero determinant falls back to 1. Written with at most 4 decimals.

  • base-content.js
  • lines 496 to 497
  • MarkupCraft 0.8.1
// lw is in page units; the stream's width is in the path's user space
if (e.lw != null) pre.push(num(e.lw / (Math.sqrt(Math.abs(obj.ctm[0] * obj.ctm[3] - obj.ctm[1] * obj.ctm[2])) || 1)) + ' w');

Replacement picture fitted into the old one's box

64-bit

Scales a replacement picture to fit, centered, inside the box of the picture it replaces, keeping its own proportions.

ia = W ÷ H; ba = box w ÷ box h; (sx, sy) = ia > ba ? (1, ba ÷ ia) : (ia ÷ ba, 1)

The box sides are the lengths of the old picture matrix's two axis vectors, (a, b) and (c, d), by Math.hypot. The two scale factors and the two offsets are written with at most 4 decimals.

  • base-content.js
  • lines 464 to 468
  • MarkupCraft 0.8.1
// a new picture in the old one's box (the unit square of its matrix), keeping its own proportions, centred
const bw = Math.hypot(obj.ctm[0], obj.ctm[1]), bh = Math.hypot(obj.ctm[2], obj.ctm[3]);
const ia = (e.imgW || 1) / (e.imgH || 1), ba = bw / (bh || 1);
const [sx, sy] = ia > ba ? [1, ba / ia] : [ia / ba, 1];
edits.push({ key: c.key, s: first.s, e: last.e, bytes: ascii(`q ${num(sx)} 0 0 ${num(sy)} ${num((1 - sx) / 2)} ${num((1 - sy) / 2)} cm /${e.imgName} Do Q`) });

Advance of a text run as read

64-bit

How far each text-showing operator moves the text position when a page's content is read, kept so a later edit can leave the rest of the line in place.

string: Σ (width ÷ 1000 × size + char spacing + word spacing on a single-byte space) × horizontal scale; number in TJ: −v ÷ 1000 × size × horizontal scale

Summed in 64-bit in the order of the operands. A glyph's width is taken as 500 when the font could not be read. The factors 0.9 and −0.22 of the size set the run's box for picking only and do not enter the advance.

  • base-content.js
  • lines 239 to 255
  • MarkupCraft 0.8.1
const asc = 0.9 * st.size, desc = -0.22 * st.size;
for (const it of items) {
  if (it.t === 'num') { x -= it.v / 1000 * st.size * st.th; continue; }
  if (it.t !== 'str' && it.t !== 'hex') continue;
  if (fi) fi.seen(it.v);
  const codes = fi ? fi.codes(it.v) : Array.from(it.v);
  const x0 = x;
  for (const c of codes) {
    const w0 = (fi ? fi.width(c) : 500) / 1000;
    x += (w0 * st.size + st.tc + (codes.length === it.v.length && c === 32 ? st.tw : 0)) * st.th;
  }
  if (fi) text += fi.text(it.v); else text += String.fromCharCode(...it.v);
  for (const [px, py] of [[x0, desc + st.rise], [x, desc + st.rise], [x0, asc + st.rise], [x, asc + st.rise]]) {
    const [qx, qy] = apply(m0, px, py); grow(bb, qx, qy);
  }
}
tm = mul(translate(x, 0), tm);

Advance of a text run

64-bit

How far a string moves the text position, in unscaled text space.

Σ (glyph width ÷ 1000 × size + char spacing + word spacing on a space) × horizontal scale

Added glyph by glyph in 64-bit, in the order of the string. Glyph widths come from the font's own width table (base-fonts.js).

  • base-content.js
  • lines 409 to 416
  • MarkupCraft 0.8.1
/** How far a string moves the text position (unscaled text space), as the walk measured it. */
export function runAdvance(obj, bytes) {
  const fi = obj.info;
  const codes = fi.codes(bytes);
  let x = 0;
  for (const c of codes) x += (fi.width(c) / 1000 * obj.size + obj.tc + (!fi.twoByte && c === 32 ? obj.tw : 0)) * obj.th;
  return x;
}

Padding after reworded text

64-bit

The adjustment written after a reworded run so the text that follows on the line does not move.

k = −(old advance − new advance) × 1000 ÷ (size × horizontal scale)

Written with at most 4 decimals, and only when |k| > 1e-3; a smaller difference is left in place. The old advance is the one measured when the page was read.

  • base-content.js
  • lines 565 to 567
  • MarkupCraft 0.8.1
// the run still ends where it ended: what follows on the line never moves
const k = obj.size && obj.th ? -(obj.adv - used) * 1000 / (obj.size * obj.th) : 0;
if (Math.abs(k) > 1e-3) parts.push('[' + num(k) + '] TJ');

Matrix of a moved text run

64-bit

The text matrix written before a text run that was moved.

Tm′ = (Tm × C) × T × C⁻¹

Computed in 64-bit from the run's matrix as read; the six entries are written with at most 4 decimals.

  • base-content.js
  • line 545
  • MarkupCraft 0.8.1
parts.push(mul(mul(obj.tm, T), Ci).map(num).join(' ') + ' Tm');

Line position put back after a moved run

64-bit

Restores where the text line continues after a run on it was moved.

Tm = line matrix; then advance by −(start + advance) × 1000 ÷ (size × horizontal scale)

The line's own matrix is written again with at most 4 decimals, so text that follows on the line is positioned from a 4-decimal copy of the line matrix rather than from the numbers in the file. The advance is written when |start + advance| > 1e-6.

  • base-content.js
  • lines 574 to 577
  • MarkupCraft 0.8.1
// back to where the line continues: the line start, then (without drawing) the distance the run ended at
parts.push(obj.tlm.map(num).join(' ') + ' Tm');
const end = obj.startX + obj.adv;
if (obj.size && obj.th && Math.abs(end) > 1e-6) parts.push('[' + num(-end * 1000 / (obj.size * obj.th)) + '] TJ');
base-edit.js MarkupCraft 0.8.1 64-bit 4 calculations Reviewed

Tests bin/test-markupcraft.mjs

A move added to an object's edits

64-bit

Adds a move, scale or turn to what an object already had.

T′ = T_old × T_new

The stored matrix is multiplied by the new one in 64-bit, so a long series of moves builds up rounding in the matrix, not in the file: the file's content is rewritten once from the original numbers. Paths drawn into the page have their points mapped at once instead.

  • base-edit.js
  • lines 108 to 122
  • MarkupCraft 0.8.1
/** Move / scale selected objects by a page-space matrix (after whatever they had). */
export function withMove(st, keys, T) {
  const next = clone(st || emptyBase());
  for (const k of keys) {
    if (isAdded(k)) {
      const a = next.add.find((x) => x.id === addedId(k));
      if (a?.kind === 'pdf') a.T = mul(a.T, T);
      else if (a) a.d = a.d.map((c) => mapCmd(c, T));
      continue;
    }
    const e = next.o[k] || (next.o[k] = {});
    e.T = mul(e.T || I, T);
  }
  return next;
}

A moved path point

64-bit

The new numbers of a path when one of its points or handles is dragged to a place on the page.

u = p_page × (C × T)⁻¹; attached handles shift by u − u_old

The target is taken into the path's own space through the inverse of the combined matrix in 64-bit. An anchor carries the handles that touch it by the same difference. If the matrix has no inverse the points are left as they were.

  • base-edit.js
  • lines 464 to 490
  • MarkupCraft 0.8.1
/**
 * Move one point to a page-space place → the object's new `nodes`. An anchor drags the handles that touch it
 * along (as drawing programs do), so the curve keeps its shape around the point.
 */
export function moveNode(pc, obj, e, node, to) {
  const c = pc.contents.get(obj.where);
  const M = mul(obj.ctm, e.T || I);
  const Mi = inv(M);
  if (!Mi) return e.nodes || {};
  const nodes = clone(e.nodes || {});
  const vals = (i) => (nodes[i] && nodes[i].length === c.ops[i].args.length ? nodes[i] : c.ops[i].args.map((t) => t.v));
  const [ux, uy] = apply(Mi, to[0], to[1]);
  const v = vals(node.i).slice();
  const ox = v[2 * node.j], oy = v[2 * node.j + 1];
  v[2 * node.j] = ux; v[2 * node.j + 1] = uy;
  const dx = ux - ox, dy = uy - oy;
  const op = c.ops[node.i].op;
  if (node.kind === 'anchor') {
    // the handle before this anchor (in the same op) and the one after it (first handle of the next c / y op)
    if (op === 'c') { v[2] += dx; v[3] += dy; }
    else if (op === 'v') { v[0] += dx; v[1] += dy; }
    const nx = c.ops[node.i + 1];
    if (nx && node.i + 1 < obj.last && (nx.op === 'c' || nx.op === 'y')) { const w = vals(node.i + 1).slice(); w[0] += dx; w[1] += dy; nodes[node.i + 1] = w; }
  }
  nodes[node.i] = v;
  return nodes;
}

Drawn-in paths written to the page

64-bit

Writes paths that were drawn into the page, and the matrix of pasted content, as content stream text.

Every coordinate, line width, dash length and matrix entry is written with num(), at most 4 decimals. These paths are in page space, so the grid is 0.0001 point. Colors are divided by 255 and written the same way.

  • base-edit.js
  • lines 187 to 212
  • MarkupCraft 0.8.1
/** Paths drawn into the page → content stream bytes (each in its own q … Q, page space). Pasted content draws its form
 *  XObject by the name in `names` (id → name; one without a name is left out). */
export function addedBytes(list, names = new Map()) {
  const parts = [];
  const rgb = (hex) => { const v = parseInt(hex.slice(1), 16); return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255].map(num).join(' '); };
  for (const a of list) {
    if (a.kind === 'pdf') { if (names.has(a.id)) parts.push('q ' + a.T.map(num).join(' ') + ' cm /' + names.get(a.id) + ' Do Q'); continue; }
    const p = ['q'];
    if (a.stroke) p.push(rgb(a.stroke) + ' RG');
    if (a.fill) p.push(rgb(a.fill) + ' rg');
    p.push(num(a.lw ?? 1) + ' w');
    if (a.cap) p.push((a.cap === 'round' ? 1 : 2) + ' J');
    if (a.join) p.push((a.join === 'round' ? 1 : 2) + ' j');
    if (a.dash) p.push('[' + a.dash.map(num).join(' ') + '] 0 d');
    for (const c of a.d) {
      if (c[0] === 'M') p.push(num(c[1]) + ' ' + num(c[2]) + ' m');
      else if (c[0] === 'L') p.push(num(c[1]) + ' ' + num(c[2]) + ' l');
      else if (c[0] === 'C') p.push(c.slice(1).map(num).join(' ') + ' c');
      else p.push('h');
    }
    p.push(a.fill && a.stroke ? (a.evenOdd ? 'B*' : 'B') : a.fill ? (a.evenOdd ? 'f*' : 'f') : 'S');
    p.push('Q');
    parts.push(p.join(' '));
  }
  return ascii(parts.join('\n') + '\n');
}

A stored matrix accepted

64-bit

Checks a move matrix read from a file or autosave before it is used.

|a·d − b·c| > 1e-9 and every |number| < 1e7

The determinant test is absolute, so a matrix that scales below about 3e-5 in both directions is dropped. The stored numbers are used as read, without rounding.

  • base-edit.js
  • lines 45 to 46
  • MarkupCraft 0.8.1
const T = matrix(v.T);
if (T && Math.abs(T[0] * T[3] - T[1] * T[2]) > 1e-9) e.T = T;

In detail

Method
A move T in page space becomes U = C × T × C⁻¹ in the object's own space (C is the matrix it is painted with). Paths get each coordinate rewritten through U; pictures and forms are wrapped in a cm with U; a text run gets a new Tm and the line's matrix put back after it. A reworded run is padded so it ends where it ended before.
Number types
64-bit numbers for the matrices and points. Decimal text with at most 4 decimals in the content stream.
Tolerances
A matrix counts as no move when all six entries are within 1e-9 of the identity. A stored matrix needs |det| > 1e-9 (1e-6 for pasted content) and every number below 1e7 in size. A rectangle stays a rectangle when the two off-diagonal entries of U are below 1e-12. The padding k of a reworded run is written when |k| > 1e-3, with k in thousandths of the text size. All are absolute.
Limits
At most 4 decimals in every written coordinate, matrix entry, line width and text position. For a matrix this limits scale and turn to 4 decimals, and the effect on the page grows with the distance from the origin of the object's own space. A matrix is inverted unless its determinant is exactly zero.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach