Resources Numerical accuracy Calculation policy

Numbers written to the saved PDF

Writes each markup into the PDF as an annotation: its position, its points, its drawn appearance, the sheet's scale and the measured text.

How the precision is kept

Each markup is written several times over. Its points go into the annotation's own keys (/L, /Vertices, /InkList, /Rect) as 64-bit numbers handed to pdf-lib, and the whole markup goes into a private JSON string where every number reads back to the same 64 bits. The drawn appearance stream is text written with 3 decimals, a 0.001 point grid. The scale is written as a /Measure dictionary and as JSON of the calibration.

Open item. Write appearance stream numbers with enough digits to read back unchanged, because a flattened markup keeps no other copy of its geometry.

Calculations under this policy

13 calculations in 1 source file. 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-write.js MarkupCraft 0.8.1 64-bit 13 calculations Reviewed

Tests bin/test-markupcraft.mjs, bin/test-markupcraft-act.mjs, bin/test-markupcraft-field.mjs, bin/test-markupcraft-links.mjs, bin/test-markupcraft-notes.mjs

A number in an appearance stream

64-bit

Turns a number into the text written in a PDF appearance stream.

text = v rounded to 3 decimals, trailing zeros removed; |v| < 1e-6 → 0

toFixed(3) rounds the value to 0.001. Every coordinate, line width, dash length, font size and color component of an appearance goes through this function. A value that is not finite is written as 0.

  • pdf-write.js
  • lines 45 to 49
  • MarkupCraft 0.8.1
const n = (v) => {
  if (!Number.isFinite(v)) return '0';
  const s = (Math.abs(v) < 1e-6 ? 0 : v).toFixed(3);
  return s.replace(/\.?0+$/, '') || '0';
};

Appearance stream of a markup

64-bit

The drawing operators that show a markup in any PDF viewer.

Path points and text positions are written with n(), so the drawn picture is on a 0.001 point grid while the annotation's own keys keep the 64-bit points. The text matrix's cos and sin are rounded to 6 decimals and then written with 3. Opacities are passed on as 64-bit numbers.

  • pdf-write.js
  • lines 61 to 120
  • MarkupCraft 0.8.1
/**
 * A display list → appearance stream content + the graphics states it needs.
 * Returns { content, gstates: {name: {CA, ca, BM}}, missing }.
 */
export function appearanceContent(list) {
  const ops = [];
  const gstates = {};
  const gsKey = new Map();
  let missing = 0;
  const gs = (CA, ca, BM) => {
    const key = n(CA) + '/' + n(ca) + '/' + (BM || '');
    if (!gsKey.has(key)) { const name = 'GS' + gsKey.size; gsKey.set(key, name); gstates[name] = { CA, ca, BM }; }
    return gsKey.get(key);
  };
  const pathOps = (d) => d.map((c) => {
    switch (c[0]) {
      case 'M': return n(c[1]) + ' ' + n(c[2]) + ' m';
      case 'L': return n(c[1]) + ' ' + n(c[2]) + ' l';
      case 'C': return c.slice(1).map(n).join(' ') + ' c';
      case 'Z': return 'h';
      default: return '';
    }
  }).join(' ');
  for (const it of list) {
    if (it.k === 'path') {
      const doFill = it.fill && it.falpha > 0;
      const doStroke = it.stroke && it.w > 0;
      if (!doFill && !doStroke) continue;
      ops.push('q');
      const a = doStroke ? it.alpha : 1, fa = doFill ? it.falpha : 1;
      if (a < 1 || fa < 1 || it.blend) ops.push('/' + gs(a, fa, it.blend === 'multiply' ? 'Multiply' : null) + ' gs');
      if (doFill) ops.push(rgb(it.fill).map(n).join(' ') + ' rg');
      if (doStroke) {
        ops.push(rgb(it.stroke).map(n).join(' ') + ' RG', n(it.w) + ' w', (it.cap === 'round' ? 1 : 0) + ' J', (it.join === 'round' ? 1 : 0) + ' j');
        if (it.dash) ops.push('[' + it.dash.map(n).join(' ') + '] 0 d');
      }
      ops.push(pathOps(it.d));
      const eo = it.rule === 'evenodd' ? '*' : '';   // cut-outs stay open
      ops.push(doFill && doStroke ? 'B' + eo : doFill ? 'f' + eo : 'S');
      ops.push('Q');
    } else if (it.k === 'text') {
      const lines = it.lines.filter((s) => s !== '');
      if (!lines.length) continue;
      const pl = placeText(it);
      const rad = (pl.angle * Math.PI) / 180;
      const c = Math.round(Math.cos(rad) * 1e6) / 1e6, s = Math.round(Math.sin(rad) * 1e6) / 1e6;
      ops.push('q');
      if ((it.alpha ?? 1) < 1) ops.push('/' + gs(it.alpha, it.alpha, null) + ' gs');
      ops.push('BT', '/' + (it.bold ? 'F2' : 'F1') + ' ' + n(it.size) + ' Tf', rgb(it.color).map(n).join(' ') + ' rg');
      for (const ln of pl.lines) {
        if (!ln.s) continue;
        const enc = winAnsiHex(ln.s);
        missing += enc.missing;
        ops.push([c, s, -s, c].map(n).join(' ') + ' ' + n(ln.x) + ' ' + n(ln.y) + ' Tm ' + enc.hex + ' Tj');
      }
      ops.push('ET', 'Q');
    }
  }
  return { content: ops.join('\n'), gstates, missing };
}

