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