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.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
- // Color mapping
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 FONT_BASE_URL = "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/fonts";
100
- const ICON_BASE_URL = "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/icons";
101
- const FLORAL_BASE_URL = "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/florals";
102
- const THREAD_COLOR_BASE_URL = "https://s3.hn-1.cloud.cmctelecom.vn/god-system-images/embroidery/thread-colors";
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 (!config.sides || config.sides.length === 0)
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
- continue;
125
- try {
126
- await loadFont(fontName);
127
- setLoadedFonts((prev) => new Set(prev).add(fontName));
128
- }
129
- catch (error) {
130
- console.warn(`Could not load font ${fontName}:`, error);
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
- const loadImages = async () => {
139
- if (!config.sides || config.sides.length === 0)
140
- return;
141
- // Load mockup image (not background). It will be drawn at bottom-right.
142
- if (config.image_url) {
143
- // Try with CORS first. If it fails (no CORS headers), retry without CORS
144
- const loadMockup = (useCors) => {
145
- const img = new Image();
146
- if (useCors)
147
- img.crossOrigin = "anonymous";
148
- img.onload = () => {
149
- imageRefs.current.set("mockup", img);
150
- setImagesLoaded((prev) => prev + 1);
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
- // Load thread color images for TEXT positions
186
- if (position.type === "TEXT") {
187
- // Load color image if position has color
188
- if (position.color) {
189
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${position.color}.png`;
190
- if (!imageRefs.current.has(threadColorUrl)) {
191
- const img = new Image();
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
- // Load thread color images for ICON positions
214
- if (position.type === "ICON" && position.layer_colors) {
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 || !config.sides || config.sides.length === 0) {
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
- canvas.width = canvasSize.width;
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
- // Helper function to wrap and draw text with word wrapping
352
- // Returns: { height: number, lastLineWidth: number, lastLineY: number }
353
- const fillTextWrapped = (ctx, text, x, y, maxWidth, lineHeight) => {
354
- const words = text.split(" ");
355
- const lines = [];
356
- let currentLine = words[0];
357
- for (let i = 1; i < words.length; i++) {
358
- const word = words[i];
359
- const testLine = currentLine + " " + word;
360
- const metrics = ctx.measureText(testLine);
361
- if (metrics.width > maxWidth && currentLine.length > 0) {
362
- lines.push(currentLine);
363
- currentLine = word;
364
- }
365
- else {
366
- currentLine = testLine;
367
- }
368
- }
369
- lines.push(currentLine);
370
- let currentY = y;
371
- lines.forEach((line) => {
372
- ctx.fillText(line, x, currentY);
373
- currentY += lineHeight;
374
- });
375
- const lastLineWidth = ctx.measureText(lines[lines.length - 1]).width;
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
- lines.push(currentLine);
406
- let currentY = y;
407
- lines.forEach((line, lineIdx) => {
408
- let currentX = x;
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
- if (!currentGroup ||
436
- currentProps.font !== position.font ||
437
- currentProps.text_shape !== position.text_shape ||
438
- currentProps.color !== position.color ||
439
- currentProps.character_colors?.join(",") !==
440
- position.character_colors?.join(",")) {
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
- if (currentGroup) {
462
- textGroups.push({ positions: currentGroup, properties: currentProps });
463
- }
464
- // Draw side header
465
- ctx.save();
466
- const headerFontSize = 200 * scaleFactor;
467
- ctx.font = `bold ${headerFontSize}px Times New Roman`;
468
- ctx.fillStyle = "#000000";
469
- ctx.textAlign = "left";
470
- ctx.textBaseline = "top";
471
- const headerResult = fillTextWrapped(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize);
472
- currentY += headerResult.height + 50 * scaleFactor;
473
- ctx.restore();
474
- // Compute side-level uniform properties across all TEXT positions
475
- const allTextPositions = [];
476
- side.positions.forEach((position) => {
477
- if (position.type === "TEXT")
478
- allTextPositions.push(position);
479
- });
480
- const sideFonts = new Set(allTextPositions.map((p) => p.font));
481
- const sideShapes = new Set(allTextPositions.map((p) => p.text_shape));
482
- const sideFlorals = new Set(allTextPositions.map((p) => p.floral_pattern ?? "None"));
483
- const colorKeyOf = (p) => p.character_colors && p.character_colors.length > 0
484
- ? p.character_colors.join(",")
485
- : p.color ?? "None";
486
- const sideColors = new Set(allTextPositions.map((p) => colorKeyOf(p)));
487
- const sideUniform = {
488
- font: sideFonts.size === 1,
489
- shape: sideShapes.size === 1,
490
- floral: sideFlorals.size === 1,
491
- color: sideColors.size === 1,
492
- };
493
- // Render side-level labels once for uniform properties
494
- currentY += renderSideUniformLabels(ctx, {
495
- font: sideUniform.font ? [...sideFonts][0] : null,
496
- shape: sideUniform.shape ? [...sideShapes][0] : null,
497
- floral: sideUniform.floral ? [...sideFlorals][0] : null,
498
- color: sideUniform.color ? [...sideColors][0] : null,
499
- }, padding, currentY, sideWidth, scaleFactor);
500
- // Render text groups first
501
- let sideTextCounter = 1;
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
- const renderText = (ctx, position, x, y, maxWidth, displayIndex, showLabels, scaleFactor = 1) => {
591
- ctx.save();
592
- // Info labels
593
- // Unified font sizing for labels and content (side title uses its own larger size)
594
- const infoLineGap = 30 * scaleFactor;
595
- const labelFontFamily = "Arial";
596
- // Use a unified content font size for both labels and text content
597
- const fontSize = 180 * scaleFactor;
598
- const infoFontSize = fontSize;
599
- ctx.font = `${infoFontSize}px ${labelFontFamily}`;
600
- ctx.textAlign = "left";
601
- ctx.textBaseline = "top";
602
- ctx.fillStyle = "#444444";
603
- let currentYCursor = y;
604
- let drawnHeight = 0; // accumulate only when something is actually drawn
605
- // Text value with unified font size
606
- const displayText = position.text;
607
- ctx.textAlign = "left";
608
- ctx.textBaseline = "top";
609
- // Label for text line
610
- const textLabel = `Text ${displayIndex}: `;
611
- ctx.font = `bold ${fontSize}px ${labelFontFamily}`;
612
- const labelWidth = ctx.measureText(textLabel).width;
613
- ctx.fillStyle = "#444444";
614
- ctx.fillText(textLabel, x, currentYCursor);
615
- // Calculate available width for text content
616
- const textMaxWidth = maxWidth - labelWidth;
617
- // Handle character_colors (alternating colors)
618
- if (position.character_colors && position.character_colors.length > 0) {
619
- // Switch to content font
620
- ctx.font = `${fontSize}px ${position.font}`;
621
- const textHeight = fillTextWrappedMultiColor(ctx, displayText, position.character_colors, x + labelWidth, currentYCursor, textMaxWidth, fontSize);
622
- currentYCursor += textHeight;
623
- drawnHeight += textHeight;
624
- }
625
- else {
626
- // No color specified
627
- // Draw text in content font, black (non-bold)
628
- ctx.font = `${fontSize}px ${position.font}`;
629
- ctx.fillStyle = COLOR_MAP[position.color ?? "None"] || "#000000";
630
- const textResult = fillTextWrapped(ctx, displayText, x + labelWidth, currentYCursor, textMaxWidth, fontSize);
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
- if (showLabels.color) {
651
- let colorLabelValue = "None";
652
- if (position.character_colors && position.character_colors.length > 0) {
653
- colorLabelValue = position.character_colors.join(", ");
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
- else if (position.color) {
656
- colorLabelValue = position.color;
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
- if (colorLabelValue !== "None") {
659
- const colorLabel = `Màu chỉ: ${colorLabelValue}`;
660
- // Reserve space for swatches
661
- const swatchReserved = 1000 * scaleFactor;
662
- const textMaxWidth = Math.max(400 * scaleFactor, maxWidth - swatchReserved);
663
- const result = fillTextWrapped(ctx, colorLabel, x, currentYCursor, textMaxWidth, infoFontSize + infoLineGap);
664
- // Draw color swatch images inline with Color label for TEXT, preserve aspect ratio; 75% of previous size
665
- // Position swatches after the last line of wrapped text
666
- const swatchH = Math.floor(infoFontSize * 2.025);
667
- let swatchX = x + Math.ceil(result.lastLineWidth) + 100 * scaleFactor; // spacing after text
668
- const swatchY = result.lastLineY + Math.floor(infoFontSize / 2 - swatchH / 2);
669
- if (position.character_colors && position.character_colors.length > 0) {
670
- position.character_colors.forEach((color) => {
671
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
672
- const img = imageRefs.current.get(threadColorUrl);
673
- if (img && img.complete && img.naturalHeight > 0) {
674
- const ratio = img.naturalWidth / img.naturalHeight;
675
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
676
- ctx.drawImage(img, swatchX, swatchY, swatchW, swatchH);
677
- swatchX += swatchW + 25 * scaleFactor;
678
- }
679
- });
680
- }
681
- else if (position.color) {
682
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${position.color}.png`;
683
- const img = imageRefs.current.get(threadColorUrl);
684
- if (img && img.complete && img.naturalHeight > 0) {
685
- const ratio = img.naturalWidth / img.naturalHeight;
686
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
687
- ctx.drawImage(img, swatchX, swatchY, swatchW, swatchH);
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
- // Show floral label after color block when not uniform at side level
695
- if (showLabels.floral && position.floral_pattern) {
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
- // (Floral per-position label is printed above the text when needed; avoid duplicate after text)
702
- ctx.restore();
703
- return drawnHeight;
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
- const renderIconLabels = (ctx, position, x, y, maxWidth, scaleFactor = 1) => {
706
- const labelFontFamily = "Arial";
707
- const fontSize = 180 * scaleFactor;
708
- const lineGap = 30 * scaleFactor;
709
- ctx.save();
710
- ctx.font = `${fontSize}px ${labelFontFamily}`;
711
- ctx.textAlign = "left";
712
- ctx.textBaseline = "top";
713
- ctx.fillStyle = "#444444";
714
- let cursorY = y;
715
- const iconText = position.icon === 0
716
- ? `Icon: icon mặc định theo file thêu`
717
- : `Icon: ${position.icon}`;
718
- const iconResult = fillTextWrapped(ctx, iconText, x, cursorY, maxWidth, fontSize + lineGap);
719
- // draw icon image inline with text, preserve aspect ratio; match line height
720
- if (position.icon !== 0) {
721
- const iconUrl = `${ICON_BASE_URL}/Icon ${position.icon}.png`;
722
- const img = imageRefs.current.get(iconUrl);
723
- if (img && img.complete && img.naturalHeight > 0) {
724
- const swatchH = fontSize;
725
- const ratio = img.naturalWidth / img.naturalHeight;
726
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
727
- // Put icon on last line of wrapped text
728
- const iconX = x + Math.ceil(iconResult.lastLineWidth) + 100 * scaleFactor;
729
- const iconY = iconResult.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
730
- ctx.drawImage(img, iconX, iconY, swatchW, swatchH);
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
- cursorY += iconResult.height;
734
- // Draw color line only when there are layer colors
735
- if (position.layer_colors && position.layer_colors.length > 0) {
736
- const colorLabelValue = position.layer_colors.join(", ");
737
- const colorText = `Màu chỉ: ${colorLabelValue}`;
738
- // Reserve space for swatches
739
- const swatchReserved = 1000 * scaleFactor;
740
- const textMaxWidth = Math.max(400 * scaleFactor, maxWidth - swatchReserved);
741
- const colorResult = fillTextWrapped(ctx, colorText, x, cursorY, textMaxWidth, fontSize + lineGap);
742
- // Draw color swatch images (only for icon)
743
- // Position swatches after the last line of wrapped text
744
- const swatchH = Math.floor(fontSize * 2.025); // 75% of previous size
745
- let swatchX = x + Math.ceil(colorResult.lastLineWidth) + 100 * scaleFactor; // spacing after text
746
- const swatchY = colorResult.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
747
- position.layer_colors.forEach((color) => {
748
- const threadColorUrl = `${THREAD_COLOR_BASE_URL}/${color}.png`;
749
- const img = imageRefs.current.get(threadColorUrl);
750
- if (img && img.complete && img.naturalHeight > 0) {
751
- const ratio = img.naturalWidth / img.naturalHeight;
752
- const swatchW = Math.max(1, Math.floor(swatchH * ratio));
753
- ctx.drawImage(img, swatchX, swatchY, swatchW, swatchH);
754
- swatchX += swatchW + 25 * scaleFactor; // spacing between swatches
755
- }
756
- });
757
- cursorY += colorResult.height;
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
- ctx.restore();
760
- return cursorY - y;
761
- };
762
- const loadFont = (fontName) => {
763
- return new Promise((resolve, reject) => {
764
- // Try to load from CDN
765
- const fontUrl = `${FONT_BASE_URL}/${encodeURIComponent(fontName)}.woff2`;
766
- const fontFace = new FontFace(fontName, `url(${fontUrl})`);
767
- fontFace
768
- .load()
769
- .then((loadedFont) => {
770
- document.fonts.add(loadedFont);
771
- resolve();
772
- })
773
- .catch(() => {
774
- // Font loading failed, will use fallback
775
- console.warn(`Could not load font ${fontName} from CDN`);
776
- resolve(); // Still resolve to not block rendering
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
- return (jsxRuntime.jsx("div", { className: `render-embroidery${className ? ` ${className}` : ""}`, style: style, children: jsxRuntime.jsx("canvas", { ref: canvasRef, className: "render-embroidery-canvas" }) }));
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