embroidery-qc-image 1.0.24 → 1.0.26

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
@@ -55,20 +55,15 @@ const LAYOUT = {
55
55
  TEXT_ALIGN: "left",
56
56
  TEXT_BASELINE: "top",
57
57
  // Spacing
58
- LINE_GAP: 40,
59
- PADDING: 40,
58
+ LINE_GAP: 50,
59
+ PADDING: 50,
60
60
  SECTION_SPACING: 60,
61
61
  ELEMENT_SPACING: 100,
62
62
  SWATCH_SPACING: 25,
63
- FLORAL_SPACING: 300,
64
63
  // Visual styling
65
64
  SWATCH_HEIGHT_RATIO: 2.025,
66
65
  UNDERLINE_POSITION: 0.9,
67
- UNDERLINE_WIDTH: 10,
68
- // Swatch reserved space
69
- SWATCH_RESERVED_SPACE: 1000,
70
- MIN_TEXT_WIDTH: 400,
71
- };
66
+ UNDERLINE_WIDTH: 10};
72
67
  // ============================================================================
73
68
  // HELPER FUNCTIONS
74
69
  // ============================================================================
@@ -103,6 +98,17 @@ const getImageUrl = (type, value) => {
103
98
  return `${BASE_URLS.THREAD_COLOR}/${value}.webp`;
104
99
  };
105
100
  const getProxyUrl = (url) => `https://proxy-img.c8p.workers.dev?url=${encodeURIComponent(url)}`;
