Turns a sheet's scale into real lengths and areas: the calibration from a preset or from two picked points, the reading of a typed length, and the length, area, perimeter and count shown for a measurement.
How the precision is kept
A calibration is one 64-bit number, real millimeters per PDF point. Lengths are points × mmPerPt and areas are points² × mmPerPt², in 64-bit from the picked points to the value. The unit factors are the exact definitions (25.4 mm per inch, 304.8 mm per foot, 92903.04 mm² per square foot, 645.16 mm² per square inch). Values are rounded only when they are turned into text; the points and mmPerPt are not rounded by this code.
Open item. Take the shoelace cross products relative to the first vertex, so a small outline far from the page origin does not subtract nearly equal numbers.
Calculations under this policy
15 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.
The size of one PDF point in millimeters, the base of every preset scale.
MM_PER_PT = 25.4 ÷ 72
25.4 mm per inch is the exact definition and 72 points per inch is the PDF unit. The quotient is not a finite binary fraction, so it is rounded once to 64 bits.
units.js
line 16
MarkupCraft 0.8.1
export const MM_PER_PT = MM_PER_IN / 72;
Calibration from a preset scale
64-bit
Real millimeters per point for a scale chosen from the list, such as 1/4 inch = 1 foot or 1:100.
mmPerPt = (25.4 ÷ 72) × ratio
One 64-bit multiplication of the constant by the preset's ratio, a whole number from the table in this file. The table's own comment says the presets assume the PDF prints at full size; nothing here checks that. Switching the unit from mm to m at ratios of 100 and above changes the text only.
units.js
lines 41 to 45
MarkupCraft 0.8.1
/** A preset ratio → calibration. Metric ratios above 1:100 read better in meters. */
export function presetCalibration(label, ratio, unit) {
const u = unit === 'mm' && ratio >= 100 ? 'm' : unit;
return { mmPerPt: MM_PER_PT * ratio, unit: u, label };
}
Calibration from two picked points
64-bit
Real millimeters per point from a distance picked on the sheet and the real length typed for it.
mmPerPt = real length (mm) ÷ picked distance (pt)
One 64-bit division; a zero, negative or non-numeric length or distance returns null. The scale rests on a single pair of picked points, with no averaging. The toFixed(2) is in the label text only.
units.js
lines 47 to 52
MarkupCraft 0.8.1
/** Two points picked on the sheet + the real distance typed → calibration (null if unusable). */
export function calibrationFrom(ptLength, realMm, unit) {
if (!(ptLength > 0) || !(realMm > 0) || !UNIT_MM[unit]) return null;
const mmPerPt = realMm / ptLength;
return { mmPerPt, unit, label: 'Calibrated: ' + formatLength(realMm, unit) + ' over ' + (ptLength / 72).toFixed(2) + ' in' };
}
Fractions and mixed numbers
64-bit
Reads 1/2, 6 1/2 or 12.5 as a number.
a/b → a ÷ b; n a/b → n + a ÷ b
A fraction is one 64-bit division of two whole numbers; a zero denominator gives NaN, which the caller refuses. Decimal text goes through parseFloat after a pattern that allows digits and one decimal point only.
units.js
lines 55 to 63
MarkupCraft 0.8.1
function num(s) {
s = s.trim();
if (!s) return NaN;
const f = s.match(FRACTION);
if (f) return +f[2] ? +f[1] / +f[2] : NaN;
const mixed = s.match(/^(\d+(?:\.\d+)?)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixed) return +mixed[3] ? +mixed[1] + +mixed[2] / +mixed[3] : NaN;
return /^\d*\.?\d+$/.test(s) ? parseFloat(s) : NaN;
}
A typed length in millimeters
64-bit
Reads what a person types for a length (24'-0", 12' 6 1/2", 3.5 m, 900 mm, or a bare number in the chosen unit) as millimeters.
mm = feet × 304.8 + inches × 25.4; metric: value × 1, 10 or 1000
The factors 304.8 and 25.4 are the exact definitions, applied in 64-bit. Every comma is replaced by a period before parsing, so 1,200 is read as 1.2. Units the patterns do not cover go to the shared unit table (parseTo). A result that is not finite or not above zero is refused.
units.js
lines 92 to 123
MarkupCraft 0.8.1
function one(text, unit) {
let s = String(text ?? '').trim().toLowerCase().replace(/[’′]/g, "'").replace(/[”″]/g, '"').replace(/''/g, '"').replace(/,/g, '.');
if (!s) return null;
// metric with a suffix
let m = s.match(/^(\d*\.?\d+)\s*(mm|cm|m|meters?|metres?|millimeters?|millimetres?|centimeters?|centimetres?)$/);
if (m) {
const u = m[2].startsWith('mm') || m[2].startsWith('milli') ? 'mm' : m[2].startsWith('c') ? 'cm' : 'm';
return pos(parseFloat(m[1]) * UNIT_MM[u]);
}
// feet and inches: 24'-0" 12' 6 1/2" 3 ft 6 in 12.5' 6" 1/2"
s = s.replace(/\s*(feet|foot|ft)\b\.?/g, "'").replace(/\s*(inches|inch|in)\b\.?/g, '"');
m = s.match(/^(?:(\d*\.?\d+)\s*')?\s*-?\s*(?:([\d\s./]+?)\s*")?$/);
if (m && (m[1] !== undefined || m[2] !== undefined) && /['"]/.test(s)) {
const ft = m[1] !== undefined ? parseFloat(m[1]) : 0;
const inch = m[2] !== undefined ? num(m[2]) : 0;
if (!Number.isFinite(ft) || !Number.isFinite(inch)) return null;
return pos(ft * 304.8 + inch * 25.4);
}
// feet with inches but no inch mark: 12' 6
m = s.match(/^(\d*\.?\d+)\s*'\s*-?\s*([\d\s./]+)$/);
if (m) {
const inch = num(m[2]);
return Number.isFinite(inch) ? pos(parseFloat(m[1]) * 304.8 + inch * 25.4) : null;
}
const n = num(s);
if (!Number.isFinite(n)) {
// every other length mark of the shared table (0.7 UNITS): 3 yd, 2 ch, 10 US ft, 1.5 km, 12 pt — as typed (case matters)
const r = parseTo(String(text ?? '').replace(/(\d),(\d)/g, '$1.$2'), 'length', 'mm');
return r.ok ? pos(r.value) : null;
}
return pos(n * (UNIT_MM[unit] || 1));
}
A typed length with arithmetic
64-bit
Reads entries such as 12'6" + 3/4" or 2*450mm as one length in millimeters.
When the text holds an operator the arithmetic is done by calc() in lencalc.js, with each length inside read by one(). A bare number inside is passed as a plain number, and calc is given the unit's millimeters (the plain option) for where it stands as a length. A result of zero or less is refused with a reason.
units.js
lines 73 to 90
MarkupCraft 0.8.1
/**
* A typed length with arithmetic (0.6 DF.3: 4+7/16, 12'6" + 3/4", (24'-0")/3, 2*450mm) → {value (mm) | null, error, pos}.
* lencalc.js is a byte-identical copy of CadCraft's (tools/lencalc/sync.mjs): its own parser, never eval. A bare number
* stays a factor in 2*450mm and becomes `unit` when it is the length itself.
*/
export function parseLengthCalc(text, unit = 'ft-in') {
const s = String(text ?? '').trim().toLowerCase().replace(/[’′]/g, "'").replace(/[”″]/g, '"').replace(/''/g, '"').replace(/[−–—]/g, '-');
if (hasArithmetic(s)) {
const r = calc(s, (x) => {
const bare = /^[\d.,\s/-]+$/.test(x);
const v = one(x, bare ? 'mm' : unit);
return v === null ? null : { value: v, unit: !bare };
}, { plain: UNIT_MM[unit] || 1 });
if (r.ok && r.value > 0) return { value: r.value, error: null, pos: -1 };
return { value: null, error: r.ok ? 'calc.err.not_positive' : 'calc.err.' + r.code, pos: r.ok ? -1 : r.pos, text: r.text || '' };
}
return { value: one(s, unit), error: null, pos: -1 };
}
Length as text
64-bit
Writes a length in millimeters in the sheet's unit: feet and inches, feet, inches, meters or millimeters.
ft-in: round(mm ÷ 25.4 × 16) sixteenths; ft: mm ÷ 304.8; in: mm ÷ 25.4; m: mm ÷ 1000
Feet and inches are rounded to the nearest 1/16 inch first and then split with whole-number arithmetic, so a value that rounds up to 12 inches carries into the feet. Feet show 2 decimals, inches 2, meters 3 and millimeters none. This rounding is in the text; the function takes no precision argument.
units.js
lines 126 to 147
MarkupCraft 0.8.1
/** Millimeters → a display string in a unit. Feet and inches round to 1/16". */
export function formatLength(mm, unit = 'ft-in') {
if (!Number.isFinite(mm)) return '';
switch (unit) {
case 'ft-in': {
let sixteenths = Math.round((mm / 25.4) * 16);
const neg = sixteenths < 0; sixteenths = Math.abs(sixteenths);
const ft = Math.floor(sixteenths / 192);
let rest = sixteenths - ft * 192;
const inch = Math.floor(rest / 16);
rest -= inch * 16;
let frac = '';
if (rest) { let n = rest, d = 16; while (n % 2 === 0) { n /= 2; d /= 2; } frac = ' ' + n + '/' + d; }
return (neg ? '-' : '') + ft + "'-" + inch + frac + '"';
}
case 'ft': return (mm / 304.8).toFixed(2) + "'";
case 'in': return trim(mm / 25.4, 2) + '"';
case 'm': return t('len.m', { v: (mm / 1000).toFixed(3) });
case 'mm': return t('len.mm', { v: Math.round(mm) });
default: return t('len.mm', { v: trim(mm, 2) });
}
}
Area as text
64-bit
Writes an area in square millimeters in the area unit that goes with the sheet's length unit.
sq ft = mm² ÷ 92903.04; sq in = mm² ÷ 645.16; m² = mm² ÷ 1e6
92903.04 is 304.8² and 645.16 is 25.4², both exact. The text shows 2 decimals for square feet, 1 for square inches, 3 for square meters and whole square millimeters.
units.js
lines 156 to 166
MarkupCraft 0.8.1
/** Square millimeters → a display string in the area unit that goes with a length unit. */
export function formatArea(mm2, unit = 'ft-in') {
if (!Number.isFinite(mm2)) return '';
switch (unit) {
case 'ft-in': case 'ft': return t('area.sq_ft', { v: (mm2 / 92903.04).toFixed(2) });
case 'in': return t('area.sq_in', { v: (mm2 / 645.16).toFixed(1) });
case 'm': return t('area.sq_m', { v: (mm2 / 1e6).toFixed(3) });
case 'mm': return t('area.sq_mm', { v: Math.round(mm2) });
default: return t('area.sq_m', { v: (mm2 / 1e6).toFixed(3) });
}
}
Area and perimeter of an outline
64-bit
The area and the perimeter of a closed outline, in points² and points.
The shoelace terms use the absolute page coordinates, not coordinates relative to a vertex, so each term can be much larger than the area and the sum subtracts nearly equal numbers for a small outline far from the page origin. The perimeter is a running sum of Math.hypot edge lengths in vertex order. An outline that crosses itself gives the absolute value of the net signed area.
units.js
lines 168 to 177
MarkupCraft 0.8.1
/** Area (absolute) and perimeter of a closed outline, in PDF points² / points. */
export function polygonAreaPerimeter(pts) {
let a = 0, p = 0;
for (let i = 0; i < pts.length; i++) {
const q = pts[i], r = pts[(i + 1) % pts.length];
a += q[0] * r[1] - r[0] * q[1];
p += Math.hypot(r[0] - q[0], r[1] - q[1]);
}
return { area: Math.abs(a) / 2, perimeter: p };
}
Area measurement with cut-outs
64-bit
The area and perimeter text of an area markup: the outline less its cut-outs, in real units.
Each cut-out's absolute area is subtracted and its edge length added, and the difference is clamped at 0. This function does not test that a cut-out lies inside the outline or apart from the others; that test is made when a cut-out is drawn (holeProblem in holes.js), while cut-outs read from a file are only checked to be finite numbers. The scale is applied once, after the subtraction.
units.js
lines 179 to 187
MarkupCraft 0.8.1
/** An area markup's values under a calibration: { area, perimeter } strings ('' with no calibration); `holes` are cut-outs. */
export function measureArea(pts, cal, holes = []) {
if (!cal || !(cal.mmPerPt > 0) || !pts || pts.length < 3) return { area: '', perimeter: '' };
let { area, perimeter } = polygonAreaPerimeter(pts);
// cut-outs (0.4 K8): their area comes off, their edges count in the perimeter
for (const h of holes || []) { const c = polygonAreaPerimeter(h); area -= c.area; perimeter += c.perimeter; }
area = Math.max(0, area);
return { area: formatArea(area * cal.mmPerPt * cal.mmPerPt, cal.unit), perimeter: formatLength(perimeter * cal.mmPerPt, cal.unit) };
}
Length under a calibration
64-bit
A distance in points as a real length in the sheet's unit.
length = points × mmPerPt
One 64-bit multiplication, then formatLength. Returns an empty string when the sheet has no calibration or mmPerPt is not above zero.
units.js
lines 150 to 154
MarkupCraft 0.8.1
/** A length in PDF points under a calibration → display string ('' with no calibration). */
export function measure(ptLength, cal) {
if (!cal || !(cal.mmPerPt > 0)) return '';
return formatLength(ptLength * cal.mmPerPt, cal.unit);
}
The length, area and perimeter, count, angle or arc length listed for a markup in the Properties panel and, through valueOf, in lists, CSV and BCF.
length = hypot(Δx, Δy) × mmPerPt; arc = r × |sweep| × mmPerPt; count = number of marks
Each value is recomputed from the markup's stored points every time it is asked for; nothing is cached or stored rounded. The distance is one Math.hypot of the two end points. The values leave this function as formatted text, so lists and exports carry the rounding of formatLength, formatArea and formatAngle.
markupcraft.js
lines 3312 to 3320
MarkupCraft 0.8.1
function valueRows(m) {
const cal = envOf(m).cal;
if (m.type === 'measure') return [[t('value.length'), cal ? measure(Math.hypot(m.pts[1][0] - m.pts[0][0], m.pts[1][1] - m.pts[0][1]), cal) : t('value.no_scale')]];
if (m.type === 'area') { const v = measureArea(m.pts, cal, m.holes); return cal ? [[t('value.area'), v.area], [t('value.perimeter'), v.perimeter], ...(m.holes?.length ? [[t('value.cutouts'), String(m.holes.length)]] : [])] : [[t('value.area'), t('value.no_scale')]]; }
if (m.type === 'count') return [[t('value.count'), String(m.pts.length)], ...spaceRow(m)];
if (m.type === 'angle' && m.pts.length === 3) return [[t('value.angle'), formatAngle(angleAt(...m.pts))]];
if (m.type === 'arc' && m.pts.length === 3) { const a = arcThrough(...m.pts); return [[t('value.length'), cal && a ? measure(arcLength(a), cal) : t('value.no_scale')]]; }
return spaceRow(m);
}
Pointer position on the paper
64-bit
The pointer's position on the sheet in the status line, in millimeters or inches of paper.
mm = pt ÷ 72 × 25.4; in = pt ÷ 72
The user-space point is mapped by the view's 64-bit matrix, then converted with the exact 25.4. The text shows 1 decimal in millimeters and 2 in inches. This is the paper position; the sheet's calibration is read but not used.
When markups are carried to a new revision of a sheet, the old sheet's calibration is given to the new one if it has none.
|w_old − w_new| ≤ 1 pt and |h_old − h_new| ≤ 1 pt
The calibration object is passed on unchanged. The test is an absolute tolerance of 1 point on each page side; it compares page size only, not the drawing on the page.
The size on the sheet of a copied area pasted as a picture: the same size on paper, or the same real size when both sheets have a scale.
k = mmPerPx ÷ mmPerPt (both scaled), else ptPerPx; w = px_w × k; h = px_h × k
One 64-bit division and two multiplications. mmPerPx and ptPerPx come with the copied area (areaclip.js); the width and height are the copy's pixel counts times k.
markupcraft.js
lines 1772 to 1799
MarkupCraft 0.8.1
/**
* Paste a copied area (0.8 D8.15) as a picture markup at its size: the same size on paper; on a sheet with a scale, when
* the area came from one too, the same size in the drawing (a 1:50 area on a 1:100 sheet comes in at half). An overlay
* starts at its strength. how as paste(): 'place' = where it was copied, 'pointer', 'offset' = beside / the view's middle.
*/
async function pasteArea(ac, how = 'offset', pt = pointer) {
const s = curSheet();
if (!s) { status(t('need.pdf')); return false; }
commitEdit(); cancelOps();
if (!author && !askedAuthor) await askAuthor();
if (tool !== 'select') setTool('select');
const cal = store.calibrations.get(s.page.key);
const k = ac.real && cal ? ac.real.mmPerPx / cal.mmPerPt : ac.ptPerPx; // points on this sheet per pixel
const [w, h] = [ac.px[0] * k, ac.px[1] * k];
const key = 'area|' + ac.at + '|' + s.page.key;
pasteRun = pasteRun.key === key ? { key, n: pasteRun.n + 1 } : { key, n: 1 };
let center;
if (how === 'pointer' && pt) center = view.toUser(pt[0], pt[1]);
else {
const d = screenStep(PASTE_STEP * (pasteRun.n - 1));
const m = view.toUser(view.w / 2, view.h / 2);
center = [m[0] + d[0], m[1] + d[1]];
}
const extra = ac.overlay ? { style: { ...styleFor('image'), opacity: ac.overlay.opacity } } : {};
commitNew(newMarkup('image', { rect: placeRect(center, w, h, view.seenRotate), img: ac.src, label: ac.label || t('area.label'), subject: TYPES.image.subject }, extra), t(ac.overlay ? 'area.paste.undo.overlay' : 'area.paste.undo'));
status(t(ac.overlay ? 'area.paste.done.overlay' : 'area.paste.done') + ' ' + t(ac.real && cal ? 'area.paste.real' : 'area.paste.paper') + ' ' + t('area.paste.lineup'));
return true;
}
In detail
Method
Preset scale: mmPerPt = (25.4 ÷ 72) × ratio. Two-point calibration: mmPerPt = typed real length ÷ picked distance. Length = points × mmPerPt. Area = shoelace area in points² × mmPerPt², less the cut-outs. Typed lengths are read by regular expressions (feet and inches, fractions, metric suffixes) and, for other units, by the shared unit table.
Number types
64-bit numbers throughout. A calibration is { mmPerPt, unit, label }.
Tolerances
No geometric tolerance. A calibration is accepted when 0 < mmPerPt < 1e7. Text rounding: feet and inches to 1/16 inch; feet 2 decimals; inches 2 decimals; meters 3 decimals; millimeters whole. Areas: square feet 2 decimals, square inches 1, square meters 3, square millimeters whole. A calibration is carried to a replacement sheet when both page sides differ by at most 1 point.
Limits
formatLength and formatArea take no precision argument, so the shown value is always rounded as listed. Every comma in a typed length is read as a decimal point. measureArea subtracts cut-outs without testing that they lie inside the outline; that test is made when a cut-out is drawn, not when one is read from a file. The shoelace sum uses absolute page coordinates.
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.