embroidery-qc-image 1.0.6 → 1.0.8
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/README.md +61 -84
- package/dist/components/EmbroideryQCImage.d.ts +9 -1
- package/dist/components/EmbroideryQCImage.d.ts.map +1 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.js +802 -628
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +803 -627
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -33,7 +33,9 @@ function styleInject(css, ref) {
|
|
|
33
33
|
var css_248z = ".render-embroidery {\r\n display: inline-block;\r\n position: relative;\r\n width: 100%;\r\n max-width: 100%;\r\n}\r\n\r\n.render-embroidery-canvas {\r\n display: block;\r\n width: 100%;\r\n height: auto;\r\n image-rendering: high-quality;\r\n background: transparent;\r\n}\r\n";
|
|
34
34
|
styleInject(css_248z);
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// CONSTANTS
|
|
38
|
+
// ============================================================================
|
|
37
39
|
const COLOR_MAP = {
|
|
38
40
|
"Army (1394)": "#4B5320",
|
|
39
41
|
Army: "#4B5320",
|
|
@@ -96,10 +98,332 @@ const COLOR_MAP = {
|
|
|
96
98
|
"White (9)": "#FFFFFF",
|
|
97
99
|
White: "#FFFFFF",
|
|
98
100
|
};
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
const DEFAULT_ERROR_COLOR = "#CC1F1A";
|
|
102
|
+
const BASE_URLS = {
|
|
103
|
+
FONT: "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/fonts",
|
|
104
|
+
ICON: "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/icons",
|
|
105
|
+
FLORAL: "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/florals",
|
|
106
|
+
THREAD_COLOR: "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/thread-colors",
|
|
107
|
+
};
|
|
108
|
+
const LAYOUT = {
|
|
109
|
+
// Font families
|
|
110
|
+
HEADER_FONT_FAMILY: "Times New Roman",
|
|
111
|
+
FONT_FAMILY: "Arial",
|
|
112
|
+
// Font sizes (base values, will be multiplied by scaleFactor)
|
|
113
|
+
HEADER_FONT_SIZE: 220,
|
|
114
|
+
TEXT_FONT_SIZE: 200,
|
|
115
|
+
OTHER_FONT_SIZE: 160,
|
|
116
|
+
// Colors
|
|
117
|
+
HEADER_COLOR: "#000000",
|
|
118
|
+
LABEL_COLOR: "#444444",
|
|
119
|
+
BACKGROUND_COLOR: "#FFFFFF",
|
|
120
|
+
// Text alignment
|
|
121
|
+
TEXT_ALIGN: "left",
|
|
122
|
+
TEXT_BASELINE: "top",
|
|
123
|
+
// Spacing
|
|
124
|
+
LINE_GAP: 40,
|
|
125
|
+
PADDING: 40,
|
|
126
|
+
SECTION_SPACING: 60,
|
|
127
|
+
ELEMENT_SPACING: 100,
|
|
128
|
+
SWATCH_SPACING: 25,
|
|
129
|
+
FLORAL_SPACING: 300,
|
|
130
|
+
// Visual styling
|
|
131
|
+
SWATCH_HEIGHT_RATIO: 2.025,
|
|
132
|
+
UNDERLINE_POSITION: 0.9,
|
|
133
|
+
UNDERLINE_WIDTH: 10,
|
|
134
|
+
// Swatch reserved space
|
|
135
|
+
SWATCH_RESERVED_SPACE: 1000,
|
|
136
|
+
MIN_TEXT_WIDTH: 400,
|
|
137
|
+
};
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// HELPER FUNCTIONS
|
|
140
|
+
// ============================================================================
|
|
141
|
+
const loadFont = (fontName) => {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
const fontUrl = `${BASE_URLS.FONT}/${encodeURIComponent(fontName)}.woff2`;
|
|
144
|
+
const fontFace = new FontFace(fontName, `url(${fontUrl})`);
|
|
145
|
+
fontFace
|
|
146
|
+
.load()
|
|
147
|
+
.then((loadedFont) => {
|
|
148
|
+
document.fonts.add(loadedFont);
|
|
149
|
+
resolve();
|
|
150
|
+
})
|
|
151
|
+
.catch(() => {
|
|
152
|
+
console.warn(`Could not load font ${fontName} from CDN`);
|
|
153
|
+
resolve();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
const getImageUrl = (type, value) => {
|
|
158
|
+
if (type === "icon")
|
|
159
|
+
return `${BASE_URLS.ICON}/Icon ${value}.png`;
|
|
160
|
+
if (type === "floral")
|
|
161
|
+
return `${BASE_URLS.FLORAL}/${value}.png`;
|
|
162
|
+
return `${BASE_URLS.THREAD_COLOR}/${value}.png`;
|
|
163
|
+
};
|
|
164
|
+
const getProxyUrl = (url) => `https://proxy-img.c8p.workers.dev?url=${encodeURIComponent(url)}`;
|
|
165
|
+
const ensureImage = (existing) => {
|
|
166
|
+
if (existing && existing.crossOrigin === "anonymous") {
|
|
167
|
+
return existing;
|
|
168
|
+
}
|
|
169
|
+
const img = new Image();
|
|
170
|
+
img.crossOrigin = "anonymous";
|
|
171
|
+
img.decoding = "async";
|
|
172
|
+
return img;
|
|
173
|
+
};
|
|
174
|
+
const loadImage = (url, imageRefs, onLoad) => {
|
|
175
|
+
const existing = imageRefs.current.get(url);
|
|
176
|
+
if (existing?.complete &&
|
|
177
|
+
existing.naturalWidth > 0 &&
|
|
178
|
+
existing.crossOrigin === "anonymous") {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const img = ensureImage(existing);
|
|
182
|
+
imageRefs.current.set(url, img);
|
|
183
|
+
let attemptedProxy = existing?.dataset?.proxyUsed === "true";
|
|
184
|
+
const cleanup = () => {
|
|
185
|
+
img.onload = null;
|
|
186
|
+
img.onerror = null;
|
|
187
|
+
};
|
|
188
|
+
img.onload = () => {
|
|
189
|
+
img.dataset.proxyUsed = attemptedProxy ? "true" : "false";
|
|
190
|
+
cleanup();
|
|
191
|
+
onLoad();
|
|
192
|
+
};
|
|
193
|
+
img.onerror = () => {
|
|
194
|
+
if (!attemptedProxy) {
|
|
195
|
+
attemptedProxy = true;
|
|
196
|
+
img.src = getProxyUrl(url);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
img.dataset.proxyUsed = attemptedProxy ? "true" : "false";
|
|
200
|
+
cleanup();
|
|
201
|
+
onLoad();
|
|
202
|
+
};
|
|
203
|
+
img.src = attemptedProxy ? getProxyUrl(url) : url;
|
|
204
|
+
};
|
|
205
|
+
const loadImageAsync = (url, imageRefs, cacheKey) => {
|
|
206
|
+
const key = cacheKey ?? url;
|
|
207
|
+
const existing = imageRefs.current.get(key) ?? imageRefs.current.get(url);
|
|
208
|
+
if (existing?.complete &&
|
|
209
|
+
existing.naturalWidth > 0 &&
|
|
210
|
+
existing.crossOrigin === "anonymous" &&
|
|
211
|
+
existing.dataset?.proxyUsed !== undefined) {
|
|
212
|
+
if (existing !== imageRefs.current.get(key)) {
|
|
213
|
+
imageRefs.current.set(key, existing);
|
|
214
|
+
}
|
|
215
|
+
if (existing !== imageRefs.current.get(url)) {
|
|
216
|
+
imageRefs.current.set(url, existing);
|
|
217
|
+
}
|
|
218
|
+
return Promise.resolve(existing);
|
|
219
|
+
}
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
const target = ensureImage(existing);
|
|
222
|
+
if (target !== existing) {
|
|
223
|
+
imageRefs.current.set(key, target);
|
|
224
|
+
imageRefs.current.set(url, target);
|
|
225
|
+
}
|
|
226
|
+
let attemptedProxy = target.dataset.proxyUsed === "true";
|
|
227
|
+
const finalize = () => {
|
|
228
|
+
target.onload = null;
|
|
229
|
+
target.onerror = null;
|
|
230
|
+
if (target.complete && target.naturalWidth > 0) {
|
|
231
|
+
imageRefs.current.set(key, target);
|
|
232
|
+
imageRefs.current.set(url, target);
|
|
233
|
+
}
|
|
234
|
+
target.dataset.proxyUsed = attemptedProxy ? "true" : "false";
|
|
235
|
+
resolve(target);
|
|
236
|
+
};
|
|
237
|
+
target.onload = finalize;
|
|
238
|
+
target.onerror = () => {
|
|
239
|
+
if (!attemptedProxy) {
|
|
240
|
+
attemptedProxy = true;
|
|
241
|
+
target.src = getProxyUrl(url);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
target.dataset.proxyUsed = attemptedProxy ? "true" : "false";
|
|
245
|
+
finalize();
|
|
246
|
+
};
|
|
247
|
+
const desiredSrc = attemptedProxy ? getProxyUrl(url) : url;
|
|
248
|
+
if (target.src !== desiredSrc) {
|
|
249
|
+
target.src = desiredSrc;
|
|
250
|
+
}
|
|
251
|
+
else if (target.complete && target.naturalWidth > 0) {
|
|
252
|
+
finalize();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
const preloadFonts = async (config) => {
|
|
257
|
+
if (config.error || !config.sides?.length)
|
|
258
|
+
return;
|
|
259
|
+
const fonts = new Set();
|
|
260
|
+
config.sides.forEach((side) => {
|
|
261
|
+
side.positions.forEach((position) => {
|
|
262
|
+
if (position.type === "TEXT" && position.font) {
|
|
263
|
+
fonts.add(position.font);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
if (fonts.size === 0)
|
|
268
|
+
return;
|
|
269
|
+
await Promise.all([...fonts].map((font) => loadFont(font)));
|
|
270
|
+
};
|
|
271
|
+
const preloadImages = async (config, imageRefs) => {
|
|
272
|
+
const entries = [];
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
if (config.image_url) {
|
|
275
|
+
entries.push({ url: config.image_url, cacheKey: "mockup" });
|
|
276
|
+
seen.add(config.image_url);
|
|
277
|
+
}
|
|
278
|
+
if (!config.sides?.length) {
|
|
279
|
+
await Promise.all(entries.map(({ url, cacheKey }) => loadImageAsync(url, imageRefs, cacheKey)));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
config.sides.forEach((side) => {
|
|
283
|
+
side.positions.forEach((position) => {
|
|
284
|
+
if (position.type === "ICON") {
|
|
285
|
+
if (position.icon !== 0) {
|
|
286
|
+
const iconUrl = getImageUrl("icon", position.icon);
|
|
287
|
+
if (!seen.has(iconUrl)) {
|
|
288
|
+
entries.push({ url: iconUrl });
|
|
289
|
+
seen.add(iconUrl);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
position.layer_colors?.forEach((color) => {
|
|
293
|
+
const colorUrl = getImageUrl("threadColor", color);
|
|
294
|
+
if (!seen.has(colorUrl)) {
|
|
295
|
+
entries.push({ url: colorUrl });
|
|
296
|
+
seen.add(colorUrl);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (position.type === "TEXT") {
|
|
301
|
+
if (position.floral_pattern) {
|
|
302
|
+
const floralUrl = getImageUrl("floral", position.floral_pattern);
|
|
303
|
+
if (!seen.has(floralUrl)) {
|
|
304
|
+
entries.push({ url: floralUrl });
|
|
305
|
+
seen.add(floralUrl);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (position.color) {
|
|
309
|
+
const threadUrl = getImageUrl("threadColor", position.color);
|
|
310
|
+
if (!seen.has(threadUrl)) {
|
|
311
|
+
entries.push({ url: threadUrl });
|
|
312
|
+
seen.add(threadUrl);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
position.character_colors?.forEach((color) => {
|
|
316
|
+
const characterColorUrl = getImageUrl("threadColor", color);
|
|
317
|
+
if (!seen.has(characterColorUrl)) {
|
|
318
|
+
entries.push({ url: characterColorUrl });
|
|
319
|
+
seen.add(characterColorUrl);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
if (entries.length === 0)
|
|
326
|
+
return;
|
|
327
|
+
await Promise.all(entries.map(({ url, cacheKey }) => loadImageAsync(url, imageRefs, cacheKey)));
|
|
328
|
+
};
|
|
329
|
+
const wrapText = (ctx, text, x, y, maxWidth, lineHeight) => {
|
|
330
|
+
const words = text.split(" ");
|
|
331
|
+
const lines = [];
|
|
332
|
+
let currentLine = words[0];
|
|
333
|
+
for (let i = 1; i < words.length; i++) {
|
|
334
|
+
const testLine = currentLine + " " + words[i];
|
|
335
|
+
if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
|
|
336
|
+
lines.push(currentLine);
|
|
337
|
+
currentLine = words[i];
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
currentLine = testLine;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
lines.push(currentLine);
|
|
344
|
+
let currentY = y;
|
|
345
|
+
lines.forEach((line) => {
|
|
346
|
+
ctx.fillText(line, x, currentY);
|
|
347
|
+
currentY += lineHeight;
|
|
348
|
+
});
|
|
349
|
+
return {
|
|
350
|
+
height: lines.length * lineHeight,
|
|
351
|
+
lastLineWidth: ctx.measureText(lines[lines.length - 1]).width,
|
|
352
|
+
lastLineY: y + (lines.length - 1) * lineHeight,
|
|
353
|
+
};
|
|
354
|
+
};
|
|
355
|
+
const buildWrappedLines = (ctx, text, maxWidth) => {
|
|
356
|
+
const words = text.split(" ").filter((word) => word.length > 0);
|
|
357
|
+
if (words.length === 0)
|
|
358
|
+
return [""];
|
|
359
|
+
const lines = [];
|
|
360
|
+
let currentLine = words[0];
|
|
361
|
+
for (let i = 1; i < words.length; i++) {
|
|
362
|
+
const testLine = `${currentLine} ${words[i]}`;
|
|
363
|
+
if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
|
|
364
|
+
lines.push(currentLine);
|
|
365
|
+
currentLine = words[i];
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
currentLine = testLine;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
lines.push(currentLine);
|
|
372
|
+
return lines;
|
|
373
|
+
};
|
|
374
|
+
const wrapTextMultiColor = (ctx, text, colors, x, y, maxWidth, lineHeight) => {
|
|
375
|
+
const words = text.split(" ");
|
|
376
|
+
const lines = [];
|
|
377
|
+
const lineStartIndices = [0];
|
|
378
|
+
let currentLine = words[0];
|
|
379
|
+
let currentCharIndex = words[0].length;
|
|
380
|
+
for (let i = 1; i < words.length; i++) {
|
|
381
|
+
const testLine = currentLine + " " + words[i];
|
|
382
|
+
if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
|
|
383
|
+
lines.push(currentLine);
|
|
384
|
+
lineStartIndices.push(currentCharIndex + 1);
|
|
385
|
+
currentLine = words[i];
|
|
386
|
+
currentCharIndex += words[i].length + 1;
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
currentLine = testLine;
|
|
390
|
+
currentCharIndex += words[i].length + 1;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
lines.push(currentLine);
|
|
394
|
+
let currentY = y;
|
|
395
|
+
lines.forEach((line, lineIdx) => {
|
|
396
|
+
let currentX = x;
|
|
397
|
+
const startCharIdx = lineIdx > 0 ? lineStartIndices[lineIdx] : 0;
|
|
398
|
+
for (let i = 0; i < line.length; i++) {
|
|
399
|
+
const char = line[i];
|
|
400
|
+
const globalCharIdx = startCharIdx + i;
|
|
401
|
+
const colorIndex = globalCharIdx % colors.length;
|
|
402
|
+
const color = colors[colorIndex];
|
|
403
|
+
ctx.fillStyle = COLOR_MAP[color] || "#000000";
|
|
404
|
+
ctx.fillText(char, currentX, currentY);
|
|
405
|
+
currentX += ctx.measureText(char).width;
|
|
406
|
+
}
|
|
407
|
+
currentY += lineHeight;
|
|
408
|
+
});
|
|
409
|
+
return lines.length * lineHeight;
|
|
410
|
+
};
|
|
411
|
+
const drawSwatches = (ctx, colors, startX, startY, swatchHeight, scaleFactor, imageRefs) => {
|
|
412
|
+
let swatchX = startX;
|
|
413
|
+
colors.forEach((color) => {
|
|
414
|
+
const url = getImageUrl("threadColor", color);
|
|
415
|
+
const img = imageRefs.current.get(url);
|
|
416
|
+
if (img && img.complete && img.naturalHeight > 0) {
|
|
417
|
+
const ratio = img.naturalWidth / img.naturalHeight;
|
|
418
|
+
const swatchW = Math.max(1, Math.floor(swatchHeight * ratio));
|
|
419
|
+
ctx.drawImage(img, swatchX, startY, swatchW, swatchHeight);
|
|
420
|
+
swatchX += swatchW + LAYOUT.SWATCH_SPACING * scaleFactor;
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
// ============================================================================
|
|
425
|
+
// MAIN COMPONENT
|
|
426
|
+
// ============================================================================
|
|
103
427
|
const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
|
|
104
428
|
const [canvasSize] = react.useState({ width: 4200, height: 4800 });
|
|
105
429
|
const [loadedFonts, setLoadedFonts] = react.useState(new Set());
|
|
@@ -109,7 +433,7 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
|
|
|
109
433
|
// Load fonts
|
|
110
434
|
react.useEffect(() => {
|
|
111
435
|
const loadFonts = async () => {
|
|
112
|
-
if (
|
|
436
|
+
if (config.error || !config.sides?.length)
|
|
113
437
|
return;
|
|
114
438
|
const fontsToLoad = new Set();
|
|
115
439
|
config.sides.forEach((side) => {
|
|
@@ -120,14 +444,14 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
|
|
|
120
444
|
});
|
|
121
445
|
});
|
|
122
446
|
for (const fontName of fontsToLoad) {
|
|
123
|
-
if (loadedFonts.has(fontName))
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
447
|
+
if (!loadedFonts.has(fontName)) {
|
|
448
|
+
try {
|
|
449
|
+
await loadFont(fontName);
|
|
450
|
+
setLoadedFonts((prev) => new Set(prev).add(fontName));
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
console.warn(`Could not load font ${fontName}:`, error);
|
|
454
|
+
}
|
|
131
455
|
}
|
|
132
456
|
}
|
|
133
457
|
};
|
|
@@ -135,650 +459,502 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
|
|
|
135
459
|
}, [config.sides, loadedFonts]);
|
|
136
460
|
// Load images
|
|
137
461
|
react.useEffect(() => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
};
|
|
152
|
-
img.onerror = () => {
|
|
153
|
-
if (useCors) {
|
|
154
|
-
// Retry without CORS; canvas may become tainted on export
|
|
155
|
-
loadMockup(false);
|
|
156
|
-
}
|
|
157
|
-
};
|
|
158
|
-
img.src = config.image_url;
|
|
159
|
-
};
|
|
160
|
-
loadMockup(true);
|
|
161
|
-
}
|
|
162
|
-
// Load icons
|
|
163
|
-
config.sides.forEach((side) => {
|
|
164
|
-
side.positions.forEach((position) => {
|
|
165
|
-
if (position.type === "ICON" && position.icon !== 0) {
|
|
166
|
-
const iconUrl = `${ICON_BASE_URL}/Icon ${position.icon}.png`;
|
|
167
|
-
if (!imageRefs.current.has(iconUrl)) {
|
|
168
|
-
const img = new Image();
|
|
169
|
-
img.crossOrigin = "anonymous";
|
|
170
|
-
img.src = iconUrl;
|
|
171
|
-
img.onload = () => setImagesLoaded((prev) => prev + 1);
|
|
172
|
-
imageRefs.current.set(iconUrl, img);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (position.type === "TEXT" && position.floral_pattern) {
|
|
176
|
-
const floralUrl = `${FLORAL_BASE_URL}/${position.floral_pattern}.png`;
|
|
177
|
-
if (!imageRefs.current.has(floralUrl)) {
|
|
178
|
-
const img = new Image();
|
|
179
|
-
img.crossOrigin = "anonymous";
|
|
180
|
-
img.src = floralUrl;
|
|
181
|
-
img.onload = () => setImagesLoaded((prev) => prev + 1);
|
|
182
|
-
imageRefs.current.set(floralUrl, img);
|
|
183
|
-
}
|
|
462
|
+
if (config.error || !config.sides?.length)
|
|
463
|
+
return;
|
|
464
|
+
const incrementCounter = () => setImagesLoaded((prev) => prev + 1);
|
|
465
|
+
// Load mockup
|
|
466
|
+
if (config.image_url) {
|
|
467
|
+
loadImage(config.image_url, imageRefs, incrementCounter);
|
|
468
|
+
}
|
|
469
|
+
// Load all other images
|
|
470
|
+
config.sides.forEach((side) => {
|
|
471
|
+
side.positions.forEach((position) => {
|
|
472
|
+
if (position.type === "ICON") {
|
|
473
|
+
if (position.icon !== 0) {
|
|
474
|
+
loadImage(getImageUrl("icon", position.icon), imageRefs, incrementCounter);
|
|
184
475
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
img.crossOrigin = "anonymous";
|
|
193
|
-
img.src = threadColorUrl;
|
|
194
|
-
img.onload = () => setImagesLoaded((prev) => prev + 1);
|
|
195
|
-
imageRefs.current.set(threadColorUrl, img);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
// Load character color images
|
|
199
|
-
if (position.character_colors &&
|
|
200
|
-
position.character_colors.length > 0) {
|
|
201
|
-
position.character_colors.forEach((color) => {
|
|
202
|
-
const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
|
|
203
|
-
if (!imageRefs.current.has(threadColorUrl)) {
|
|
204
|
-
const img = new Image();
|
|
205
|
-
img.crossOrigin = "anonymous";
|
|
206
|
-
img.src = threadColorUrl;
|
|
207
|
-
img.onload = () => setImagesLoaded((prev) => prev + 1);
|
|
208
|
-
imageRefs.current.set(threadColorUrl, img);
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
}
|
|
476
|
+
position.layer_colors?.forEach((color) => {
|
|
477
|
+
loadImage(getImageUrl("threadColor", color), imageRefs, incrementCounter);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (position.type === "TEXT") {
|
|
481
|
+
if (position.floral_pattern) {
|
|
482
|
+
loadImage(getImageUrl("floral", position.floral_pattern), imageRefs, incrementCounter);
|
|
212
483
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
position.layer_colors.forEach((color) => {
|
|
216
|
-
const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
|
|
217
|
-
if (!imageRefs.current.has(threadColorUrl)) {
|
|
218
|
-
const img = new Image();
|
|
219
|
-
img.crossOrigin = "anonymous";
|
|
220
|
-
img.src = threadColorUrl;
|
|
221
|
-
img.onload = () => setImagesLoaded((prev) => prev + 1);
|
|
222
|
-
imageRefs.current.set(threadColorUrl, img);
|
|
223
|
-
}
|
|
224
|
-
});
|
|
484
|
+
if (position.color) {
|
|
485
|
+
loadImage(getImageUrl("threadColor", position.color), imageRefs, incrementCounter);
|
|
225
486
|
}
|
|
226
|
-
|
|
487
|
+
position.character_colors?.forEach((color) => {
|
|
488
|
+
loadImage(getImageUrl("threadColor", color), imageRefs, incrementCounter);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
227
491
|
});
|
|
228
|
-
};
|
|
229
|
-
loadImages();
|
|
492
|
+
});
|
|
230
493
|
}, [config]);
|
|
231
494
|
// Render canvas
|
|
232
495
|
react.useEffect(() => {
|
|
233
496
|
const renderCanvas = () => {
|
|
234
|
-
if (!canvasRef.current
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
const canvas = canvasRef.current;
|
|
238
|
-
const ctx = canvas.getContext("2d");
|
|
239
|
-
if (!ctx)
|
|
497
|
+
if (!canvasRef.current)
|
|
240
498
|
return;
|
|
241
|
-
|
|
242
|
-
canvas.height = canvasSize.height;
|
|
243
|
-
// Clear with white background
|
|
244
|
-
ctx.fillStyle = "#FFFFFF";
|
|
245
|
-
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
246
|
-
// Collect floral images (for later drawing)
|
|
247
|
-
const floralAssets = [];
|
|
248
|
-
const seenFlorals = new Set();
|
|
249
|
-
if (config.sides) {
|
|
250
|
-
config.sides.forEach((side) => {
|
|
251
|
-
side.positions.forEach((position) => {
|
|
252
|
-
if (position.type === "TEXT" && position.floral_pattern) {
|
|
253
|
-
const floralUrl = `${FLORAL_BASE_URL}/${position.floral_pattern}.png`;
|
|
254
|
-
if (!seenFlorals.has(floralUrl)) {
|
|
255
|
-
const img = imageRefs.current.get(floralUrl);
|
|
256
|
-
if (img && img.complete && img.naturalWidth > 0) {
|
|
257
|
-
floralAssets.push(img);
|
|
258
|
-
seenFlorals.add(floralUrl);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
// Helper function to draw mockup and florals
|
|
266
|
-
const drawMockupAndFlorals = () => {
|
|
267
|
-
const mockupImg = imageRefs.current.get("mockup");
|
|
268
|
-
const margin = 40; // small padding
|
|
269
|
-
let mockupBox = null;
|
|
270
|
-
if (mockupImg && mockupImg.complete && mockupImg.naturalWidth > 0) {
|
|
271
|
-
const maxTargetWidth = Math.min(1800, canvas.width * 0.375);
|
|
272
|
-
const maxTargetHeight = canvas.height * 0.375;
|
|
273
|
-
const scale = Math.min(maxTargetWidth / mockupImg.naturalWidth, maxTargetHeight / mockupImg.naturalHeight);
|
|
274
|
-
const targetWidth = Math.max(1, Math.floor(mockupImg.naturalWidth * scale));
|
|
275
|
-
const targetHeight = Math.max(1, Math.floor(mockupImg.naturalHeight * scale));
|
|
276
|
-
const targetX = canvas.width - margin - targetWidth;
|
|
277
|
-
const targetY = canvas.height - margin - targetHeight;
|
|
278
|
-
mockupBox = {
|
|
279
|
-
x: targetX,
|
|
280
|
-
y: targetY,
|
|
281
|
-
w: targetWidth,
|
|
282
|
-
h: targetHeight,
|
|
283
|
-
};
|
|
284
|
-
ctx.drawImage(mockupImg, targetX, targetY, targetWidth, targetHeight);
|
|
285
|
-
}
|
|
286
|
-
// Draw florals to the left of mockup
|
|
287
|
-
if (mockupBox && floralAssets.length > 0) {
|
|
288
|
-
const spacing = 300;
|
|
289
|
-
const targetHeight = mockupBox.h;
|
|
290
|
-
const floralFixedH = Math.min(900, targetHeight);
|
|
291
|
-
let currentX = mockupBox.x - spacing;
|
|
292
|
-
for (let i = floralAssets.length - 1; i >= 0; i--) {
|
|
293
|
-
const img = floralAssets[i];
|
|
294
|
-
const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
|
|
295
|
-
const h = floralFixedH;
|
|
296
|
-
const w = Math.max(1, Math.floor(h * ratio));
|
|
297
|
-
currentX -= w;
|
|
298
|
-
const y = mockupBox.y + (targetHeight - h);
|
|
299
|
-
ctx.drawImage(img, currentX, y, w, h);
|
|
300
|
-
currentX -= spacing;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
};
|
|
304
|
-
// New approach: Draw images first (bottom layer), then text on top
|
|
305
|
-
// This allows text to overlay images when needed
|
|
306
|
-
// Pass 1: Measure actual height with original size (use offscreen canvas for measurement)
|
|
307
|
-
const measureCanvas = document.createElement("canvas");
|
|
308
|
-
measureCanvas.width = canvas.width;
|
|
309
|
-
measureCanvas.height = canvas.height;
|
|
310
|
-
const measureCtx = measureCanvas.getContext("2d");
|
|
311
|
-
if (!measureCtx)
|
|
312
|
-
return;
|
|
313
|
-
// Set up measurement context
|
|
314
|
-
measureCtx.font = ctx.font;
|
|
315
|
-
measureCtx.textAlign = ctx.textAlign;
|
|
316
|
-
measureCtx.textBaseline = ctx.textBaseline;
|
|
317
|
-
let measureY = 40;
|
|
318
|
-
const measureSpacing = 100;
|
|
319
|
-
config.sides.forEach((side) => {
|
|
320
|
-
const sideHeight = renderSide(measureCtx, side, measureY, canvas.width, 1);
|
|
321
|
-
measureY += sideHeight + measureSpacing;
|
|
322
|
-
});
|
|
323
|
-
const totalMeasuredHeight = measureY; // Total height used
|
|
324
|
-
// Calculate scale factor - only scale down when necessary
|
|
325
|
-
// Keep original font sizes (no scale up) - font size is the maximum
|
|
326
|
-
const topPadding = 40;
|
|
327
|
-
// No bottom padding - content can go to bottom, mockup will overlay
|
|
328
|
-
const targetContentHeight = canvas.height - topPadding;
|
|
329
|
-
// Only scale down if content exceeds canvas height
|
|
330
|
-
// Never scale up - preserve original font sizes
|
|
331
|
-
let scaleFactor = 1;
|
|
332
|
-
if (totalMeasuredHeight > targetContentHeight) {
|
|
333
|
-
// Scale down to fit exactly
|
|
334
|
-
scaleFactor = targetContentHeight / totalMeasuredHeight;
|
|
335
|
-
scaleFactor = Math.max(0.5, scaleFactor); // Minimum scale to prevent tiny fonts
|
|
336
|
-
}
|
|
337
|
-
// If content fits, keep scaleFactor = 1 (original font sizes)
|
|
338
|
-
// Draw mockup and florals first (bottom layer)
|
|
339
|
-
drawMockupAndFlorals();
|
|
340
|
-
// Draw content on top (top layer) - text will overlay images if needed
|
|
341
|
-
let currentY = topPadding * scaleFactor;
|
|
342
|
-
config.sides.forEach((side) => {
|
|
343
|
-
const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor);
|
|
344
|
-
currentY += sideHeight + measureSpacing * scaleFactor;
|
|
345
|
-
});
|
|
499
|
+
renderEmbroideryCanvas(canvasRef.current, config, canvasSize, imageRefs);
|
|
346
500
|
};
|
|
347
|
-
// Delay rendering to ensure fonts and images are loaded
|
|
348
501
|
const timer = setTimeout(renderCanvas, 100);
|
|
349
502
|
return () => clearTimeout(timer);
|
|
350
503
|
}, [config, canvasSize, loadedFonts, imagesLoaded]);
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const lastLineY = y + (lines.length - 1) * lineHeight;
|
|
377
|
-
return {
|
|
378
|
-
height: lines.length * lineHeight,
|
|
379
|
-
lastLineWidth,
|
|
380
|
-
lastLineY,
|
|
381
|
-
};
|
|
382
|
-
};
|
|
383
|
-
// Helper to wrap and draw multi-color text (for character_colors)
|
|
384
|
-
const fillTextWrappedMultiColor = (ctx, text, colors, x, y, maxWidth, lineHeight) => {
|
|
385
|
-
const words = text.split(" ");
|
|
386
|
-
const lines = [];
|
|
387
|
-
const lineStartIndices = [0];
|
|
388
|
-
let currentLine = words[0];
|
|
389
|
-
let currentCharIndex = words[0].length;
|
|
390
|
-
for (let i = 1; i < words.length; i++) {
|
|
391
|
-
const word = words[i];
|
|
392
|
-
const testLine = currentLine + " " + word;
|
|
393
|
-
const metrics = ctx.measureText(testLine);
|
|
394
|
-
if (metrics.width > maxWidth && currentLine.length > 0) {
|
|
395
|
-
lines.push(currentLine);
|
|
396
|
-
lineStartIndices.push(currentCharIndex + 1); // +1 for space
|
|
397
|
-
currentLine = word;
|
|
398
|
-
currentCharIndex += word.length + 1;
|
|
399
|
-
}
|
|
400
|
-
else {
|
|
401
|
-
currentLine = testLine;
|
|
402
|
-
currentCharIndex += word.length + 1;
|
|
403
|
-
}
|
|
504
|
+
return (jsxRuntime.jsx("div", { className: `render-embroidery${className ? ` ${className}` : ""}`, style: style, children: jsxRuntime.jsx("canvas", { ref: canvasRef, className: "render-embroidery-canvas" }) }));
|
|
505
|
+
};
|
|
506
|
+
// ============================================================================
|
|
507
|
+
// RENDERING FUNCTIONS
|
|
508
|
+
// ============================================================================
|
|
509
|
+
const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
|
|
510
|
+
const ctx = canvas.getContext("2d");
|
|
511
|
+
if (!ctx)
|
|
512
|
+
return;
|
|
513
|
+
canvas.width = canvasSize.width;
|
|
514
|
+
canvas.height = canvasSize.height;
|
|
515
|
+
ctx.fillStyle = LAYOUT.BACKGROUND_COLOR;
|
|
516
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
517
|
+
if (config.error) {
|
|
518
|
+
renderErrorState(ctx, canvas, config.error);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!config.sides?.length)
|
|
522
|
+
return;
|
|
523
|
+
ctx.textAlign = LAYOUT.TEXT_ALIGN;
|
|
524
|
+
ctx.textBaseline = LAYOUT.TEXT_BASELINE;
|
|
525
|
+
if (config.image_url) {
|
|
526
|
+
const mockupImage = imageRefs.current.get(config.image_url);
|
|
527
|
+
if (mockupImage) {
|
|
528
|
+
imageRefs.current.set("mockup", mockupImage);
|
|
404
529
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const startCharIdx = lineIdx > 0 ? lineStartIndices[lineIdx] : 0;
|
|
410
|
-
for (let i = 0; i < line.length; i++) {
|
|
411
|
-
const char = line[i];
|
|
412
|
-
const globalCharIdx = startCharIdx + i;
|
|
413
|
-
const colorIndex = globalCharIdx % colors.length;
|
|
414
|
-
const color = colors[colorIndex];
|
|
415
|
-
ctx.fillStyle = COLOR_MAP[color] || "#000000";
|
|
416
|
-
ctx.fillText(char, currentX, currentY);
|
|
417
|
-
currentX += ctx.measureText(char).width;
|
|
418
|
-
}
|
|
419
|
-
currentY += lineHeight;
|
|
420
|
-
});
|
|
421
|
-
return lines.length * lineHeight;
|
|
422
|
-
};
|
|
423
|
-
const renderSide = (ctx, side, startY, width, scaleFactor = 1) => {
|
|
424
|
-
let currentY = startY;
|
|
425
|
-
const padding = 40 * scaleFactor;
|
|
426
|
-
const sideWidth = width - 2 * padding;
|
|
427
|
-
const sectionHeight = 200 * scaleFactor;
|
|
428
|
-
// No background section anymore - just white background
|
|
429
|
-
// Group positions by common properties for optimization
|
|
430
|
-
const textGroups = [];
|
|
431
|
-
let currentGroup = null;
|
|
432
|
-
let currentProps = null;
|
|
530
|
+
}
|
|
531
|
+
const floralAssets = [];
|
|
532
|
+
const seenFlorals = new Set();
|
|
533
|
+
config.sides.forEach((side) => {
|
|
433
534
|
side.positions.forEach((position) => {
|
|
434
|
-
if (position.type === "TEXT") {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
// Start new group
|
|
442
|
-
if (currentGroup) {
|
|
443
|
-
textGroups.push({
|
|
444
|
-
positions: currentGroup,
|
|
445
|
-
properties: currentProps,
|
|
446
|
-
});
|
|
535
|
+
if (position.type === "TEXT" && position.floral_pattern) {
|
|
536
|
+
const url = getImageUrl("floral", position.floral_pattern);
|
|
537
|
+
if (!seenFlorals.has(url)) {
|
|
538
|
+
const img = imageRefs.current.get(url);
|
|
539
|
+
if (img?.complete && img.naturalWidth > 0) {
|
|
540
|
+
floralAssets.push(img);
|
|
541
|
+
seenFlorals.add(url);
|
|
447
542
|
}
|
|
448
|
-
currentGroup = [position];
|
|
449
|
-
currentProps = {
|
|
450
|
-
font: position.font,
|
|
451
|
-
text_shape: position.text_shape,
|
|
452
|
-
color: position.color,
|
|
453
|
-
character_colors: position.character_colors,
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
else {
|
|
457
|
-
currentGroup.push(position);
|
|
458
543
|
}
|
|
459
544
|
}
|
|
460
545
|
});
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
textGroups.forEach((group, groupIndex) => {
|
|
503
|
-
group.positions.forEach((position, index) => {
|
|
504
|
-
if (index === 0 && groupIndex !== 0)
|
|
505
|
-
currentY += 50 * scaleFactor;
|
|
506
|
-
const drawnHeight = renderText(ctx, position, padding, currentY, sideWidth, sideTextCounter, {
|
|
507
|
-
font: !sideUniform.font,
|
|
508
|
-
shape: !sideUniform.shape,
|
|
509
|
-
floral: !sideUniform.floral,
|
|
510
|
-
color: !sideUniform.color,
|
|
511
|
-
}, scaleFactor);
|
|
512
|
-
sideTextCounter += 1;
|
|
513
|
-
// add padding only if something was actually drawn
|
|
514
|
-
if (drawnHeight > 0) {
|
|
515
|
-
currentY += drawnHeight + 40 * scaleFactor;
|
|
516
|
-
}
|
|
517
|
-
});
|
|
518
|
-
});
|
|
519
|
-
// Render ICON titles/values (no images here)
|
|
520
|
-
currentY += 30 * scaleFactor; // minimal spacing before icon labels
|
|
521
|
-
side.positions.forEach((position) => {
|
|
522
|
-
if (position.type === "ICON") {
|
|
523
|
-
currentY += renderIconLabels(ctx, position, padding, currentY, sideWidth, scaleFactor);
|
|
524
|
-
currentY += 10 * scaleFactor;
|
|
525
|
-
}
|
|
526
|
-
});
|
|
527
|
-
return Math.max(currentY - startY, sectionHeight);
|
|
528
|
-
};
|
|
529
|
-
const renderSideUniformLabels = (ctx, values, x, y, maxWidth, scaleFactor = 1) => {
|
|
530
|
-
const labelFontFamily = "Arial";
|
|
531
|
-
const fontSize = 180 * scaleFactor;
|
|
532
|
-
const lineGap = 20 * scaleFactor;
|
|
533
|
-
ctx.save();
|
|
534
|
-
ctx.font = `${fontSize}px ${labelFontFamily}`;
|
|
535
|
-
ctx.textAlign = "left";
|
|
536
|
-
ctx.textBaseline = "top";
|
|
537
|
-
ctx.fillStyle = "#444444";
|
|
538
|
-
let cursorY = y;
|
|
539
|
-
let rendered = 0;
|
|
540
|
-
if (values.font) {
|
|
541
|
-
const fontText = `Font: ${values.font}`;
|
|
542
|
-
const result = fillTextWrapped(ctx, fontText, x, cursorY, maxWidth, fontSize + lineGap);
|
|
543
|
-
cursorY += result.height;
|
|
544
|
-
rendered++;
|
|
545
|
-
}
|
|
546
|
-
if (values.shape && values.shape !== "None") {
|
|
547
|
-
const shapeText = `Kiểu chữ: ${values.shape}`;
|
|
548
|
-
const result = fillTextWrapped(ctx, shapeText, x, cursorY, maxWidth, fontSize + lineGap);
|
|
549
|
-
cursorY += result.height;
|
|
550
|
-
rendered++;
|
|
551
|
-
}
|
|
552
|
-
if (values.color && values.color !== "None") {
|
|
553
|
-
const colorText = `Màu chỉ: ${values.color}`;
|
|
554
|
-
// Reserve space for swatches (estimate: max 5 swatches × 200px each = 1000px)
|
|
555
|
-
const swatchReserved = 1000 * scaleFactor;
|
|
556
|
-
const textMaxWidth = Math.max(400 * scaleFactor, maxWidth - swatchReserved);
|
|
557
|
-
const result = fillTextWrapped(ctx, colorText, x, cursorY, textMaxWidth, fontSize + lineGap);
|
|
558
|
-
// Draw swatches inline for side-level color, preserving aspect ratio; 75% of previous size
|
|
559
|
-
// Position swatches after the last line of wrapped text
|
|
560
|
-
const swatchH = Math.floor(fontSize * 2.025);
|
|
561
|
-
let swatchX = x + Math.ceil(result.lastLineWidth) + 100 * scaleFactor;
|
|
562
|
-
const swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
|
|
563
|
-
const colorTokens = values.color.includes(",")
|
|
564
|
-
? values.color.split(",").map((s) => s.trim())
|
|
565
|
-
: [values.color];
|
|
566
|
-
colorTokens.forEach((color) => {
|
|
567
|
-
const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
|
|
568
|
-
const img = imageRefs.current.get(threadColorUrl);
|
|
569
|
-
if (img && img.complete && img.naturalHeight > 0) {
|
|
570
|
-
const ratio = img.naturalWidth / img.naturalHeight;
|
|
571
|
-
const swatchW = Math.max(1, Math.floor(swatchH * ratio));
|
|
572
|
-
ctx.drawImage(img, swatchX, swatchY, swatchW, swatchH);
|
|
573
|
-
swatchX += swatchW + 25 * scaleFactor;
|
|
574
|
-
}
|
|
575
|
-
});
|
|
576
|
-
cursorY += result.height;
|
|
577
|
-
rendered++;
|
|
578
|
-
}
|
|
579
|
-
if (values.floral && values.floral !== "None") {
|
|
580
|
-
const floralText = `Mẫu hoa: ${values.floral}`;
|
|
581
|
-
const result = fillTextWrapped(ctx, floralText, x, cursorY, maxWidth, fontSize + lineGap);
|
|
582
|
-
cursorY += result.height;
|
|
583
|
-
rendered++;
|
|
584
|
-
}
|
|
585
|
-
if (rendered > 0)
|
|
586
|
-
cursorY += 50 * scaleFactor; // extra gap before first text line
|
|
587
|
-
ctx.restore();
|
|
588
|
-
return cursorY - y;
|
|
546
|
+
});
|
|
547
|
+
const measureCanvas = document.createElement("canvas");
|
|
548
|
+
measureCanvas.width = canvas.width;
|
|
549
|
+
measureCanvas.height = canvas.height;
|
|
550
|
+
const measureCtx = measureCanvas.getContext("2d");
|
|
551
|
+
if (!measureCtx)
|
|
552
|
+
return;
|
|
553
|
+
measureCtx.textAlign = LAYOUT.TEXT_ALIGN;
|
|
554
|
+
measureCtx.textBaseline = LAYOUT.TEXT_BASELINE;
|
|
555
|
+
let measureY = LAYOUT.PADDING;
|
|
556
|
+
const measureSpacing = LAYOUT.ELEMENT_SPACING;
|
|
557
|
+
config.sides.forEach((side) => {
|
|
558
|
+
const sideHeight = renderSide(measureCtx, side, measureY, canvas.width, 1, imageRefs);
|
|
559
|
+
measureY += sideHeight + measureSpacing;
|
|
560
|
+
});
|
|
561
|
+
const scaleFactor = Math.max(0.5, Math.min(1, (canvas.height - LAYOUT.PADDING) / measureY));
|
|
562
|
+
drawMockupAndFlorals(ctx, canvas, floralAssets, imageRefs);
|
|
563
|
+
let currentY = LAYOUT.PADDING * scaleFactor;
|
|
564
|
+
config.sides.forEach((side) => {
|
|
565
|
+
const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor, imageRefs);
|
|
566
|
+
currentY += sideHeight + measureSpacing * scaleFactor;
|
|
567
|
+
});
|
|
568
|
+
};
|
|
569
|
+
const renderErrorState = (ctx, canvas, message) => {
|
|
570
|
+
const sanitizedMessage = message.trim() || "Đã xảy ra lỗi";
|
|
571
|
+
const horizontalPadding = LAYOUT.PADDING * 3;
|
|
572
|
+
const maxWidth = canvas.width - horizontalPadding * 2;
|
|
573
|
+
const baseFontSize = LAYOUT.HEADER_FONT_SIZE;
|
|
574
|
+
const minFontSize = 60;
|
|
575
|
+
const centerX = canvas.width / 2;
|
|
576
|
+
ctx.save();
|
|
577
|
+
ctx.textAlign = "center";
|
|
578
|
+
ctx.textBaseline = "top";
|
|
579
|
+
ctx.fillStyle = DEFAULT_ERROR_COLOR;
|
|
580
|
+
let fontSize = baseFontSize;
|
|
581
|
+
let lineGap = LAYOUT.LINE_GAP;
|
|
582
|
+
let lineHeight = fontSize + lineGap;
|
|
583
|
+
const adjustMetrics = () => {
|
|
584
|
+
ctx.font = `bold ${fontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
585
|
+
lineGap = LAYOUT.LINE_GAP * (fontSize / baseFontSize);
|
|
586
|
+
lineHeight = fontSize + lineGap;
|
|
589
587
|
};
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
ctx.
|
|
630
|
-
|
|
631
|
-
currentYCursor += textResult.height;
|
|
632
|
-
drawnHeight += textResult.height;
|
|
633
|
-
}
|
|
634
|
-
// After text, print Kiểu chữ (when not uniform), then Font and Color as needed
|
|
635
|
-
currentYCursor += infoLineGap;
|
|
636
|
-
ctx.font = `${infoFontSize}px ${labelFontFamily}`;
|
|
637
|
-
ctx.fillStyle = "#444444";
|
|
638
|
-
if (showLabels.shape && position.text_shape) {
|
|
639
|
-
const shapeLabelAfter = `Kiểu chữ: ${position.text_shape}`;
|
|
640
|
-
const result = fillTextWrapped(ctx, shapeLabelAfter, x, currentYCursor, maxWidth, infoFontSize + infoLineGap);
|
|
641
|
-
currentYCursor += result.height;
|
|
642
|
-
drawnHeight += result.height;
|
|
643
|
-
}
|
|
644
|
-
if (showLabels.font && position.font) {
|
|
645
|
-
const fontLabel = `Font: ${position.font}`;
|
|
646
|
-
const result = fillTextWrapped(ctx, fontLabel, x, currentYCursor, maxWidth, infoFontSize + infoLineGap);
|
|
647
|
-
currentYCursor += result.height;
|
|
648
|
-
drawnHeight += result.height;
|
|
588
|
+
adjustMetrics();
|
|
589
|
+
let lines = buildWrappedLines(ctx, sanitizedMessage, maxWidth);
|
|
590
|
+
let longestLineWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
|
591
|
+
while (longestLineWidth > maxWidth && fontSize > minFontSize) {
|
|
592
|
+
fontSize = Math.max(minFontSize, Math.floor(fontSize * 0.9));
|
|
593
|
+
adjustMetrics();
|
|
594
|
+
lines = buildWrappedLines(ctx, sanitizedMessage, maxWidth);
|
|
595
|
+
longestLineWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
|
596
|
+
}
|
|
597
|
+
const totalHeight = lines.length * lineHeight;
|
|
598
|
+
const startY = Math.max(LAYOUT.PADDING * 2, canvas.height / 2 - totalHeight / 2);
|
|
599
|
+
lines.forEach((line, index) => {
|
|
600
|
+
const y = startY + index * lineHeight;
|
|
601
|
+
ctx.fillText(line, centerX, y);
|
|
602
|
+
});
|
|
603
|
+
ctx.restore();
|
|
604
|
+
};
|
|
605
|
+
const drawMockupAndFlorals = (ctx, canvas, floralAssets, imageRefs) => {
|
|
606
|
+
const mockupImg = imageRefs.current.get("mockup");
|
|
607
|
+
if (!mockupImg?.complete || !mockupImg.naturalWidth)
|
|
608
|
+
return;
|
|
609
|
+
const margin = LAYOUT.PADDING;
|
|
610
|
+
const maxWidth = Math.min(1800, canvas.width * 0.375);
|
|
611
|
+
const maxHeight = canvas.height * 0.375;
|
|
612
|
+
const scale = Math.min(maxWidth / mockupImg.naturalWidth, maxHeight / mockupImg.naturalHeight);
|
|
613
|
+
const width = Math.max(1, Math.floor(mockupImg.naturalWidth * scale));
|
|
614
|
+
const height = Math.max(1, Math.floor(mockupImg.naturalHeight * scale));
|
|
615
|
+
const x = canvas.width - margin - width;
|
|
616
|
+
const y = canvas.height - margin - height;
|
|
617
|
+
ctx.drawImage(mockupImg, x, y, width, height);
|
|
618
|
+
// Draw florals
|
|
619
|
+
if (floralAssets.length > 0) {
|
|
620
|
+
const floralH = Math.min(900, height);
|
|
621
|
+
let currentX = x - LAYOUT.FLORAL_SPACING;
|
|
622
|
+
for (let i = floralAssets.length - 1; i >= 0; i--) {
|
|
623
|
+
const img = floralAssets[i];
|
|
624
|
+
const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
|
|
625
|
+
const w = Math.max(1, Math.floor(floralH * ratio));
|
|
626
|
+
currentX -= w;
|
|
627
|
+
ctx.drawImage(img, currentX, y + height - floralH, w, floralH);
|
|
628
|
+
currentX -= LAYOUT.FLORAL_SPACING;
|
|
649
629
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
|
|
633
|
+
let currentY = startY;
|
|
634
|
+
const padding = LAYOUT.PADDING * scaleFactor;
|
|
635
|
+
const sideWidth = width - 2 * padding;
|
|
636
|
+
// Draw header
|
|
637
|
+
ctx.save();
|
|
638
|
+
const headerFontSize = LAYOUT.HEADER_FONT_SIZE * scaleFactor;
|
|
639
|
+
ctx.font = `bold ${headerFontSize}px ${LAYOUT.HEADER_FONT_FAMILY}`;
|
|
640
|
+
ctx.fillStyle = LAYOUT.HEADER_COLOR;
|
|
641
|
+
const headerResult = wrapText(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize);
|
|
642
|
+
// Draw underline
|
|
643
|
+
const underlineY = headerResult.lastLineY + headerFontSize * LAYOUT.UNDERLINE_POSITION;
|
|
644
|
+
ctx.strokeStyle = LAYOUT.HEADER_COLOR;
|
|
645
|
+
ctx.lineWidth = LAYOUT.UNDERLINE_WIDTH * scaleFactor;
|
|
646
|
+
ctx.beginPath();
|
|
647
|
+
ctx.moveTo(padding, underlineY);
|
|
648
|
+
ctx.lineTo(padding + headerResult.lastLineWidth, underlineY);
|
|
649
|
+
ctx.stroke();
|
|
650
|
+
currentY += headerResult.height + LAYOUT.SECTION_SPACING * scaleFactor;
|
|
651
|
+
ctx.restore();
|
|
652
|
+
// Compute uniform properties
|
|
653
|
+
const textPositions = side.positions.filter((p) => p.type === "TEXT");
|
|
654
|
+
const uniformProps = computeUniformProperties(textPositions);
|
|
655
|
+
// Render uniform labels (only if more than 1 TEXT position)
|
|
656
|
+
if (textPositions.length > 1) {
|
|
657
|
+
currentY += renderUniformLabels(ctx, uniformProps, padding, currentY, sideWidth, scaleFactor, imageRefs);
|
|
658
|
+
}
|
|
659
|
+
// Group text positions by common properties
|
|
660
|
+
const textGroups = groupTextPositions(textPositions);
|
|
661
|
+
// Render text positions (with proper spacing between groups)
|
|
662
|
+
let textCounter = 1;
|
|
663
|
+
textGroups.forEach((group, groupIndex) => {
|
|
664
|
+
group.forEach((position, index) => {
|
|
665
|
+
// Add extra spacing between different groups
|
|
666
|
+
if (index === 0 && groupIndex !== 0) {
|
|
667
|
+
currentY += LAYOUT.SECTION_SPACING * scaleFactor;
|
|
654
668
|
}
|
|
655
|
-
|
|
656
|
-
|
|
669
|
+
// If only 1 TEXT position, show all labels (no uniform labels rendered)
|
|
670
|
+
const showLabels = textPositions.length === 1
|
|
671
|
+
? { font: true, shape: true, floral: true, color: true }
|
|
672
|
+
: {
|
|
673
|
+
font: !uniformProps.isUniform.font,
|
|
674
|
+
shape: !uniformProps.isUniform.shape,
|
|
675
|
+
floral: !uniformProps.isUniform.floral,
|
|
676
|
+
color: !uniformProps.isUniform.color,
|
|
677
|
+
};
|
|
678
|
+
const height = renderTextPosition(ctx, position, padding, currentY, sideWidth, textCounter, showLabels, scaleFactor, imageRefs);
|
|
679
|
+
if (height > 0) {
|
|
680
|
+
currentY += height + LAYOUT.PADDING * scaleFactor;
|
|
681
|
+
textCounter++;
|
|
657
682
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
currentYCursor += result.height;
|
|
691
|
-
drawnHeight += result.height;
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
// Render icon positions
|
|
686
|
+
currentY += LAYOUT.LINE_GAP * scaleFactor;
|
|
687
|
+
side.positions.forEach((position) => {
|
|
688
|
+
if (position.type === "ICON") {
|
|
689
|
+
currentY += renderIconPosition(ctx, position, padding, currentY, sideWidth, scaleFactor, imageRefs);
|
|
690
|
+
currentY += (LAYOUT.LINE_GAP / 3) * scaleFactor;
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
return currentY - startY;
|
|
694
|
+
};
|
|
695
|
+
const groupTextPositions = (textPositions) => {
|
|
696
|
+
const groups = [];
|
|
697
|
+
let currentGroup = null;
|
|
698
|
+
let currentProps = null;
|
|
699
|
+
textPositions.forEach((position) => {
|
|
700
|
+
const posProps = {
|
|
701
|
+
font: position.font,
|
|
702
|
+
text_shape: position.text_shape,
|
|
703
|
+
color: position.color,
|
|
704
|
+
character_colors: position.character_colors?.join(","),
|
|
705
|
+
};
|
|
706
|
+
if (!currentGroup ||
|
|
707
|
+
currentProps.font !== posProps.font ||
|
|
708
|
+
currentProps.text_shape !== posProps.text_shape ||
|
|
709
|
+
currentProps.color !== posProps.color ||
|
|
710
|
+
currentProps.character_colors !== posProps.character_colors) {
|
|
711
|
+
if (currentGroup) {
|
|
712
|
+
groups.push(currentGroup);
|
|
692
713
|
}
|
|
714
|
+
currentGroup = [position];
|
|
715
|
+
currentProps = posProps;
|
|
693
716
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const floralText = `Mẫu hoa: ${position.floral_pattern}`;
|
|
697
|
-
const result = fillTextWrapped(ctx, floralText, x, currentYCursor, maxWidth, infoFontSize + infoLineGap);
|
|
698
|
-
currentYCursor += result.height;
|
|
699
|
-
drawnHeight += result.height;
|
|
717
|
+
else {
|
|
718
|
+
currentGroup.push(position);
|
|
700
719
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
720
|
+
});
|
|
721
|
+
if (currentGroup) {
|
|
722
|
+
groups.push(currentGroup);
|
|
723
|
+
}
|
|
724
|
+
return groups;
|
|
725
|
+
};
|
|
726
|
+
const computeUniformProperties = (textPositions) => {
|
|
727
|
+
if (textPositions.length === 0) {
|
|
728
|
+
return {
|
|
729
|
+
values: { font: null, shape: null, floral: null, color: null },
|
|
730
|
+
isUniform: { font: false, shape: false, floral: false, color: false },
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const fonts = new Set(textPositions.map((p) => p.font));
|
|
734
|
+
const shapes = new Set(textPositions.map((p) => p.text_shape));
|
|
735
|
+
const florals = new Set(textPositions.map((p) => p.floral_pattern ?? "None"));
|
|
736
|
+
const colors = new Set(textPositions.map((p) => p.character_colors?.length
|
|
737
|
+
? p.character_colors.join(",")
|
|
738
|
+
: p.color ?? "None"));
|
|
739
|
+
return {
|
|
740
|
+
values: {
|
|
741
|
+
font: fonts.size === 1 ? [...fonts][0] : null,
|
|
742
|
+
shape: shapes.size === 1 ? [...shapes][0] : null,
|
|
743
|
+
floral: florals.size === 1 ? [...florals][0] : null,
|
|
744
|
+
color: colors.size === 1 ? [...colors][0] : null,
|
|
745
|
+
},
|
|
746
|
+
isUniform: {
|
|
747
|
+
font: fonts.size === 1,
|
|
748
|
+
shape: shapes.size === 1,
|
|
749
|
+
floral: florals.size === 1,
|
|
750
|
+
color: colors.size === 1,
|
|
751
|
+
},
|
|
704
752
|
};
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
753
|
+
};
|
|
754
|
+
const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, imageRefs) => {
|
|
755
|
+
const { values } = uniformProps;
|
|
756
|
+
const fontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
|
|
757
|
+
const lineGap = LAYOUT.LINE_GAP * scaleFactor;
|
|
758
|
+
ctx.save();
|
|
759
|
+
ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
760
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
761
|
+
let cursorY = y;
|
|
762
|
+
let rendered = 0;
|
|
763
|
+
if (values.font) {
|
|
764
|
+
const result = wrapText(ctx, `Font: ${values.font}`, x, cursorY, maxWidth, fontSize + lineGap);
|
|
765
|
+
cursorY += result.height;
|
|
766
|
+
rendered++;
|
|
767
|
+
}
|
|
768
|
+
if (values.shape && values.shape !== "None") {
|
|
769
|
+
const result = wrapText(ctx, `Kiểu chữ: ${values.shape}`, x, cursorY, maxWidth, fontSize + lineGap);
|
|
770
|
+
cursorY += result.height;
|
|
771
|
+
rendered++;
|
|
772
|
+
}
|
|
773
|
+
if (values.color && values.color !== "None") {
|
|
774
|
+
const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
|
|
775
|
+
const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, textMaxWidth, fontSize + lineGap);
|
|
776
|
+
const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
|
|
777
|
+
const swatchX = x +
|
|
778
|
+
Math.ceil(result.lastLineWidth) +
|
|
779
|
+
LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
780
|
+
const swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
|
|
781
|
+
const colors = values.color.includes(",")
|
|
782
|
+
? values.color.split(",").map((s) => s.trim())
|
|
783
|
+
: [values.color];
|
|
784
|
+
drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
|
|
785
|
+
cursorY += result.height;
|
|
786
|
+
rendered++;
|
|
787
|
+
}
|
|
788
|
+
if (values.floral && values.floral !== "None") {
|
|
789
|
+
const result = wrapText(ctx, `Mẫu hoa: ${values.floral}`, x, cursorY, maxWidth, fontSize + lineGap);
|
|
790
|
+
cursorY += result.height;
|
|
791
|
+
rendered++;
|
|
792
|
+
}
|
|
793
|
+
if (rendered > 0)
|
|
794
|
+
cursorY += LAYOUT.SECTION_SPACING * scaleFactor;
|
|
795
|
+
ctx.restore();
|
|
796
|
+
return cursorY - y;
|
|
797
|
+
};
|
|
798
|
+
const renderTextPosition = (ctx, position, x, y, maxWidth, displayIndex, showLabels, scaleFactor, imageRefs) => {
|
|
799
|
+
ctx.save();
|
|
800
|
+
const textFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
|
|
801
|
+
const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
|
|
802
|
+
const lineGap = LAYOUT.LINE_GAP * scaleFactor;
|
|
803
|
+
let currentY = y;
|
|
804
|
+
let drawnHeight = 0;
|
|
805
|
+
// Draw label
|
|
806
|
+
const textLabel = `Text ${displayIndex}: `;
|
|
807
|
+
ctx.font = `bold ${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
808
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
809
|
+
const labelWidth = ctx.measureText(textLabel).width;
|
|
810
|
+
ctx.fillText(textLabel, x, currentY);
|
|
811
|
+
const textMaxWidth = maxWidth - labelWidth;
|
|
812
|
+
// Get display text (handle empty/null/undefined)
|
|
813
|
+
const isEmptyText = !position.text || position.text.trim() === "";
|
|
814
|
+
// Draw text content
|
|
815
|
+
if (isEmptyText) {
|
|
816
|
+
ctx.font = `${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
817
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
818
|
+
const textResult = wrapText(ctx, "không thay đổi", x + labelWidth, currentY, textMaxWidth, textFontSize);
|
|
819
|
+
currentY += textResult.height;
|
|
820
|
+
drawnHeight += textResult.height;
|
|
821
|
+
}
|
|
822
|
+
else if (position.character_colors?.length) {
|
|
823
|
+
ctx.font = `${textFontSize}px ${position.font}`;
|
|
824
|
+
const textHeight = wrapTextMultiColor(ctx, position.text, position.character_colors, x + labelWidth, currentY, textMaxWidth, textFontSize);
|
|
825
|
+
currentY += textHeight;
|
|
826
|
+
drawnHeight += textHeight;
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
ctx.font = `${textFontSize}px ${position.font}`;
|
|
830
|
+
ctx.fillStyle = COLOR_MAP[position.color ?? "None"] || "#000000";
|
|
831
|
+
const textResult = wrapText(ctx, position.text, x + labelWidth, currentY, textMaxWidth, textFontSize);
|
|
832
|
+
currentY += textResult.height;
|
|
833
|
+
drawnHeight += textResult.height;
|
|
834
|
+
}
|
|
835
|
+
// Draw additional labels
|
|
836
|
+
currentY += lineGap;
|
|
837
|
+
ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
838
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
839
|
+
if (showLabels.shape && position.text_shape) {
|
|
840
|
+
const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap);
|
|
841
|
+
currentY += result.height;
|
|
842
|
+
drawnHeight += result.height;
|
|
843
|
+
}
|
|
844
|
+
if (showLabels.font && position.font) {
|
|
845
|
+
const result = wrapText(ctx, `Font: ${position.font}`, x, currentY, maxWidth, otherFontSize + lineGap);
|
|
846
|
+
currentY += result.height;
|
|
847
|
+
drawnHeight += result.height;
|
|
848
|
+
}
|
|
849
|
+
if (showLabels.color) {
|
|
850
|
+
const colorValue = position.character_colors?.join(", ") || position.color;
|
|
851
|
+
if (colorValue) {
|
|
852
|
+
const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
|
|
853
|
+
const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, textMaxWidth, otherFontSize + lineGap);
|
|
854
|
+
const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
|
|
855
|
+
const swatchX = x +
|
|
856
|
+
Math.ceil(result.lastLineWidth) +
|
|
857
|
+
LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
858
|
+
const swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
|
|
859
|
+
const colors = position.character_colors || [position.color];
|
|
860
|
+
drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
|
|
861
|
+
currentY += result.height;
|
|
862
|
+
drawnHeight += result.height;
|
|
732
863
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
864
|
+
}
|
|
865
|
+
if (showLabels.floral && position.floral_pattern) {
|
|
866
|
+
const result = wrapText(ctx, `Mẫu hoa: ${position.floral_pattern}`, x, currentY, maxWidth, otherFontSize + lineGap);
|
|
867
|
+
currentY += result.height;
|
|
868
|
+
drawnHeight += result.height;
|
|
869
|
+
}
|
|
870
|
+
ctx.restore();
|
|
871
|
+
return drawnHeight;
|
|
872
|
+
};
|
|
873
|
+
const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs) => {
|
|
874
|
+
const iconFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
|
|
875
|
+
const lineGap = LAYOUT.LINE_GAP * scaleFactor;
|
|
876
|
+
ctx.save();
|
|
877
|
+
ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
878
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
879
|
+
let cursorY = y;
|
|
880
|
+
const iconText = position.icon === 0
|
|
881
|
+
? `Icon: icon mặc định theo file thêu`
|
|
882
|
+
: `Icon: ${position.icon}`;
|
|
883
|
+
const iconResult = wrapText(ctx, iconText, x, cursorY, maxWidth, iconFontSize + lineGap);
|
|
884
|
+
// Draw icon image
|
|
885
|
+
if (position.icon !== 0) {
|
|
886
|
+
const url = getImageUrl("icon", position.icon);
|
|
887
|
+
const img = imageRefs.current.get(url);
|
|
888
|
+
if (img?.complete && img.naturalHeight > 0) {
|
|
889
|
+
const ratio = img.naturalWidth / img.naturalHeight;
|
|
890
|
+
const swatchW = Math.max(1, Math.floor(iconFontSize * ratio));
|
|
891
|
+
const iconX = x +
|
|
892
|
+
Math.ceil(iconResult.lastLineWidth) +
|
|
893
|
+
LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
894
|
+
const iconY = iconResult.lastLineY + Math.floor(iconFontSize / 2 - iconFontSize / 2);
|
|
895
|
+
ctx.drawImage(img, iconX, iconY, swatchW, iconFontSize);
|
|
758
896
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
897
|
+
}
|
|
898
|
+
cursorY += iconResult.height;
|
|
899
|
+
// Draw color swatches
|
|
900
|
+
if (position.layer_colors?.length) {
|
|
901
|
+
const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
|
|
902
|
+
const colorResult = wrapText(ctx, `Màu chỉ: ${position.layer_colors.join(", ")}`, x, cursorY, textMaxWidth, iconFontSize + lineGap);
|
|
903
|
+
const swatchH = Math.floor(iconFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
|
|
904
|
+
const swatchX = x +
|
|
905
|
+
Math.ceil(colorResult.lastLineWidth) +
|
|
906
|
+
LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
907
|
+
const swatchY = colorResult.lastLineY + Math.floor(iconFontSize / 2 - swatchH / 2);
|
|
908
|
+
drawSwatches(ctx, position.layer_colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
|
|
909
|
+
cursorY += colorResult.height;
|
|
910
|
+
}
|
|
911
|
+
ctx.restore();
|
|
912
|
+
return cursorY - y;
|
|
913
|
+
};
|
|
914
|
+
const prepareExportCanvas = async (config, options = {}) => {
|
|
915
|
+
const { width = 4200, height = 4800 } = options;
|
|
916
|
+
const canvas = document.createElement("canvas");
|
|
917
|
+
const imageRefs = {
|
|
918
|
+
current: new Map(),
|
|
779
919
|
};
|
|
780
|
-
|
|
920
|
+
await preloadFonts(config);
|
|
921
|
+
await preloadImages(config, imageRefs);
|
|
922
|
+
renderEmbroideryCanvas(canvas, config, { width, height }, imageRefs);
|
|
923
|
+
if (!canvas.width || !canvas.height) {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
return canvas;
|
|
927
|
+
};
|
|
928
|
+
const generateEmbroideryQCImageBlob = async (config, options = {}) => {
|
|
929
|
+
if (typeof document === "undefined") {
|
|
930
|
+
throw new Error("generateEmbroideryQCImageBlob requires a browser environment.");
|
|
931
|
+
}
|
|
932
|
+
const { mimeType = "image/png", quality } = options;
|
|
933
|
+
const canvas = await prepareExportCanvas(config, options);
|
|
934
|
+
if (!canvas || typeof canvas.toBlob !== "function") {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
const blob = await new Promise((resolve) => {
|
|
938
|
+
canvas.toBlob((result) => resolve(result), mimeType, quality);
|
|
939
|
+
});
|
|
940
|
+
return blob;
|
|
941
|
+
};
|
|
942
|
+
const generateEmbroideryQCImageData = async (config, options = {}) => {
|
|
943
|
+
if (typeof document === "undefined") {
|
|
944
|
+
throw new Error("generateEmbroideryQCImageData requires a browser environment.");
|
|
945
|
+
}
|
|
946
|
+
const { mimeType = "image/png", quality } = options;
|
|
947
|
+
const canvas = await prepareExportCanvas(config, options);
|
|
948
|
+
if (!canvas) {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
if (mimeType === "image/png" || typeof quality === "undefined") {
|
|
952
|
+
return canvas.toDataURL(mimeType);
|
|
953
|
+
}
|
|
954
|
+
return canvas.toDataURL(mimeType, quality);
|
|
781
955
|
};
|
|
782
956
|
|
|
783
957
|
exports.EmbroideryQCImage = EmbroideryQCImage;
|
|
958
|
+
exports.generateEmbroideryQCImageBlob = generateEmbroideryQCImageBlob;
|
|
959
|
+
exports.generateEmbroideryQCImageData = generateEmbroideryQCImageData;
|
|
784
960
|
//# sourceMappingURL=index.js.map
|