web-to-print 0.1.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/app-globals-V2Kpy_OQ.js +5 -0
  3. package/dist/cjs/canvas-helpers-A6rp5rPD.js +765 -0
  4. package/dist/cjs/index-IFGFRm-i.js +1649 -0
  5. package/dist/cjs/index.cjs.js +232 -0
  6. package/dist/cjs/loader.cjs.js +13 -0
  7. package/dist/cjs/logo-BUX-b45R.js +18 -0
  8. package/dist/cjs/web-to-print.cjs.js +25 -0
  9. package/dist/cjs/wtp-editor_2.cjs.entry.js +12386 -0
  10. package/dist/cjs/wtp-logo-renderer.cjs.entry.js +353 -0
  11. package/dist/cjs/wtp-print-area-editor.cjs.entry.js +431 -0
  12. package/dist/collection/collection-manifest.json +16 -0
  13. package/dist/collection/components/wtp-editor/wtp-editor.css +124 -0
  14. package/dist/collection/components/wtp-editor/wtp-editor.js +1114 -0
  15. package/dist/collection/components/wtp-logo-renderer/wtp-logo-renderer.css +30 -0
  16. package/dist/collection/components/wtp-logo-renderer/wtp-logo-renderer.js +455 -0
  17. package/dist/collection/components/wtp-logo-upload/wtp-logo-upload.css +428 -0
  18. package/dist/collection/components/wtp-logo-upload/wtp-logo-upload.js +573 -0
  19. package/dist/collection/components/wtp-print-area-editor/wtp-print-area-editor.css +20 -0
  20. package/dist/collection/components/wtp-print-area-editor/wtp-print-area-editor.js +600 -0
  21. package/dist/collection/examples/schaeffler--big.svg +1 -0
  22. package/dist/collection/index.js +8 -0
  23. package/dist/collection/types/editor.js +1 -0
  24. package/dist/collection/types/index.js +2 -0
  25. package/dist/collection/types/labels.js +30 -0
  26. package/dist/collection/types/logo.js +13 -0
  27. package/dist/collection/utils/background-removal.js +717 -0
  28. package/dist/collection/utils/canvas-helpers.js +380 -0
  29. package/dist/collection/utils/format-detection.js +48 -0
  30. package/dist/collection/utils/html-render-helpers.js +106 -0
  31. package/dist/collection/utils/image-preview.js +54 -0
  32. package/dist/collection/utils/logo-validation.js +141 -0
  33. package/dist/collection/utils/pdf-export.js +224 -0
  34. package/dist/components/index.d.ts +35 -0
  35. package/dist/components/index.js +1 -0
  36. package/dist/components/p-5qCsRzlt.js +1 -0
  37. package/dist/components/p-Bn9gR_8e.js +1 -0
  38. package/dist/components/p-D8pVJRuX.js +1 -0
  39. package/dist/components/wtp-editor.d.ts +11 -0
  40. package/dist/components/wtp-editor.js +1 -0
  41. package/dist/components/wtp-logo-renderer.d.ts +11 -0
  42. package/dist/components/wtp-logo-renderer.js +1 -0
  43. package/dist/components/wtp-logo-upload.d.ts +11 -0
  44. package/dist/components/wtp-logo-upload.js +1 -0
  45. package/dist/components/wtp-print-area-editor.d.ts +11 -0
  46. package/dist/components/wtp-print-area-editor.js +1 -0
  47. package/dist/esm/app-globals-DQuL1Twl.js +3 -0
  48. package/dist/esm/canvas-helpers-CK8OAq2J.js +748 -0
  49. package/dist/esm/index-CUetmLbL.js +1641 -0
  50. package/dist/esm/index.js +228 -0
  51. package/dist/esm/loader.js +11 -0
  52. package/dist/esm/logo-D8pVJRuX.js +15 -0
  53. package/dist/esm/web-to-print.js +21 -0
  54. package/dist/esm/wtp-editor_2.entry.js +12383 -0
  55. package/dist/esm/wtp-logo-renderer.entry.js +351 -0
  56. package/dist/esm/wtp-print-area-editor.entry.js +429 -0
  57. package/dist/index.cjs.js +1 -0
  58. package/dist/index.js +1 -0
  59. package/dist/types/components/wtp-editor/wtp-editor.d.ts +101 -0
  60. package/dist/types/components/wtp-logo-renderer/wtp-logo-renderer.d.ts +55 -0
  61. package/dist/types/components/wtp-logo-upload/wtp-logo-upload.d.ts +76 -0
  62. package/dist/types/components/wtp-print-area-editor/wtp-print-area-editor.d.ts +43 -0
  63. package/dist/types/components.d.ts +507 -0
  64. package/dist/types/index.d.ts +11 -0
  65. package/dist/types/stencil-public-runtime.d.ts +1860 -0
  66. package/dist/types/types/editor.d.ts +79 -0
  67. package/dist/types/types/index.d.ts +5 -0
  68. package/dist/types/types/labels.d.ts +30 -0
  69. package/dist/types/types/logo.d.ts +47 -0
  70. package/dist/types/utils/background-removal.d.ts +95 -0
  71. package/dist/types/utils/canvas-helpers.d.ts +60 -0
  72. package/dist/types/utils/format-detection.d.ts +4 -0
  73. package/dist/types/utils/html-render-helpers.d.ts +44 -0
  74. package/dist/types/utils/image-preview.d.ts +13 -0
  75. package/dist/types/utils/logo-validation.d.ts +2 -0
  76. package/dist/types/utils/pdf-export.d.ts +32 -0
  77. package/dist/web-to-print/index.esm.js +1 -0
  78. package/dist/web-to-print/p-611ec561.entry.js +1 -0
  79. package/dist/web-to-print/p-703e4c52.entry.js +1 -0
  80. package/dist/web-to-print/p-CK8OAq2J.js +1 -0
  81. package/dist/web-to-print/p-CUetmLbL.js +2 -0
  82. package/dist/web-to-print/p-D8pVJRuX.js +1 -0
  83. package/dist/web-to-print/p-DQuL1Twl.js +1 -0
  84. package/dist/web-to-print/p-b532777b.entry.js +1 -0
  85. package/dist/web-to-print/web-to-print.esm.js +1 -0
  86. package/loader/cdn.js +1 -0
  87. package/loader/index.cjs.js +1 -0
  88. package/loader/index.d.ts +24 -0
  89. package/loader/index.es2017.js +1 -0
  90. package/loader/index.js +2 -0
  91. package/package.json +68 -0
  92. package/readme.md +490 -0
