@pooder/kit 2.0.0 → 3.0.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.
- package/CHANGELOG.md +11 -0
- package/dist/index.d.mts +246 -134
- package/dist/index.d.ts +246 -134
- package/dist/index.js +2051 -1045
- package/dist/index.mjs +2042 -1050
- package/package.json +3 -2
- package/src/CanvasService.ts +65 -0
- package/src/background.ts +156 -109
- package/src/coordinate.ts +49 -0
- package/src/dieline.ts +536 -336
- package/src/film.ts +120 -89
- package/src/geometry.ts +251 -38
- package/src/hole.ts +422 -286
- package/src/image.ts +374 -174
- package/src/index.ts +1 -0
- package/src/mirror.ts +86 -49
- package/src/ruler.ts +188 -118
- package/src/tracer.ts +372 -0
- package/src/white-ink.ts +186 -142
package/src/tracer.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Tracer Utility
|
|
3
|
+
* Converts raster images (URL/Base64) to SVG Path Data using Marching Squares algorithm.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface Point {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ImageTracer {
|
|
12
|
+
/**
|
|
13
|
+
* Main entry point: Traces an image URL to an SVG path string.
|
|
14
|
+
* @param imageUrl The URL or Base64 string of the image.
|
|
15
|
+
* @param options Configuration options.
|
|
16
|
+
*/
|
|
17
|
+
public static async trace(
|
|
18
|
+
imageUrl: string,
|
|
19
|
+
options: {
|
|
20
|
+
threshold?: number; // 0-255, default 128
|
|
21
|
+
simplifyTolerance?: number; // default 1.0
|
|
22
|
+
scale?: number; // Scale factor for the processing canvas, default 1.0 (or smaller for speed)
|
|
23
|
+
scaleToWidth?: number;
|
|
24
|
+
scaleToHeight?: number;
|
|
25
|
+
} = {},
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
const img = await this.loadImage(imageUrl);
|
|
28
|
+
const width = img.width;
|
|
29
|
+
const height = img.height;
|
|
30
|
+
|
|
31
|
+
// 1. Draw to canvas and get pixel data
|
|
32
|
+
const canvas = document.createElement("canvas");
|
|
33
|
+
canvas.width = width;
|
|
34
|
+
canvas.height = height;
|
|
35
|
+
const ctx = canvas.getContext("2d");
|
|
36
|
+
if (!ctx) throw new Error("Could not get 2D context");
|
|
37
|
+
|
|
38
|
+
ctx.drawImage(img, 0, 0);
|
|
39
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
40
|
+
|
|
41
|
+
// 2. Trace contours using Marching Squares
|
|
42
|
+
const points = this.marchingSquares(imageData, options.threshold ?? 10);
|
|
43
|
+
|
|
44
|
+
// 2.1 Scale points if target size is provided
|
|
45
|
+
let finalPoints = points;
|
|
46
|
+
if (options.scaleToWidth && options.scaleToHeight && points.length > 0) {
|
|
47
|
+
finalPoints = this.scalePoints(
|
|
48
|
+
points,
|
|
49
|
+
options.scaleToWidth,
|
|
50
|
+
options.scaleToHeight,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Simplify path
|
|
55
|
+
const simplifiedPoints = this.douglasPeucker(
|
|
56
|
+
finalPoints,
|
|
57
|
+
options.simplifyTolerance ?? 0.5,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// 4. Convert to SVG Path
|
|
61
|
+
return this.pointsToSVG(simplifiedPoints);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static loadImage(url: string): Promise<HTMLImageElement> {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const img = new Image();
|
|
67
|
+
img.crossOrigin = "Anonymous";
|
|
68
|
+
img.onload = () => resolve(img);
|
|
69
|
+
img.onerror = (e) => reject(e);
|
|
70
|
+
img.src = url;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Moore-Neighbor Tracing Algorithm
|
|
76
|
+
* More robust for irregular shapes than simple Marching Squares walker.
|
|
77
|
+
*/
|
|
78
|
+
private static marchingSquares(
|
|
79
|
+
imageData: ImageData,
|
|
80
|
+
alphaThreshold: number,
|
|
81
|
+
): Point[] {
|
|
82
|
+
const width = imageData.width;
|
|
83
|
+
const height = imageData.height;
|
|
84
|
+
const data = imageData.data;
|
|
85
|
+
|
|
86
|
+
// Use Luminance for solid check if Alpha is fully opaque
|
|
87
|
+
// Or check Alpha first, then Luminance?
|
|
88
|
+
// Let's assume:
|
|
89
|
+
// If pixel is transparent (Alpha <= threshold), it's empty.
|
|
90
|
+
// If pixel is opaque (Alpha > threshold):
|
|
91
|
+
// If it's white (Luminance > some_high_value), it's empty (background).
|
|
92
|
+
// Else it's solid.
|
|
93
|
+
// This supports black shapes on white background (JPG).
|
|
94
|
+
|
|
95
|
+
// Luminance = 0.299*R + 0.587*G + 0.114*B
|
|
96
|
+
// We treat "Dark" as solid? Or "Light" as solid?
|
|
97
|
+
// Usually "Content" is non-white on white background.
|
|
98
|
+
// Let's add a `luminanceThreshold` option?
|
|
99
|
+
// For now, let's hardcode a heuristic:
|
|
100
|
+
// If R,G,B are all > 240, treat as background (white).
|
|
101
|
+
|
|
102
|
+
const isSolid = (x: number, y: number): boolean => {
|
|
103
|
+
if (x < 0 || x >= width || y < 0 || y >= height) return false;
|
|
104
|
+
const index = (y * width + x) * 4;
|
|
105
|
+
const r = data[index];
|
|
106
|
+
const g = data[index + 1];
|
|
107
|
+
const b = data[index + 2];
|
|
108
|
+
const a = data[index + 3];
|
|
109
|
+
|
|
110
|
+
if (a <= alphaThreshold) return false;
|
|
111
|
+
|
|
112
|
+
// Check for White Background (approx)
|
|
113
|
+
// If average > 240, treat as empty
|
|
114
|
+
if (r > 240 && g > 240 && b > 240) return false;
|
|
115
|
+
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// 1. Find Starting Pixel (Scanline)
|
|
120
|
+
// We want the *largest* contour ideally, or the first one.
|
|
121
|
+
// For now, let's just find the first one.
|
|
122
|
+
// To support holes, we would need to keep scanning.
|
|
123
|
+
// But Moore Tracing follows a single contour.
|
|
124
|
+
|
|
125
|
+
let startX = -1;
|
|
126
|
+
let startY = -1;
|
|
127
|
+
|
|
128
|
+
// Gaussian Blur Simulation (Box Blur) to reduce noise?
|
|
129
|
+
// Or just simple neighbor check?
|
|
130
|
+
// Let's implement a simple noise filter in isSolid? No, isSolid is per pixel.
|
|
131
|
+
// Let's preprocess data? Too slow in JS?
|
|
132
|
+
// Let's just rely on threshold.
|
|
133
|
+
|
|
134
|
+
searchLoop: for (let y = 0; y < height; y++) {
|
|
135
|
+
for (let x = 0; x < width; x++) {
|
|
136
|
+
if (isSolid(x, y)) {
|
|
137
|
+
startX = x;
|
|
138
|
+
startY = y;
|
|
139
|
+
break searchLoop;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (startX === -1) return [];
|
|
145
|
+
|
|
146
|
+
const points: Point[] = [];
|
|
147
|
+
|
|
148
|
+
// Moore-Neighbor Tracing
|
|
149
|
+
// We enter from the Left (since we scan Left->Right), so "backtrack" is Left.
|
|
150
|
+
// B = (startX - 1, startY)
|
|
151
|
+
// P = (startX, startY)
|
|
152
|
+
|
|
153
|
+
let cx = startX;
|
|
154
|
+
let cy = startY;
|
|
155
|
+
|
|
156
|
+
// Start backtrack direction: Left (since we found it scanning from left)
|
|
157
|
+
// Directions: 0=Up, 1=UpRight, 2=Right, 3=DownRight, 4=Down, 5=DownLeft, 6=Left, 7=UpLeft
|
|
158
|
+
// Offsets for 8 neighbors starting from Up (0,-1) clockwise
|
|
159
|
+
const neighbors = [
|
|
160
|
+
{ x: 0, y: -1 },
|
|
161
|
+
{ x: 1, y: -1 },
|
|
162
|
+
{ x: 1, y: 0 },
|
|
163
|
+
{ x: 1, y: 1 },
|
|
164
|
+
{ x: 0, y: 1 },
|
|
165
|
+
{ x: -1, y: 1 },
|
|
166
|
+
{ x: -1, y: 0 },
|
|
167
|
+
{ x: -1, y: -1 },
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
// Backtrack is Left -> Index 6.
|
|
171
|
+
let backtrack = 6;
|
|
172
|
+
|
|
173
|
+
const maxSteps = width * height * 3;
|
|
174
|
+
let steps = 0;
|
|
175
|
+
|
|
176
|
+
do {
|
|
177
|
+
points.push({ x: cx, y: cy });
|
|
178
|
+
|
|
179
|
+
// Search for next solid neighbor in clockwise order, starting from backtrack
|
|
180
|
+
let found = false;
|
|
181
|
+
|
|
182
|
+
// We check 8 neighbors.
|
|
183
|
+
// Moore algorithm says: start from backtrack, go clockwise until you find a black pixel.
|
|
184
|
+
// The backtrack for the NEXT step will be the neighbor BEFORE the one we found.
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < 8; i++) {
|
|
187
|
+
// Index in neighbors array. Start from backtrack direction.
|
|
188
|
+
// Actually Moore algorithm typically starts from (backtrack + 1) % 8 ?
|
|
189
|
+
// Let's standard: Start searching clockwise from the pixel entered from.
|
|
190
|
+
|
|
191
|
+
const idx = (backtrack + 1 + i) % 8;
|
|
192
|
+
const nx = cx + neighbors[idx].x;
|
|
193
|
+
const ny = cy + neighbors[idx].y;
|
|
194
|
+
|
|
195
|
+
if (isSolid(nx, ny)) {
|
|
196
|
+
// Found next pixel P
|
|
197
|
+
cx = nx;
|
|
198
|
+
cy = ny;
|
|
199
|
+
// New backtrack is the neighbor pointing back to current P from previous P?
|
|
200
|
+
// No, backtrack is the empty neighbor immediately counter-clockwise from the new P.
|
|
201
|
+
// In our loop, it's the previous index (idx - 1).
|
|
202
|
+
backtrack = (idx + 4) % 8; // Actually, backtrack direction relative to New P is opposite?
|
|
203
|
+
// Let's strictly follow Moore:
|
|
204
|
+
// Entering P from direction D. Start scan from D-1 (or D+something).
|
|
205
|
+
// Let's use the property:
|
|
206
|
+
// We entered P from `idx`. The previous check `idx-1` was empty.
|
|
207
|
+
// So for the next step, we can start checking from `idx-3` (approx 90 deg back) or `idx-2`.
|
|
208
|
+
// Standard Moore: Backtrack = neighbor index that was empty previously.
|
|
209
|
+
// Here, `idx` is the direction FROM old P TO new P.
|
|
210
|
+
// The direction FROM new P TO old P is `(idx + 4) % 8`.
|
|
211
|
+
// We want to start scanning around new P.
|
|
212
|
+
// We start scanning from the neighbor that is "Left" of the incoming edge.
|
|
213
|
+
|
|
214
|
+
// Let's simplify: Start scanning from (EntryDirection + 5) % 8 ?
|
|
215
|
+
// EntryDirection is (idx). Backwards is (idx+4).
|
|
216
|
+
// We want to start from the white pixel we just passed.
|
|
217
|
+
// That was `(idx - 1)`.
|
|
218
|
+
// Direction FROM old P to (idx-1) is neighbors[idx-1].
|
|
219
|
+
// We want direction FROM new P to that same white pixel? No.
|
|
220
|
+
|
|
221
|
+
// Working Heuristic:
|
|
222
|
+
// Next search starts from (current_incoming_direction + 4 + 1) ?
|
|
223
|
+
// Let's set backtrack to point to the neighbor we entered from, then rotate CCW?
|
|
224
|
+
// Let's use: start search from `(idx + 5) % 8`.
|
|
225
|
+
// Why? idx is direction 0..7. Back is idx+4. +1 is clockwise.
|
|
226
|
+
// We want counter-clockwise.
|
|
227
|
+
|
|
228
|
+
backtrack = (idx + 4 + 1) % 8; // Start searching from neighbor "after" the one we came from (CCW)?
|
|
229
|
+
// Wait, loop above is Clockwise.
|
|
230
|
+
// To trace outer boundary counter-clockwise, we scan neighbors counter-clockwise?
|
|
231
|
+
// Or trace outer boundary clockwise, scan neighbors clockwise.
|
|
232
|
+
|
|
233
|
+
// Let's trace Clockwise.
|
|
234
|
+
// Scan neighbors Clockwise.
|
|
235
|
+
// Start scan from (IncomingDirection + 5) % 8. (Backwards + 1 CW).
|
|
236
|
+
backtrack = (idx + 4 + 1) % 8; // Backwards + 1 step CW.
|
|
237
|
+
|
|
238
|
+
// But wait, if we are tracing an 1-pixel line, we turn around (backtrack).
|
|
239
|
+
// Let's try `(idx + 5) % 8` if neighbors are ordered CW.
|
|
240
|
+
// neighbors[0] is Up. [2] is Right.
|
|
241
|
+
// If we move Right (idx=2). Back is Left (6).
|
|
242
|
+
// We want to check UpLeft (7), Up (0), UpRight (1)...
|
|
243
|
+
// So start from 7? That is 6+1.
|
|
244
|
+
// So `(idx + 4 + 1) % 8` seems correct.
|
|
245
|
+
|
|
246
|
+
found = true;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!found) {
|
|
252
|
+
// Isolated pixel
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
steps++;
|
|
257
|
+
} while ((cx !== startX || cy !== startY) && steps < maxSteps);
|
|
258
|
+
|
|
259
|
+
return points;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Douglas-Peucker Line Simplification
|
|
264
|
+
*/
|
|
265
|
+
private static douglasPeucker(points: Point[], tolerance: number): Point[] {
|
|
266
|
+
if (points.length <= 2) return points;
|
|
267
|
+
|
|
268
|
+
const sqTolerance = tolerance * tolerance;
|
|
269
|
+
let maxSqDist = 0;
|
|
270
|
+
let index = 0;
|
|
271
|
+
|
|
272
|
+
const first = points[0];
|
|
273
|
+
const last = points[points.length - 1];
|
|
274
|
+
|
|
275
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
276
|
+
const sqDist = this.getSqSegDist(points[i], first, last);
|
|
277
|
+
if (sqDist > maxSqDist) {
|
|
278
|
+
index = i;
|
|
279
|
+
maxSqDist = sqDist;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (maxSqDist > sqTolerance) {
|
|
284
|
+
// Check if closed loop?
|
|
285
|
+
// If closed loop, we shouldn't simplify start/end connection too much?
|
|
286
|
+
// Douglas-Peucker works on segments.
|
|
287
|
+
const left = this.douglasPeucker(points.slice(0, index + 1), tolerance);
|
|
288
|
+
const right = this.douglasPeucker(points.slice(index), tolerance);
|
|
289
|
+
return left.slice(0, left.length - 1).concat(right);
|
|
290
|
+
} else {
|
|
291
|
+
return [first, last];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private static getSqSegDist(p: Point, p1: Point, p2: Point): number {
|
|
296
|
+
let x = p1.x;
|
|
297
|
+
let y = p1.y;
|
|
298
|
+
let dx = p2.x - x;
|
|
299
|
+
let dy = p2.y - y;
|
|
300
|
+
|
|
301
|
+
if (dx !== 0 || dy !== 0) {
|
|
302
|
+
const t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy);
|
|
303
|
+
if (t > 1) {
|
|
304
|
+
x = p2.x;
|
|
305
|
+
y = p2.y;
|
|
306
|
+
} else if (t > 0) {
|
|
307
|
+
x += dx * t;
|
|
308
|
+
y += dy * t;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
dx = p.x - x;
|
|
313
|
+
dy = p.y - y;
|
|
314
|
+
|
|
315
|
+
return dx * dx + dy * dy;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private static scalePoints(
|
|
319
|
+
points: Point[],
|
|
320
|
+
targetWidth: number,
|
|
321
|
+
targetHeight: number,
|
|
322
|
+
): Point[] {
|
|
323
|
+
if (points.length === 0) return points;
|
|
324
|
+
|
|
325
|
+
// Find bounds
|
|
326
|
+
let minX = Infinity,
|
|
327
|
+
minY = Infinity,
|
|
328
|
+
maxX = -Infinity,
|
|
329
|
+
maxY = -Infinity;
|
|
330
|
+
for (const p of points) {
|
|
331
|
+
if (p.x < minX) minX = p.x;
|
|
332
|
+
if (p.y < minY) minY = p.y;
|
|
333
|
+
if (p.x > maxX) maxX = p.x;
|
|
334
|
+
if (p.y > maxY) maxY = p.y;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const srcW = maxX - minX;
|
|
338
|
+
const srcH = maxY - minY;
|
|
339
|
+
|
|
340
|
+
if (srcW === 0 || srcH === 0) return points;
|
|
341
|
+
|
|
342
|
+
const scaleX = targetWidth / srcW;
|
|
343
|
+
const scaleY = targetHeight / srcH;
|
|
344
|
+
|
|
345
|
+
// Scale and center? Or just scale?
|
|
346
|
+
// User usually wants to fit the shape into the box.
|
|
347
|
+
// Let's just scale and align top-left to 0,0 for now, or center it?
|
|
348
|
+
// Dieline usually expects centered shape?
|
|
349
|
+
// geometry.ts createBaseShape aligns path.position = center.
|
|
350
|
+
// So the path data coordinates should probably be relative to 0,0 or centered.
|
|
351
|
+
// Paper.js Path(pathData) creates path in original coordinates.
|
|
352
|
+
// If we return points in 0..targetWidth, 0..targetHeight, paper will create it there.
|
|
353
|
+
// geometry.ts will then center it.
|
|
354
|
+
|
|
355
|
+
return points.map((p) => ({
|
|
356
|
+
x: (p.x - minX) * scaleX,
|
|
357
|
+
y: (p.y - minY) * scaleY,
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private static pointsToSVG(points: Point[]): string {
|
|
362
|
+
if (points.length === 0) return "";
|
|
363
|
+
const head = points[0];
|
|
364
|
+
const tail = points.slice(1);
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
`M ${head.x} ${head.y} ` +
|
|
368
|
+
tail.map((p) => `L ${p.x} ${p.y}`).join(" ") +
|
|
369
|
+
" Z"
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|