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