Private copy of a markup

64-bit

The JSON kept with each annotation so it reopens exactly: points, box, cut-outs, style and the rest.

ar = round(rect × 1000) ÷ 1000

The points, box and cut-outs are serialized by JSON.stringify without rounding. Only `ar`, the copy of the annotation rectangle used on reopening to tell whether another program moved it, is rounded to 3 decimals.

  • pdf-write.js
  • lines 140 to 147
  • MarkupCraft 0.8.1
/** The private JSON kept with each annotation: everything needed to reopen it exactly. */
export function privateData(m, rect) {
  return JSON.stringify({
    v: 1, id: m.id, type: m.type, pts: m.pts, rect: m.rect, rot: m.rot, style: m.style, text: m.text, label: m.label,
    subject: m.subject, comment: m.comment, author: m.author, created: m.created, modified: m.modified, status: m.status,
    punch: m.punch, layer: m.layer || undefined, img: m.type === 'image' ? m.img : undefined, link: m.link || undefined, legend: m.legend || undefined, fields: Object.keys(m.fields || {}).length ? m.fields : undefined, holes: m.holes?.length ? m.holes : undefined, xd: m.xd || undefined, task: m.task || undefined, voice: m.voice || undefined, decision: m.decision || undefined, flat: m.flat === true ? true : undefined, ar: rect.map((v) => Math.round(v * 1000) / 1000),
  });
}

Annotation rectangle

64-bit

The /Rect of a markup's annotation, which is also the bounding box of its appearance.

rect = bounds ± 1 pt; each side ≥ 2 × line width + 2 pt

Computed in 64-bit from the display list's bounds and passed on unrounded. When a side is shorter than the minimum it is grown equally on both ends.

  • pdf-write.js
  • lines 294 to 301
  • MarkupCraft 0.8.1
const pad = 1;
const rect = [b[0] - pad, b[1] - pad, b[2] + pad, b[3] + pad];
// readers ignore a border wider than half the box: keep the box at least that big
const minD = 2 * (m.style.width || 0) + 2;
for (const [lo, hi] of [[0, 2], [1, 3]]) {
  const grow = minD - (rect[hi] - rect[lo]);
  if (grow > 0) { rect[lo] -= grow / 2; rect[hi] += grow / 2; }
}

Bounds of a display list

64-bit

The box round everything a markup draws, used for its annotation rectangle.

Takes the minimum and maximum over line end points and Bézier control points, not over the curve itself, and adds half the line width (1.5 line widths where joins are mitered). The box is therefore an outer bound, not the tight box of the curve.

  • pdf-write.js
  • lines 531 to 546
  • MarkupCraft 0.8.1
function boundsOfList(list, m) {
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  const add = (x, y, h = 0) => { if (x - h < x0) x0 = x - h; if (y - h < y0) y0 = y - h; if (x + h > x1) x1 = x + h; if (y + h > y1) y1 = y + h; };
  for (const it of list) {
    if (it.k === 'text' || it.k === 'image') { add(it.box[0], it.box[1]); add(it.box[2], it.box[3]); continue; }
    const h = it.stroke ? it.w / 2 + (it.join === 'miter' ? it.w : 0) : 0;
    let px = 0, py = 0;
    for (const c of it.d) {
      if (c[0] === 'M' || c[0] === 'L') { add(c[1], c[2], h); px = c[1]; py = c[2]; }
      else if (c[0] === 'C') { add(c[1], c[2], h); add(c[3], c[4], h); add(c[5], c[6], h); px = c[5]; py = c[6]; }
    }
    void px; void py;
  }
  if (!Number.isFinite(x0)) return m.rect || null;
  return [x0, y0, x1, y1];
}

Rectangle differences of a box or ellipse

64-bit

The /RD entry: how far the shape's outer edge sits inside the annotation rectangle on each side.

RD = [left, top, right, bottom] gaps between rect and box ± half line width, each ≥ 0

Four 64-bit subtractions, clamped at 0. A reader that rebuilds the box as rect minus RD minus half the line width gets the box back to rounding, not bit for bit; MarkupCraft itself reopens the box from the private JSON.

  • pdf-write.js
  • lines 351 to 353
  • MarkupCraft 0.8.1
