@pooder/kit 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,448 @@
1
+ "use strict";
2
+ /**
3
+ * Image Tracer Utility
4
+ * Converts raster images (URL/Base64) to SVG Path Data using Marching Squares algorithm.
5
+ */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.ImageTracer = void 0;
11
+ const paper_1 = __importDefault(require("paper"));
12
+ class ImageTracer {
13
+ /**
14
+ * Main entry point: Traces an image URL to an SVG path string.
15
+ * @param imageUrl The URL or Base64 string of the image.
16
+ * @param options Configuration options.
17
+ */
18
+ static async trace(imageUrl, options = {}) {
19
+ const img = await this.loadImage(imageUrl);
20
+ const width = img.width;
21
+ const height = img.height;
22
+ // 1. Draw to canvas and get pixel data
23
+ const canvas = document.createElement("canvas");
24
+ canvas.width = width;
25
+ canvas.height = height;
26
+ const ctx = canvas.getContext("2d");
27
+ if (!ctx)
28
+ throw new Error("Could not get 2D context");
29
+ ctx.drawImage(img, 0, 0);
30
+ const imageData = ctx.getImageData(0, 0, width, height);
31
+ // 2. Morphology processing
32
+ const threshold = options.threshold ?? 10;
33
+ // Adaptive radius: 3% of the image's largest dimension, at least 5px
34
+ const adaptiveRadius = Math.max(5, Math.floor(Math.max(width, height) * 0.02));
35
+ const radius = options.morphologyRadius ?? adaptiveRadius;
36
+ const expand = options.expand ?? 0;
37
+ // Add padding to the processing canvas to avoid edge clipping during dilation
38
+ // Padding should be at least the radius + expansion size
39
+ const padding = radius + expand + 2;
40
+ const paddedWidth = width + padding * 2;
41
+ const paddedHeight = height + padding * 2;
42
+ let mask = this.createMask(imageData, threshold, padding, paddedWidth, paddedHeight);
43
+ if (radius > 0) {
44
+ // 1. Primary Closing (Large Radius, Circular) to merge distant parts
45
+ mask = this.circularMorphology(mask, paddedWidth, paddedHeight, radius, "closing");
46
+ // 2. Fill internal holes to ensure we only get the overall outer contour
47
+ mask = this.fillHoles(mask, paddedWidth, paddedHeight);
48
+ // 3. Secondary Smoothing (Small Radius, Circular) to round off sharp corners
49
+ const smoothRadius = Math.max(2, Math.floor(radius * 0.3));
50
+ mask = this.circularMorphology(mask, paddedWidth, paddedHeight, smoothRadius, "closing");
51
+ }
52
+ else {
53
+ // Even if no smoothing radius, we usually want to fill holes for a dieline
54
+ mask = this.fillHoles(mask, paddedWidth, paddedHeight);
55
+ }
56
+ // 4. Expand (Dilation) - Apply safety distance
57
+ if (expand > 0) {
58
+ mask = this.circularMorphology(mask, paddedWidth, paddedHeight, expand, "dilate");
59
+ }
60
+ // 5. Trace contours from the unified mask
61
+ const allContourPoints = this.traceAllContours(mask, paddedWidth, paddedHeight);
62
+ if (allContourPoints.length === 0) {
63
+ // Fallback: Return a rectangular outline matching dimensions
64
+ const w = options.scaleToWidth ?? width;
65
+ const h = options.scaleToHeight ?? height;
66
+ return `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`;
67
+ }
68
+ // 6. Select the largest contour to ensure a single, consistent overall shape
69
+ const primaryContour = allContourPoints.sort((a, b) => b.length - a.length)[0];
70
+ // 7. Restore coordinates (remove padding)
71
+ const unpaddedPoints = primaryContour.map(p => ({
72
+ x: p.x - padding,
73
+ y: p.y - padding
74
+ }));
75
+ // 8. Find bounds for the selected contour
76
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
77
+ for (const p of unpaddedPoints) {
78
+ if (p.x < minX)
79
+ minX = p.x;
80
+ if (p.y < minY)
81
+ minY = p.y;
82
+ if (p.x > maxX)
83
+ maxX = p.x;
84
+ if (p.y > maxY)
85
+ maxY = p.y;
86
+ }
87
+ const globalBounds = {
88
+ minX,
89
+ minY,
90
+ width: maxX - minX,
91
+ height: maxY - minY,
92
+ };
93
+ // 9. Post-processing (Scale)
94
+ let finalPoints = unpaddedPoints;
95
+ if (options.scaleToWidth && options.scaleToHeight) {
96
+ finalPoints = this.scalePoints(unpaddedPoints, options.scaleToWidth, options.scaleToHeight, globalBounds);
97
+ }
98
+ // 10. Simplify and Generate SVG
99
+ const useSmoothing = options.smoothing !== false; // Default true
100
+ if (useSmoothing) {
101
+ return this.pointsToSVGPaper(finalPoints, options.simplifyTolerance ?? 2.5);
102
+ }
103
+ else {
104
+ const simplifiedPoints = this.douglasPeucker(finalPoints, options.simplifyTolerance ?? 2.0);
105
+ return this.pointsToSVG(simplifiedPoints);
106
+ }
107
+ }
108
+ static createMask(imageData, threshold, padding, paddedWidth, paddedHeight) {
109
+ const { width, height, data } = imageData;
110
+ const mask = new Uint8Array(paddedWidth * paddedHeight);
111
+ // 1. Detect if the image has transparency (any pixel with alpha < 255)
112
+ let hasTransparency = false;
113
+ for (let i = 3; i < data.length; i += 4) {
114
+ if (data[i] < 255) {
115
+ hasTransparency = true;
116
+ break;
117
+ }
118
+ }
119
+ // 2. Binarize based on alpha or luminance
120
+ for (let y = 0; y < height; y++) {
121
+ for (let x = 0; x < width; x++) {
122
+ const srcIdx = (y * width + x) * 4;
123
+ const r = data[srcIdx];
124
+ const g = data[srcIdx + 1];
125
+ const b = data[srcIdx + 2];
126
+ const a = data[srcIdx + 3];
127
+ const destIdx = (y + padding) * paddedWidth + (x + padding);
128
+ if (hasTransparency) {
129
+ if (a > threshold) {
130
+ mask[destIdx] = 1;
131
+ }
132
+ }
133
+ else {
134
+ if (!(r > 240 && g > 240 && b > 240)) {
135
+ mask[destIdx] = 1;
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return mask;
141
+ }
142
+ /**
143
+ * Fast circular morphology using a distance-transform inspired separable approach.
144
+ * O(N * R) complexity, where R is the radius.
145
+ */
146
+ static circularMorphology(mask, width, height, radius, op) {
147
+ const dilate = (m, r) => {
148
+ const horizontalDist = new Int32Array(width * height);
149
+ // Horizontal pass: dist to nearest solid pixel in row
150
+ for (let y = 0; y < height; y++) {
151
+ let lastSolid = -r * 2;
152
+ for (let x = 0; x < width; x++) {
153
+ if (m[y * width + x])
154
+ lastSolid = x;
155
+ horizontalDist[y * width + x] = x - lastSolid;
156
+ }
157
+ lastSolid = width + r * 2;
158
+ for (let x = width - 1; x >= 0; x--) {
159
+ if (m[y * width + x])
160
+ lastSolid = x;
161
+ horizontalDist[y * width + x] = Math.min(horizontalDist[y * width + x], lastSolid - x);
162
+ }
163
+ }
164
+ const result = new Uint8Array(width * height);
165
+ const r2 = r * r;
166
+ // Vertical pass: check Euclidean distance using precomputed horizontal distances
167
+ for (let x = 0; x < width; x++) {
168
+ for (let y = 0; y < height; y++) {
169
+ let found = false;
170
+ const minY = Math.max(0, y - r);
171
+ const maxY = Math.min(height - 1, y + r);
172
+ for (let dy = minY; dy <= maxY; dy++) {
173
+ const dY = dy - y;
174
+ const hDist = horizontalDist[dy * width + x];
175
+ if (hDist * hDist + dY * dY <= r2) {
176
+ found = true;
177
+ break;
178
+ }
179
+ }
180
+ if (found)
181
+ result[y * width + x] = 1;
182
+ }
183
+ }
184
+ return result;
185
+ };
186
+ const erode = (m, r) => {
187
+ // Erosion is dilation of the inverted mask
188
+ const inverted = new Uint8Array(m.length);
189
+ for (let i = 0; i < m.length; i++)
190
+ inverted[i] = m[i] ? 0 : 1;
191
+ const dilatedInverted = dilate(inverted, r);
192
+ const result = new Uint8Array(m.length);
193
+ for (let i = 0; i < m.length; i++)
194
+ result[i] = dilatedInverted[i] ? 0 : 1;
195
+ return result;
196
+ };
197
+ switch (op) {
198
+ case "dilate":
199
+ return dilate(mask, radius);
200
+ case "erode":
201
+ return erode(mask, radius);
202
+ case "closing":
203
+ return erode(dilate(mask, radius), radius);
204
+ case "opening":
205
+ return dilate(erode(mask, radius), radius);
206
+ default:
207
+ return mask;
208
+ }
209
+ }
210
+ /**
211
+ * Fills internal holes in the binary mask using flood fill from edges.
212
+ */
213
+ static fillHoles(mask, width, height) {
214
+ const background = new Uint8Array(width * height);
215
+ const queue = [];
216
+ // Add all edge pixels that are 0 to the queue
217
+ for (let x = 0; x < width; x++) {
218
+ if (mask[x] === 0) {
219
+ background[x] = 1;
220
+ queue.push([x, 0]);
221
+ }
222
+ const lastRow = (height - 1) * width + x;
223
+ if (mask[lastRow] === 0) {
224
+ background[lastRow] = 1;
225
+ queue.push([x, height - 1]);
226
+ }
227
+ }
228
+ for (let y = 1; y < height - 1; y++) {
229
+ if (mask[y * width] === 0) {
230
+ background[y * width] = 1;
231
+ queue.push([0, y]);
232
+ }
233
+ if (mask[y * width + width - 1] === 0) {
234
+ background[y * width + width - 1] = 1;
235
+ queue.push([width - 1, y]);
236
+ }
237
+ }
238
+ // Flood fill from the edges to find all background pixels
239
+ const dirs = [
240
+ [0, 1],
241
+ [0, -1],
242
+ [1, 0],
243
+ [-1, 0],
244
+ ];
245
+ let head = 0;
246
+ while (head < queue.length) {
247
+ const [cx, cy] = queue[head++];
248
+ for (const [dx, dy] of dirs) {
249
+ const nx = cx + dx;
250
+ const ny = cy + dy;
251
+ if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
252
+ const nidx = ny * width + nx;
253
+ if (mask[nidx] === 0 && background[nidx] === 0) {
254
+ background[nidx] = 1;
255
+ queue.push([nx, ny]);
256
+ }
257
+ }
258
+ }
259
+ }
260
+ // Any pixel that is NOT reachable from the background is part of the "filled" mask
261
+ const filledMask = new Uint8Array(width * height);
262
+ for (let i = 0; i < width * height; i++) {
263
+ filledMask[i] = background[i] === 0 ? 1 : 0;
264
+ }
265
+ return filledMask;
266
+ }
267
+ /**
268
+ * Traces all contours in the mask with optimized start-point detection
269
+ */
270
+ static traceAllContours(mask, width, height) {
271
+ const visited = new Uint8Array(width * height);
272
+ const allContours = [];
273
+ for (let y = 0; y < height; y++) {
274
+ for (let x = 0; x < width; x++) {
275
+ const idx = y * width + x;
276
+ if (mask[idx] && !visited[idx]) {
277
+ // Only start a new trace if it's a potential outer boundary (left edge)
278
+ const isLeftEdge = x === 0 || mask[idx - 1] === 0;
279
+ if (isLeftEdge) {
280
+ const contour = this.marchingSquares(mask, visited, x, y, width, height);
281
+ if (contour.length > 2) {
282
+ allContours.push(contour);
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ return allContours;
289
+ }
290
+ static loadImage(url) {
291
+ return new Promise((resolve, reject) => {
292
+ const img = new Image();
293
+ img.crossOrigin = "Anonymous";
294
+ img.onload = () => resolve(img);
295
+ img.onerror = (e) => reject(e);
296
+ img.src = url;
297
+ });
298
+ }
299
+ /**
300
+ * Moore-Neighbor Tracing Algorithm
301
+ * More robust for irregular shapes than simple Marching Squares walker.
302
+ */
303
+ static marchingSquares(mask, visited, startX, startY, width, height) {
304
+ const isSolid = (x, y) => {
305
+ if (x < 0 || x >= width || y < 0 || y >= height)
306
+ return false;
307
+ return mask[y * width + x] === 1;
308
+ };
309
+ const points = [];
310
+ // Moore-Neighbor Tracing
311
+ // We enter from the Left (since we scan Left->Right), so "backtrack" is Left.
312
+ // B = (startX - 1, startY)
313
+ // P = (startX, startY)
314
+ let cx = startX;
315
+ let cy = startY;
316
+ // Start backtrack direction: Left (since we found it scanning from left)
317
+ // Directions: 0=Up, 1=UpRight, 2=Right, 3=DownRight, 4=Down, 5=DownLeft, 6=Left, 7=UpLeft
318
+ // Offsets for 8 neighbors starting from Up (0,-1) clockwise
319
+ const neighbors = [
320
+ { x: 0, y: -1 },
321
+ { x: 1, y: -1 },
322
+ { x: 1, y: 0 },
323
+ { x: 1, y: 1 },
324
+ { x: 0, y: 1 },
325
+ { x: -1, y: 1 },
326
+ { x: -1, y: 0 },
327
+ { x: -1, y: -1 },
328
+ ];
329
+ // Backtrack is Left -> Index 6.
330
+ let backtrack = 6;
331
+ const maxSteps = width * height * 3;
332
+ let steps = 0;
333
+ do {
334
+ points.push({ x: cx, y: cy });
335
+ visited[cy * width + cx] = 1; // Mark as visited to avoid re-starting here
336
+ // Search for next solid neighbor in clockwise order, starting from backtrack
337
+ let found = false;
338
+ for (let i = 0; i < 8; i++) {
339
+ const idx = (backtrack + 1 + i) % 8;
340
+ const nx = cx + neighbors[idx].x;
341
+ const ny = cy + neighbors[idx].y;
342
+ if (isSolid(nx, ny)) {
343
+ cx = nx;
344
+ cy = ny;
345
+ backtrack = (idx + 4 + 1) % 8;
346
+ found = true;
347
+ break;
348
+ }
349
+ }
350
+ if (!found)
351
+ break;
352
+ steps++;
353
+ } while ((cx !== startX || cy !== startY) && steps < maxSteps);
354
+ return points;
355
+ }
356
+ /**
357
+ * Douglas-Peucker Line Simplification
358
+ */
359
+ static douglasPeucker(points, tolerance) {
360
+ if (points.length <= 2)
361
+ return points;
362
+ const sqTolerance = tolerance * tolerance;
363
+ let maxSqDist = 0;
364
+ let index = 0;
365
+ const first = points[0];
366
+ const last = points[points.length - 1];
367
+ for (let i = 1; i < points.length - 1; i++) {
368
+ const sqDist = this.getSqSegDist(points[i], first, last);
369
+ if (sqDist > maxSqDist) {
370
+ index = i;
371
+ maxSqDist = sqDist;
372
+ }
373
+ }
374
+ if (maxSqDist > sqTolerance) {
375
+ // Check if closed loop?
376
+ // If closed loop, we shouldn't simplify start/end connection too much?
377
+ // Douglas-Peucker works on segments.
378
+ const left = this.douglasPeucker(points.slice(0, index + 1), tolerance);
379
+ const right = this.douglasPeucker(points.slice(index), tolerance);
380
+ return left.slice(0, left.length - 1).concat(right);
381
+ }
382
+ else {
383
+ return [first, last];
384
+ }
385
+ }
386
+ static getSqSegDist(p, p1, p2) {
387
+ let x = p1.x;
388
+ let y = p1.y;
389
+ let dx = p2.x - x;
390
+ let dy = p2.y - y;
391
+ if (dx !== 0 || dy !== 0) {
392
+ const t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy);
393
+ if (t > 1) {
394
+ x = p2.x;
395
+ y = p2.y;
396
+ }
397
+ else if (t > 0) {
398
+ x += dx * t;
399
+ y += dy * t;
400
+ }
401
+ }
402
+ dx = p.x - x;
403
+ dy = p.y - y;
404
+ return dx * dx + dy * dy;
405
+ }
406
+ static scalePoints(points, targetWidth, targetHeight, bounds) {
407
+ if (points.length === 0)
408
+ return points;
409
+ if (bounds.width === 0 || bounds.height === 0)
410
+ return points;
411
+ const scaleX = targetWidth / bounds.width;
412
+ const scaleY = targetHeight / bounds.height;
413
+ return points.map((p) => ({
414
+ x: (p.x - bounds.minX) * scaleX,
415
+ y: (p.y - bounds.minY) * scaleY,
416
+ }));
417
+ }
418
+ static pointsToSVG(points) {
419
+ if (points.length === 0)
420
+ return "";
421
+ const head = points[0];
422
+ const tail = points.slice(1);
423
+ return (`M ${head.x} ${head.y} ` +
424
+ tail.map((p) => `L ${p.x} ${p.y}`).join(" ") +
425
+ " Z");
426
+ }
427
+ static ensurePaper() {
428
+ if (!paper_1.default.project) {
429
+ paper_1.default.setup(new paper_1.default.Size(100, 100));
430
+ }
431
+ }
432
+ static pointsToSVGPaper(points, tolerance) {
433
+ if (points.length < 3)
434
+ return this.pointsToSVG(points);
435
+ this.ensurePaper();
436
+ // Create Path
437
+ const path = new paper_1.default.Path({
438
+ segments: points.map(p => [p.x, p.y]),
439
+ closed: true
440
+ });
441
+ // Simplify
442
+ path.simplify(tolerance);
443
+ const data = path.pathData;
444
+ path.remove();
445
+ return data;
446
+ }
447
+ }
448
+ exports.ImageTracer = ImageTracer;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseLengthToMm = parseLengthToMm;
4
+ exports.formatMm = formatMm;
5
+ const coordinate_1 = require("./coordinate");
6
+ function parseLengthToMm(input, defaultUnit) {
7
+ if (typeof input === "number") {
8
+ if (!Number.isFinite(input))
9
+ return 0;
10
+ return coordinate_1.Coordinate.convertUnit(input, defaultUnit, "mm");
11
+ }
12
+ const raw = input.trim();
13
+ if (!raw)
14
+ return 0;
15
+ const match = raw.match(/^([+-]?\d+(?:\.\d+)?)\s*(px|mm|cm|in)?$/i);
16
+ if (!match)
17
+ return 0;
18
+ const value = Number(match[1]);
19
+ if (!Number.isFinite(value))
20
+ return 0;
21
+ const unit = match[2]?.toLowerCase() ?? defaultUnit;
22
+ return coordinate_1.Coordinate.convertUnit(value, unit, "mm");
23
+ }
24
+ function formatMm(valueMm, displayUnit, fractionDigits = 2) {
25
+ if (!Number.isFinite(valueMm))
26
+ return "0";
27
+ const value = coordinate_1.Coordinate.convertUnit(valueMm, "mm", displayUnit);
28
+ const rounded = Number(value.toFixed(fractionDigits));
29
+ return rounded.toString();
30
+ }