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