d.Subtype = m.type === 'ellipse' ? 'Circle' : 'Square';
const r = m.rect;
d.RD = [r[0] - half - rect[0], rect[3] - (r[3] + half), rect[2] - (r[2] + half), r[1] - half - rect[1]].map((v) => Math.max(0, v));

Count marks as ink rings

64-bit

Writes a count group as one Ink annotation with a small ring round each mark.

ring: 13 points at 30° steps, radius max(3, 0.8 × font size)

The ring points are center + r × (cos, sin) in 64-bit; the first and last point coincide to rounding. The mark's center itself is in the private JSON, and a reader without it recovers the center as the mean of the ring.

  • pdf-write.js
  • lines 374 to 378
  • MarkupCraft 0.8.1
// one annotation per group: each mark a small ring in the ink list (the appearance draws the numbers)
d.Subtype = 'Ink';
d.IT = 'CountMarks';
const r = Math.max(3, s.fontSize * 0.8);
d.InkList = m.pts.map(([x, y]) => { const ring = []; for (let k = 0; k <= 12; k++) { const a = (k / 12) * Math.PI * 2; ring.push(x + Math.cos(a) * r, y + Math.sin(a) * r); } return ring; });

Arc exported as a PolyLine

64-bit

Writes an arc markup's /Vertices as points along the arc, because PDF has no arc annotation.

n = max(8, ⌈|sweep| ÷ (π ÷ 32)⌉); vertex k = c + r × (cos, sin)(a₀ + sweep × k ÷ n)

The vertices lie on the circle to 64-bit rounding, at most π ÷ 32 (5.625°) apart. The straight pieces between them are shorter than the arc, by a factor of sin(x) ÷ x with x = half the step, which is at most about 0.04% short. The three picked points stay in the private JSON.

  • pdf-write.js
  • lines 386 to 390
  • MarkupCraft 0.8.1
// no PDF arc annotation: a PolyLine along the arc (our private data keeps the three points)
d.Subtype = 'PolyLine';
const arc = m.pts.length === 3 ? arcThrough(m.pts[0], m.pts[1], m.pts[2]) : null;
const n = arc ? Math.max(8, Math.ceil(Math.abs(arc.sweep) / (Math.PI / 32))) : 0;
d.Vertices = arc ? Array.from({ length: n + 1 }, (_, k) => { const a = arc.a0 + (arc.sweep * k) / n; return [arc.c[0] + arc.r * Math.cos(a), arc.c[1] + arc.r * Math.sin(a)]; }).flat() : [m.pts[0], m.pts.at(-1)].flat();

Picture placement matrix

64-bit

The matrix that paints a picture markup into its box, upright for the page's rotation.

cm = [u·W, −v·H, origin + v·H], each number to 4 decimals

The six numbers are rounded with toFixed(4) before they are written into the appearance stream. The picture's box itself is stored unrounded in the private JSON.

  • pdf-write.js
  • lines 310 to 312
  • MarkupCraft 0.8.1
const f = imgFrame(m.rot || 0, m.rect);
const cm = [f.u[0] * f.W, f.u[1] * f.W, -f.v[0] * f.H, -f.v[1] * f.H, f.o[0] + f.v[0] * f.H, f.o[1] + f.v[1] * f.H].map((v) => +v.toFixed(4));
ap.content = 'q ' + cm.join(' ') + ' cm /MCImg Do Q\n' + ap.content;   // i18n-ignore (PDF operators)

Scale as a /Measure dictionary

64-bit

The sheet's scale in the form other PDF programs read: the factor from points to the unit and the display precision.

X.C = mmPerPt ÷ 304.8 (ft), ÷ 25.4 (in), ÷ 1000 (m), or mmPerPt (mm)

One 64-bit division by an exact constant. The D values are display precisions for other viewers (1/16 inch, 1/100 foot, 1/1000 meter), not rounding of the factor. The area entry's factor is 1 because the area unit is the square of the distance unit.

  • pdf-write.js
  • lines 125 to 138
  • MarkupCraft 0.8.1