@@ -0,0 +1,717 @@
1
+ import { DEFAULT_BG_REMOVAL_CONFIG } from "../types";
2
+ /** Euclidean distance between two RGB colors. */
3
+ export function colorDistance(r1, g1, b1, r2, g2, b2) {
4
+ return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2);
5
+ }
6
+ /**
7
+ * Find the dominant color among a list of RGB samples.
8
+ *
9
+ * Phase 1: Coarse quantization (step 24) to find the candidate color cluster center.
10
+ * Phase 2: Count all samples within `tolerance` Euclidean RGB distance of that center.
11
+ * This two-phase approach handles JPEG artifacts and gradients that split across
12
+ * fine bucket boundaries.
13
+ *
14
+ * Returns [r, g, b] or null if no dominant color is found.
15
+ */
16
+ export function findDominantColor(samples, minRatio, tolerance = 60) {
17
+ if (samples.length === 0)
18
+ return null;
19
+ // Phase 1: coarse buckets to find candidate center
20
+ const step = 24;
21
+ const buckets = new Map();
22
+ for (const [r, g, b] of samples) {
23
+ const key = `${Math.floor(r / step)},${Math.floor(g / step)},${Math.floor(b / step)}`;
24
+ const bucket = buckets.get(key);
25
+ if (bucket !== undefined) {
26
+ bucket.count++;
27
+ bucket.sumR += r;
28
+ bucket.sumG += g;
29
+ bucket.sumB += b;
30
+ }
31
+ else {
32
+ buckets.set(key, { count: 1, sumR: r, sumG: g, sumB: b });
33
+ }
34
+ }
35
+ let maxBucket = null;
36
+ for (const bucket of buckets.values()) {
37
+ if (maxBucket === null || bucket.count > maxBucket.count) {
38
+ maxBucket = bucket;
39
+ }
40
+ }
41
+ if (maxBucket === null)
42
+ return null;
43
+ const candR = Math.round(maxBucket.sumR / maxBucket.count);
44
+ const candG = Math.round(maxBucket.sumG / maxBucket.count);
45
+ const candB = Math.round(maxBucket.sumB / maxBucket.count);
46
+ // Phase 2: count all samples within tolerance of the candidate color
47
+ let matchCount = 0;
48
+ let totalR = 0;
49
+ let totalG = 0;
50
+ let totalB = 0;
51
+ for (const [r, g, b] of samples) {
52
+ if (colorDistance(r, g, b, candR, candG, candB) <= tolerance) {
53
+ matchCount++;
54
+ totalR += r;
55
+ totalG += g;
56
+ totalB += b;
57
+ }
58
+ }
59
+ if (matchCount / samples.length < minRatio) {
60
+ return null;
61
+ }
62
+ return [
63
+ Math.round(totalR / matchCount),
64
+ Math.round(totalG / matchCount),
65
+ Math.round(totalB / matchCount),
66
+ ];
67
+ }
68
+ /**
69
+ * Sample all pixels along the 4 image edges.
70
+ * Returns array of [r, g, b] samples.
71
+ */
72
+ export function sampleImageEdges(pixels) {
73
+ const { data, width, height } = pixels;
74
+ const samples = [];
75
+ const sample = (x, y) => {
76
+ const idx = (y * width + x) * 4;
77
+ // Skip transparent pixels (e.g. PNGs with pre-existing transparent background)
78
+ if (data[idx + 3] === 0)
79
+ return;
80
+ samples.push([data[idx], data[idx + 1], data[idx + 2]]);
81
+ };
82
+ for (let x = 0; x < width; x++) {
83
+ sample(x, 0);
84
+ sample(x, height - 1);
85
+ }
86
+ for (let y = 1; y < height - 1; y++) {
87
+ sample(0, y);
88
+ sample(width - 1, y);
89
+ }
90
+ return samples;
91
+ }
92
+ /**
93
+ * Sample opaque pixels that border transparent pixels (the transparency boundary).
94
+ * Returns array of [r, g, b] samples.
95
+ */
96
+ export function sampleTransparencyBoundary(pixels) {
97
+ const { data, width, height } = pixels;
98
+ const samples = [];
99
+ for (let y = 0; y < height; y++) {
100
+ for (let x = 0; x < width; x++) {
101
+ const idx = (y * width + x) * 4;
102
+ if (data[idx + 3] === 0)
103
+ continue;
104
+ let hasTransparentNeighbor = false;
105
+ for (let dy = -1; dy <= 1 && !hasTransparentNeighbor; dy++) {
106
+ for (let dx = -1; dx <= 1 && !hasTransparentNeighbor; dx++) {
107
+ if (dx === 0 && dy === 0)
108
+ continue;
109
+ const nx = x + dx;
110
+ const ny = y + dy;
111
+ if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
112
+ if (data[(ny * width + nx) * 4 + 3] === 0) {
113
+ hasTransparentNeighbor = true;
114
+ }
115
+ }
116
+ }
117
+ }
118
+ if (hasTransparentNeighbor) {
119
+ samples.push([data[idx], data[idx + 1], data[idx + 2]]);
120
+ }
121
+ }
122
+ }
123
+ return samples;
124
+ }
125
+ /** Backward-compatible wrapper: detect dominant color along image edges. */
126
+ export function detectBackgroundColor(pixels, minEdgeRatio, tolerance = 60) {
127
+ return findDominantColor(sampleImageEdges(pixels), minEdgeRatio, tolerance);
128
+ }
129
+ /**
130
+ * BFS flood-fill from all edge pixels that match the background color within tolerance.
131
+ * Uses 8-directional connectivity for better coverage through diagonal gaps.
132
+ * Sets alpha to 0 for all connected matching pixels.
133
+ * Modifies `data` in place.
134
+ */
135
+ export function floodFillFromEdges(pixels, bgColor, tolerance) {
136
+ const { data, width, height } = pixels;
137
+ const total = width * height;
138
+ const visited = new Uint8Array(total);
139
+ // Index-pointer queue for O(n) BFS
140
+ const queue = new Int32Array(total);
141
+ let head = 0;
142
+ let tail = 0;
143
+ const [bgR, bgG, bgB] = bgColor;
144
+ const enqueueIfMatch = (x, y) => {
145
+ const pos = y * width + x;
146
+ if (visited[pos] !== 0)
147
+ return;
148
+ visited[pos] = 1;
149
+ const idx = pos * 4;
150
+ // Skip already-transparent pixels — don't let BFS spread through them
151
+ // into adjacent content (e.g. transparent-bg PNGs where RGB under alpha=0
152
+ // happens to match foreground colors)
153
+ if (data[idx + 3] === 0)
154
+ return;
155
+ const dist = colorDistance(data[idx], data[idx + 1], data[idx + 2], bgR, bgG, bgB);
156
+ if (dist <= tolerance) {
157
+ queue[tail++] = pos;
158
+ }
159
+ };
160
+ // Seed from all 4 edges
161
+ for (let x = 0; x < width; x++) {
162
+ enqueueIfMatch(x, 0);
163
+ enqueueIfMatch(x, height - 1);
164
+ }
165
+ for (let y = 1; y < height - 1; y++) {
166
+ enqueueIfMatch(0, y);
167
+ enqueueIfMatch(width - 1, y);
168
+ }
169
+ // BFS with 8-directional connectivity
170
+ while (head < tail) {
171
+ const pos = queue[head++];
172
+ data[pos * 4 + 3] = 0;
173
+ const x = pos % width;
174
+ const y = (pos - x) / width;
175
+ // Cardinal directions
176
+ if (x > 0)
177
+ enqueueIfMatch(x - 1, y);
178
+ if (x < width - 1)
179
+ enqueueIfMatch(x + 1, y);
180
+ if (y > 0)
181
+ enqueueIfMatch(x, y - 1);
182
+ if (y < height - 1)
183
+ enqueueIfMatch(x, y + 1);
184
+ // Diagonal directions
185
+ if (x > 0 && y > 0)
186
+ enqueueIfMatch(x - 1, y - 1);
187
+ if (x < width - 1 && y > 0)
188
+ enqueueIfMatch(x + 1, y - 1);
189
+ if (x > 0 && y < height - 1)
190
+ enqueueIfMatch(x - 1, y + 1);
191
+ if (x < width - 1 && y < height - 1)
192
+ enqueueIfMatch(x + 1, y + 1);
193
+ }
194
+ }
195
+ /**
196
+ * BFS flood-fill inward from the transparency boundary (opaque pixels adjacent to transparent).
197
+ * Removes connected pixels matching bgColor within tolerance.
198
+ * Modifies `data` in place. Returns the number of pixels removed.
199
+ */
200
+ export function floodFillFromBoundary(pixels, bgColor, tolerance) {
201
+ const { data, width, height } = pixels;
202
+ const total = width * height;
203
+ const visited = new Uint8Array(total);
204
+ const queue = new Int32Array(total);
205
+ let head = 0;
206
+ let tail = 0;
207
+ let removed = 0;
208
+ const [bgR, bgG, bgB] = bgColor;
209
+ const enqueueIfMatch = (x, y) => {
210
+ const pos = y * width + x;
211
+ if (visited[pos] !== 0)
212
+ return;
213
+ visited[pos] = 1;
214
+ const idx = pos * 4;
215
+ // Skip already transparent pixels
216
+ if (data[idx + 3] === 0)
217
+ return;
218
+ const dist = colorDistance(data[idx], data[idx + 1], data[idx + 2], bgR, bgG, bgB);
219
+ if (dist <= tolerance) {
220
+ queue[tail++] = pos;
221
+ }
222
+ };
223
+ // Seed: opaque pixels adjacent to transparent pixels
224
+ for (let y = 0; y < height; y++) {
225
+ for (let x = 0; x < width; x++) {
226
+ const pos = y * width + x;
227
+ const idx = pos * 4;
228
+ if (data[idx + 3] === 0) {
229
+ visited[pos] = 1;
230
+ continue;
231
+ }
232
+ let hasTransparentNeighbor = false;
233
+ for (let dy = -1; dy <= 1 && !hasTransparentNeighbor; dy++) {
234
+ for (let dx = -1; dx <= 1 && !hasTransparentNeighbor; dx++) {
235
+ if (dx === 0 && dy === 0)
236
+ continue;
237
+ const nx = x + dx;
238
+ const ny = y + dy;
239
+ if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
240
+ if (data[(ny * width + nx) * 4 + 3] === 0) {
241
+ hasTransparentNeighbor = true;
242
+ }
243
+ }
244
+ }
245
+ }
246
+ if (hasTransparentNeighbor) {
247
+ const dist = colorDistance(data[idx], data[idx + 1], data[idx + 2], bgR, bgG, bgB);
248
+ if (dist <= tolerance) {
249
+ visited[pos] = 1;
250
+ queue[tail++] = pos;
251
+ }
252
+ }
253
+ }
254
+ }
255
+ // BFS with 8-directional connectivity
256
+ while (head < tail) {
257
+ const pos = queue[head++];
258
+ data[pos * 4 + 3] = 0;
259
+ removed++;
260
+ const x = pos % width;
261
+ const y = (pos - x) / width;
262
+ if (x > 0)
263
+ enqueueIfMatch(x - 1, y);
264
+ if (x < width - 1)
265
+ enqueueIfMatch(x + 1, y);
266
+ if (y > 0)
267
+ enqueueIfMatch(x, y - 1);
268
+ if (y < height - 1)
269
+ enqueueIfMatch(x, y + 1);
270
+ if (x > 0 && y > 0)
271
+ enqueueIfMatch(x - 1, y - 1);
272
+ if (x < width - 1 && y > 0)
273
+ enqueueIfMatch(x + 1, y - 1);
274
+ if (x > 0 && y < height - 1)
275
+ enqueueIfMatch(x - 1, y + 1);
276
+ if (x < width - 1 && y < height - 1)
277
+ enqueueIfMatch(x + 1, y + 1);
278
+ }
279
+ return removed;
280
+ }
281
+ /**
282
+ * Remove all opaque pixels matching `color` within `tolerance` (color-key removal).
283
+ * Unlike flood-fill, this does not require connectivity — it removes matching pixels
284
+ * anywhere in the image. Useful for cleaning up trapped interior regions and bypassing
285
+ * anti-aliased fringes that block BFS-based approaches.
286
+ * Modifies `data` in place. Returns the number of pixels removed.
287
+ */
288
+ export function removeMatchingPixels(pixels, color, tolerance) {
289
+ const { data } = pixels;
290
+ const [cR, cG, cB] = color;
291
+ let removed = 0;
292
+ for (let i = 0; i < data.length; i += 4) {
293
+ if (data[i + 3] === 0)
294
+ continue;
295
+ if (colorDistance(data[i], data[i + 1], data[i + 2], cR, cG, cB) <= tolerance) {
296
+ data[i + 3] = 0;
297
+ removed++;
298
+ }
299
+ }
300
+ return removed;
301
+ }
302
+ /**
303
+ * Remove bg-colored pixel clusters that are fully enclosed by non-bg opaque pixels
304
+ * (no pixel in the cluster is adjacent to a transparent pixel). These are trapped
305
+ * interior regions — e.g. white background inside a letter 'O' — that edge flood fill
306
+ * can't reach because the foreground forms a closed boundary.
307
+ * Unlike removeMatchingPixels, this preserves bg-colored pixels at foreground edges
308
+ * (anti-aliased transitions), preventing the inner-pass safety check from failing.
309
+ * Modifies `data` in place. Returns the number of pixels removed.
310
+ */
311
+ export function removeEnclosedBackground(pixels, bgColor, tolerance) {
312
+ const { data, width, height } = pixels;
313
+ const total = width * height;
314
+ const [bgR, bgG, bgB] = bgColor;
315
+ // Mark bg-colored opaque pixels
316
+ const isBg = new Uint8Array(total);
317
+ for (let i = 0; i < total; i++) {
318
+ const idx = i * 4;
319
+ if (data[idx + 3] > 0 && colorDistance(data[idx], data[idx + 1], data[idx + 2], bgR, bgG, bgB) <= tolerance) {
320
+ isBg[i] = 1;
321
+ }
322
+ }
323
+ // Find connected components of bg-colored pixels via DFS
324
+ const compId = new Int32Array(total).fill(-1);
325
+ const touchesTransparent = [];
326
+ let nextId = 0;
327
+ for (let start = 0; start < total; start++) {
328
+ if (isBg[start] !== 1 || compId[start] !== -1)
329
+ continue;
330
+ const id = nextId++;
331
+ touchesTransparent.push(false);
332
+ const stack = [start];
333
+ compId[start] = id;
334
+ while (stack.length > 0) {
335
+ const pos = stack.pop();
336
+ const x = pos % width;
337
+ const y = (pos - x) / width;
338
+ for (let dy = -1; dy <= 1; dy++) {
339
+ for (let dx = -1; dx <= 1; dx++) {
340
+ if (dx === 0 && dy === 0)
341
+ continue;
342
+ const nx = x + dx;
343
+ const ny = y + dy;
344
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height)
345
+ continue;
346
+ const nPos = ny * width + nx;
347
+ if (data[nPos * 4 + 3] === 0) {
348
+ touchesTransparent[id] = true;
349
+ continue;
350
+ }
351
+ if (isBg[nPos] === 1 && compId[nPos] === -1) {
352
+ compId[nPos] = id;
353
+ stack.push(nPos);
354
+ }
355
+ }
356
+ }
357
+ }
358
+ }
359
+ // Remove pixels in enclosed components (not touching any transparent pixel)
360
+ let removed = 0;
361
+ for (let i = 0; i < total; i++) {
362
+ if (compId[i] !== -1 && !touchesTransparent[compId[i]]) {
363
+ data[i * 4 + 3] = 0;
364
+ removed++;
365
+ }
366
+ }
367
+ return removed;
368
+ }
369
+ /**
370
+ * Anti-alias edges between transparent (removed) and opaque (kept) pixels.
371
+ * For each opaque pixel adjacent to a transparent pixel, set partial alpha
372
+ * based on its color distance from the background — creating smooth transitions.
373
+ * Modifies `data` in place.
374
+ */
375
+ export function antiAliasEdges(pixels, bgColor, tolerance) {
376
+ const { data, width, height } = pixels;
377
+ const [bgR, bgG, bgB] = bgColor;
378
+ // Collect edge pixel positions first, then modify (avoid read-during-write)
379
+ const edgePositions = [];
380
+ for (let y = 0; y < height; y++) {
381
+ for (let x = 0; x < width; x++) {
382
+ const pos = y * width + x;
383
+ const idx = pos * 4;
384
+ // Skip already transparent pixels
385
+ if (data[idx + 3] === 0)
386
+ continue;
387
+ // Check 8 neighbors for at least one transparent pixel
388
+ let hasTransparentNeighbor = false;
389
+ for (let dy = -1; dy <= 1 && !hasTransparentNeighbor; dy++) {
390
+ for (let dx = -1; dx <= 1 && !hasTransparentNeighbor; dx++) {
391
+ if (dx === 0 && dy === 0)
392
+ continue;
393
+ const nx = x + dx;
394
+ const ny = y + dy;
395
+ if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
396
+ if (data[(ny * width + nx) * 4 + 3] === 0) {
397
+ hasTransparentNeighbor = true;
398
+ }
399
+ }
400
+ }
401
+ }
402
+ if (hasTransparentNeighbor) {
403
+ edgePositions.push(pos);
404
+ }
405
+ }
406
+ }
407
+ // Apply soft alpha to edge pixels based on color distance from background
408
+ for (const pos of edgePositions) {
409
+ const idx = pos * 4;
410
+ const dist = colorDistance(data[idx], data[idx + 1], data[idx + 2], bgR, bgG, bgB);
411
+ // Scale alpha: pixels close to bg become mostly transparent, far from bg stay opaque
412
+ const alpha = Math.min(255, Math.round(255 * (dist / tolerance)));
413
+ data[idx + 3] = alpha;
414
+ }
415
+ }
416
+ /**
417
+ * Compute the fraction of non-matching opaque pixels that sit on the transparency
418
+ * boundary (adjacent to a transparent pixel). A high fraction indicates the non-matching
419
+ * pixels are anti-aliased foreground edges, meaning the matching color IS the foreground
420
+ * content and should not be removed.
421
+ */
422
+ export function nonMatchingBoundaryFraction(pixels, color, tolerance) {
423
+ const { data, width, height } = pixels;
424
+ const [cR, cG, cB] = color;
425
+ let nonMatchTotal = 0;
426
+ let nonMatchAtBoundary = 0;
427
+ for (let y = 0; y < height; y++) {
428
+ for (let x = 0; x < width; x++) {
429
+ const idx = (y * width + x) * 4;
430
+ if (data[idx + 3] === 0)
431
+ continue;
432
+ if (colorDistance(data[idx], data[idx + 1], data[idx + 2], cR, cG, cB) <= tolerance)
433
+ continue;
434
+ nonMatchTotal++;
435
+ let adjTransparent = false;
436
+ for (let dy = -1; dy <= 1 && !adjTransparent; dy++) {
437
+ for (let dx = -1; dx <= 1 && !adjTransparent; dx++) {
438
+ if (dx === 0 && dy === 0)
439
+ continue;
440
+ const nx = x + dx;
441
+ const ny = y + dy;
442
+ if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
443
+ if (data[(ny * width + nx) * 4 + 3] === 0) {
444
+ adjTransparent = true;
445
+ }
446
+ }
447
+ }
448
+ }
449
+ if (adjTransparent)
450
+ nonMatchAtBoundary++;
451
+ }
452
+ }
453
+ return nonMatchTotal === 0 ? 0 : nonMatchAtBoundary / nonMatchTotal;
454
+ }
455
+ /**
456
+ * Compute the fraction of matching pixels in the largest connected component.
457
+ * Text consists of multiple disconnected components (individual letters);
458
+ * background panels are one large contiguous region.
459
+ * Uses 8-directional connectivity for consistency with flood fill.
460
+ */
461
+ export function largestMatchingComponentFraction(pixels, color, tolerance, matchCount) {
462
+ if (matchCount === 0)
463
+ return 0;
464
+ const { data, width, height } = pixels;
465
+ const total = width * height;
466
+ const [cR, cG, cB] = color;
467
+ const visited = new Uint8Array(total);
468
+ const queue = new Int32Array(total);
469
+ let largestSize = 0;
470
+ for (let start = 0; start < total; start++) {
471
+ if (visited[start] !== 0)
472
+ continue;
473
+ visited[start] = 1;
474
+ const startIdx = start * 4;
475
+ if (data[startIdx + 3] === 0)
476
+ continue;
477
+ if (colorDistance(data[startIdx], data[startIdx + 1], data[startIdx + 2], cR, cG, cB) > tolerance)
478
+ continue;
479
+ // BFS from this matching pixel
480
+ let head = 0;
481
+ let tail = 0;
482
+ queue[tail++] = start;
483
+ let size = 1;
484
+ while (head < tail) {
485
+ const pos = queue[head++];
486
+ const x = pos % width;
487
+ const y = (pos - x) / width;
488
+ for (let dy = -1; dy <= 1; dy++) {
489
+ for (let dx = -1; dx <= 1; dx++) {
490
+ if (dx === 0 && dy === 0)
491
+ continue;
492
+ const nx = x + dx;
493
+ const ny = y + dy;
494
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height)
495
+ continue;
496
+ const nPos = ny * width + nx;
497
+ if (visited[nPos] !== 0)
498
+ continue;
499
+ visited[nPos] = 1;
500
+ const nIdx = nPos * 4;
501
+ if (data[nIdx + 3] === 0)
502
+ continue;
503
+ if (colorDistance(data[nIdx], data[nIdx + 1], data[nIdx + 2], cR, cG, cB) > tolerance)
504
+ continue;
505
+ queue[tail++] = nPos;
506
+ size++;
507
+ }
508
+ }
509
+ }
510
+ if (size > largestSize)
511
+ largestSize = size;
512
+ // Early exit: single large region clearly dominates
513
+ if (largestSize >= matchCount * 0.8)
514
+ return largestSize / matchCount;
515
+ }
516
+ return largestSize / matchCount;
517
+ }
518
+ /**
519
+ * Find the bounding box of all opaque pixels (alpha > threshold).
520
+ * Returns [minX, minY, maxX, maxY] or null if no opaque pixels are found.
521
+ */
522
+ export function findOpaqueBoundingBox(pixels, alphaThreshold = 0) {
523
+ const { data, width, height } = pixels;
524
+ let minX = width;
525
+ let minY = height;
526
+ let maxX = -1;
527
+ let maxY = -1;
528
+ for (let y = 0; y < height; y++) {
529
+ for (let x = 0; x < width; x++) {
530
+ if (data[(y * width + x) * 4 + 3] > alphaThreshold) {
531
+ if (x < minX)
532
+ minX = x;
533
+ if (x > maxX)
534
+ maxX = x;
535
+ if (y < minY)
536
+ minY = y;
537
+ if (y > maxY)
538
+ maxY = y;
539
+ }
540
+ }
541
+ }
542
+ if (maxX === -1)
543
+ return null;
544
+ return [minX, minY, maxX, maxY];
545
+ }
546
+ function loadImageFromSource(source) {
547
+ return new Promise((resolve, reject) => {
548
+ const url = URL.createObjectURL(source);
549
+ const img = new Image();
550
+ img.onload = () => {
551
+ URL.revokeObjectURL(url);
552
+ resolve(img);
553
+ };
554
+ img.onerror = () => {
555
+ URL.revokeObjectURL(url);
556
+ reject(new Error('Failed to load image'));
557
+ };
558
+ img.src = url;
559
+ });
560
+ }
561
+ export async function removeBackground(source, config) {
562
+ const { tolerance, minEdgeRatio, autoCrop, autoCropPadding } = { ...DEFAULT_BG_REMOVAL_CONFIG, ...config };
563
+ const img = await loadImageFromSource(source);
564
+ const canvas = document.createElement('canvas');
565
+ canvas.width = img.naturalWidth;
566
+ canvas.height = img.naturalHeight;
567
+ const ctx = canvas.getContext('2d');
568
+ if (ctx === null)
569
+ throw new Error('Could not get canvas 2d context');
570
+ ctx.drawImage(img, 0, 0);
571
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
572
+ const pixels = { data: imageData.data, width: canvas.width, height: canvas.height };
573
+ // Pass 1: detect dominant color on image edges and flood-fill from edges.
574
+ // First check if the background is already transparent (e.g. PNG with alpha channel).
575
+ // If most edge pixels are transparent, there's no opaque background to remove.
576
+ const edgeSamples = sampleImageEdges(pixels);
577
+ const totalEdgePixels = 2 * pixels.width + 2 * Math.max(0, pixels.height - 2);
578
+ const opaqueEdgeRatio = totalEdgePixels > 0 ? edgeSamples.length / totalEdgePixels : 0;
579
+ let bgColor = null;
580
+ if (opaqueEdgeRatio >= 0.5) {
581
+ // Enough opaque edges to detect a background color
582
+ bgColor = findDominantColor(edgeSamples, minEdgeRatio, tolerance);
583
+ }
584
+ if (bgColor !== null) {
585
+ floodFillFromEdges(pixels, bgColor, tolerance);
586
+ // Pass 1b: remove trapped interior regions of the edge background color.
587
+ // After edge flood fill, pockets enclosed by foreground (e.g. white inside letter 'O')
588
+ // are not reached. Remove bg-colored clusters that don't touch any transparent pixel.
589
+ // This is more surgical than removeMatchingPixels — it preserves anti-aliased edges,
590
+ // preventing the inner-pass from misidentifying foreground as a removable layer.
591
+ removeEnclosedBackground(pixels, bgColor, tolerance);
592
+ }
593
+ // If no background was removed and no transparent pixels exist, there's nothing to do.
594
+ let hasTransparent = false;
595
+ for (let i = 0; i < pixels.data.length; i += 4) {
596
+ if (pixels.data[i + 3] === 0) {
597
+ hasTransparent = true;
598
+ break;
599
+ }
600
+ }
601
+ if (!hasTransparent) {
602
+ throw new Error('No dominant background color detected');
603
+ }
604
+ // Pass 2+: detect dominant color on the transparency boundary and remove by color-key.
605
+ // This removes inner background panels (e.g. colored rectangle behind a logo).
606
+ // Uses color-key removal instead of boundary flood fill to bypass anti-aliased fringes.
607
+ let lastBgColor = bgColor ?? [255, 255, 255];
608
+ for (let pass = 0; pass < 3; pass++) {
609
+ const boundarySamples = sampleTransparencyBoundary(pixels);
610
+ const innerColor = findDominantColor(boundarySamples, minEdgeRatio, tolerance);
611
+ if (innerColor === null)
612
+ break;
613
+ // Safety 1: if this color matches >90% of remaining opaque pixels, it's likely
614
+ // the actual foreground content, not an inner background layer — stop.
615
+ let opaqueCount = 0;
616
+ let matchCount = 0;
617
+ let matchMinX = pixels.width;
618
+ let matchMaxX = 0;
619
+ let matchMinY = pixels.height;
620
+ let matchMaxY = 0;
621
+ for (let i = 0; i < pixels.data.length; i += 4) {
622
+ if (pixels.data[i + 3] > 0) {
623
+ opaqueCount++;
624
+ if (colorDistance(pixels.data[i], pixels.data[i + 1], pixels.data[i + 2], innerColor[0], innerColor[1], innerColor[2]) <= tolerance) {
625
+ matchCount++;
626
+ const pixelIdx = i / 4;
627
+ const px = pixelIdx % pixels.width;
628
+ const py = (pixelIdx - px) / pixels.width;
629
+ if (px < matchMinX)
630
+ matchMinX = px;
631
+ if (px > matchMaxX)
632
+ matchMaxX = px;
633
+ if (py < matchMinY)
634
+ matchMinY = py;
635
+ if (py > matchMaxY)
636
+ matchMaxY = py;
637
+ }
638
+ }
639
+ }
640
+ if (opaqueCount === 0 || matchCount / opaqueCount > 0.9)
641
+ break;
642
+ // Safety 1b: if non-matching pixels are predominantly anti-aliased edges
643
+ // (adjacent to transparent), the matching color is the foreground content.
644
+ // For text-only logos after bg removal, the non-matching pixels are the thin
645
+ // gray anti-aliased ring around letters — nearly all at the transparency boundary.
646
+ // For legitimate panels, non-matching pixels are foreground content in the interior.
647
+ // Only check when the matching color covers a significant portion of content.
648
+ if (matchCount / opaqueCount > 0.4) {
649
+ const nmBoundaryFrac = nonMatchingBoundaryFraction(pixels, innerColor, tolerance);
650
+ if (nmBoundaryFrac > 0.5)
651
+ break;
652
+ }
653
+ // Safety 2: if most matching pixels are at the transparency boundary (thin shell),
654
+ // they're anti-aliased foreground edges, not a removable background layer.
655
+ // A true inner background extends deep past the boundary into the interior.
656
+ let boundaryMatchCount = 0;
657
+ for (const [r, g, b] of boundarySamples) {
658
+ if (colorDistance(r, g, b, innerColor[0], innerColor[1], innerColor[2]) <= tolerance) {
659
+ boundaryMatchCount++;
660
+ }
661
+ }
662
+ if (matchCount > 0 && boundaryMatchCount / matchCount > 0.7)
663
+ break;
664
+ // Safety 3: check spatial compactness. Background panels fill their bounding box
665
+ // densely (fill ratio well above 0.45), while foreground text/logos are sparse
666
+ // (letters with gaps give fill ratios well below 0.45).
667
+ const bboxArea = (matchMaxX - matchMinX + 1) * (matchMaxY - matchMinY + 1);
668
+ if (bboxArea > 0 && matchCount / bboxArea < 0.45)
669
+ break;
670
+ // Safety 4: check component fragmentation. Text consists of multiple disconnected
671
+ // components (individual letters), while a background panel is one large region.
672
+ // Only run this when fill ratio didn't catch it (the check is O(n)).
673
+ const largestFraction = largestMatchingComponentFraction(pixels, innerColor, tolerance, matchCount);
674
+ if (largestFraction < 0.8)
675
+ break;
676
+ const removed = removeMatchingPixels(pixels, innerColor, tolerance);
677
+ if (removed === 0)
678
+ break;
679
+ lastBgColor = innerColor;
680
+ }
681
+ antiAliasEdges(pixels, lastBgColor, tolerance);
682
+ ctx.putImageData(imageData, 0, 0);
683
+ // Auto-crop to opaque content bounding box
684
+ let outputCanvas = canvas;
685
+ if (autoCrop) {
686
+ const bbox = findOpaqueBoundingBox(pixels);
687
+ if (bbox !== null) {
688
+ const [bMinX, bMinY, bMaxX, bMaxY] = bbox;
689
+ const cropX = Math.max(0, bMinX - autoCropPadding);
690
+ const cropY = Math.max(0, bMinY - autoCropPadding);
691
+ const cropRight = Math.min(canvas.width, bMaxX + 1 + autoCropPadding);
692
+ const cropBottom = Math.min(canvas.height, bMaxY + 1 + autoCropPadding);
693
+ const cropW = cropRight - cropX;
694
+ const cropH = cropBottom - cropY;
695
+ if (cropW < canvas.width || cropH < canvas.height) {
696
+ const croppedCanvas = document.createElement('canvas');
697
+ croppedCanvas.width = cropW;
698
+ croppedCanvas.height = cropH;
699
+ const croppedCtx = croppedCanvas.getContext('2d');
700
+ if (croppedCtx !== null) {
701
+ croppedCtx.drawImage(canvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
702
+ outputCanvas = croppedCanvas;
703
+ }
704
+ }
705
+ }
706
+ }
707
+ const blob = await new Promise((resolve, reject) => {
708
+ outputCanvas.toBlob(b => {
709
+ if (b !== null)
710
+ resolve(b);
711
+ else
712
+ reject(new Error('Canvas toBlob failed'));
713
+ }, 'image/png');
714
+ });
715
+ const dataUrl = outputCanvas.toDataURL('image/png');
716
+ return { blob, dataUrl, width: outputCanvas.width, height: outputCanvas.height };
717
+ }