CI Log in

zach/dotter

src/svg.js 363 lines · 13.3 KB · javascript
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
}