/** /Measure dictionary values for a calibration (plain data; the writer turns it into PDF objects). */
export function measureSpec(cal) {
  if (!cal) return null;
  const perPt = cal.mmPerPt;
  const R = cal.label || t('cal.calibrated');
  // the unit abbreviations below are PDF /Measure data that viewers show as written   i18n-ignore
  switch (cal.unit) {
    case 'ft-in': return { R, X: [{ U: 'ft', C: perPt / 304.8, D: 1, F: 'D' }], D: [{ U: "'", C: 1, D: 1, F: 'T' }, { U: '"', C: 12, D: 16, F: 'F' }], A: [{ U: 'sq ft', C: 1, D: 100, F: 'D' }] };   // i18n-ignore
    case 'ft': return { R, X: [{ U: 'ft', C: perPt / 304.8, D: 100, F: 'D' }], D: [{ U: 'ft', C: 1, D: 100, F: 'D' }], A: [{ U: 'sq ft', C: 1, D: 100, F: 'D' }] };   // i18n-ignore
    case 'in': return { R, X: [{ U: 'in', C: perPt / 25.4, D: 100, F: 'D' }], D: [{ U: 'in', C: 1, D: 100, F: 'D' }], A: [{ U: 'sq in', C: 1, D: 100, F: 'D' }] };   // i18n-ignore
    case 'm': return { R, X: [{ U: 'm', C: perPt / 1000, D: 1000, F: 'D' }], D: [{ U: 'm', C: 1, D: 1000, F: 'D' }], A: [{ U: 'sq m', C: 1, D: 100, F: 'D' }] };   // i18n-ignore
    default: return { R, X: [{ U: 'mm', C: perPt, D: 1, F: 'D' }], D: [{ U: 'mm', C: 1, D: 1, F: 'D' }], A: [{ U: 'sq mm', C: 1, D: 1, F: 'D' }] };   // i18n-ignore
  }
}

Scale written on the page

64-bit

Stores a sheet's calibration on its page in the saved PDF.

Written twice: as a /VP viewport over the page's media box with the /Measure dictionary, and as JSON.stringify of the calibration object, which keeps mmPerPt in full.

  • pdf-write.js
  • lines 483 to 488
  • MarkupCraft 0.8.1
// the sheet's calibration: a measure viewport other tools read, plus our exact copy
if (pg.cal) {
  const spec = measureSpec(pg.cal);
  const box = page.getMediaBox();
  page.node.set(N('VP'), ctx.obj([ctx.obj({ Type: 'Viewport', BBox: [box.x, box.y, box.x + box.width, box.y + box.height], Name: PDFString.of('MarkupCraft'), Measure: measureObj(spec) })]));
  page.node.set(N(PRIVATE_CAL_KEY), txt(JSON.stringify(pg.cal)));

Measured text in /Contents

64-bit

The text other PDF programs show for a measurement: the length, the area and perimeter, the angle or the count.

For a length or angle this is the first text line of the display list, and for an area the text from measureArea, so it carries the rounding of formatLength, formatArea and formatAngle. It is text for other viewers; the points are in the annotation's geometry keys.

  • pdf-write.js
  • lines 340 to 346
  • MarkupCraft 0.8.1
const areaV = m.type === 'area' ? measureArea(m.pts, pg.cal, m.holes) : null;
const contents = m.type === 'text' || m.type === 'callout' ? m.text
  : m.type === 'measure' || m.type === 'angle' ? [list.find((i) => i.k === 'text')?.lines[0], m.comment].filter(Boolean).join('\n')
  : m.type === 'area' ? [areaV.area ? t('pdf.contents.area', { area: areaV.area, perimeter: areaV.perimeter }) : '', m.comment].filter(Boolean).join('\n')
  : m.type === 'count' ? [t('count.tag', { group: m.label || t('count.default'), n: m.pts.length }), m.comment].filter(Boolean).join('\n')
  : m.type === 'stamp' ? [m.label, m.comment].filter(Boolean).join('\n') : m.comment;
d.Contents = txt(contents || '');

Flattened markup

64-bit

Paints a markup into the page itself instead of saving it as an annotation.

The markup's appearance stream is drawn by the page content and the loop moves on before the annotation is registered, so no /L, /Vertices or private JSON is saved for it. What remains in the file is the appearance, whose numbers have 3 decimals.

  • pdf-write.js
  • lines 463 to 467
  • MarkupCraft 0.8.1
// drawn into the page: its appearance (BBox in user space, identity matrix) painted where the annotation would be
const name = page.node.newXObject('MCFlat', apRef);
page.pushOperators(lib.pushGraphicsState(), lib.drawObject(name), lib.popGraphicsState());
report.flattened++;
continue;

In detail

Method
Appearance: the shapes.js display list as PDF path and text operators. Private copy: JSON.stringify of the markup. Scale: a /VP viewport with /Measure (subtype RL) plus the calibration as JSON. An arc is exported as a PolyLine of chords; a count group as one Ink annotation with a ring per mark.
Number types
64-bit numbers. Appearance streams and one picture matrix are decimal text.
Tolerances
Appearance numbers: 3 decimals, and |v| < 1e-6 is written as 0. Picture matrix: 4 decimals. The private rectangle copy `ar`: 3 decimals. Annotation rectangle: 1 point outside the drawn bounds, and at least 2 × line width + 2 points on each side.
Limits
A markup that is flattened into the page keeps only its 3-decimal appearance. Another program that measures the exported arc PolyLine gets the chord length. The /Contents text of a measurement is the rounded label.

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