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.
- package/LICENSE +21 -0
- package/dist/cjs/app-globals-V2Kpy_OQ.js +5 -0
- package/dist/cjs/canvas-helpers-A6rp5rPD.js +765 -0
- package/dist/cjs/index-IFGFRm-i.js +1649 -0
- package/dist/cjs/index.cjs.js +232 -0
- package/dist/cjs/loader.cjs.js +13 -0
- package/dist/cjs/logo-BUX-b45R.js +18 -0
- package/dist/cjs/web-to-print.cjs.js +25 -0
- package/dist/cjs/wtp-editor_2.cjs.entry.js +12386 -0
- package/dist/cjs/wtp-logo-renderer.cjs.entry.js +353 -0
- package/dist/cjs/wtp-print-area-editor.cjs.entry.js +431 -0
- package/dist/collection/collection-manifest.json +16 -0
- package/dist/collection/components/wtp-editor/wtp-editor.css +124 -0
- package/dist/collection/components/wtp-editor/wtp-editor.js +1114 -0
- package/dist/collection/components/wtp-logo-renderer/wtp-logo-renderer.css +30 -0
- package/dist/collection/components/wtp-logo-renderer/wtp-logo-renderer.js +455 -0
- package/dist/collection/components/wtp-logo-upload/wtp-logo-upload.css +428 -0
- package/dist/collection/components/wtp-logo-upload/wtp-logo-upload.js +573 -0
- package/dist/collection/components/wtp-print-area-editor/wtp-print-area-editor.css +20 -0
- package/dist/collection/components/wtp-print-area-editor/wtp-print-area-editor.js +600 -0
- package/dist/collection/examples/schaeffler--big.svg +1 -0
- package/dist/collection/index.js +8 -0
- package/dist/collection/types/editor.js +1 -0
- package/dist/collection/types/index.js +2 -0
- package/dist/collection/types/labels.js +30 -0
- package/dist/collection/types/logo.js +13 -0
- package/dist/collection/utils/background-removal.js +717 -0
- package/dist/collection/utils/canvas-helpers.js +380 -0
- package/dist/collection/utils/format-detection.js +48 -0
- package/dist/collection/utils/html-render-helpers.js +106 -0
- package/dist/collection/utils/image-preview.js +54 -0
- package/dist/collection/utils/logo-validation.js +141 -0
- package/dist/collection/utils/pdf-export.js +224 -0
- package/dist/components/index.d.ts +35 -0
- package/dist/components/index.js +1 -0
- package/dist/components/p-5qCsRzlt.js +1 -0
- package/dist/components/p-Bn9gR_8e.js +1 -0
- package/dist/components/p-D8pVJRuX.js +1 -0
- package/dist/components/wtp-editor.d.ts +11 -0
- package/dist/components/wtp-editor.js +1 -0
- package/dist/components/wtp-logo-renderer.d.ts +11 -0
- package/dist/components/wtp-logo-renderer.js +1 -0
- package/dist/components/wtp-logo-upload.d.ts +11 -0
- package/dist/components/wtp-logo-upload.js +1 -0
- package/dist/components/wtp-print-area-editor.d.ts +11 -0
- package/dist/components/wtp-print-area-editor.js +1 -0
- package/dist/esm/app-globals-DQuL1Twl.js +3 -0
- package/dist/esm/canvas-helpers-CK8OAq2J.js +748 -0
- package/dist/esm/index-CUetmLbL.js +1641 -0
- package/dist/esm/index.js +228 -0
- package/dist/esm/loader.js +11 -0
- package/dist/esm/logo-D8pVJRuX.js +15 -0
- package/dist/esm/web-to-print.js +21 -0
- package/dist/esm/wtp-editor_2.entry.js +12383 -0
- package/dist/esm/wtp-logo-renderer.entry.js +351 -0
- package/dist/esm/wtp-print-area-editor.entry.js +429 -0
- package/dist/index.cjs.js +1 -0
- package/dist/index.js +1 -0
- package/dist/types/components/wtp-editor/wtp-editor.d.ts +101 -0
- package/dist/types/components/wtp-logo-renderer/wtp-logo-renderer.d.ts +55 -0
- package/dist/types/components/wtp-logo-upload/wtp-logo-upload.d.ts +76 -0
- package/dist/types/components/wtp-print-area-editor/wtp-print-area-editor.d.ts +43 -0
- package/dist/types/components.d.ts +507 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/stencil-public-runtime.d.ts +1860 -0
- package/dist/types/types/editor.d.ts +79 -0
- package/dist/types/types/index.d.ts +5 -0
- package/dist/types/types/labels.d.ts +30 -0
- package/dist/types/types/logo.d.ts +47 -0
- package/dist/types/utils/background-removal.d.ts +95 -0
- package/dist/types/utils/canvas-helpers.d.ts +60 -0
- package/dist/types/utils/format-detection.d.ts +4 -0
- package/dist/types/utils/html-render-helpers.d.ts +44 -0
- package/dist/types/utils/image-preview.d.ts +13 -0
- package/dist/types/utils/logo-validation.d.ts +2 -0
- package/dist/types/utils/pdf-export.d.ts +32 -0
- package/dist/web-to-print/index.esm.js +1 -0
- package/dist/web-to-print/p-611ec561.entry.js +1 -0
- package/dist/web-to-print/p-703e4c52.entry.js +1 -0
- package/dist/web-to-print/p-CK8OAq2J.js +1 -0
- package/dist/web-to-print/p-CUetmLbL.js +2 -0
- package/dist/web-to-print/p-D8pVJRuX.js +1 -0
- package/dist/web-to-print/p-DQuL1Twl.js +1 -0
- package/dist/web-to-print/p-b532777b.entry.js +1 -0
- package/dist/web-to-print/web-to-print.esm.js +1 -0
- package/loader/cdn.js +1 -0
- package/loader/index.cjs.js +1 -0
- package/loader/index.d.ts +24 -0
- package/loader/index.es2017.js +1 -0
- package/loader/index.js +2 -0
- package/package.json +68 -0
- package/readme.md +490 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { FabricImage } from "fabric";
|
|
2
|
+
export function generateObjectId() {
|
|
3
|
+
return crypto.randomUUID();
|
|
4
|
+
}
|
|
5
|
+
const IMAGE_PROXY_BASE = 'http://localhost:3001';
|
|
6
|
+
/** Load a FabricImage from a URL, trying CORS → local proxy → plain load (tainted). */
|
|
7
|
+
async function loadFabricImage(url) {
|
|
8
|
+
// Data URLs and blob URLs are always same-origin
|
|
9
|
+
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
|
10
|
+
return FabricImage.fromURL(url);
|
|
11
|
+
}
|
|
12
|
+
// Try direct CORS
|
|
13
|
+
try {
|
|
14
|
+
return await FabricImage.fromURL(url, { crossOrigin: 'anonymous' });
|
|
15
|
+
}
|
|
16
|
+
catch { /* CORS rejected */ }
|
|
17
|
+
// Try local image proxy
|
|
18
|
+
try {
|
|
19
|
+
const proxyUrl = `${IMAGE_PROXY_BASE}/?url=${encodeURIComponent(url)}`;
|
|
20
|
+
return await FabricImage.fromURL(proxyUrl, { crossOrigin: 'anonymous' });
|
|
21
|
+
}
|
|
22
|
+
catch { /* proxy not available */ }
|
|
23
|
+
// Fallback: load without CORS (canvas will be tainted, export blocked)
|
|
24
|
+
return FabricImage.fromURL(url);
|
|
25
|
+
}
|
|
26
|
+
export async function setCanvasBackground(canvas, imageUrl, fitMode = 'contain') {
|
|
27
|
+
const img = await loadFabricImage(imageUrl);
|
|
28
|
+
const canvasWidth = canvas.getWidth();
|
|
29
|
+
const canvasHeight = canvas.getHeight();
|
|
30
|
+
let scaleX;
|
|
31
|
+
let scaleY;
|
|
32
|
+
if (fitMode === 'fill') {
|
|
33
|
+
scaleX = canvasWidth / (img.width ?? canvasWidth);
|
|
34
|
+
scaleY = canvasHeight / (img.height ?? canvasHeight);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const imgWidth = img.width ?? canvasWidth;
|
|
38
|
+
const imgHeight = img.height ?? canvasHeight;
|
|
39
|
+
const scale = fitMode === 'contain'
|
|
40
|
+
? Math.min(canvasWidth / imgWidth, canvasHeight / imgHeight)
|
|
41
|
+
: Math.max(canvasWidth / imgWidth, canvasHeight / imgHeight);
|
|
42
|
+
scaleX = scale;
|
|
43
|
+
scaleY = scale;
|
|
44
|
+
// In contain mode, resize canvas to match scaled image so there's no letterboxing
|
|
45
|
+
if (fitMode === 'contain') {
|
|
46
|
+
const fittedWidth = imgWidth * scale;
|
|
47
|
+
const fittedHeight = imgHeight * scale;
|
|
48
|
+
canvas.setDimensions({ width: fittedWidth, height: fittedHeight });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const finalWidth = canvas.getWidth();
|
|
52
|
+
const finalHeight = canvas.getHeight();
|
|
53
|
+
img.set({
|
|
54
|
+
scaleX,
|
|
55
|
+
scaleY,
|
|
56
|
+
originX: 'center',
|
|
57
|
+
originY: 'center',
|
|
58
|
+
left: finalWidth / 2,
|
|
59
|
+
top: finalHeight / 2,
|
|
60
|
+
selectable: false,
|
|
61
|
+
evented: false,
|
|
62
|
+
excludeFromExport: true,
|
|
63
|
+
});
|
|
64
|
+
// Remove existing background image if any
|
|
65
|
+
const objects = canvas.getObjects();
|
|
66
|
+
const existingBg = objects.find(o => o._isBackground === true);
|
|
67
|
+
if (existingBg !== undefined)
|
|
68
|
+
canvas.remove(existingBg);
|
|
69
|
+
img._isBackground = true;
|
|
70
|
+
canvas.insertAt(0, img);
|
|
71
|
+
canvas.renderAll();
|
|
72
|
+
}
|
|
73
|
+
/** Convert PrintArea corner coordinates (0-1) to absolute pixel positions. */
|
|
74
|
+
export function printAreaToPixelCorners(pa, canvasW, canvasH) {
|
|
75
|
+
return [
|
|
76
|
+
{ x: pa.topLeft.x * canvasW, y: pa.topLeft.y * canvasH },
|
|
77
|
+
{ x: pa.topRight.x * canvasW, y: pa.topRight.y * canvasH },
|
|
78
|
+
{ x: pa.bottomRight.x * canvasW, y: pa.bottomRight.y * canvasH },
|
|
79
|
+
{ x: pa.bottomLeft.x * canvasW, y: pa.bottomLeft.y * canvasH },
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
/** Convert absolute pixel corner positions back to 0-1 relative PrintArea. */
|
|
83
|
+
export function pixelCornersToPrintArea(corners, canvasW, canvasH, bulge = 0) {
|
|
84
|
+
return {
|
|
85
|
+
topLeft: { x: corners[0].x / canvasW, y: corners[0].y / canvasH },
|
|
86
|
+
topRight: { x: corners[1].x / canvasW, y: corners[1].y / canvasH },
|
|
87
|
+
bottomRight: { x: corners[2].x / canvasW, y: corners[2].y / canvasH },
|
|
88
|
+
bottomLeft: { x: corners[3].x / canvasW, y: corners[3].y / canvasH },
|
|
89
|
+
...(bulge !== 0 ? { bulge } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Convert a legacy center+dimensions print area to the 4-corner format.
|
|
94
|
+
* Applies taper, skew, and rotation in 0-1 coordinate space.
|
|
95
|
+
*/
|
|
96
|
+
export function legacyToPrintArea(legacy) {
|
|
97
|
+
const { x, y, width, height, angle = 0, skewX = 0, skewY = 0, taper = 0, bulge = 0 } = legacy;
|
|
98
|
+
const halfW = width / 2;
|
|
99
|
+
const halfH = height / 2;
|
|
100
|
+
const topHalfW = halfW * (1 - taper);
|
|
101
|
+
// Local corners (relative to center, in 0-1 coords)
|
|
102
|
+
let corners = [
|
|
103
|
+
{ x: -topHalfW, y: -halfH }, // TL
|
|
104
|
+
{ x: topHalfW, y: -halfH }, // TR
|
|
105
|
+
{ x: halfW, y: halfH }, // BR
|
|
106
|
+
{ x: -halfW, y: halfH }, // BL
|
|
107
|
+
];
|
|
108
|
+
// Apply skew
|
|
109
|
+
if (skewX !== 0 || skewY !== 0) {
|
|
110
|
+
const tanSkX = Math.tan(skewX * Math.PI / 180);
|
|
111
|
+
const tanSkY = Math.tan(skewY * Math.PI / 180);
|
|
112
|
+
corners = corners.map(c => ({
|
|
113
|
+
x: c.x + c.y * tanSkX,
|
|
114
|
+
y: c.y + c.x * tanSkY,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
// Apply rotation
|
|
118
|
+
if (angle !== 0) {
|
|
119
|
+
const rad = angle * Math.PI / 180;
|
|
120
|
+
const cos = Math.cos(rad);
|
|
121
|
+
const sin = Math.sin(rad);
|
|
122
|
+
corners = corners.map(c => ({
|
|
123
|
+
x: c.x * cos - c.y * sin,
|
|
124
|
+
y: c.x * sin + c.y * cos,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
// Translate to center
|
|
128
|
+
return {
|
|
129
|
+
topLeft: { x: x + corners[0].x, y: y + corners[0].y },
|
|
130
|
+
topRight: { x: x + corners[1].x, y: y + corners[1].y },
|
|
131
|
+
bottomRight: { x: x + corners[2].x, y: y + corners[2].y },
|
|
132
|
+
bottomLeft: { x: x + corners[3].x, y: y + corners[3].y },
|
|
133
|
+
...(bulge !== 0 ? { bulge } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Detect whether a PrintArea uses pixel coordinates (at least one value > 1). */
|
|
137
|
+
export function isPixelPrintArea(pa) {
|
|
138
|
+
return [pa.topLeft, pa.topRight, pa.bottomRight, pa.bottomLeft].some(p => p.x > 1 || p.y > 1);
|
|
139
|
+
}
|
|
140
|
+
/** Normalize a PrintArea from pixel coordinates to 0–1 relative values. Already-normalized areas are returned unchanged. */
|
|
141
|
+
export function normalizePrintArea(pa, imageWidth, imageHeight) {
|
|
142
|
+
if (!isPixelPrintArea(pa))
|
|
143
|
+
return pa;
|
|
144
|
+
return pixelCornersToPrintArea([pa.topLeft, pa.topRight, pa.bottomRight, pa.bottomLeft], imageWidth, imageHeight, pa.bulge ?? 0);
|
|
145
|
+
}
|
|
146
|
+
/** Returns a default centered print area (30% width x 35% height rectangle). */
|
|
147
|
+
export function defaultPrintArea() {
|
|
148
|
+
return {
|
|
149
|
+
topLeft: { x: 0.35, y: 0.325 },
|
|
150
|
+
topRight: { x: 0.65, y: 0.325 },
|
|
151
|
+
bottomRight: { x: 0.65, y: 0.675 },
|
|
152
|
+
bottomLeft: { x: 0.35, y: 0.675 },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Compute a CanvasTransform to fit a logo within a 4-corner print area.
|
|
157
|
+
* Uses the centroid for position, average edge lengths for dimensions,
|
|
158
|
+
* and the bottom edge angle for rotation.
|
|
159
|
+
*/
|
|
160
|
+
export function fitLogoToPrintArea(logoWidth, logoHeight, printArea, canvasWidth, canvasHeight) {
|
|
161
|
+
const [tl, tr, br, bl] = printAreaToPixelCorners(printArea, canvasWidth, canvasHeight);
|
|
162
|
+
// Centroid
|
|
163
|
+
const cx = (tl.x + tr.x + br.x + bl.x) / 4;
|
|
164
|
+
const cy = (tl.y + tr.y + br.y + bl.y) / 4;
|
|
165
|
+
// Average edge lengths for effective width/height
|
|
166
|
+
const topLen = Math.hypot(tr.x - tl.x, tr.y - tl.y);
|
|
167
|
+
const botLen = Math.hypot(br.x - bl.x, br.y - bl.y);
|
|
168
|
+
const leftLen = Math.hypot(bl.x - tl.x, bl.y - tl.y);
|
|
169
|
+
const rightLen = Math.hypot(br.x - tr.x, br.y - tr.y);
|
|
170
|
+
const avgWidth = (topLen + botLen) / 2;
|
|
171
|
+
const avgHeight = (leftLen + rightLen) / 2;
|
|
172
|
+
// Angle from bottom edge
|
|
173
|
+
const angle = Math.atan2(br.y - bl.y, br.x - bl.x) * 180 / Math.PI;
|
|
174
|
+
const scale = Math.min(avgWidth / logoWidth, avgHeight / logoHeight);
|
|
175
|
+
return {
|
|
176
|
+
x: cx,
|
|
177
|
+
y: cy,
|
|
178
|
+
scaleX: scale,
|
|
179
|
+
scaleY: scale,
|
|
180
|
+
angle,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Warp a logo image to follow the bulge curvature of a print area.
|
|
185
|
+
* Slices the image into vertical strips and displaces each vertically
|
|
186
|
+
* according to the same quadratic Bezier curve used by the print area outline.
|
|
187
|
+
*/
|
|
188
|
+
export function warpImageForBulge(sourceImg, bulge, areaHeight, logoScale) {
|
|
189
|
+
const w = sourceImg.width;
|
|
190
|
+
const h = sourceImg.height;
|
|
191
|
+
// Max displacement at center (t=0.5): 0.5 * |bulge| * areaHeight / logoScale
|
|
192
|
+
const maxAbsDy = Math.ceil(Math.abs(0.5 * bulge * areaHeight / logoScale)) + 1;
|
|
193
|
+
const canvas = document.createElement('canvas');
|
|
194
|
+
canvas.width = w;
|
|
195
|
+
canvas.height = h + maxAbsDy * 2;
|
|
196
|
+
const ctx = canvas.getContext('2d');
|
|
197
|
+
for (let x = 0; x < w; x++) {
|
|
198
|
+
const t = w > 1 ? x / (w - 1) : 0.5;
|
|
199
|
+
// Displacement matching the quadratic Bezier control-point shift in PrintAreaQuad._render:
|
|
200
|
+
// control point is at midY - bulge * areaHeight, Bezier weight at parameter t is 2t(1-t)
|
|
201
|
+
const dy = -2 * t * (1 - t) * bulge * areaHeight / logoScale;
|
|
202
|
+
ctx.drawImage(sourceImg, x, 0, 1, h, x, maxAbsDy + dy, 1, h);
|
|
203
|
+
}
|
|
204
|
+
return canvas;
|
|
205
|
+
}
|
|
206
|
+
/** Decode SVG text from a data URL. Returns null for non-SVG URLs. */
|
|
207
|
+
function decodeSvgDataUrl(svgDataUrl) {
|
|
208
|
+
if (!svgDataUrl.startsWith('data:image/svg+xml'))
|
|
209
|
+
return null;
|
|
210
|
+
const base64Idx = svgDataUrl.indexOf(';base64,');
|
|
211
|
+
if (base64Idx !== -1) {
|
|
212
|
+
return atob(svgDataUrl.slice(base64Idx + 8));
|
|
213
|
+
}
|
|
214
|
+
const commaIdx = svgDataUrl.indexOf(',');
|
|
215
|
+
if (commaIdx === -1)
|
|
216
|
+
return null;
|
|
217
|
+
return decodeURIComponent(svgDataUrl.slice(commaIdx + 1));
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Extract width/height from an SVG data URL by parsing viewBox or width/height attributes.
|
|
221
|
+
* Returns null for non-SVG data URLs or when dimensions cannot be determined.
|
|
222
|
+
*/
|
|
223
|
+
export function parseSvgDimensions(svgDataUrl) {
|
|
224
|
+
const svgText = decodeSvgDataUrl(svgDataUrl);
|
|
225
|
+
if (svgText === null)
|
|
226
|
+
return null;
|
|
227
|
+
const widthMatch = svgText.match(/\bwidth=["']([.\d]+)/);
|
|
228
|
+
const heightMatch = svgText.match(/\bheight=["']([.\d]+)/);
|
|
229
|
+
if (widthMatch !== null && heightMatch !== null) {
|
|
230
|
+
return { width: Math.round(parseFloat(widthMatch[1])), height: Math.round(parseFloat(heightMatch[1])) };
|
|
231
|
+
}
|
|
232
|
+
const viewBoxMatch = svgText.match(/viewBox=["']\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)/);
|
|
233
|
+
if (viewBoxMatch !== null) {
|
|
234
|
+
return { width: Math.round(parseFloat(viewBoxMatch[1])), height: Math.round(parseFloat(viewBoxMatch[2])) };
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Upscale an SVG data URL so the browser rasterizes it at high resolution.
|
|
240
|
+
* SVGs loaded via `<img src>` are rasterized at their intrinsic dimensions
|
|
241
|
+
* (from viewBox or width/height attributes), which are often small. This
|
|
242
|
+
* function sets explicit width/height on the root `<svg>` element to ensure
|
|
243
|
+
* high-resolution rasterization when loaded into Fabric.js.
|
|
244
|
+
*
|
|
245
|
+
* Returns the (possibly modified) data URL and the uniform scale factor applied.
|
|
246
|
+
* For non-SVG data URLs, returns the input unchanged with scaleApplied = 1.
|
|
247
|
+
*/
|
|
248
|
+
export function upscaleSvgDataUrl(svgDataUrl, maxSize = 4000) {
|
|
249
|
+
const svgText = decodeSvgDataUrl(svgDataUrl);
|
|
250
|
+
if (svgText === null) {
|
|
251
|
+
return { dataUrl: svgDataUrl, scaleApplied: 1 };
|
|
252
|
+
}
|
|
253
|
+
// Parse SVG document
|
|
254
|
+
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
|
|
255
|
+
const svgEl = doc.documentElement;
|
|
256
|
+
if (svgEl.tagName !== 'svg')
|
|
257
|
+
return { dataUrl: svgDataUrl, scaleApplied: 1 };
|
|
258
|
+
// Determine intrinsic dimensions from viewBox or width/height attributes
|
|
259
|
+
const viewBox = svgEl.getAttribute('viewBox');
|
|
260
|
+
let intrinsicW;
|
|
261
|
+
let intrinsicH;
|
|
262
|
+
if (viewBox !== null) {
|
|
263
|
+
const parts = viewBox.trim().split(/[\s,]+/);
|
|
264
|
+
intrinsicW = parseFloat(parts[2]) || 0;
|
|
265
|
+
intrinsicH = parseFloat(parts[3]) || 0;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
intrinsicW = parseFloat(svgEl.getAttribute('width') || '') || 0;
|
|
269
|
+
intrinsicH = parseFloat(svgEl.getAttribute('height') || '') || 0;
|
|
270
|
+
}
|
|
271
|
+
if (intrinsicW <= 0 || intrinsicH <= 0)
|
|
272
|
+
return { dataUrl: svgDataUrl, scaleApplied: 1 };
|
|
273
|
+
// Skip if already large enough
|
|
274
|
+
const maxDim = Math.max(intrinsicW, intrinsicH);
|
|
275
|
+
if (maxDim >= maxSize)
|
|
276
|
+
return { dataUrl: svgDataUrl, scaleApplied: 1 };
|
|
277
|
+
// Compute scale factor and target dimensions
|
|
278
|
+
const scale = maxSize / maxDim;
|
|
279
|
+
const targetW = Math.round(intrinsicW * scale);
|
|
280
|
+
const targetH = Math.round(intrinsicH * scale);
|
|
281
|
+
// Set explicit width/height and ensure viewBox is present for proper scaling
|
|
282
|
+
svgEl.setAttribute('width', String(targetW));
|
|
283
|
+
svgEl.setAttribute('height', String(targetH));
|
|
284
|
+
if (viewBox === null) {
|
|
285
|
+
svgEl.setAttribute('viewBox', `0 0 ${intrinsicW} ${intrinsicH}`);
|
|
286
|
+
}
|
|
287
|
+
// Serialize and re-encode as base64 data URL
|
|
288
|
+
const newSvgText = new XMLSerializer().serializeToString(doc);
|
|
289
|
+
const bytes = new TextEncoder().encode(newSvgText);
|
|
290
|
+
let binary = '';
|
|
291
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
292
|
+
binary += String.fromCharCode(bytes[i]);
|
|
293
|
+
}
|
|
294
|
+
return { dataUrl: 'data:image/svg+xml;base64,' + btoa(binary), scaleApplied: scale };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Trim excess whitespace from an SVG by adjusting its viewBox to the actual content bounds.
|
|
298
|
+
* Uses `getBBox()` which requires the SVG to be temporarily inserted into the DOM.
|
|
299
|
+
* Returns the input unchanged for non-SVG data URLs or when trimming is not possible.
|
|
300
|
+
*/
|
|
301
|
+
export async function trimSvgWhitespace(svgDataUrl, padding = 1) {
|
|
302
|
+
const svgText = decodeSvgDataUrl(svgDataUrl);
|
|
303
|
+
if (svgText === null) {
|
|
304
|
+
return svgDataUrl;
|
|
305
|
+
}
|
|
306
|
+
// Parse SVG document
|
|
307
|
+
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
|
|
308
|
+
const svgEl = doc.documentElement;
|
|
309
|
+
if (svgEl.tagName !== 'svg')
|
|
310
|
+
return svgDataUrl;
|
|
311
|
+
// Insert SVG offscreen to enable getBBox()
|
|
312
|
+
const container = document.createElement('div');
|
|
313
|
+
container.style.cssText = 'visibility:hidden;position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden';
|
|
314
|
+
container.appendChild(svgEl);
|
|
315
|
+
document.body.appendChild(container);
|
|
316
|
+
let bbox;
|
|
317
|
+
try {
|
|
318
|
+
bbox = svgEl.getBBox();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
container.remove();
|
|
322
|
+
return svgDataUrl;
|
|
323
|
+
}
|
|
324
|
+
container.remove();
|
|
325
|
+
// Graceful fallback: if getBBox returned zeros (e.g. JSDOM), skip trimming
|
|
326
|
+
if (bbox.width <= 0 || bbox.height <= 0) {
|
|
327
|
+
return svgDataUrl;
|
|
328
|
+
}
|
|
329
|
+
// Check if trimming is needed by comparing to existing viewBox
|
|
330
|
+
const existingVB = svgEl.getAttribute('viewBox');
|
|
331
|
+
if (existingVB !== null) {
|
|
332
|
+
const parts = existingVB.trim().split(/[\s,]+/).map(Number);
|
|
333
|
+
if (parts.length === 4 &&
|
|
334
|
+
Math.abs(parts[0] - bbox.x) < 1 &&
|
|
335
|
+
Math.abs(parts[1] - bbox.y) < 1 &&
|
|
336
|
+
Math.abs(parts[2] - bbox.width) < 1 &&
|
|
337
|
+
Math.abs(parts[3] - bbox.height) < 1) {
|
|
338
|
+
return svgDataUrl;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Apply trimmed viewBox with padding
|
|
342
|
+
const newVB = `${bbox.x - padding} ${bbox.y - padding} ${bbox.width + 2 * padding} ${bbox.height + 2 * padding}`;
|
|
343
|
+
svgEl.setAttribute('viewBox', newVB);
|
|
344
|
+
// Update width/height to match new aspect ratio
|
|
345
|
+
const newW = bbox.width + 2 * padding;
|
|
346
|
+
const newH = bbox.height + 2 * padding;
|
|
347
|
+
svgEl.setAttribute('width', String(newW));
|
|
348
|
+
svgEl.setAttribute('height', String(newH));
|
|
349
|
+
// Re-attach svgEl to doc for serialization (it was moved to container)
|
|
350
|
+
if (svgEl.parentNode !== doc) {
|
|
351
|
+
doc.appendChild(svgEl);
|
|
352
|
+
}
|
|
353
|
+
// Serialize and re-encode as base64 data URL
|
|
354
|
+
const newSvgText = new XMLSerializer().serializeToString(doc);
|
|
355
|
+
const bytes = new TextEncoder().encode(newSvgText);
|
|
356
|
+
let binary = '';
|
|
357
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
358
|
+
binary += String.fromCharCode(bytes[i]);
|
|
359
|
+
}
|
|
360
|
+
return 'data:image/svg+xml;base64,' + btoa(binary);
|
|
361
|
+
}
|
|
362
|
+
export async function addLogoToCanvas(canvas, logoDataUrl, transform, id) {
|
|
363
|
+
const { dataUrl, scaleApplied } = upscaleSvgDataUrl(logoDataUrl);
|
|
364
|
+
const img = await FabricImage.fromURL(dataUrl);
|
|
365
|
+
img.set({
|
|
366
|
+
left: transform.x,
|
|
367
|
+
top: transform.y,
|
|
368
|
+
scaleX: transform.scaleX / scaleApplied,
|
|
369
|
+
scaleY: transform.scaleY / scaleApplied,
|
|
370
|
+
angle: transform.angle,
|
|
371
|
+
skewX: transform.skewX ?? 0,
|
|
372
|
+
skewY: transform.skewY ?? 0,
|
|
373
|
+
originX: 'center',
|
|
374
|
+
originY: 'center',
|
|
375
|
+
});
|
|
376
|
+
img._objectId = id;
|
|
377
|
+
canvas.add(img);
|
|
378
|
+
canvas.renderAll();
|
|
379
|
+
return img;
|
|
380
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const MAGIC_BYTES = [
|
|
2
|
+
{ format: 'png', bytes: [0x89, 0x50, 0x4e, 0x47] },
|
|
3
|
+
{ format: 'jpeg', bytes: [0xff, 0xd8, 0xff] },
|
|
4
|
+
{ format: 'tiff', bytes: [0x49, 0x49, 0x2a, 0x00] }, // Little-endian
|
|
5
|
+
{ format: 'tiff', bytes: [0x4d, 0x4d, 0x00, 0x2a] }, // Big-endian
|
|
6
|
+
{ format: 'avif', bytes: [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], offset: 4 }, // ISO BMFF "ftypavif"
|
|
7
|
+
];
|
|
8
|
+
export function mimeToFormat(mime) {
|
|
9
|
+
const map = {
|
|
10
|
+
'image/png': 'png',
|
|
11
|
+
'image/jpeg': 'jpeg',
|
|
12
|
+
'image/svg+xml': 'svg',
|
|
13
|
+
'image/tiff': 'tiff',
|
|
14
|
+
'image/avif': 'avif',
|
|
15
|
+
};
|
|
16
|
+
return map[mime] ?? 'unknown';
|
|
17
|
+
}
|
|
18
|
+
export function isSvgContent(text) {
|
|
19
|
+
const trimmed = text.trim();
|
|
20
|
+
return trimmed.startsWith('<?xml') || trimmed.startsWith('<svg');
|
|
21
|
+
}
|
|
22
|
+
function matchMagicBytes(bytes) {
|
|
23
|
+
for (const entry of MAGIC_BYTES) {
|
|
24
|
+
const offset = entry.offset ?? 0;
|
|
25
|
+
if (bytes.length < offset + entry.bytes.length)
|
|
26
|
+
continue;
|
|
27
|
+
const match = entry.bytes.every((b, i) => bytes[offset + i] === b);
|
|
28
|
+
if (match)
|
|
29
|
+
return entry.format;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
export async function detectFileFormat(file) {
|
|
34
|
+
// Try magic bytes first (most reliable)
|
|
35
|
+
const buffer = await file.slice(0, 16).arrayBuffer();
|
|
36
|
+
const bytes = new Uint8Array(buffer);
|
|
37
|
+
const magicFormat = matchMagicBytes(bytes);
|
|
38
|
+
if (magicFormat !== null)
|
|
39
|
+
return magicFormat;
|
|
40
|
+
// Check for SVG by reading text content
|
|
41
|
+
if (file.type === 'image/svg+xml' || file.name.endsWith('.svg')) {
|
|
42
|
+
const text = await file.text();
|
|
43
|
+
if (isSvgContent(text))
|
|
44
|
+
return 'svg';
|
|
45
|
+
}
|
|
46
|
+
// Fallback to MIME type
|
|
47
|
+
return mimeToFormat(file.type);
|
|
48
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Utility functions for the HTML/CSS-based logo renderer and Canvas 2D export. */
|
|
2
|
+
/** Load an image from a URL/data-URL and return the HTMLImageElement once loaded. */
|
|
3
|
+
export function loadImage(src) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const img = new Image();
|
|
6
|
+
img.onload = () => resolve(img);
|
|
7
|
+
img.onerror = () => reject(new Error(`Failed to load image: ${src.slice(0, 80)}`));
|
|
8
|
+
img.src = src;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
/** Compute contain-fit dimensions (uniform scale to fit inside container). */
|
|
12
|
+
export function computeContainFit(containerW, containerH, imgW, imgH) {
|
|
13
|
+
const scale = Math.min(containerW / imgW, containerH / imgH);
|
|
14
|
+
return { fittedW: imgW * scale, fittedH: imgH * scale };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Convert a Fabric.js center-origin transform to CSS top-left positioning.
|
|
18
|
+
* Fabric.js stores x/y as the center of the object. CSS `transform-origin: center`
|
|
19
|
+
* means the origin is at the center of the unscaled element box, so left/top are
|
|
20
|
+
* computed by subtracting half the *natural* (unscaled) dimensions.
|
|
21
|
+
*/
|
|
22
|
+
export function centerOriginToTopLeft(transform, naturalW, naturalH) {
|
|
23
|
+
return {
|
|
24
|
+
left: transform.x - naturalW / 2,
|
|
25
|
+
top: transform.y - naturalH / 2,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parse an SVG data URL to extract intrinsic dimensions from viewBox or width/height attributes.
|
|
30
|
+
* Returns null if the data URL is not SVG or dimensions cannot be determined.
|
|
31
|
+
*/
|
|
32
|
+
export function getSvgIntrinsicSize(svgDataUrl) {
|
|
33
|
+
if (!svgDataUrl.startsWith('data:image/svg+xml'))
|
|
34
|
+
return null;
|
|
35
|
+
let svgText;
|
|
36
|
+
const base64Idx = svgDataUrl.indexOf(';base64,');
|
|
37
|
+
if (base64Idx !== -1) {
|
|
38
|
+
svgText = atob(svgDataUrl.slice(base64Idx + 8));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const commaIdx = svgDataUrl.indexOf(',');
|
|
42
|
+
if (commaIdx === -1)
|
|
43
|
+
return null;
|
|
44
|
+
svgText = decodeURIComponent(svgDataUrl.slice(commaIdx + 1));
|
|
45
|
+
}
|
|
46
|
+
// Use regex instead of DOMParser to avoid JSDOM/mock-doc XML parsing limitations
|
|
47
|
+
const svgMatch = svgText.match(/<svg[^>]*>/i);
|
|
48
|
+
if (svgMatch === null)
|
|
49
|
+
return null;
|
|
50
|
+
const svgTag = svgMatch[0];
|
|
51
|
+
const viewBoxMatch = svgTag.match(/viewBox=["']([^"']+)["']/);
|
|
52
|
+
if (viewBoxMatch !== null) {
|
|
53
|
+
const parts = viewBoxMatch[1].trim().split(/[\s,]+/);
|
|
54
|
+
const vbW = parseFloat(parts[2]);
|
|
55
|
+
const vbH = parseFloat(parts[3]);
|
|
56
|
+
if (vbW > 0 && vbH > 0)
|
|
57
|
+
return { width: vbW, height: vbH };
|
|
58
|
+
}
|
|
59
|
+
const widthMatch = svgTag.match(/\bwidth=["']([^"']+)["']/);
|
|
60
|
+
const heightMatch = svgTag.match(/\bheight=["']([^"']+)["']/);
|
|
61
|
+
if (widthMatch !== null && heightMatch !== null) {
|
|
62
|
+
const svgW = parseFloat(widthMatch[1]);
|
|
63
|
+
const svgH = parseFloat(heightMatch[1]);
|
|
64
|
+
if (svgW > 0 && svgH > 0)
|
|
65
|
+
return { width: svgW, height: svgH };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Canvas 2D export: draw product background + logo layers onto a plain canvas.
|
|
71
|
+
* Returns a data URL string.
|
|
72
|
+
*/
|
|
73
|
+
export function renderToCanvas(containerW, containerH, bgColor, productImg, layers, format = 'png', quality = 1) {
|
|
74
|
+
const canvas = document.createElement('canvas');
|
|
75
|
+
canvas.width = containerW;
|
|
76
|
+
canvas.height = containerH;
|
|
77
|
+
const ctx = canvas.getContext('2d');
|
|
78
|
+
// Background color
|
|
79
|
+
ctx.fillStyle = bgColor;
|
|
80
|
+
ctx.fillRect(0, 0, containerW, containerH);
|
|
81
|
+
// Product image (contain-fit, centered)
|
|
82
|
+
if (productImg !== undefined) {
|
|
83
|
+
const { fittedW, fittedH } = computeContainFit(containerW, containerH, productImg.naturalWidth, productImg.naturalHeight);
|
|
84
|
+
const ox = (containerW - fittedW) / 2;
|
|
85
|
+
const oy = (containerH - fittedH) / 2;
|
|
86
|
+
ctx.drawImage(productImg, ox, oy, fittedW, fittedH);
|
|
87
|
+
}
|
|
88
|
+
// Logo layers
|
|
89
|
+
for (const layer of layers) {
|
|
90
|
+
ctx.save();
|
|
91
|
+
// Move to the center of the object (top-left + half natural size, matching CSS transform-origin: center)
|
|
92
|
+
const cx = layer.left + layer.naturalWidth / 2;
|
|
93
|
+
const cy = layer.top + layer.naturalHeight / 2;
|
|
94
|
+
ctx.translate(cx, cy);
|
|
95
|
+
// Apply transforms in Fabric.js order: rotate → skew → scale
|
|
96
|
+
ctx.rotate((layer.angle * Math.PI) / 180);
|
|
97
|
+
const tanSkX = Math.tan((layer.skewX * Math.PI) / 180);
|
|
98
|
+
const tanSkY = Math.tan((layer.skewY * Math.PI) / 180);
|
|
99
|
+
ctx.transform(1, tanSkY, tanSkX, 1, 0, 0);
|
|
100
|
+
ctx.scale(layer.scaleX, layer.scaleY);
|
|
101
|
+
// Draw image centered at origin
|
|
102
|
+
ctx.drawImage(layer.img, -layer.naturalWidth / 2, -layer.naturalHeight / 2, layer.naturalWidth, layer.naturalHeight);
|
|
103
|
+
ctx.restore();
|
|
104
|
+
}
|
|
105
|
+
return canvas.toDataURL(`image/${format}`, quality);
|
|
106
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const DEFAULT_MAX_SIZE = 800;
|
|
2
|
+
/**
|
|
3
|
+
* Returns true if the data URL contains an SVG image (image/svg+xml).
|
|
4
|
+
*/
|
|
5
|
+
function isSvgDataUrl(dataUrl) {
|
|
6
|
+
return dataUrl.startsWith('data:image/svg+xml');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Generate a downscaled preview data URL from a full-resolution data URL.
|
|
10
|
+
*
|
|
11
|
+
* - SVG data URLs are returned unchanged (vectors are compact and scale-independent).
|
|
12
|
+
* - Raster images are downscaled so the longest side is at most `maxSize` pixels.
|
|
13
|
+
* If the image is already smaller, the original is returned unchanged.
|
|
14
|
+
* - Output is PNG to preserve transparency.
|
|
15
|
+
*
|
|
16
|
+
* @param dataUrl Full-resolution data URL
|
|
17
|
+
* @param maxSize Maximum pixel dimension for the longest side (default: 800)
|
|
18
|
+
* @returns Downscaled PNG data URL, or the original for SVGs / small images
|
|
19
|
+
*/
|
|
20
|
+
export async function generatePreviewDataUrl(dataUrl, maxSize = DEFAULT_MAX_SIZE) {
|
|
21
|
+
if (isSvgDataUrl(dataUrl)) {
|
|
22
|
+
return dataUrl;
|
|
23
|
+
}
|
|
24
|
+
// Browser-only: requires Image and canvas
|
|
25
|
+
if (typeof globalThis.Image === 'undefined' || typeof document === 'undefined') {
|
|
26
|
+
return dataUrl;
|
|
27
|
+
}
|
|
28
|
+
const img = await loadImageElement(dataUrl);
|
|
29
|
+
const { width, height } = img;
|
|
30
|
+
// Already within bounds — no scaling needed
|
|
31
|
+
if (width <= maxSize && height <= maxSize) {
|
|
32
|
+
return dataUrl;
|
|
33
|
+
}
|
|
34
|
+
const scale = maxSize / Math.max(width, height);
|
|
35
|
+
const targetW = Math.round(width * scale);
|
|
36
|
+
const targetH = Math.round(height * scale);
|
|
37
|
+
const canvas = document.createElement('canvas');
|
|
38
|
+
canvas.width = targetW;
|
|
39
|
+
canvas.height = targetH;
|
|
40
|
+
const ctx = canvas.getContext('2d');
|
|
41
|
+
if (ctx === null) {
|
|
42
|
+
return dataUrl;
|
|
43
|
+
}
|
|
44
|
+
ctx.drawImage(img, 0, 0, targetW, targetH);
|
|
45
|
+
return canvas.toDataURL('image/png');
|
|
46
|
+
}
|
|
47
|
+
function loadImageElement(src) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const img = new Image();
|
|
50
|
+
img.onload = () => resolve(img);
|
|
51
|
+
img.onerror = () => reject(new Error('Failed to load image for preview generation'));
|
|
52
|
+
img.src = src;
|
|
53
|
+
});
|
|
54
|
+
}
|