Turns the annotations of an opened PDF into editable markups (boxes, lines, outlines, pen strokes, count marks, text boxes) and reads the sheet's scale.
How the precision is kept
A markup MarkupCraft saved is restored from its private JSON, so its numbers come back bit for bit. Other annotations are rebuilt from their own keys, which pdf-lib hands over as 64-bit numbers. When pdf-lib cannot read the file, the code falls back to what pdf.js reports; pdf.js 6.3.289 holds /Vertices and /InkList in Float32Array, so in that case outline and stroke points have 32-bit precision, and its line coordinates have their ends sorted.
Open item. When pdf-lib cannot read a file, read /Vertices, /InkList and /L in 64-bit rather than taking the 32-bit arrays and the sorted line rectangle that pdf.js reports.
Calculations under this policy
10 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.
pdf-read.jsMarkupCraft 0.8.1 32-bit in part8 calculationsReviewed
The points of a polyline, polygon, cloud or area markup read from an annotation.
The points come from /Vertices as pdf-lib reads them, in 64-bit. When pdf-lib gave nothing, the fallback is a.vertices from pdf.js, which pdf.js 6.3.289 builds with Float32Array.from; the points are then 32-bit values, and an area, perimeter or length measured from them has about 7 significant digits. The function pairs() converts each value with unary plus and does not round.
pdf-read.js
lines 233 to 235
MarkupCraft 0.8.1
case 'PolyLine': case 'Polygon': {
const pts = pairs(Array.isArray(x.Vertices) ? x.Vertices : a.vertices ? Array.from(a.vertices) : []);
if (pts.length < 2) return foreign();
Stroke points of an Ink annotation
32-bit in part
The points of pen and highlighter strokes, and of count rings, read from an annotation.
Taken from /InkList as pdf-lib reads it, in 64-bit. When pdf-lib gave nothing, the fallback is a.inkLists from pdf.js, which pdf.js 6.3.289 holds as Float32Array, so the stroke points and the count mark centers derived from them are 32-bit values.
The two end points of a line, arrow or length measurement read from an annotation.
Taken from /L as pdf-lib reads it. When pdf-lib gave nothing, the fallback is pdf.js's lineCoordinates, which pdf.js 6.3.289 passes through its rectangle normalizer: the smaller x and the smaller y come first, so a line that runs down to the right is read as running up to the right. Its length is unchanged.
The box of a rectangle, ellipse or cloud rectangle markup from the annotation's /Rect.
box = rect inset by RD and by half the border width on each side
64-bit additions and subtractions only. A box with no width or height is left as a read-only annotation. For a cloudy border the box is inset further by half of max(4, 6 × width + 8), an estimate of the scallop size, since the file does not state it.
pdf-read.js
lines 211 to 218
MarkupCraft 0.8.1
case 'Square': case 'Circle': {
const rd = Array.isArray(x.RD) && x.RD.length === 4 ? x.RD : [0, 0, 0, 0];
const r = a.rect;
// RD is [left top right bottom] from /Rect to the shape's edge; the border is painted inside that edge
const rect = [r[0] + rd[0] + width / 2, r[1] + rd[3] + width / 2, r[2] - rd[2] - width / 2, r[3] - rd[1] - width / 2];
if (rect[2] <= rect[0] || rect[3] <= rect[1]) return foreign();
const type = sub === 'Circle' ? 'ellipse' : cloudy ? 'cloudrect' : 'rect';
if (cloudy) { const inset = Math.max(4, width * 6 + 8) * 0.5; rect[0] += inset; rect[1] += inset; rect[2] -= inset; rect[3] -= inset; }
Count mark from its ring
64-bit
The position of each count mark, from the ring written for it in the ink list.
center = mean of the ring's points (the closing point dropped)
A plain mean of the x and of the y values, added in order. For a ring MarkupCraft wrote (12 points evenly round a circle) this returns the center to rounding, not bit for bit; the exact position is in the private JSON when it is present. The closing point is dropped when it is within 1e-6 points of the first.
pdf-read.js
lines 244 to 248
MarkupCraft 0.8.1
// a count group: each ring's center is a mark
const marks = strokes.map((ring0) => {
const ring = ring0.length > 2 && Math.hypot(ring0[0][0] - ring0.at(-1)[0], ring0[0][1] - ring0.at(-1)[1]) < 1e-6 ? ring0.slice(0, -1) : ring0;
return [ring.reduce((t, p) => t + p[0], 0) / ring.length, ring.reduce((t, p) => t + p[1], 0) / ring.length];
});
Moved by another program
64-bit
Decides whether a markup's private JSON still matches the annotation, or another program moved or resized it since.
|ar − rect| > 0.75 pt on any of the four numbers → use the annotation's own geometry
An absolute tolerance of 0.75 points, compared against the 3-decimal rectangle saved in the JSON. Within it, the stored points are restored exactly; beyond it, the JSON is dropped and the markup is rebuilt from the annotation's keys.
pdf-read.js
lines 151 to 152
MarkupCraft 0.8.1
// moved or resized by another program since we saved it? Then trust the annotation's geometry instead.
if (okRect(d.ar) && a?.rect && d.ar.some((v, i) => Math.abs(v - a.rect[i]) > 0.75)) return null;
Scale from another program's /Measure
64-bit
A sheet calibration from the measurement dictionary another PDF program wrote.
mmPerPt = C × mm per unit (ft 304.8, in 25.4, m 1000, cm 10, mm 1)
One 64-bit multiplication of the first /X entry's factor by an exact unit constant. Only X[0] is read; further entries, which the format allows for a chain of units, are ignored. A unit that is not in the list, or a factor that is not above zero, gives no calibration.
pdf-read.js
lines 453 to 463
MarkupCraft 0.8.1
/** A /Measure dictionary another program wrote (plain JS) → calibration, when its unit is one we know. */
export function calFromMeasure(meas) {
if (!meas || meas.Subtype !== 'RL' || !Array.isArray(meas.X) || !meas.X[0]) return null;
const x = meas.X[0];
const C = Number(x.C);
const U = String(x.U || '').trim().toLowerCase().replace(/[.\s]/g, '');
const mm = { ft: 304.8, "'": 304.8, feet: 304.8, in: 25.4, '"': 25.4, inch: 25.4, m: 1000, cm: 10, mm: 1 }[U];
if (!(C > 0) || !mm) return null;
const unit = U === 'ft' || U === "'" || U === 'feet' ? 'ft-in' : U === 'in' || U === '"' || U === 'inch' ? 'in' : U === 'cm' ? 'mm' : U;
return { mmPerPt: C * mm, unit, label: String(meas.R || t('cal.from_file')).slice(0, 120) };
}
Which scale a sheet opens with
64-bit
Picks the calibration of a page: MarkupCraft's own saved copy, else the page's measure viewport.
The private JSON copy is used when it passes validCalibration (0 < mmPerPt < 1e7 and a known unit); its mmPerPt is taken as parsed, unrounded. Otherwise the first /VP entry that has a /Measure goes to calFromMeasure.
pdf-read.js
lines 366 to 370
MarkupCraft 0.8.1
if (validCalibration(cal)) record.calibrations[pg.key] = { mmPerPt: cal.mmPerPt, unit: cal.unit, label: String(cal.label || '').slice(0, 120) };
else if (node) {
const vp = plain(lib, node.lookup(lib.PDFName.of('VP')));
const c = Array.isArray(vp) ? calFromMeasure(vp.find((v) => v && v.Measure)?.Measure) : null;
if (c) record.calibrations[pg.key] = c;
The size of the first visible text that another program's text box draws.
size = |Tf size × text matrix scale × cm scale|, rounded to 0.01
The text matrix scale is hypot(a, b) of the last Tm; the cm scale is the product of hypot(a, b) over the cm matrices in force. The result is rounded to 2 decimals and is kept only when it is above zero; okSize limits it to 2–300 points.
Reads 12pt, 16px or 1.2em from a text box's style as points.
px × 0.75; em × 12; pt as written
0.75 is the exact CSS ratio of 72 points to 96 pixels. An em is taken as 12 points, a fixed assumption. The result is limited to 2–300 points.
freetext.js
lines 45 to 51
MarkupCraft 0.8.1
/** "12pt", "16px", "1.2em" → points (em: of 12 pt). */
function cssSize(v) {
const m = /^([\d.]+)\s*(pt|px|em)?$/.exec(String(v || '').trim().toLowerCase());
if (!m) return undefined;
const n = +m[1];
return okSize(m[2] === 'px' ? n * 0.75 : m[2] === 'em' ? n * 12 : n);
}
In detail
Method
Private JSON first, checked against the annotation rectangle. Otherwise: Square and Circle from /Rect, /RD and the border width; Line from /L; PolyLine and Polygon from /Vertices; Ink from /InkList; count marks as the mean of each ring. Scale: the private calibration JSON, else the first /VP entry with a /Measure of subtype RL.
Number types
64-bit numbers from pdf-lib and from JSON. Float32Array from pdf.js on the fallback path for vertices and ink lists.
Tolerances
The private JSON is dropped when any number of the saved rectangle differs from the annotation's /Rect by more than 0.75 points. A count ring's last point is dropped when it is within 1e-6 points of the first. A text box smaller than 1 point either way stays read-only. Text size from an appearance is rounded to 0.01 and limited to 2–300 points.
Limits
Only the first entry of /Measure /X is read, so a scale written as a chain of units is not followed. On the pdf.js fallback path a line's direction can be mirrored, because pdf.js sorts the ends of /L.
Approach: 32-bit step, flagged
One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.