| 1 | // SVG -> polylines in inches, for plotting. Handles path, line, polyline,
|
| 2 | // polygon, rect, circle and ellipse elements, nested transforms, physical
|
| 3 | // units and the root viewBox. Curves are flattened to within `tolerance`
|
| 4 | // inches. Text must be converted to paths first (e.g. in Inkscape).
|
| 5 |
|
| 6 | const PX_PER_INCH = 96;
|
| 7 | const UNIT_INCHES = { in: 1, mm: 1 / 25.4, cm: 1 / 2.54, pt: 1 / 72, pc: 1 / 6, px: 1 / 96, '': 1 / 96, q: 1 / 101.6 };
|
| 8 | const SKIP_TAGS = new Set(['defs', 'clippath', 'mask', 'symbol', 'marker', 'pattern', 'metadata', 'title',
|
| 9 | 'desc', 'style', 'script', 'text', 'image', 'lineargradient', 'radialgradient', 'filter', 'foreignobject']);
|
| 10 |
|
| 11 | /** Parse an SVG length like "8.5in" or "210mm" into inches (null if absent or %). */
|
| 12 | export function lengthToInches(value) {
|
| 13 | if (value == null) return null;
|
| 14 | const m = String(value).trim().match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)$/i);
|
| 15 | if (!m || m[2] === '%') return null;
|
| 16 | const unit = UNIT_INCHES[m[2].toLowerCase()];
|
| 17 | return unit == null ? null : parseFloat(m[1]) * unit;
|
| 18 | }
|
| 19 |
|
| 20 | // --- Affine matrices [a, b, c, d, e, f]: x' = a x + c y + e, y' = b x + d y + f ---
|
| 21 |
|
| 22 | export const IDENTITY = [1, 0, 0, 1, 0, 0];
|
| 23 |
|
| 24 | export function multiply(m, n) {
|
| 25 | return [
|
| 26 | m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
|
| 27 | m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
|
| 28 | m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5],
|
| 29 | ];
|
| 30 | }
|
| 31 |
|
| 32 | export function apply(m, [x, y]) {
|
| 33 | return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
|
| 34 | }
|
| 35 |
|
| 36 | /** Parse a transform attribute into a matrix. */
|
| 37 | export function parseTransform(text) {
|
| 38 | let m = IDENTITY;
|
| 39 | if (!text) return m;
|
| 40 | const re = /(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/g;
|
| 41 | let match;
|
| 42 | while ((match = re.exec(text))) {
|
| 43 | const v = (match[2].match(/[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi) || []).map(Number);
|
| 44 | let t = IDENTITY;
|
| 45 | switch (match[1]) {
|
| 46 | case 'matrix': t = v.length === 6 ? v : IDENTITY; break;
|
| 47 | case 'translate': t = [1, 0, 0, 1, v[0] || 0, v[1] || 0]; break;
|
| 48 | case 'scale': t = [v[0] ?? 1, 0, 0, v[1] ?? v[0] ?? 1, 0, 0]; break;
|
| 49 | case 'rotate': {
|
| 50 | const a = (v[0] || 0) * Math.PI / 180;
|
| 51 | const r = [Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a), 0, 0];
|
| 52 | if (v.length >= 3) {
|
| 53 | t = multiply(multiply([1, 0, 0, 1, v[1], v[2]], r), [1, 0, 0, 1, -v[1], -v[2]]);
|
| 54 | } else {
|
| 55 | t = r;
|
| 56 | }
|
| 57 | break;
|
| 58 | }
|
| 59 | case 'skewX': t = [1, 0, Math.tan((v[0] || 0) * Math.PI / 180), 1, 0, 0]; break;
|
| 60 | case 'skewY': t = [1, Math.tan((v[0] || 0) * Math.PI / 180), 0, 1, 0, 0]; break;
|
| 61 | }
|
| 62 | m = multiply(m, t);
|
| 63 | }
|
| 64 | return m;
|
| 65 | }
|
| 66 |
|
| 67 | // --- Path data ---
|
| 68 |
|
| 69 | function tokenize(d) {
|
| 70 | const tokens = [];
|
| 71 | const re = /([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
|
| 72 | let m;
|
| 73 | while ((m = re.exec(d))) tokens.push(m[1] ?? Number(m[2]));
|
| 74 | return tokens;
|
| 75 | }
|
| 76 |
|
| 77 | const ARG_COUNTS = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
|
| 78 |
|
| 79 | /** Recursively flatten a cubic Bezier until it is within tol of its chord. */
|
| 80 | function flattenCubic(p0, p1, p2, p3, tol, out, depth = 0) {
|
| 81 | const dx = p3[0] - p0[0];
|
| 82 | const dy = p3[1] - p0[1];
|
| 83 | const len = Math.hypot(dx, dy);
|
| 84 | const dist = (p) => (len > 1e-12
|
| 85 | ? Math.abs((p[0] - p0[0]) * dy - (p[1] - p0[1]) * dx) / len
|
| 86 | : Math.hypot(p[0] - p0[0], p[1] - p0[1]));
|
| 87 | if (depth > 16 || Math.max(dist(p1), dist(p2)) <= tol) {
|
| 88 | out.push(p3);
|
| 89 | return;
|
| 90 | }
|
| 91 | const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
| 92 | const p01 = mid(p0, p1);
|
| 93 | const p12 = mid(p1, p2);
|
| 94 | const p23 = mid(p2, p3);
|
| 95 | const p012 = mid(p01, p12);
|
| 96 | const p123 = mid(p12, p23);
|
| 97 | const m = mid(p012, p123);
|
| 98 | flattenCubic(p0, p01, p012, m, tol, out, depth + 1);
|
| 99 | flattenCubic(m, p123, p23, p3, tol, out, depth + 1);
|
| 100 | }
|
| 101 |
|
| 102 | /** Flatten an SVG elliptical arc (endpoint parameterization). */
|
| 103 | function flattenArc(p0, rx, ry, phiDeg, largeArc, sweep, p1, tol, out) {
|
| 104 | rx = Math.abs(rx);
|
| 105 | ry = Math.abs(ry);
|
| 106 | if (rx === 0 || ry === 0) {
|
| 107 | out.push(p1);
|
| 108 | return;
|
| 109 | }
|
| 110 | const phi = phiDeg * Math.PI / 180;
|
| 111 | const cos = Math.cos(phi);
|
| 112 | const sin = Math.sin(phi);
|
| 113 | const dx2 = (p0[0] - p1[0]) / 2;
|
| 114 | const dy2 = (p0[1] - p1[1]) / 2;
|
| 115 | const x1p = cos * dx2 + sin * dy2;
|
| 116 | const y1p = -sin * dx2 + cos * dy2;
|
| 117 | const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
|
| 118 | if (lambda > 1) {
|
| 119 | rx *= Math.sqrt(lambda);
|
| 120 | ry *= Math.sqrt(lambda);
|
| 121 | }
|
| 122 | const num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
|
| 123 | const den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
|
| 124 | let coef = Math.sqrt(Math.max(0, num / den));
|
| 125 | if (largeArc === sweep) coef = -coef;
|
| 126 | const cxp = coef * (rx * y1p / ry);
|
| 127 | const cyp = coef * (-ry * x1p / rx);
|
| 128 | const cx = cos * cxp - sin * cyp + (p0[0] + p1[0]) / 2;
|
| 129 | const cy = sin * cxp + cos * cyp + (p0[1] + p1[1]) / 2;
|
| 130 | const angle = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
|
| 131 | const theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
|
| 132 | let dTheta = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
|
| 133 | if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
|
| 134 | if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
|
| 135 | // Chord error for a step angle a on radius r is r(1 - cos(a/2)).
|
| 136 | const r = Math.max(rx, ry);
|
| 137 | const maxStep = tol < r ? 2 * Math.acos(1 - tol / r) : Math.PI / 2;
|
| 138 | const n = Math.max(1, Math.ceil(Math.abs(dTheta) / maxStep));
|
| 139 | for (let i = 1; i <= n; i++) {
|
| 140 | const t = theta1 + dTheta * i / n;
|
| 141 | const x = rx * Math.cos(t);
|
| 142 | const y = ry * Math.sin(t);
|
| 143 | out.push(i === n ? p1 : [cos * x - sin * y + cx, sin * x + cos * y + cy]);
|
| 144 | }
|
| 145 | }
|
| 146 |
|
| 147 | /**
|
| 148 | * Flatten SVG path data into subpaths (arrays of [x, y]) in user units.
|
| 149 | * `tol` is the allowed deviation in the same units.
|
| 150 | */
|
| 151 | export function parsePathData(d, tol = 0.1) {
|
| 152 | const tokens = tokenize(d || '');
|
| 153 | const subpaths = [];
|
| 154 | let current = null;
|
| 155 | let pos = [0, 0];
|
| 156 | let start = [0, 0];
|
| 157 | let lastCtrl = null; // For S/s and T/t reflection
|
| 158 | let lastCmd = '';
|
| 159 | let i = 0;
|
| 160 | let cmd = null;
|
| 161 |
|
| 162 | const begin = (p) => {
|
| 163 | current = [p];
|
| 164 | subpaths.push(current);
|
| 165 | start = p;
|
| 166 | };
|
| 167 |
|
| 168 | while (i < tokens.length) {
|
| 169 | if (typeof tokens[i] === 'string') cmd = tokens[i++];
|
| 170 | else if (cmd == null) break;
|
| 171 | const upper = cmd.toUpperCase();
|
| 172 | const rel = cmd !== upper;
|
| 173 | const n = ARG_COUNTS[upper];
|
| 174 | if (upper === 'Z') {
|
| 175 | if (current && current.length) current.push([...start]);
|
| 176 | pos = [...start];
|
| 177 | current = null;
|
| 178 | lastCtrl = null;
|
| 179 | lastCmd = 'Z';
|
| 180 | continue;
|
| 181 | }
|
| 182 | if (i + n > tokens.length || tokens.slice(i, i + n).some((t) => typeof t !== 'number')) break;
|
| 183 | const a = tokens.slice(i, i + n);
|
| 184 | i += n;
|
| 185 | const pt = (x, y) => (rel ? [pos[0] + x, pos[1] + y] : [x, y]);
|
| 186 | if (!current && upper !== 'M') begin([...pos]);
|
| 187 |
|
| 188 | switch (upper) {
|
| 189 | case 'M': {
|
| 190 | pos = pt(a[0], a[1]);
|
| 191 | begin(pos);
|
| 192 | cmd = rel ? 'l' : 'L'; // Extra coordinate pairs are implicit linetos.
|
| 193 | lastCtrl = null;
|
| 194 | break;
|
| 195 | }
|
| 196 | case 'L': pos = pt(a[0], a[1]); current.push(pos); lastCtrl = null; break;
|
| 197 | case 'H': pos = [rel ? pos[0] + a[0] : a[0], pos[1]]; current.push(pos); lastCtrl = null; break;
|
| 198 | case 'V': pos = [pos[0], rel ? pos[1] + a[0] : a[0]]; current.push(pos); lastCtrl = null; break;
|
| 199 | case 'C': case 'S': {
|
| 200 | let c1;
|
| 201 | let c2;
|
| 202 | let end;
|
| 203 | if (upper === 'C') {
|
| 204 | c1 = pt(a[0], a[1]); c2 = pt(a[2], a[3]); end = pt(a[4], a[5]);
|
| 205 | } else {
|
| 206 | c1 = lastCtrl && 'CS'.includes(lastCmd) ? [2 * pos[0] - lastCtrl[0], 2 * pos[1] - lastCtrl[1]] : [...pos];
|
| 207 | c2 = pt(a[0], a[1]); end = pt(a[2], a[3]);
|
| 208 | }
|
| 209 | flattenCubic(pos, c1, c2, end, tol, current);
|
| 210 | lastCtrl = c2;
|
| 211 | pos = end;
|
| 212 | break;
|
| 213 | }
|
| 214 | case 'Q': case 'T': {
|
| 215 | let q;
|
| 216 | let end;
|
| 217 | if (upper === 'Q') {
|
| 218 | q = pt(a[0], a[1]); end = pt(a[2], a[3]);
|
| 219 | } else {
|
| 220 | q = lastCtrl && 'QT'.includes(lastCmd) ? [2 * pos[0] - lastCtrl[0], 2 * pos[1] - lastCtrl[1]] : [...pos];
|
| 221 | end = pt(a[0], a[1]);
|
| 222 | }
|
| 223 | // Elevate quadratic to cubic.
|
| 224 | const c1 = [pos[0] + 2 / 3 * (q[0] - pos[0]), pos[1] + 2 / 3 * (q[1] - pos[1])];
|
| 225 | const c2 = [end[0] + 2 / 3 * (q[0] - end[0]), end[1] + 2 / 3 * (q[1] - end[1])];
|
| 226 | flattenCubic(pos, c1, c2, end, tol, current);
|
| 227 | lastCtrl = q;
|
| 228 | pos = end;
|
| 229 | break;
|
| 230 | }
|
| 231 | case 'A': {
|
| 232 | const end = pt(a[5], a[6]);
|
| 233 | flattenArc(pos, a[0], a[1], a[2], Boolean(a[3]), Boolean(a[4]), end, tol, current);
|
| 234 | pos = end;
|
| 235 | lastCtrl = null;
|
| 236 | break;
|
| 237 | }
|
| 238 | }
|
| 239 | lastCmd = upper;
|
| 240 | }
|
| 241 | return subpaths.filter((s) => s.length >= 2);
|
| 242 | }
|
| 243 |
|
| 244 | function num(el, name, dflt = 0) {
|
| 245 | const v = parseFloat(el.getAttribute(name));
|
| 246 | return Number.isFinite(v) ? v : dflt;
|
| 247 | }
|
| 248 |
|
| 249 | /** Path data equivalent of a basic shape element, or null. */
|
| 250 | export function shapeToPathData(tag, el) {
|
| 251 | switch (tag) {
|
| 252 | case 'path': return el.getAttribute('d');
|
| 253 | case 'line': return `M${num(el, 'x1')},${num(el, 'y1')} L${num(el, 'x2')},${num(el, 'y2')}`;
|
| 254 | case 'polyline':
|
| 255 | case 'polygon': {
|
| 256 | const pts = (el.getAttribute('points') || '').trim();
|
| 257 | if (!pts) return null;
|
| 258 | return `M${pts}${tag === 'polygon' ? 'Z' : ''}`;
|
| 259 | }
|
| 260 | case 'rect': {
|
| 261 | const x = num(el, 'x');
|
| 262 | const y = num(el, 'y');
|
| 263 | const w = num(el, 'width');
|
| 264 | const h = num(el, 'height');
|
| 265 | if (w <= 0 || h <= 0) return null;
|
| 266 | let rx = el.hasAttribute('rx') ? num(el, 'rx') : null;
|
| 267 | let ry = el.hasAttribute('ry') ? num(el, 'ry') : null;
|
| 268 | rx = Math.min(rx ?? ry ?? 0, w / 2);
|
| 269 | ry = Math.min(ry ?? rx ?? 0, h / 2);
|
| 270 | if (rx <= 0 || ry <= 0) return `M${x},${y}H${x + w}V${y + h}H${x}Z`;
|
| 271 | return `M${x + rx},${y}H${x + w - rx}A${rx},${ry} 0 0 1 ${x + w},${y + ry}V${y + h - ry}` +
|
| 272 | `A${rx},${ry} 0 0 1 ${x + w - rx},${y + h}H${x + rx}A${rx},${ry} 0 0 1 ${x},${y + h - ry}` +
|
| 273 | `V${y + ry}A${rx},${ry} 0 0 1 ${x + rx},${y}Z`;
|
| 274 | }
|
| 275 | case 'circle':
|
| 276 | case 'ellipse': {
|
| 277 | const cx = num(el, 'cx');
|
| 278 | const cy = num(el, 'cy');
|
| 279 | const rx = tag === 'circle' ? num(el, 'r') : num(el, 'rx');
|
| 280 | const ry = tag === 'circle' ? rx : num(el, 'ry');
|
| 281 | if (rx <= 0 || ry <= 0) return null;
|
| 282 | return `M${cx + rx},${cy}A${rx},${ry} 0 1 1 ${cx - rx},${cy}A${rx},${ry} 0 1 1 ${cx + rx},${cy}Z`;
|
| 283 | }
|
| 284 | default: return null;
|
| 285 | }
|
| 286 | }
|
| 287 |
|
| 288 | function isHidden(el) {
|
| 289 | const style = el.getAttribute('style') || '';
|
| 290 | return el.getAttribute('display') === 'none' || el.getAttribute('visibility') === 'hidden' ||
|
| 291 | /display\s*:\s*none/.test(style) || /visibility\s*:\s*hidden/.test(style);
|
| 292 | }
|
| 293 |
|
| 294 | /** Document size in inches and the user-unit -> inch matrix for an <svg> root. */
|
| 295 | export function documentFrame(svg) {
|
| 296 | const vb = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
|
| 297 | const hasVb = vb.length === 4 && vb.every(Number.isFinite) && vb[2] > 0 && vb[3] > 0;
|
| 298 | let width = lengthToInches(svg.getAttribute('width'));
|
| 299 | let height = lengthToInches(svg.getAttribute('height'));
|
| 300 | if (width == null) width = hasVb ? vb[2] / PX_PER_INCH : 8.5;
|
| 301 | if (height == null) height = hasVb ? vb[3] / PX_PER_INCH : 11;
|
| 302 | let m = [1 / PX_PER_INCH, 0, 0, 1 / PX_PER_INCH, 0, 0];
|
| 303 | if (hasVb) {
|
| 304 | // preserveAspectRatio="xMidYMid meet" (the default)
|
| 305 | const s = Math.min(width / vb[2], height / vb[3]);
|
| 306 | const tx = (width - vb[2] * s) / 2 - vb[0] * s;
|
| 307 | const ty = (height - vb[3] * s) / 2 - vb[1] * s;
|
| 308 | m = [s, 0, 0, s, tx, ty];
|
| 309 | }
|
| 310 | return { width, height, matrix: m };
|
| 311 | }
|
| 312 |
|
| 313 | /**
|
| 314 | * Convert a parsed SVG document (DOM Document or root element) to polylines
|
| 315 | * in inches. Options: tolerance (in), autoRotate (rotate portrait pages to fit
|
| 316 | * the landscape travel area).
|
| 317 | */
|
| 318 | export function svgToPolylines(doc, { tolerance = 0.002, autoRotate = true } = {}) {
|
| 319 | const svg = doc.documentElement ?? doc;
|
| 320 | const { width, height, matrix } = documentFrame(svg);
|
| 321 | const rotate = autoRotate && height > width;
|
| 322 | // Rotate 90° counter-clockwise: page top edge goes to the machine's left side.
|
| 323 | const rootMatrix = rotate ? multiply([0, -1, 1, 0, 0, width], matrix) : matrix;
|
| 324 | const scale = Math.sqrt(Math.abs(rootMatrix[0] * rootMatrix[3] - rootMatrix[1] * rootMatrix[2]));
|
| 325 | const polylines = [];
|
| 326 |
|
| 327 | const walk = (el, m) => {
|
| 328 | const tag = (el.localName || el.nodeName || '').toLowerCase();
|
| 329 | if (SKIP_TAGS.has(tag) || isHidden(el)) return;
|
| 330 | const local = multiply(m, parseTransform(el.getAttribute('transform')));
|
| 331 | const d = shapeToPathData(tag, el);
|
| 332 | if (d) {
|
| 333 | const det = Math.sqrt(Math.abs(local[0] * local[3] - local[1] * local[2])) || scale;
|
| 334 | for (const sub of parsePathData(d, tolerance / det)) polylines.push(sub.map((p) => apply(local, p)));
|
| 335 | }
|
| 336 | for (const child of el.children ?? []) walk(child, local);
|
| 337 | };
|
| 338 | for (const child of svg.children ?? []) walk(child, rootMatrix);
|
| 339 |
|
| 340 | return {
|
| 341 | polylines,
|
| 342 | width: rotate ? height : width,
|
| 343 | height: rotate ? width : height,
|
| 344 | rotated: rotate,
|
| 345 | };
|
| 346 | }
|
| 347 |
|
| 348 | /** Join consecutive polylines whose ends meet within minGap (saves pen lifts). */
|
| 349 | export function joinPolylines(polylines, minGap = 0.006) {
|
| 350 | const out = [];
|
| 351 | for (const p of polylines) {
|
| 352 | const prev = out[out.length - 1];
|
| 353 | if (prev) {
|
| 354 | const end = prev[prev.length - 1];
|
| 355 | if (Math.hypot(end[0] - p[0][0], end[1] - p[0][1]) <= minGap) {
|
| 356 | prev.push(...p.slice(1));
|
| 357 | continue;
|
| 358 | }
|
| 359 | }
|
| 360 | out.push([...p]);
|
| 361 | }
|
| 362 | return out;
|
| 363 | }
|