101
+ const getIconImageUrl = (position) => {
102
+ if (position.is_delete_icon)
103
+ return null;
104
+ if (position.icon_image && position.icon_image.trim().length > 0) {
105
+ return position.icon_image;
106
+ }
107
+ if (position.icon !== 0) {
108
+ return getImageUrl("icon", position.icon);
109
+ }
110
+ return null;
111
+ };
106
112
  const ensureImage = (existing) => {
107
113
  if (existing && existing.crossOrigin === "anonymous") {
108
114
  return existing;
@@ -134,14 +140,14 @@ const loadImage = (url, imageRefs, onLoad) => {
134
140
  img.onerror = () => {
135
141
  if (!attemptedProxy) {
136
142
  attemptedProxy = true;
137
- img.src = getProxyUrl(url);
143
+ img.src = getProxyUrl(getResizeUrl(url));
138
144
  return;
139
145
  }
140
146
  img.dataset.proxyUsed = attemptedProxy ? "true" : "false";
141
147
  cleanup();
142
148
  onLoad();
143
149
  };
144
- img.src = attemptedProxy ? getProxyUrl(url) : url;
150
+ img.src = attemptedProxy ? getProxyUrl(getResizeUrl(url)) : getResizeUrl(url);
145
151
  };
146
152
  const loadImageAsync = (url, imageRefs, cacheKey) => {
147
153
  const key = cacheKey ?? url;
@@ -179,13 +185,13 @@ const loadImageAsync = (url, imageRefs, cacheKey) => {
179
185
  target.onerror = () => {
180
186
  if (!attemptedProxy) {
181
187
  attemptedProxy = true;
182
- target.src = getProxyUrl(url);
188
+ target.src = getProxyUrl(getResizeUrl(url));
183
189
  return;
184
190
  }
185
191
  target.dataset.proxyUsed = attemptedProxy ? "true" : "false";
186
192
  finalize();
187
193
  };
188
- const desiredSrc = attemptedProxy ? getProxyUrl(url) : url;
194
+ const desiredSrc = attemptedProxy ? getProxyUrl(getResizeUrl(url)) : getResizeUrl(url);
189
195
  if (target.src !== desiredSrc) {
190
196
  target.src = desiredSrc;
191
197
  }
@@ -194,6 +200,57 @@ const loadImageAsync = (url, imageRefs, cacheKey) => {
194
200
  }
195
201
  });
196
202
  };
203
+ const getResizeUrl = (url) => {
204
+ try {
205
+ const urlObj = new URL(url);
206
+ // Xử lý cdn.shopify.com
207
+ if (urlObj.hostname === 'cdn.shopify.com') {
208
+ // Set hoặc update query param width=400
209
+ urlObj.searchParams.set('width', '400');
210
+ return urlObj.toString();
211
+ }
212
+ // Xử lý m.media-amazon.com
213
+ if (urlObj.hostname === 'm.media-amazon.com') {
214
+ const pathname = urlObj.pathname;
215
+ // Split pathname theo dấu /
216
+ const pathArr = pathname.split('/');
217
+ // Lấy filename (phần cuối cùng)
218
+ const filename = pathArr[pathArr.length - 1];
219
+ // Xóa pattern ._.*_ (ví dụ: ._AC_SX569_)
220
+ const cleanedFilename = filename.replace(/\._.*_/g, '');
221
+ // Split filename đã clean theo dấu .
222
+ const parts = cleanedFilename.split('.');
223
+ if (parts.length >= 2) {
224
+ // Lấy phần đầu và phần cuối
225
+ const firstPart = parts[0];
226
+ const lastPart = parts[parts.length - 1];
227
+ // Chèn _AC_SX400_ vào giữa và join lại
228
+ const newFilename = `${firstPart}._AC_SX400_.${lastPart}`;
229
+ // Thay filename mới vào pathArr
230
+ pathArr[pathArr.length - 1] = newFilename;
231
+ // Join lại
232
+ urlObj.pathname = pathArr.join('/');
233
+ return urlObj.toString();
234
+ }
235
+ }
236
+ // Xử lý i.etsystatic.com
237
+ if (urlObj.hostname === 'i.etsystatic.com') {
238
+ const pathname = urlObj.pathname;
239
+ // Thay il_fullxfull bằng il_400x400
240
+ if (pathname.includes('il_fullxfull')) {
241
+ const newPathname = pathname.replace(/il_fullxfull/g, 'il_400x400');
242
+ urlObj.pathname = newPathname;
243
+ return urlObj.toString();
244
+ }
245
+ }
246
+ // Nếu không phải các domain cần xử lý, return URL gốc
247
+ return url;
248
+ }
249
+ catch (error) {
250
+ // Nếu URL không hợp lệ, return URL gốc
251
+ return url;
252
+ }
253
+ };
197
254
  const preloadFonts = async (config) => {
198
255
  if (config.error_message || !config.sides?.length)
199
256
  return;
@@ -223,12 +280,10 @@ const preloadImages = async (config, imageRefs) => {
223
280
  config.sides.forEach((side) => {
224
281
  side.positions.forEach((position) => {
225
282
  if (position.type === "ICON") {
226
- if (position.icon !== 0) {
227
- const iconUrl = getImageUrl("icon", position.icon);
228
- if (!seen.has(iconUrl)) {
229
- entries.push({ url: iconUrl });
230
- seen.add(iconUrl);
231
- }
283
+ const iconUrl = getIconImageUrl(position);
284
+ if (iconUrl && !seen.has(iconUrl)) {
285
+ entries.push({ url: iconUrl });
286
+ seen.add(iconUrl);
232
287
  }
233
288
  if (position.color) {
234
289
  const threadUrl = getImageUrl("threadColor", position.color);
@@ -301,23 +356,46 @@ const wrapText = (ctx, text, x, y, maxWidth, lineHeight) => {
301
356
  };
302
357
  };
303
358
  const buildWrappedLines = (ctx, text, maxWidth) => {
304
- const words = text.split(" ").filter((word) => word.length > 0);
305
- if (words.length === 0)
306
- return [""];
307
- const lines = [];
308
- let currentLine = words[0];
309
- for (let i = 1; i < words.length; i++) {
310
- const testLine = `${currentLine} ${words[i]}`;
311
- if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
312
- lines.push(currentLine);
313
- currentLine = words[i];
359
+ // Mỗi '\n' tương đương với một line break giống như khi wrap tự động.
360
+ const segments = text.split("\n");
361
+ const result = [];
362
+ segments.forEach((segment) => {
363
+ const words = segment.split(" ").filter((word) => word.length > 0);
364
+ if (words.length === 0) {
365
+ // Nếu đoạn rỗng, thêm một dòng trống (break đúng 1 line)
366
+ result.push("");
367
+ return;
314
368
  }
315
- else {
316
- currentLine = testLine;
369
+ let currentLine = words[0];
370
+ for (let i = 1; i < words.length; i++) {
371
+ const testLine = `${currentLine} ${words[i]}`;
372
+ if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
373
+ result.push(currentLine);
374
+ currentLine = words[i];
375
+ }
376
+ else {
377
+ currentLine = testLine;
378
+ }
317
379
  }
318
- }
319
- lines.push(currentLine);
320
- return lines;
380
+ result.push(currentLine);
381
+ });
382
+ return result.length ? result : [""];
383
+ };
384
+ const calculateSwatchesWidth = (colors, swatchHeight, scaleFactor, imageRefs) => {
385
+ let totalWidth = 0;
386
+ colors.forEach((color, index) => {
387
+ const url = getImageUrl("threadColor", color);
388
+ const img = imageRefs.current.get(url);
389
+ if (img && img.complete && img.naturalHeight > 0) {
390
+ const ratio = img.naturalWidth / img.naturalHeight;
391
+ const swatchW = Math.max(1, Math.floor(swatchHeight * ratio));
392
+ totalWidth += swatchW;
393
+ if (index < colors.length - 1) {
394
+ totalWidth += LAYOUT.SWATCH_SPACING * scaleFactor;
395
+ }
396
+ }
397
+ });
398
+ return totalWidth;
321
399
  };
322
400
  const drawSwatches = (ctx, colors, startX, startY, swatchHeight, scaleFactor, imageRefs) => {
323
401
  let swatchX = startX;
@@ -381,8 +459,9 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
381
459
  config.sides.forEach((side) => {
382
460
  side.positions.forEach((position) => {
383
461
  if (position.type === "ICON") {
384
- if (position.icon !== 0) {
385
- loadImage(getImageUrl("icon", position.icon), imageRefs, incrementCounter);
462
+ const iconUrl = getIconImageUrl(position);
463
+ if (iconUrl) {
464
+ loadImage(iconUrl, imageRefs, incrementCounter);
386
465
  }
387
466
  position.layer_colors?.forEach((color) => {
388
467
  loadImage(getImageUrl("threadColor", color), imageRefs, incrementCounter);
@@ -433,7 +512,7 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
433
512
  return;
434
513
  ctx.textAlign = LAYOUT.TEXT_ALIGN;
435
514
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
436
- // Calculate warning height (with scaleFactor = 1 for measurement)
515
+ // Calculate warning & message height (with scaleFactor = 1 for measurement)
437
516
  let warningHeight = 0;
438
517
  let warningLineHeight = 0;
439
518
  let warningLineCount = 0;
@@ -452,28 +531,30 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
452
531
  warningHeight = warningLineCount * warningLineHeight + LAYOUT.PADDING;
453
532
  }
454
533
  }
534
+ let messageHeight = 0;
535
+ let messageLineHeight = 0;
536
+ let messageLineCount = 0;
537
+ if (config.message) {
538
+ const measureMessageCanvas = document.createElement("canvas");
539
+ measureMessageCanvas.width = canvas.width;
540
+ measureMessageCanvas.height = canvas.height;
541
+ const measureMessageCtx = measureMessageCanvas.getContext("2d");
542
+ if (measureMessageCtx) {
543
+ measureMessageCtx.textAlign = "left";
544
+ measureMessageCtx.textBaseline = "top";
545
+ measureMessageCtx.font = `${LAYOUT.HEADER_FONT_SIZE * 0.7}px ${LAYOUT.FONT_FAMILY}`;
546
+ const messageLines = buildWrappedLines(measureMessageCtx, config.message.trim(), canvas.width - LAYOUT.PADDING * 4);
547
+ messageLineCount = messageLines.length;
548
+ messageLineHeight = LAYOUT.HEADER_FONT_SIZE * 0.7 + LAYOUT.LINE_GAP;
549
+ messageHeight = messageLineCount * messageLineHeight + LAYOUT.PADDING;
550
+ }
551
+ }
455
552
  if (config.image_url) {
456
553
  const mockupImage = imageRefs.current.get(config.image_url);
457
554
  if (mockupImage) {
458
555
  imageRefs.current.set("mockup", mockupImage);
459
556
  }
460
557
  }
461
- const floralAssets = [];
462
- const seenFlorals = new Set();
463
- config.sides.forEach((side) => {
464
- side.positions.forEach((position) => {
465
- if (position.type === "TEXT" && position.floral_pattern) {
466
- const url = getImageUrl("floral", position.floral_pattern);
467
- if (!seenFlorals.has(url)) {
468
- const img = imageRefs.current.get(url);
469
- if (img?.complete && img.naturalWidth > 0) {
470
- floralAssets.push(img);
471
- seenFlorals.add(url);
472
- }
473
- }
474
- }
475
- });
476
- });
477
558
  const measureCanvas = document.createElement("canvas");
478
559
  measureCanvas.width = canvas.width;
479
560
  measureCanvas.height = canvas.height;
@@ -484,27 +565,40 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
484
565
  measureCtx.textBaseline = LAYOUT.TEXT_BASELINE;
485
566
  let measureY = LAYOUT.PADDING;
486
567
  const measureSpacing = LAYOUT.ELEMENT_SPACING;
487
- // Add warning text height (without bottom padding, no spacing)
568
+ // Add warning & message text height (without bottom padding, no spacing)
488
569
  if (config.warning_message) {
489
570
  const warningTextHeight = warningHeight - LAYOUT.PADDING;
490
571
  measureY += warningTextHeight;
491
572
  }
573
+ if (config.message) {
574
+ const messageTextHeight = messageHeight - LAYOUT.PADDING;
575
+ measureY += messageTextHeight;
576
+ }
492
577
  config.sides.forEach((side) => {
493
578
  const sideHeight = renderSide(measureCtx, side, measureY, canvas.width, 1, imageRefs);
494
579
  measureY += sideHeight + measureSpacing;
495
580
  });
496
- const scaleFactor = Math.max(0.5, Math.min(1, (canvas.height - LAYOUT.PADDING - warningHeight) / measureY));
497
- drawMockupAndFlorals(ctx, canvas, floralAssets, imageRefs);
498
- // Render warning with scaleFactor and get actual height
581
+ const scaleFactor = Math.max(0.5, Math.min(1, (canvas.height - LAYOUT.PADDING - warningHeight - messageHeight) / measureY));
582
+ drawMockupAndFlorals(ctx, canvas, imageRefs);
583
+ // Render warning & message with scaleFactor and get actual heights
499
584
  let actualWarningHeight = 0;
585
+ let actualMessageHeight = 0;
500
586
  if (config.warning_message) {
501
587
  actualWarningHeight = renderWarning(ctx, canvas, config.warning_message, scaleFactor);
502
588
  }
503
- // Calculate currentY: padding top + actual warning height (no spacing)
589
+ if (config.message) {
590
+ actualMessageHeight = renderWarning(ctx, canvas, config.message, scaleFactor, actualWarningHeight, "", // message: không cần prefix "Note"
591
+ DEFAULT_ERROR_COLOR // message: hiển thị màu đỏ
592
+ );
593
+ }
594
+ // Calculate currentY: padding top + actual warning & message height (no spacing)
504
595
  let currentY = LAYOUT.PADDING * scaleFactor;
505
596
  if (config.warning_message && actualWarningHeight > 0) {
506
597
  currentY += actualWarningHeight;
507
598
  }
599
+ if (config.message && actualMessageHeight > 0) {
600
+ currentY += actualMessageHeight;
601
+ }
508
602
  config.sides.forEach((side) => {
509
603
  const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor, imageRefs);
510
604
  currentY += sideHeight + measureSpacing * scaleFactor;
@@ -546,8 +640,8 @@ const renderErrorState = (ctx, canvas, message) => {
546
640
  });
547
641
  ctx.restore();
548
642
  };
549
- const renderWarning = (ctx, canvas, message, scaleFactor = 1) => {
550
- const sanitizedMessage = `Note: ${message.trim()}`;
643
+ const renderWarning = (ctx, canvas, message, scaleFactor = 1, offsetY = 0, prefix = "Note: ", color = DEFAULT_WARNING_COLOR) => {
644
+ const sanitizedMessage = `${prefix}${message.trim()}`;
551
645
  const horizontalPadding = LAYOUT.PADDING * 2 * scaleFactor;
552
646
  const maxWidth = canvas.width - horizontalPadding * 2;
553
647
  const baseFontSize = LAYOUT.HEADER_FONT_SIZE * 0.7 * scaleFactor;
@@ -556,7 +650,7 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1) => {
556
650
  ctx.save();
557
651
  ctx.textAlign = "left";
558
652
  ctx.textBaseline = "top";
559
- ctx.fillStyle = DEFAULT_WARNING_COLOR;
653
+ ctx.fillStyle = color;
560
654
  ctx.font = `${baseFontSize}px ${LAYOUT.FONT_FAMILY}`;
561
655
  let fontSize = baseFontSize;
562
656
  let lineGap = LAYOUT.LINE_GAP * scaleFactor;
@@ -575,7 +669,7 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1) => {
575
669
  lines = buildWrappedLines(ctx, sanitizedMessage, maxWidth);
576
670
  longestLineWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
577
671
  }
578
- const startY = LAYOUT.PADDING * scaleFactor;
672
+ const startY = LAYOUT.PADDING * scaleFactor + offsetY;
579
673
  lines.forEach((line, index) => {
580
674
  const y = startY + index * lineHeight;
581
675
  ctx.fillText(line, leftX, y);
@@ -584,7 +678,7 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1) => {
584
678
  // Return the actual height of the warning (number of lines * lineHeight)
585
679
  return lines.length * lineHeight;
586
680
  };
587
- const drawMockupAndFlorals = (ctx, canvas, floralAssets, imageRefs) => {
681
+ const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
588
682
  const mockupImg = imageRefs.current.get("mockup");
589
683
  if (!mockupImg?.complete || !mockupImg.naturalWidth)
590
684
  return;
@@ -597,19 +691,7 @@ const drawMockupAndFlorals = (ctx, canvas, floralAssets, imageRefs) => {
597
691
  const x = canvas.width - margin - width;
598
692
  const y = canvas.height - margin - height;
599
693
  ctx.drawImage(mockupImg, x, y, width, height);
600
- // Draw florals
601
- if (floralAssets.length > 0) {
602
- const floralH = Math.min(900, height);
603
- let currentX = x - LAYOUT.FLORAL_SPACING;
604
- for (let i = floralAssets.length - 1; i >= 0; i--) {
605
- const img = floralAssets[i];
606
- const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
607
- const w = Math.max(1, Math.floor(floralH * ratio));
608
- currentX -= w;
609
- ctx.drawImage(img, currentX, y + height - floralH, w, floralH);
610
- currentX -= LAYOUT.FLORAL_SPACING;
611
- }
612
- }
694
+ // Bỏ phần vẽ florals cạnh mockup vì đã hiển thị cạnh text rồi
613
695
  };
614
696
  const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
615
697
  let currentY = startY;
@@ -631,8 +713,33 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
631
713
  ctx.stroke();
632
714
  currentY += headerResult.height + LAYOUT.SECTION_SPACING * scaleFactor;
633
715
  ctx.restore();
634
- // Compute uniform properties
716
+ // Kiểm tra xem có phải trường hợp "không thêu gì" không
635
717
  const textPositions = side.positions.filter((p) => p.type === "TEXT");
718
+ const iconPositions = side.positions.filter((p) => p.type === "ICON");
719
+ // Kiểm tra tất cả TEXT positions có trống không
720
+ // Nếu không có TEXT positions, coi như "tất cả TEXT trống" = true
721
+ const allTextEmpty = textPositions.length === 0 || textPositions.every((p) => {
722
+ const text = p.text ?? "";
723
+ return text.trim() === "";
724
+ });
725
+ // Kiểm tra tất cả ICON positions có is_delete_icon = true không
726
+ // Nếu không có ICON positions, coi như "tất cả ICON bị xóa" = true
727
+ const allIconsDeleted = iconPositions.length === 0 || iconPositions.every((p) => {
728
+ return p.is_delete_icon === true;
729
+ });
730
+ // Nếu tất cả TEXT trống và tất cả ICON bị xóa, chỉ render dòng "(không thêu gì)"
731
+ if (allTextEmpty && allIconsDeleted && side.positions.length > 0) {
732
+ ctx.save();
733
+ const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
734
+ const lineGap = LAYOUT.LINE_GAP * scaleFactor;
735
+ ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
736
+ ctx.fillStyle = DEFAULT_ERROR_COLOR;
737
+ ctx.fillText("(không thêu gì)", padding, currentY);
738
+ currentY += otherFontSize + lineGap;
739
+ ctx.restore();
740
+ return currentY - startY;
741
+ }
742
+ // Compute uniform properties
636
743
  const iconColorPositions = side.positions.filter((p) => p.type === "ICON" && (!p.layer_colors?.length || p.layer_colors.length === 1));
637
744
  const iconColorValues = iconColorPositions
638
745
  .map((p) => {
@@ -817,23 +924,87 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
817
924
  rendered++;
818
925
  }
819
926
  if (values.color && values.color !== "None" && shouldRenderField("color")) {
820
- const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
821
- const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, textMaxWidth, fontSize + lineGap);
822
- const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
823
- const swatchX = x +
824
- Math.ceil(result.lastLineWidth) +
825
- LAYOUT.ELEMENT_SPACING * scaleFactor;
826
- const swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
827
927
  const colors = values.color.includes(",")
828
928
  ? values.color.split(",").map((s) => s.trim())
829
929
  : [values.color];
930
+ const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
931
+ const totalSwatchWidth = calculateSwatchesWidth(colors, swatchH, scaleFactor, imageRefs);
932
+ // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
933
+ const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, maxWidth, fontSize + lineGap);
934
+ // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
935
+ const textEndX = x + Math.ceil(result.lastLineWidth);
936
+ const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
937
+ const swatchesStartX = textEndX + spacing;
938
+ const swatchesEndX = swatchesStartX + totalSwatchWidth;
939
+ const shouldWrapSwatches = swatchesEndX > x + maxWidth;
940
+ let swatchX;
941
+ let swatchY;
942
+ if (shouldWrapSwatches) {
943
+ // Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
944
+ swatchX = x;
945
+ swatchY = result.lastLineY + fontSize + lineGap;
946
+ cursorY += result.height + fontSize + lineGap;
947
+ }
948
+ else {
949
+ // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
950
+ swatchX = swatchesStartX;
951
+ swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
952
+ cursorY += result.height;
953
+ }
830
954
  drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
831
- cursorY += result.height;
955
+ if (shouldWrapSwatches) {
956
+ cursorY += swatchH;
957
+ }
832
958
  rendered++;
833
959
  }
834
960
  if (values.floral && values.floral !== "None" && shouldRenderField("floral")) {
835
- const result = wrapText(ctx, `Mẫu hoa: ${values.floral}`, x, cursorY, maxWidth, fontSize + lineGap);
836
- cursorY += result.height;
961
+ const floralUrl = getImageUrl("floral", values.floral);
962
+ const floralImg = imageRefs.current.get(floralUrl);
963
+ // Tính kích thước ảnh floral (thêm 50% = 2.5x fontSize)
964
+ const floralH = fontSize * 2.5;
965
+ let totalFloralWidth = 0;
966
+ if (floralImg?.complete && floralImg.naturalHeight > 0) {
967
+ const ratio = floralImg.naturalWidth / floralImg.naturalHeight;
968
+ totalFloralWidth = Math.max(1, Math.floor(floralH * ratio));
969
+ }
970
+ // Line height giống icon_image: floralH + lineGap
971
+ const floralLineHeight = floralH + lineGap;
972
+ // Text align bottom: đặt text ở dưới cùng của dòng
973
+ const textBottomY = cursorY + floralH;
974
+ // Đo width trước khi vẽ
975
+ ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
976
+ const labelText = `Mẫu hoa: ${values.floral}`;
977
+ const labelWidth = ctx.measureText(labelText).width;
978
+ // Vẽ text với textBaseline = bottom
979
+ ctx.textBaseline = "bottom";
980
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
981
+ ctx.fillText(labelText, x, textBottomY);
982
+ // Reset textBaseline về top cho các phần tiếp theo
983
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
984
+ // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
985
+ const textEndX = x + labelWidth;
986
+ const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
987
+ const floralStartX = textEndX + spacing;
988
+ const floralEndX = floralStartX + totalFloralWidth;
989
+ const shouldWrapFloral = floralEndX > x + maxWidth;
990
+ let floralX;
991
+ let floralY;
992
+ if (shouldWrapFloral) {
993
+ // Không đủ chỗ, cho ảnh floral xuống dòng mới
994
+ floralX = x;
995
+ floralY = textBottomY + lineGap;
996
+ cursorY += floralLineHeight;
997
+ }
998
+ else {
999
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1000
+ floralX = floralStartX;
1001
+ floralY = textBottomY - floralH; // Align bottom với text
1002
+ cursorY += floralLineHeight;
1003
+ }
1004
+ // Vẽ ảnh floral
1005
+ if (floralImg?.complete && floralImg.naturalHeight > 0) {
1006
+ ctx.drawImage(floralImg, floralX, floralY, totalFloralWidth, floralH);
1007
+ }
837
1008
  rendered++;
838
1009
  }
839
1010
  if (rendered > 0)
@@ -841,40 +1012,82 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
841
1012
  ctx.restore();
842
1013
  return cursorY - y;
843
1014
  };
844
- const renderTextPosition = (ctx, position, x, y, maxWidth, displayIndex, showLabels, scaleFactor, imageRefs) => {
1015
+ const renderTextPosition = (ctx, position, x, y, maxWidth, // tổng chiều rộng usable (không tính padding ngoài)
1016
+ displayIndex, showLabels, scaleFactor, imageRefs) => {
845
1017
  ctx.save();
846
1018
  const textFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
847
1019
  const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
848
1020
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
849
1021
  let currentY = y;
850
1022
  let drawnHeight = 0;
851
- // Draw label
1023
+ // Chuẩn hóa xuống dòng:
1024
+ // - Hỗ trợ cả newline thật (\n) và chuỗi literal "\\n" từ JSON
1025
+ const normalizeNewlines = (text) => text
1026
+ .replace(/\r\n/g, "\n")
1027
+ .replace(/\r/g, "\n")
1028
+ .replace(/\\n/g, "\n");
1029
+ // Get display text (handle empty/null/undefined) sau khi normalize
1030
+ const rawOriginalText = position.text ?? "";
1031
+ const normalizedText = normalizeNewlines(rawOriginalText);
1032
+ const isEmptyText = normalizedText.trim() === "";
1033
+ // ===========================================================================
1034
+ // PHẦN TEXT CHÍNH
1035
+ // - Giữ nguyên format: chỉ xuống dòng khi có '\n'
1036
+ // - Không tự wrap theo maxWidth
1037
+ // - Nếu tổng chiều ngang > maxWidth, tự động giảm font-size để vừa
1038
+ // ===========================================================================
1039
+ // Label "Text N: " luôn là font mặc định (không bị co theo nội dung)
852
1040
  const textLabel = `Text ${displayIndex}: `;
853
1041
  ctx.font = `bold ${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
854
1042
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
855
1043
  const labelWidth = ctx.measureText(textLabel).width;
856
1044
  ctx.fillText(textLabel, x, currentY);
857
- const textMaxWidth = maxWidth - labelWidth;
858
- // Get display text (handle empty/null/undefined)
859
- const isEmptyText = !position.text || position.text.trim() === "";
860
- // Draw text content - dùng font mặc định và màu đỏ
861
- ctx.font = `${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
862
- ctx.fillStyle = DEFAULT_ERROR_COLOR;
863
- if (isEmptyText) {
864
- const textResult = wrapText(ctx, "(không có text)", x + labelWidth, currentY, textMaxWidth, textFontSize);
865
- currentY += textResult.height;
866
- drawnHeight += textResult.height;
867
- }
868
- else {
869
- const textResult = wrapText(ctx, position.text, x + labelWidth, currentY, textMaxWidth, textFontSize);
870
- currentY += textResult.height;
871
- drawnHeight += textResult.height;
1045
+ // Phần text value bắt đầu sau label
1046
+ const valueStartX = x + labelWidth;
1047
+ const availableWidth = Math.max(1, maxWidth - labelWidth);
1048
+ // Chuẩn hóa nội dung text để render
1049
+ const rawText = isEmptyText ? "(không có text)" : normalizedText;
1050
+ const lines = rawText.split("\n");
1051
+ // Tính font-size hiệu dụng cho phần value sao cho:
1052
+ // - Không vượt quá availableWidth
1053
+ // - Có thể thu nhỏ tùy ý (theo yêu cầu, không giới hạn tối thiểu)
1054
+ const measureMaxLineWidth = (fontSize) => {
1055
+ ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
1056
+ let maxLineWidth = 0;
1057
+ lines.forEach((line) => {
1058
+ const w = ctx.measureText(line).width;
1059
+ if (w > maxLineWidth)
1060
+ maxLineWidth = w;
1061
+ });
1062
+ return maxLineWidth;
1063
+ };
1064
+ let effectiveTextFontSize = textFontSize;
1065
+ if (!isEmptyText) {
1066
+ const baseMaxWidth = measureMaxLineWidth(textFontSize);
1067
+ if (baseMaxWidth > availableWidth) {
1068
+ const shrinkRatio = availableWidth / baseMaxWidth;
1069
+ effectiveTextFontSize = textFontSize * shrinkRatio;
1070
+ }
872
1071
  }
1072
+ // Vẽ phần value với font hiệu dụng, màu đỏ
1073
+ ctx.font = `${effectiveTextFontSize}px ${LAYOUT.FONT_FAMILY}`;
1074
+ ctx.fillStyle = DEFAULT_ERROR_COLOR;
1075
+ const valueLineHeight = effectiveTextFontSize; // giữ giống wrapText cũ (lineHeight = fontSize)
1076
+ let localY = currentY;
1077
+ lines.forEach((line, idx) => {
1078
+ ctx.fillText(line, valueStartX, localY);
1079
+ localY += valueLineHeight;
1080
+ });
1081
+ const textBlockHeight = lines.length * valueLineHeight;
1082
+ currentY += textBlockHeight;
1083
+ drawnHeight += textBlockHeight;
873
1084
  // Draw additional labels (skip when text is empty)
874
1085
  if (!isEmptyText) {
875
1086
  currentY += lineGap;
876
1087
  ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
877
1088
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1089
+ // Lưu ý: phần dưới này vẫn có thể wrap theo maxWidth vì đây chỉ là label mô tả,
1090
+ // không phải nội dung Text chính cần giữ nguyên format.
878
1091
  if (showLabels.shape && position.text_shape) {
879
1092
  const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap);
880
1093
  currentY += result.height;
@@ -905,74 +1118,213 @@ const renderTextPosition = (ctx, position, x, y, maxWidth, displayIndex, showLab
905
1118
  if (showLabels.color) {
906
1119
  const colorValue = position.character_colors?.join(", ") || position.color;
907
1120
  if (colorValue) {
908
- const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
909
- const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, textMaxWidth, otherFontSize + lineGap);
910
- const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
911
- const swatchX = x +
912
- Math.ceil(result.lastLineWidth) +
913
- LAYOUT.ELEMENT_SPACING * scaleFactor;
914
- const swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
915
1121
  const colors = position.character_colors || [position.color];
1122
+ const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1123
+ const totalSwatchWidth = calculateSwatchesWidth(colors, swatchH, scaleFactor, imageRefs);
1124
+ // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
1125
+ const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, maxWidth, otherFontSize + lineGap);
1126
+ // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1127
+ const textEndX = x + Math.ceil(result.lastLineWidth);
1128
+ const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1129
+ const swatchesStartX = textEndX + spacing;
1130
+ const swatchesEndX = swatchesStartX + totalSwatchWidth;
1131
+ const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1132
+ let swatchX;
1133
+ let swatchY;
1134
+ if (shouldWrapSwatches) {
1135
+ // Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
1136
+ swatchX = x;
1137
+ swatchY = result.lastLineY + otherFontSize + lineGap;
1138
+ currentY += result.height + otherFontSize + lineGap;
1139
+ drawnHeight += result.height + otherFontSize + lineGap;
1140
+ }
1141
+ else {
1142
+ // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1143
+ swatchX = swatchesStartX;
1144
+ swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1145
+ currentY += result.height;
1146
+ drawnHeight += result.height;
1147
+ }
916
1148
  drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
917
- currentY += result.height;
918
- drawnHeight += result.height;
1149
+ if (shouldWrapSwatches) {
1150
+ currentY += swatchH;
1151
+ drawnHeight += swatchH;
1152
+ }
919
1153
  }
920
1154
  }
921
1155
  if (showLabels.floral && position.floral_pattern) {
922
- const result = wrapText(ctx, `Mẫu hoa: ${position.floral_pattern}`, x, currentY, maxWidth, otherFontSize + lineGap);
923
- currentY += result.height;
924
- drawnHeight += result.height;
1156
+ const floralUrl = getImageUrl("floral", position.floral_pattern);
1157
+ const floralImg = imageRefs.current.get(floralUrl);
1158
+ // Tính kích thước ảnh floral (thêm 50% = 2.5x otherFontSize)
1159
+ const floralH = otherFontSize * 2.5;
1160
+ let totalFloralWidth = 0;
1161
+ if (floralImg?.complete && floralImg.naturalHeight > 0) {
1162
+ const ratio = floralImg.naturalWidth / floralImg.naturalHeight;
1163
+ totalFloralWidth = Math.max(1, Math.floor(floralH * ratio));
1164
+ }
1165
+ // Line height giống icon_image: floralH + lineGap
1166
+ const floralLineHeight = floralH + lineGap;
1167
+ // Text align bottom: đặt text ở dưới cùng của dòng
1168
+ const textBottomY = currentY + floralH;
1169
+ // Đo width trước khi vẽ
1170
+ ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1171
+ const labelText = `Mẫu hoa: ${position.floral_pattern}`;
1172
+ const labelWidth = ctx.measureText(labelText).width;
1173
+ // Vẽ text với textBaseline = bottom
1174
+ ctx.textBaseline = "bottom";
1175
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1176
+ ctx.fillText(labelText, x, textBottomY);
1177
+ // Reset textBaseline về top cho các phần tiếp theo
1178
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1179
+ // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
1180
+ const textEndX = x + labelWidth;
1181
+ const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1182
+ const floralStartX = textEndX + spacing;
1183
+ const floralEndX = floralStartX + totalFloralWidth;
1184
+ const shouldWrapFloral = floralEndX > x + maxWidth;
1185
+ let floralX;
1186
+ let floralY;
1187
+ if (shouldWrapFloral) {
1188
+ // Không đủ chỗ, cho ảnh floral xuống dòng mới
1189
+ floralX = x;
1190
+ floralY = textBottomY + lineGap;
1191
+ currentY += floralLineHeight;
1192
+ drawnHeight += floralLineHeight;
1193
+ }
1194
+ else {
1195
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1196
+ floralX = floralStartX;
1197
+ floralY = textBottomY - floralH; // Align bottom với text
1198
+ currentY += floralLineHeight;
1199
+ drawnHeight += floralLineHeight;
1200
+ }
1201
+ // Vẽ ảnh floral
1202
+ if (floralImg?.complete && floralImg.naturalHeight > 0) {
1203
+ ctx.drawImage(floralImg, floralX, floralY, totalFloralWidth, floralH);
1204
+ }
925
1205
  }
926
1206
  }
927
1207
  ctx.restore();
928
1208
  return drawnHeight;
929
1209
  };
930
1210
  const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs, options) => {
931
- const iconFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1211
+ // Dùng cùng font size với Text cho label và value icon
1212
+ const iconFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
932
1213
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
933
1214
  ctx.save();
934
- ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
935
1215
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
936
1216
  let cursorY = y;
937
- const iconText = position.note ? `Icon: ${position.note}` :
938
- position.icon === 0
939
- ? `Icon: (icon mặc định theo file thêu)`
940
- : `Icon: ${position.icon}`;
941
- const iconResult = wrapText(ctx, iconText, x, cursorY, maxWidth, iconFontSize + lineGap);
1217
+ // Tách label "Icon:" (in đậm) và phần value (thường)
1218
+ const iconLabel = "Icon:";
1219
+ let iconValue;
1220
+ if (position.is_delete_icon) {
1221
+ // Ưu tiên hiển thị không icon nếu được đánh dấu xóa
1222
+ iconValue = "(không có icon)";
1223
+ }
1224
+ else if (position.note) {
1225
+ iconValue = position.note;
1226
+ }
1227
+ else if (position.icon_name && position.icon_name.trim().length > 0) {
1228
+ // Nếu có icon_name thì hiển thị tên đó
1229
+ iconValue = position.icon_name;
1230
+ }
1231
+ else if (position.icon === 0) {
1232
+ // Icon mặc định theo file thêu
1233
+ iconValue = "(icon mặc định theo file thêu)";
1234
+ }
1235
+ else {
1236
+ // Fallback: hiển thị mã icon (ép sang string)
1237
+ iconValue = String(position.icon);
1238
+ }
1239
+ // Kiểm tra xem có icon_image không để tính height phù hợp
1240
+ const hasIconImage = position.icon_image && position.icon_image.trim().length > 0;
1241
+ const iconImageHeight = hasIconImage ? iconFontSize * 2 : iconFontSize;
1242
+ // Text align bottom: đặt text ở dưới cùng của dòng
1243
+ const textBottomY = cursorY + iconImageHeight;
1244
+ // Đo width trước khi vẽ
1245
+ ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1246
+ const labelWidth = ctx.measureText(iconLabel).width;
1247
+ ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1248
+ const valueText = ` ${iconValue}`;
1249
+ const valueWidth = ctx.measureText(valueText).width;
1250
+ // Vẽ text với textBaseline = bottom
1251
+ ctx.textBaseline = "bottom";
1252
+ ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1253
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1254
+ ctx.fillText(iconLabel, x, textBottomY);
1255
+ ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1256
+ ctx.fillStyle = DEFAULT_ERROR_COLOR;
1257
+ ctx.fillText(valueText, x + labelWidth, textBottomY);
1258
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1259
+ // Reset textBaseline về top cho các phần tiếp theo
1260
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1261
+ const iconResult = {
1262
+ height: iconImageHeight + lineGap,
1263
+ // tổng width của cả label + value, dùng để canh icon image lệch sang phải
1264
+ lastLineWidth: labelWidth + valueWidth};
942
1265
  // Draw icon image
943
- if (position.icon !== 0) {
944
- const url = getImageUrl("icon", position.icon);
945
- const img = imageRefs.current.get(url);
1266
+ const iconUrl = getIconImageUrl(position);
1267
+ if (iconUrl) {
1268
+ const img = imageRefs.current.get(iconUrl);
946
1269
  if (img?.complete && img.naturalHeight > 0) {
947
1270
  const ratio = img.naturalWidth / img.naturalHeight;
948
- const swatchW = Math.max(1, Math.floor(iconFontSize * ratio));
1271
+ // Nếu icon_image thì hiển thị to gấp đôi
1272
+ const iconHeight = hasIconImage ? iconFontSize * 2 : iconFontSize;
1273
+ const swatchW = Math.max(1, Math.floor(iconHeight * ratio));
949
1274
  const iconX = x +
950
1275
  Math.ceil(iconResult.lastLineWidth) +
951
1276
  LAYOUT.ELEMENT_SPACING * scaleFactor;
952
- const iconY = iconResult.lastLineY + Math.floor(iconFontSize / 2 - iconFontSize / 2);
953
- ctx.drawImage(img, iconX, iconY, swatchW, iconFontSize);
1277
+ const iconY = textBottomY - iconHeight; // Align bottom với text
1278
+ ctx.drawImage(img, iconX, iconY, swatchW, iconHeight);
954
1279
  }
955
1280
  }
956
1281
  cursorY += iconResult.height;
957
1282
  // Draw color swatches (prefer layer_colors, fallback to single color)
958
- const iconColors = position.layer_colors?.length
959
- ? position.layer_colors
960
- : position.color
961
- ? [position.color]
962
- : null;
1283
+ // Nếu icon đã bị xóa thì không cần hiển thị màu chỉ nữa
1284
+ const iconColors = position.is_delete_icon
1285
+ ? null
1286
+ : position.layer_colors?.length
1287
+ ? position.layer_colors
1288
+ : position.color
1289
+ ? [position.color]
1290
+ : null;
963
1291
  const layerCount = position.layer_colors?.length ?? 0;
964
1292
  const hasMultiLayerColors = layerCount > 1;
965
1293
  const shouldSkipColorSection = options?.hideColor && !hasMultiLayerColors;
966
1294
  if (iconColors?.length && !shouldSkipColorSection) {
967
- const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
968
- const colorResult = wrapText(ctx, `Màu chỉ: ${iconColors.join(", ")}`, x, cursorY, textMaxWidth, iconFontSize + lineGap);
969
- const swatchH = Math.floor(iconFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
970
- const swatchX = x +
971
- Math.ceil(colorResult.lastLineWidth) +
972
- LAYOUT.ELEMENT_SPACING * scaleFactor;
973
- const swatchY = colorResult.lastLineY + Math.floor(iconFontSize / 2 - swatchH / 2);
1295
+ // Dòng "Màu chỉ:" của icon dùng OTHER_FONT_SIZE, không dùng iconFontSize
1296
+ const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1297
+ const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1298
+ const totalSwatchWidth = calculateSwatchesWidth(iconColors, swatchH, scaleFactor, imageRefs);
1299
+ // Set font về OTHER_FONT_SIZE trước khi vẽ text "Màu chỉ:"
1300
+ ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1301
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1302
+ // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
1303
+ const colorResult = wrapText(ctx, `Màu chỉ: ${iconColors.join(", ")}`, x, cursorY, maxWidth, otherFontSize + lineGap);
1304
+ // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1305
+ const textEndX = x + Math.ceil(colorResult.lastLineWidth);
1306
+ const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1307
+ const swatchesStartX = textEndX + spacing;
1308
+ const swatchesEndX = swatchesStartX + totalSwatchWidth;
1309
+ const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1310
+ let swatchX;
1311
+ let swatchY;
1312
+ if (shouldWrapSwatches) {
1313
+ // Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
1314
+ swatchX = x;
1315
+ swatchY = colorResult.lastLineY + otherFontSize + lineGap;
1316
+ cursorY += colorResult.height + otherFontSize + lineGap;
1317
+ }
1318
+ else {
1319
+ // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1320
+ swatchX = swatchesStartX;
1321
+ swatchY = colorResult.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1322
+ cursorY += colorResult.height;
1323
+ }
974
1324
  drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
975
- cursorY += colorResult.height;
1325
+ if (shouldWrapSwatches) {
1326
+ cursorY += swatchH;
1327
+ }
976
1328
  }
977
1329
  ctx.restore();
978
1330
  return cursorY - y;