embroidery-qc-image 1.0.29 → 1.0.31

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,6 +33,71 @@ 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
+ // ============================================================================
37
+ // CONSTANTS
38
+ // ============================================================================
39
+ const COLOR_MAP = {
40
+ "Army (1394)": "#545541",
41
+ Army: "#545541",
42
+ "Black (8)": "#060608",
43
+ Black: "#060608",
44
+ "Bubblegum (1309)": "#E77B9F",
45
+ Bubblegum: "#E77B9F",
46
+ "Carolina Blue (1274)": "#608CC9",
47
+ "Carolina Blue": "#608CC9",
48
+ "Celadon (1098)": "#8EAD8D",
49
+ Celadon: "#8EAD8D",
50
+ "Coffee Bean (1145)": "#502B23",
51
+ "Coffee Bean": "#502B23",
52
+ "Daffodil (1180)": "#FBE30D",
53
+ Daffodil: "#FBE30D",
54
+ "Dark Gray (1131)": "#2E272E",
55
+ "Dark Gray": "#2E272E",
56
+ "Doe Skin Beige (1344)": "#AE9B8B",
57
+ "Doe Skin Beige": "#AE9B8B",
58
+ "Dusty Blue (1373)": "#7B90A9",
59
+ "Dusty Blue": "#7B90A9",
60
+ "Forest Green (1397)": "#073020",
61
+ "Forest Green": "#073020",
62
+ "Gold (1425)": "#D2920A",
63
+ Gold: "#D2920A",
64
+ "Gray (1118)": "#9999A3",
65
+ Gray: "#9999A3",
66
+ "Ivory (1072)": "#E3DAC9",
67
+ Ivory: "#E3DAC9",
68
+ "Lavender (1032)": "#9274B6",
69
+ Lavender: "#9274B6",
70
+ "Light Denim (1133)": "#366696",
71
+ "Light Denim": "#366696",
72
+ "Light Salmon (1018)": "#E0A793",
73
+ "Light Salmon": "#E0A793",
74
+ "Maroon (1374)": "#480C1C",
75
+ Maroon: "#480C1C",
76
+ "Navy Blue (1044)": "#04072A",
77
+ "Navy Blue": "#04072A",
78
+ "Olive Green (1157)": "#625E1F",
79
+ "Olive Green": "#625E1F",
80
+ "Orange (1278)": "#D45D03",
81
+ Orange: "#D45D03",
82
+ "Peach Blush (1053)": "#E2C0B6",
83
+ "Peach Blush": "#E2C0B6",
84
+ "Pink (1148)": "#EFAFBF",
85
+ Pink: "#EFAFBF",
86
+ "Purple (1412)": "#37196F",
87
+ Purple: "#37196F",
88
+ "Red (1037)": "#9D000B",
89
+ Red: "#9D000B",
90
+ "Silver Sage (1396)": "#424F45",
91
+ "Silver Sage": "#424F45",
92
+ "Summer Sky (1432)": "#65A8D2",
93
+ "Summer Sky": "#65A8D2",
94
+ "Terra Cotta (1477)": "#AE3111",
95
+ "Terra Cotta": "#AE3111",
96
+ "Sand (1055)": "#D2C2AB",
97
+ Sand: "#D2C2AB",
98
+ "White (9)": "#D8D7DC",
99
+ White: "#D8D7DC",
100
+ };
36
101
  const DEFAULT_ERROR_COLOR = "#CC1F1A";
37
102
  const DEFAULT_WARNING_COLOR = "#FF8C00";
38
103
  const BASE_URLS = {
@@ -73,7 +138,7 @@ const loadFont = (fontName) => {
73
138
  // Check if font is already loaded in document.fonts (browser cache)
74
139
  const fontFaceSet = document.fonts;
75
140
  for (const font of fontFaceSet) {
76
- if (font.family === fontName && font.status === 'loaded') {
141
+ if (font.family === fontName && font.status === "loaded") {
77
142
  return Promise.resolve();
78
143
  }
79
144
  }
@@ -193,7 +258,9 @@ const loadImageAsync = (url, imageRefs, cacheKey) => {
193
258
  target.dataset.proxyUsed = attemptedProxy ? "true" : "false";
194
259
  finalize();
195
260
  };
196
- const desiredSrc = attemptedProxy ? getProxyUrl(getResizeUrl(url)) : getResizeUrl(url);
261
+ const desiredSrc = attemptedProxy
262
+ ? getProxyUrl(getResizeUrl(url))
263
+ : getResizeUrl(url);
197
264
  if (target.src !== desiredSrc) {
198
265
  target.src = desiredSrc;
199
266
  }
@@ -206,22 +273,22 @@ const getResizeUrl = (url) => {
206
273
  try {
207
274
  const urlObj = new URL(url);
208
275
  // Xử lý cdn.shopify.com
209
- if (urlObj.hostname === 'cdn.shopify.com') {
276
+ if (urlObj.hostname === "cdn.shopify.com") {
210
277
  // Set hoặc update query param width=400
211
- urlObj.searchParams.set('width', '400');
278
+ urlObj.searchParams.set("width", "400");
212
279
  return urlObj.toString();
213
280
  }
214
281
  // Xử lý m.media-amazon.com
215
- if (urlObj.hostname === 'm.media-amazon.com') {
282
+ if (urlObj.hostname === "m.media-amazon.com") {
216
283
  const pathname = urlObj.pathname;
217
284
  // Split pathname theo dấu /
218
- const pathArr = pathname.split('/');
285
+ const pathArr = pathname.split("/");
219
286
  // Lấy filename (phần cuối cùng)
220
287
  const filename = pathArr[pathArr.length - 1];
221
288
  // Xóa pattern ._.*_ (ví dụ: ._AC_SX569_)
222
- const cleanedFilename = filename.replace(/\._.*_/g, '');
289
+ const cleanedFilename = filename.replace(/\._.*_/g, "");
223
290
  // Split filename đã clean theo dấu .
224
- const parts = cleanedFilename.split('.');
291
+ const parts = cleanedFilename.split(".");
225
292
  if (parts.length >= 2) {
226
293
  // Lấy phần đầu và phần cuối
227
294
  const firstPart = parts[0];
@@ -231,16 +298,16 @@ const getResizeUrl = (url) => {
231
298
  // Thay filename mới vào pathArr
232
299
  pathArr[pathArr.length - 1] = newFilename;
233
300
  // Join lại
234
- urlObj.pathname = pathArr.join('/');
301
+ urlObj.pathname = pathArr.join("/");
235
302
  return urlObj.toString();
236
303
  }
237
304
  }
238
305
  // Xử lý i.etsystatic.com
239
- if (urlObj.hostname === 'i.etsystatic.com') {
306
+ if (urlObj.hostname === "i.etsystatic.com") {
240
307
  const pathname = urlObj.pathname;
241
308
  // Thay il_fullxfull bằng il_400x400
242
- if (pathname.includes('il_fullxfull')) {
243
- const newPathname = pathname.replace(/il_fullxfull/g, 'il_400x400');
309
+ if (pathname.includes("il_fullxfull")) {
310
+ const newPathname = pathname.replace(/il_fullxfull/g, "il_400x400");
244
311
  urlObj.pathname = newPathname;
245
312
  return urlObj.toString();
246
313
  }
@@ -364,7 +431,8 @@ const wrapText = (ctx, text, x, y, maxWidth, lineHeight, mockupBounds = null) =>
364
431
  let lineToRender = line;
365
432
  if (ctx.measureText(line).width > effectiveMaxWidth) {
366
433
  // Cắt từng ký tự cho đến khi vừa
367
- while (ctx.measureText(lineToRender).width > effectiveMaxWidth && lineToRender.length > 0) {
434
+ while (ctx.measureText(lineToRender).width > effectiveMaxWidth &&
435
+ lineToRender.length > 0) {
368
436
  lineToRender = lineToRender.slice(0, -1);
369
437
  }
370
438
  }
@@ -535,6 +603,262 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
535
603
  // ============================================================================
536
604
  // RENDERING FUNCTIONS
537
605
  // ============================================================================
606
+ const renderStrokePatchesCanvas = (ctx, canvas, config, imageRefs) => {
607
+ // Clear canvas
608
+ ctx.fillStyle = "#e7e7e7";
609
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
610
+ const padding = LAYOUT.PADDING * 6;
611
+ const usableWidth = canvas.width - padding * 2;
612
+ const usableHeight = canvas.height - padding * 2;
613
+ // Calculate sections
614
+ const topSectionHeight = Math.floor(usableHeight * (2 / 3)); // 2/3 top
615
+ const bottomSectionHeight = usableHeight - topSectionHeight; // 1/3 bottom
616
+ const topSectionY = padding;
617
+ const bottomSectionY = topSectionY + topSectionHeight;
618
+ // Get first side (stroke_patches should have only one side)
619
+ const side = config.sides[0];
620
+ if (!side || !side.positions.length) {
621
+ renderErrorState(ctx, canvas, "Không có dữ liệu positions");
622
+ return;
623
+ }
624
+ const position = side.positions[0];
625
+ if (position.type !== "TEXT") {
626
+ renderErrorState(ctx, canvas, "Position phải là TEXT");
627
+ return;
628
+ }
629
+ // Validate required fields
630
+ if (!position.font) {
631
+ renderErrorState(ctx, canvas, "Thiếu font");
632
+ return;
633
+ }
634
+ // Validate layer_colors
635
+ if (!position.layer_colors) {
636
+ renderErrorState(ctx, canvas, "Thiếu layer_colors");
637
+ return;
638
+ }
639
+ if (position.layer_colors.length !== 3) {
640
+ renderErrorState(ctx, canvas, `layer_colors phải có đúng 3 phần tử, hiện tại có ${position.layer_colors.length}`);
641
+ return;
642
+ }
643
+ // Validate từng màu
644
+ const [textColor, borderColor, backgroundColor] = position.layer_colors;
645
+ const missingColors = [];
646
+ if (!textColor || textColor.trim() === "") {
647
+ missingColors.push("Màu text (vị trí 1)");
648
+ }
649
+ if (!borderColor || borderColor.trim() === "") {
650
+ missingColors.push("Màu border (vị trí 2)");
651
+ }
652
+ if (!backgroundColor || backgroundColor.trim() === "") {
653
+ missingColors.push("Màu background (vị trí 3)");
654
+ }
655
+ if (missingColors.length > 0) {
656
+ renderErrorState(ctx, canvas, `Thiếu màu trong layer_colors:\n${missingColors.join("\n")}`);
657
+ return;
658
+ }
659
+ // ============================================================================
660
+ // TOP SECTION (2/3): Hiển thị mẫu preview
661
+ // ============================================================================
662
+ ctx.save();
663
+ // Draw "Hình mẫu:" label at the top
664
+ const titleFontSize = LAYOUT.HEADER_FONT_SIZE * 0.8;
665
+ ctx.font = `bold ${titleFontSize}px ${LAYOUT.FONT_FAMILY}`;
666
+ ctx.fillStyle = "#CC0000"; // Red color
667
+ ctx.fillText("Hình mẫu:", padding, topSectionY);
668
+ // Adjust top section Y to account for title + extra spacing (40px)
669
+ const extraSpacing = 40;
670
+ const actualTopSectionY = topSectionY + titleFontSize + LAYOUT.LINE_GAP + extraSpacing;
671
+ const actualTopSectionHeight = topSectionHeight - titleFontSize - LAYOUT.LINE_GAP - extraSpacing;
672
+ // Calculate text size to fit top section
673
+ let previewFontSize = LAYOUT.HEADER_FONT_SIZE * 3; // Start with large size
674
+ ctx.font = `${previewFontSize}px ${position.font}`;
675
+ const text = position.text || "";
676
+ const maxTextWidth = usableWidth * 0.9; // Use 75% of width for better padding
677
+ const maxTextHeight = actualTopSectionHeight; // Use 60% of height
678
+ // Scale down font size to fit
679
+ let textWidth = ctx.measureText(text).width;
680
+ while (textWidth > maxTextWidth && previewFontSize > 50) {
681
+ previewFontSize *= 0.95;
682
+ ctx.font = `${previewFontSize}px ${position.font}`;
683
+ textWidth = ctx.measureText(text).width;
684
+ }
685
+ // Ensure text height also fits
686
+ while (previewFontSize > maxTextHeight && previewFontSize > 50) {
687
+ previewFontSize *= 0.95;
688
+ ctx.font = `${previewFontSize}px ${position.font}`;
689
+ }
690
+ // Update textWidth after final scaling
691
+ textWidth = ctx.measureText(text).width;
692
+ // Center the text in top section
693
+ const textX = padding + usableWidth / 2 - textWidth / 2;
694
+ const textY = actualTopSectionY + actualTopSectionHeight / 2 - previewFontSize / 2;
695
+ // Get color hex values
696
+ const textColorHex = COLOR_MAP[textColor] || LAYOUT.LABEL_COLOR;
697
+ const borderColorHex = COLOR_MAP[borderColor] || LAYOUT.LABEL_COLOR;
698
+ const bgColorHex = COLOR_MAP[backgroundColor] || "#FFFFFF";
699
+ // Calculate stroke widths
700
+ // Background needs to be MUCH thicker to create spacing from text
701
+ // Border needs to be even thicker to wrap around background
702
+ const bgWidth = Math.max(80, previewFontSize / 2); // Background - rất dày để tạo khoảng cách lớn
703
+ const borderWidth = Math.max(100, previewFontSize / 1.58); // Border - dày hơn để bọc background
704
+ // Layer 1: Draw border stroke (outermost)
705
+ ctx.strokeStyle = borderColorHex;
706
+ ctx.lineWidth = borderWidth;
707
+ ctx.lineJoin = "round";
708
+ ctx.lineCap = "round";
709
+ ctx.strokeText(text, textX, textY);
710
+ // Layer 2: Draw background color stroke (middle - creates spacing from text)
711
+ ctx.strokeStyle = bgColorHex;
712
+ ctx.lineWidth = bgWidth;
713
+ ctx.lineJoin = "round";
714
+ ctx.lineCap = "round";
715
+ ctx.strokeText(text, textX, textY);
716
+ // Layer 3: Draw text fill ONLY (no stroke on text itself)
717
+ ctx.fillStyle = textColorHex;
718
+ ctx.fillText(text, textX, textY);
719
+ ctx.restore();
720
+ // ============================================================================
721
+ // BOTTOM SECTION (1/3): Flex layout with info (left) and image (right)
722
+ // ============================================================================
723
+ ctx.save();
724
+ // Draw border around bottom section for debugging
725
+ // ctx.strokeStyle = "#ccc";
726
+ // ctx.lineWidth = 2;
727
+ // ctx.strokeRect(padding, bottomSectionY, usableWidth, bottomSectionHeight);
728
+ const bottomPadding = 0; // Không padding ngang, sát lề
729
+ const bottomUsableWidth = usableWidth; // Sử dụng toàn bộ width
730
+ const bottomUsableHeight = bottomSectionHeight - LAYOUT.PADDING * 2; // Chỉ padding dọc
731
+ // Split bottom section: 60% left for info, 40% right for image
732
+ const infoSectionWidth = Math.floor(bottomUsableWidth * 0.6);
733
+ const imageSectionWidth = bottomUsableWidth - infoSectionWidth;
734
+ const imageSectionX = padding + infoSectionWidth;
735
+ // Left side: Info list
736
+ const infoFontSize = LAYOUT.OTHER_FONT_SIZE * 0.9; // Giảm từ 1.2 xuống 0.9
737
+ const infoLineHeight = infoFontSize * 1.4; // Giảm từ 1.5 xuống 1.4
738
+ let infoY = bottomSectionY + LAYOUT.PADDING;
739
+ ctx.font = `${infoFontSize}px ${LAYOUT.FONT_FAMILY}`;
740
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
741
+ ctx.textAlign = "left";
742
+ ctx.textBaseline = "top";
743
+ // Asterisk prefix style
744
+ const drawAsterisk = (x, y) => {
745
+ ctx.save();
746
+ ctx.fillStyle = "#CC0000"; // Red asterisk
747
+ ctx.font = `bold ${infoFontSize}px ${LAYOUT.FONT_FAMILY}`;
748
+ ctx.fillText("*", x, y);
749
+ ctx.restore();
750
+ };
751
+ const asteriskWidth = ctx.measureText("*").width + 5;
752
+ const startX = padding + asteriskWidth;
753
+ // Font - render "Font: " với font mặc định, tên font với font đó
754
+ drawAsterisk(padding, infoY);
755
+ const fontPrefix = "Font: ";
756
+ ctx.font = `${infoFontSize}px ${LAYOUT.FONT_FAMILY}`;
757
+ ctx.fillText(fontPrefix, startX, infoY);
758
+ // Render font name với chính font đó
759
+ const prefixWidth = ctx.measureText(fontPrefix).width;
760
+ ctx.font = `${infoFontSize}px ${position.font}`;
761
+ ctx.fillText(position.font, startX + prefixWidth, infoY);
762
+ infoY += infoLineHeight;
763
+ // Reset font về mặc định cho các dòng tiếp theo
764
+ ctx.font = `${infoFontSize}px ${LAYOUT.FONT_FAMILY}`;
765
+ // Màu chữ (Text Color)
766
+ drawAsterisk(padding, infoY);
767
+ const textColorLabel = `Màu chữ: ${textColor}`;
768
+ ctx.fillText(textColorLabel, startX, infoY);
769
+ // Draw text color swatch
770
+ const swatchSize = infoFontSize * 1.3; // Giảm từ 1.5 xuống 1.3
771
+ const swatchX = startX +
772
+ ctx.measureText(textColorLabel).width +
773
+ LAYOUT.ELEMENT_SPACING * 0.3; // Giảm spacing
774
+ const swatchY = infoY + infoFontSize / 2 - swatchSize / 2;
775
+ const textColorSwatchUrl = getImageUrl("threadColor", textColor);
776
+ const textColorSwatchImg = imageRefs.current.get(textColorSwatchUrl);
777
+ if (textColorSwatchImg?.complete && textColorSwatchImg.naturalHeight > 0) {
778
+ const ratio = textColorSwatchImg.naturalWidth / textColorSwatchImg.naturalHeight;
779
+ const swatchW = Math.max(1, Math.floor(swatchSize * ratio));
780
+ ctx.drawImage(textColorSwatchImg, swatchX, swatchY, swatchW, swatchSize);
781
+ }
782
+ infoY += infoLineHeight;
783
+ // Màu nền (Background Color)
784
+ drawAsterisk(padding + bottomPadding, infoY);
785
+ const bgColorLabel = `Màu nền: ${backgroundColor}`;
786
+ ctx.fillText(bgColorLabel, startX, infoY);
787
+ // Draw background color swatch
788
+ const bgSwatchX = startX + ctx.measureText(bgColorLabel).width + LAYOUT.ELEMENT_SPACING * 0.3;
789
+ const bgSwatchY = infoY + infoFontSize / 2 - swatchSize / 2;
790
+ const bgColorSwatchUrl = getImageUrl("threadColor", backgroundColor);
791
+ const bgColorSwatchImg = imageRefs.current.get(bgColorSwatchUrl);
792
+ if (bgColorSwatchImg?.complete && bgColorSwatchImg.naturalHeight > 0) {
793
+ const ratio = bgColorSwatchImg.naturalWidth / bgColorSwatchImg.naturalHeight;
794
+ const swatchW = Math.max(1, Math.floor(swatchSize * ratio));
795
+ ctx.drawImage(bgColorSwatchImg, bgSwatchX, bgSwatchY, swatchW, swatchSize);
796
+ }
797
+ infoY += infoLineHeight;
798
+ // Màu viền (Border Color)
799
+ drawAsterisk(padding + bottomPadding, infoY);
800
+ const borderColorLabel = `Màu viền: ${borderColor}`;
801
+ ctx.fillText(borderColorLabel, startX, infoY);
802
+ // Draw border color swatch
803
+ const borderSwatchX = startX +
804
+ ctx.measureText(borderColorLabel).width +
805
+ LAYOUT.ELEMENT_SPACING * 0.3;
806
+ const borderSwatchY = infoY + infoFontSize / 2 - swatchSize / 2;
807
+ const borderColorSwatchUrl = getImageUrl("threadColor", borderColor);
808
+ const borderColorSwatchImg = imageRefs.current.get(borderColorSwatchUrl);
809
+ if (borderColorSwatchImg?.complete &&
810
+ borderColorSwatchImg.naturalHeight > 0) {
811
+ const ratio = borderColorSwatchImg.naturalWidth / borderColorSwatchImg.naturalHeight;
812
+ const swatchW = Math.max(1, Math.floor(swatchSize * ratio));
813
+ ctx.drawImage(borderColorSwatchImg, borderSwatchX, borderSwatchY, swatchW, swatchSize);
814
+ }
815
+ infoY += infoLineHeight;
816
+ // Attachment
817
+ if (position.attachment) {
818
+ drawAsterisk(padding + bottomPadding, infoY);
819
+ const attachmentLabel = `Attachment: ${position.attachment}`;
820
+ ctx.fillText(attachmentLabel, startX, infoY);
821
+ infoY += infoLineHeight;
822
+ }
823
+ // Size
824
+ if (side.size) {
825
+ drawAsterisk(padding + bottomPadding, infoY);
826
+ const sizeLabel = `Size: ${side.size}`;
827
+ ctx.fillText(sizeLabel, startX, infoY);
828
+ infoY += infoLineHeight;
829
+ }
830
+ // Right side: Image from config.image_url
831
+ if (config.image_url) {
832
+ // Draw "Mockup" label
833
+ ctx.font = `bold ${infoFontSize * 1.2}px ${LAYOUT.FONT_FAMILY}`;
834
+ ctx.fillStyle = "#000000";
835
+ const mockupLabel = "Mockup";
836
+ const mockupLabelWidth = ctx.measureText(mockupLabel).width;
837
+ const mockupLabelX = imageSectionX + (imageSectionWidth - mockupLabelWidth) / 1.2;
838
+ ctx.fillText(mockupLabel, mockupLabelX, bottomSectionY + LAYOUT.PADDING);
839
+ const mockupLabelHeight = infoFontSize * 1.2 + LAYOUT.LINE_GAP * 0.5;
840
+ const img = imageRefs.current.get(config.image_url) ??
841
+ imageRefs.current.get("mockup");
842
+ if (img?.complete && img.naturalWidth > 0) {
843
+ const maxImgWidth = imageSectionWidth; // Sử dụng toàn bộ width, sát lề phải
844
+ const maxImgHeight = bottomUsableHeight - mockupLabelHeight;
845
+ const imgAspectRatio = img.naturalWidth / img.naturalHeight;
846
+ let drawWidth = maxImgWidth;
847
+ let drawHeight = drawWidth / imgAspectRatio;
848
+ if (drawHeight > maxImgHeight) {
849
+ drawHeight = maxImgHeight;
850
+ drawWidth = drawHeight * imgAspectRatio;
851
+ }
852
+ const imgX = imageSectionX + (imageSectionWidth - drawWidth) / 0.8;
853
+ const imgY = bottomSectionY +
854
+ LAYOUT.PADDING +
855
+ mockupLabelHeight +
856
+ (bottomUsableHeight - mockupLabelHeight - drawHeight) / 2;
857
+ ctx.drawImage(img, imgX, imgY, drawWidth, drawHeight);
858
+ }
859
+ }
860
+ ctx.restore();
861
+ };
538
862
  const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
539
863
  const ctx = canvas.getContext("2d");
540
864
  if (!ctx)
@@ -549,6 +873,12 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
549
873
  }
550
874
  if (!config.sides?.length)
551
875
  return;
876
+ // Check if this is a stroke_patches layout
877
+ const hasStrokePatches = config.sides.some((side) => side.item_type && side.item_type.includes("stroke_patches"));
878
+ if (hasStrokePatches) {
879
+ renderStrokePatchesCanvas(ctx, canvas, config, imageRefs);
880
+ return;
881
+ }
552
882
  ctx.textAlign = LAYOUT.TEXT_ALIGN;
553
883
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
554
884
  if (config.image_url) {
@@ -721,7 +1051,8 @@ const getEffectiveMaxWidth = (x, y, lineHeight, originalMaxWidth, mockupBounds)
721
1051
  // Kiểm tra xem dòng text có nằm trong phạm vi Y của mockup không
722
1052
  const lineTopY = y;
723
1053
  const lineBottomY = y + lineHeight;
724
- const overlapsY = lineTopY < mockupBounds.y + mockupBounds.height && lineBottomY > mockupBounds.y;
1054
+ const overlapsY = lineTopY < mockupBounds.y + mockupBounds.height &&
1055
+ lineBottomY > mockupBounds.y;
725
1056
  if (overlapsY) {
726
1057
  // Nếu overlap theo Y, giới hạn maxWidth để text không vượt quá mockup.x
727
1058
  const maxAllowedWidth = mockupBounds.x - x;
@@ -768,15 +1099,17 @@ const renderSide = (ctx, side, startY, width, height, scaleFactor, imageRefs, mo
768
1099
  const iconPositions = side.positions.filter((p) => p.type === "ICON");
769
1100
  // Kiểm tra tất cả TEXT positions có trống không
770
1101
  // Nếu không có TEXT positions, coi như "tất cả TEXT trống" = true
771
- const allTextEmpty = textPositions.length === 0 || textPositions.every((p) => {
772
- const text = p.text ?? "";
773
- return text.trim() === "";
774
- });
1102
+ const allTextEmpty = textPositions.length === 0 ||
1103
+ textPositions.every((p) => {
1104
+ const text = p.text ?? "";
1105
+ return text.trim() === "";
1106
+ });
775
1107
  // Kiểm tra tất cả ICON positions có is_delete_icon = true không
776
1108
  // Nếu không có ICON positions, coi như "tất cả ICON bị xóa" = true
777
- const allIconsDeleted = iconPositions.length === 0 || iconPositions.every((p) => {
778
- return p.is_delete_icon === true;
779
- });
1109
+ const allIconsDeleted = iconPositions.length === 0 ||
1110
+ iconPositions.every((p) => {
1111
+ return p.is_delete_icon === true;
1112
+ });
780
1113
  // 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ì)"
781
1114
  if (allTextEmpty && allIconsDeleted && side.positions.length > 0) {
782
1115
  ctx.save();
@@ -790,7 +1123,8 @@ const renderSide = (ctx, side, startY, width, height, scaleFactor, imageRefs, mo
790
1123
  return currentY - startY;
791
1124
  }
792
1125
  // Compute uniform properties
793
- const iconColorPositions = side.positions.filter((p) => p.type === "ICON" && (!p.layer_colors?.length || p.layer_colors.length === 1));
1126
+ const iconColorPositions = side.positions.filter((p) => p.type === "ICON" &&
1127
+ (!p.layer_colors?.length || p.layer_colors.length === 1));
794
1128
  const iconColorValues = iconColorPositions
795
1129
  .map((p) => {
796
1130
  if (p.layer_colors?.length === 1)
@@ -912,7 +1246,8 @@ const computeUniformProperties = (textPositions, options) => {
912
1246
  ...(options?.additionalColorValues?.map((color) => color ?? "None") ?? []),
913
1247
  ];
914
1248
  if (textPositions.length === 0 &&
915
- (!options?.additionalColorValues || options.additionalColorValues.length === 0)) {
1249
+ (!options?.additionalColorValues ||
1250
+ options.additionalColorValues.length === 0)) {
916
1251
  return defaults;
917
1252
  }
918
1253
  const colors = new Set(colorSources);
@@ -1013,7 +1348,9 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
1013
1348
  }
1014
1349
  rendered++;
1015
1350
  }
1016
- if (values.floral && values.floral !== "None" && shouldRenderField("floral")) {
1351
+ if (values.floral &&
1352
+ values.floral !== "None" &&
1353
+ shouldRenderField("floral")) {
1017
1354
  const floralUrl = getImageUrl("floral", values.floral);
1018
1355
  const floralImg = imageRefs.current.get(floralUrl);
1019
1356
  // Tính kích thước ảnh floral (thêm 50% = 2.5x fontSize)
@@ -1077,9 +1414,7 @@ displayIndex, showLabels, scaleFactor, imageRefs, mockupBounds = null) => {
1077
1414
  let currentY = y;
1078
1415
  // Chuẩn hóa xuống dòng:
1079
1416
  // - Hỗ trợ cả newline thật (\n) và chuỗi literal "\\n" từ JSON
1080
- const normalizeNewlines = (text) => text
1081
- .replace(/\r\n/g, "\n")
1082
- .replace(/\r/g, "\n");
1417
+ const normalizeNewlines = (text) => text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1083
1418
  // Get display text (handle empty/null/undefined) sau khi normalize
1084
1419
  const rawOriginalText = position.text ?? "";
1085
1420
  const normalizedText = normalizeNewlines(rawOriginalText);
@@ -1116,7 +1451,9 @@ displayIndex, showLabels, scaleFactor, imageRefs, mockupBounds = null) => {
1116
1451
  let minShrinkRatio = 1;
1117
1452
  ctx.font = `${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
1118
1453
  lines.forEach((line, idx) => {
1119
- const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1454
+ const lineY = textCenterY -
1455
+ ((lines.length - 1) / 2) * valueLineHeight +
1456
+ idx * valueLineHeight;
1120
1457
  // Tính effectiveMaxWidth cho dòng này
1121
1458
  const effectiveMaxWidth = mockupBounds
1122
1459
  ? getEffectiveMaxWidth(valueStartX, lineY, valueLineHeight, availableWidth, mockupBounds)
@@ -1144,7 +1481,9 @@ displayIndex, showLabels, scaleFactor, imageRefs, mockupBounds = null) => {
1144
1481
  // Vẽ từ trên xuống: căn giữa mỗi dòng
1145
1482
  // Font size đã được tính để vừa với effectiveMaxWidth của từng dòng, nên không cần cắt text
1146
1483
  lines.forEach((line, idx) => {
1147
- const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1484
+ const lineY = textCenterY -
1485
+ ((lines.length - 1) / 2) * valueLineHeight +
1486
+ idx * valueLineHeight;
1148
1487
  ctx.fillText(line, valueStartX, lineY);
1149
1488
  });
1150
1489
  // Reset textBaseline về top cho các phần tiếp theo
@@ -1162,25 +1501,127 @@ displayIndex, showLabels, scaleFactor, imageRefs, mockupBounds = null) => {
1162
1501
  currentY += result.height;
1163
1502
  }
1164
1503
  if (showLabels.font && position.font) {
1165
- // Render "Font: " với font mặc định
1166
1504
  const prefix = "Font: ";
1167
- ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1168
- const prefixWidth = ctx.measureText(prefix).width;
1169
- let currentX = x + prefixWidth;
1170
- ctx.fillText(prefix, x, currentY);
1171
- // Render tên font với font từ config
1172
- ctx.font = `${otherFontSize}px ${position.font}`;
1173
- const fontNameWidth = ctx.measureText(position.font).width;
1174
- ctx.fillText(position.font, currentX, currentY);
1175
- currentX += fontNameWidth;
1176
- // Render "(Mặc định)" hoặc "(Custom)" với font mặc định
1177
1505
  const suffix = position.is_font_default === true ? " (Mặc định)" : " (Custom)";
1178
- ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1179
- ctx.measureText(suffix).width;
1180
- ctx.fillText(suffix, currentX, currentY);
1181
- // Tính toán height và di chuyển cursorY
1506
+ const fontName = position.font;
1507
+ const fullText = `${prefix}${fontName}${suffix}`;
1182
1508
  const lineHeight = otherFontSize + lineGap;
1183
- currentY += lineHeight;
1509
+ const textTopY = currentY;
1510
+ const effectiveMaxWidth = mockupBounds
1511
+ ? getEffectiveMaxWidth(x, textTopY, lineHeight, maxWidth, mockupBounds)
1512
+ : maxWidth;
1513
+ const MIN_FONT_FONT_SIZE = otherFontSize * 0.5;
1514
+ const measureFontTextWidth = (fontSize) => {
1515
+ ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
1516
+ const prefixWidth = ctx.measureText(prefix).width;
1517
+ ctx.font = `${fontSize}px ${position.font}`;
1518
+ const fontNameWidth = ctx.measureText(fontName).width;
1519
+ ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
1520
+ const suffixWidth = ctx.measureText(suffix).width;
1521
+ return prefixWidth + fontNameWidth + suffixWidth;
1522
+ };
1523
+ const checkFontSizeFits = (fontSize) => {
1524
+ const textTopY = currentY;
1525
+ const lineHeight = fontSize + lineGap;
1526
+ const effectiveMaxWidth = mockupBounds
1527
+ ? getEffectiveMaxWidth(x, textTopY, lineHeight, maxWidth, mockupBounds)
1528
+ : maxWidth;
1529
+ const textWidth = measureFontTextWidth(fontSize);
1530
+ return textWidth <= effectiveMaxWidth;
1531
+ };
1532
+ let effectiveFontSize = otherFontSize;
1533
+ let needsWrap = false;
1534
+ // Bước 1: Thử giảm font size, kiểm tra xem có vừa chiều ngang không
1535
+ const baseMaxWidth = measureFontTextWidth(otherFontSize);
1536
+ if (baseMaxWidth > effectiveMaxWidth) {
1537
+ // Binary search để tìm font size lớn nhất mà vẫn vừa
1538
+ let left = MIN_FONT_FONT_SIZE;
1539
+ let right = otherFontSize;
1540
+ let bestFontSize = MIN_FONT_FONT_SIZE;
1541
+ while (right - left > 0.1) {
1542
+ const mid = (left + right) / 2;
1543
+ if (checkFontSizeFits(mid)) {
1544
+ bestFontSize = mid;
1545
+ left = mid;
1546
+ }
1547
+ else {
1548
+ right = mid;
1549
+ }
1550
+ }
1551
+ if (checkFontSizeFits(bestFontSize)) {
1552
+ // Bước 1 thành công: font size đã shrink vừa chiều ngang
1553
+ effectiveFontSize = Math.floor(bestFontSize);
1554
+ }
1555
+ else {
1556
+ // Bước 1 thất bại: đã shrink đến MIN nhưng vẫn không vừa, sang bước 2
1557
+ needsWrap = true;
1558
+ effectiveFontSize = otherFontSize;
1559
+ }
1560
+ }
1561
+ if (needsWrap) {
1562
+ // Bước 2: Xuống dòng với font size gốc
1563
+ ctx.font = `${effectiveFontSize}px ${LAYOUT.FONT_FAMILY}`;
1564
+ const wrappedLines = buildWrappedLines(ctx, fullText, effectiveMaxWidth, x, textTopY, lineHeight, mockupBounds);
1565
+ wrappedLines.forEach((line, index) => {
1566
+ const lineY = textTopY + index * lineHeight;
1567
+ const lineEffectiveMaxWidth = mockupBounds
1568
+ ? getEffectiveMaxWidth(x, lineY, lineHeight, maxWidth, mockupBounds)
1569
+ : maxWidth;
1570
+ let lineToRender = line;
1571
+ const lineWidth = ctx.measureText(lineToRender).width;
1572
+ if (lineWidth > lineEffectiveMaxWidth) {
1573
+ while (ctx.measureText(lineToRender).width > lineEffectiveMaxWidth &&
1574
+ lineToRender.length > 0) {
1575
+ lineToRender = lineToRender.slice(0, -1);
1576
+ }
1577
+ }
1578
+ ctx.fillText(lineToRender, x, lineY);
1579
+ });
1580
+ currentY += wrappedLines.length * lineHeight;
1581
+ }
1582
+ else {
1583
+ // Bước 1 thành công: Render với font size đã shrink (1 dòng)
1584
+ const shrunkTextTopY = currentY;
1585
+ const shrunkLineHeight = effectiveFontSize + lineGap;
1586
+ const shrunkEffectiveMaxWidth = mockupBounds
1587
+ ? getEffectiveMaxWidth(x, shrunkTextTopY, shrunkLineHeight, maxWidth, mockupBounds)
1588
+ : maxWidth;
1589
+ ctx.font = `${effectiveFontSize}px ${LAYOUT.FONT_FAMILY}`;
1590
+ const prefixWidth = ctx.measureText(prefix).width;
1591
+ let currentX = x + prefixWidth;
1592
+ ctx.fillText(prefix, x, currentY);
1593
+ ctx.font = `${effectiveFontSize}px ${position.font}`;
1594
+ const fontNameWidth = ctx.measureText(fontName).width;
1595
+ const totalWidth = prefixWidth + fontNameWidth;
1596
+ if (totalWidth > shrunkEffectiveMaxWidth) {
1597
+ // Cần cắt font name
1598
+ let truncatedFontName = fontName;
1599
+ while (ctx.measureText(truncatedFontName).width >
1600
+ shrunkEffectiveMaxWidth - prefixWidth &&
1601
+ truncatedFontName.length > 0) {
1602
+ truncatedFontName = truncatedFontName.slice(0, -1);
1603
+ }
1604
+ ctx.fillText(truncatedFontName, currentX, currentY);
1605
+ currentX += ctx.measureText(truncatedFontName).width;
1606
+ }
1607
+ else {
1608
+ ctx.fillText(fontName, currentX, currentY);
1609
+ currentX += fontNameWidth;
1610
+ }
1611
+ ctx.font = `${effectiveFontSize}px ${LAYOUT.FONT_FAMILY}`;
1612
+ const remainingWidth = shrunkEffectiveMaxWidth - (currentX - x);
1613
+ if (remainingWidth > 0) {
1614
+ let truncatedSuffix = suffix;
1615
+ while (ctx.measureText(truncatedSuffix).width > remainingWidth &&
1616
+ truncatedSuffix.length > 0) {
1617
+ truncatedSuffix = truncatedSuffix.slice(0, -1);
1618
+ }
1619
+ if (truncatedSuffix.length > 0) {
1620
+ ctx.fillText(truncatedSuffix, currentX, currentY);
1621
+ }
1622
+ }
1623
+ currentY += shrunkLineHeight;
1624
+ }
1184
1625
  }
1185
1626
  if (showLabels.color) {
1186
1627
  const colorValue = position.character_colors?.join(", ") || position.color;
@@ -1213,7 +1654,8 @@ displayIndex, showLabels, scaleFactor, imageRefs, mockupBounds = null) => {
1213
1654
  else {
1214
1655
  // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1215
1656
  swatchX = swatchesStartX;
1216
- swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1657
+ swatchY =
1658
+ result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1217
1659
  currentY += result.height;
1218
1660
  }
1219
1661
  drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
@@ -1322,130 +1764,122 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1322
1764
  const ratio = img.naturalWidth / img.naturalHeight;
1323
1765
  const iconHeight = iconFontSize * 2;
1324
1766
  const iconWidth = Math.max(1, Math.floor(iconHeight * ratio));
1325
- iconImageReservedWidth = iconWidth + LAYOUT.ELEMENT_SPACING * scaleFactor;
1767
+ iconImageReservedWidth =
1768
+ iconWidth + LAYOUT.ELEMENT_SPACING * scaleFactor;
1326
1769
  }
1327
1770
  }
1328
1771
  }
1329
- // Tính available width cho icon value (trừ đi khoảng trống cho icon_image)
1330
1772
  const availableWidth = Math.max(1, maxWidth - labelWidth - iconImageReservedWidth);
1331
- // Tính font-size hiệu dụng cho icon value
1332
- // Giới hạn thu nhỏ tối đa 50% (tối thiểu = iconFontSize * 0.5)
1773
+ const textCenterY = cursorY + iconImageHeight / 2;
1774
+ const valueStartX = x + labelWidth;
1775
+ const textTopY = textCenterY - iconFontSize / 2;
1776
+ const lineHeight = iconFontSize;
1777
+ const effectiveMaxWidth = mockupBounds
1778
+ ? getEffectiveMaxWidth(valueStartX, textTopY, lineHeight, availableWidth, mockupBounds)
1779
+ : availableWidth;
1333
1780
  const MIN_ICON_VALUE_FONT_SIZE = iconFontSize * 0.5;
1334
1781
  const measureIconValueWidth = (fontSize) => {
1335
1782
  ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
1336
1783
  return ctx.measureText(` ${iconValue}`).width;
1337
1784
  };
1785
+ const checkFontSizeFits = (fontSize) => {
1786
+ const textTopY = textCenterY - fontSize / 2;
1787
+ const lineHeight = fontSize;
1788
+ const effectiveMaxWidth = mockupBounds
1789
+ ? getEffectiveMaxWidth(valueStartX, textTopY, lineHeight, availableWidth, mockupBounds)
1790
+ : availableWidth;
1791
+ const textWidth = measureIconValueWidth(fontSize);
1792
+ return textWidth <= effectiveMaxWidth;
1793
+ };
1338
1794
  let effectiveIconValueFontSize = iconFontSize;
1339
- const baseMaxWidth = measureIconValueWidth(iconFontSize);
1340
1795
  let needsWrap = false;
1341
- if (baseMaxWidth > availableWidth) {
1342
- const shrinkRatio = availableWidth / baseMaxWidth;
1343
- effectiveIconValueFontSize = Math.max(MIN_ICON_VALUE_FONT_SIZE, iconFontSize * shrinkRatio);
1344
- // Kiểm tra xem sau khi thu nhỏ đến 50% vẫn overflow không
1345
- const minMaxWidth = measureIconValueWidth(MIN_ICON_VALUE_FONT_SIZE);
1346
- if (minMaxWidth > availableWidth) {
1347
- // Vẫn overflow, cần dùng wrap text
1796
+ // Bước 1: Thử giảm font size, kiểm tra xem có vừa chiều ngang không
1797
+ const baseMaxWidth = measureIconValueWidth(iconFontSize);
1798
+ if (baseMaxWidth > effectiveMaxWidth) {
1799
+ // Binary search để tìm font size lớn nhất vẫn vừa
1800
+ let left = MIN_ICON_VALUE_FONT_SIZE;
1801
+ let right = iconFontSize;
1802
+ let bestFontSize = MIN_ICON_VALUE_FONT_SIZE;
1803
+ while (right - left > 0.1) {
1804
+ const mid = (left + right) / 2;
1805
+ if (checkFontSizeFits(mid)) {
1806
+ bestFontSize = mid;
1807
+ left = mid;
1808
+ }
1809
+ else {
1810
+ right = mid;
1811
+ }
1812
+ }
1813
+ if (checkFontSizeFits(bestFontSize)) {
1814
+ // Bước 1 thành công: font size đã shrink vừa chiều ngang
1815
+ effectiveIconValueFontSize = Math.floor(bestFontSize);
1816
+ }
1817
+ else {
1818
+ // Bước 1 thất bại: đã shrink đến MIN nhưng vẫn không vừa, sang bước 2
1348
1819
  needsWrap = true;
1349
- effectiveIconValueFontSize = MIN_ICON_VALUE_FONT_SIZE;
1820
+ effectiveIconValueFontSize = iconFontSize;
1350
1821
  }
1351
1822
  }
1352
- // Tính line height block height cho icon value
1353
- const valueLineHeight = effectiveIconValueFontSize;
1354
- let allWrappedLines = [];
1355
- // Text align center: căn giữa theo chiều dọc trong block
1356
- const textCenterY = cursorY + iconImageHeight / 2;
1357
- const valueStartX = x + labelWidth;
1358
- if (needsWrap) {
1359
- // Dùng wrap text logic
1360
- const wrappedLines = buildWrappedLines(ctx, iconValue, availableWidth, valueStartX, textCenterY, valueLineHeight, mockupBounds);
1361
- allWrappedLines = wrappedLines;
1362
- wrappedLines.length * valueLineHeight;
1363
- }
1364
- else {
1365
- // Không cần wrap, chỉ một dòng
1366
- allWrappedLines = [iconValue];
1367
- }
1368
- // Vẽ label với textBaseline = middle để align center với value
1823
+ // Bước 2: Nếu bước 1 thất bại, xuống dòng với font size gốc
1369
1824
  ctx.textBaseline = "middle";
1370
1825
  ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1371
1826
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1372
1827
  ctx.fillText(iconLabel, x, textCenterY);
1373
- // Vẽ icon value với align center
1374
1828
  ctx.font = `${effectiveIconValueFontSize}px ${LAYOUT.FONT_FAMILY}`;
1375
1829
  ctx.fillStyle = DEFAULT_ERROR_COLOR;
1376
1830
  let maxValueLineWidth = 0;
1377
- // Vẽ icon value, căn giữa theo chiều dọc
1831
+ let totalTextHeight = iconImageHeight;
1378
1832
  if (needsWrap) {
1379
- // nhiều dòng sau khi wrap
1380
- allWrappedLines.forEach((line, index) => {
1381
- const lineY = textCenterY - (allWrappedLines.length - 1) / 2 * valueLineHeight + index * valueLineHeight;
1382
- // Kiểm tra overlap với mockup và điều chỉnh text nếu cần
1383
- if (mockupBounds) {
1384
- const effectiveMaxWidth = getEffectiveMaxWidth(valueStartX, lineY, valueLineHeight, availableWidth, mockupBounds);
1385
- const lineWidth = ctx.measureText(line).width;
1386
- if (lineWidth > effectiveMaxWidth) {
1387
- // Cắt text cho đến khi vừa với effectiveMaxWidth
1388
- let truncatedLine = line;
1389
- while (ctx.measureText(truncatedLine).width > effectiveMaxWidth && truncatedLine.length > 0) {
1390
- truncatedLine = truncatedLine.slice(0, -1);
1391
- }
1392
- ctx.fillText(truncatedLine, valueStartX, lineY);
1393
- const w = ctx.measureText(truncatedLine).width;
1394
- if (w > maxValueLineWidth)
1395
- maxValueLineWidth = w;
1396
- }
1397
- else {
1398
- ctx.fillText(line, valueStartX, lineY);
1399
- const w = ctx.measureText(line).width;
1400
- if (w > maxValueLineWidth)
1401
- maxValueLineWidth = w;
1833
+ // Bước 2: Xuống dòng với font size gốc
1834
+ const wrappedLines = buildWrappedLines(ctx, iconValue, effectiveMaxWidth, valueStartX, textTopY, lineHeight, mockupBounds);
1835
+ // Tính height dựa trên số dòng thực tế
1836
+ totalTextHeight = Math.max(iconImageHeight, wrappedLines.length * lineHeight);
1837
+ wrappedLines.forEach((line, index) => {
1838
+ const lineTopY = textTopY + index * lineHeight;
1839
+ const lineCenterY = lineTopY + lineHeight / 2;
1840
+ const lineEffectiveMaxWidth = mockupBounds
1841
+ ? getEffectiveMaxWidth(valueStartX, lineTopY, lineHeight, availableWidth, mockupBounds)
1842
+ : availableWidth;
1843
+ let lineToRender = line;
1844
+ const lineWidth = ctx.measureText(lineToRender).width;
1845
+ if (lineWidth > lineEffectiveMaxWidth) {
1846
+ while (ctx.measureText(lineToRender).width > lineEffectiveMaxWidth &&
1847
+ lineToRender.length > 0) {
1848
+ lineToRender = lineToRender.slice(0, -1);
1402
1849
  }
1403
1850
  }
1404
- else {
1405
- ctx.fillText(line, valueStartX, lineY);
1406
- const w = ctx.measureText(line).width;
1407
- if (w > maxValueLineWidth)
1408
- maxValueLineWidth = w;
1409
- }
1851
+ ctx.fillText(lineToRender, valueStartX, lineCenterY);
1852
+ const w = ctx.measureText(lineToRender).width;
1853
+ if (w > maxValueLineWidth)
1854
+ maxValueLineWidth = w;
1410
1855
  });
1411
1856
  }
1412
1857
  else {
1413
- // Chỉ một dòng, căn giữa
1858
+ // Bước 1 thành công: Render với font size đã shrink (1 dòng)
1859
+ const shrunkTextTopY = textCenterY - effectiveIconValueFontSize / 2;
1860
+ const shrunkLineHeight = effectiveIconValueFontSize;
1861
+ const shrunkEffectiveMaxWidth = mockupBounds
1862
+ ? getEffectiveMaxWidth(valueStartX, shrunkTextTopY, shrunkLineHeight, availableWidth, mockupBounds)
1863
+ : availableWidth;
1414
1864
  const lineText = ` ${iconValue}`;
1415
- // Kiểm tra overlap với mockup và điều chỉnh text nếu cần
1416
- if (mockupBounds) {
1417
- const effectiveMaxWidth = getEffectiveMaxWidth(valueStartX, textCenterY, valueLineHeight, availableWidth, mockupBounds);
1418
- const lineWidth = ctx.measureText(lineText).width;
1419
- if (lineWidth > effectiveMaxWidth) {
1420
- // Cắt text cho đến khi vừa với effectiveMaxWidth
1421
- let truncatedLine = lineText;
1422
- while (ctx.measureText(truncatedLine).width > effectiveMaxWidth && truncatedLine.length > 0) {
1423
- truncatedLine = truncatedLine.slice(0, -1);
1424
- }
1425
- ctx.fillText(truncatedLine, valueStartX, textCenterY);
1426
- const w = ctx.measureText(truncatedLine).width;
1427
- if (w > maxValueLineWidth)
1428
- maxValueLineWidth = w;
1429
- }
1430
- else {
1431
- ctx.fillText(lineText, valueStartX, textCenterY);
1432
- const w = ctx.measureText(lineText).width;
1433
- if (w > maxValueLineWidth)
1434
- maxValueLineWidth = w;
1865
+ let lineToRender = lineText;
1866
+ const lineWidth = ctx.measureText(lineToRender).width;
1867
+ if (lineWidth > shrunkEffectiveMaxWidth) {
1868
+ while (ctx.measureText(lineToRender).width > shrunkEffectiveMaxWidth &&
1869
+ lineToRender.length > 0) {
1870
+ lineToRender = lineToRender.slice(0, -1);
1435
1871
  }
1436
1872
  }
1437
- else {
1438
- ctx.fillText(lineText, valueStartX, textCenterY);
1439
- const w = ctx.measureText(lineText).width;
1440
- if (w > maxValueLineWidth)
1441
- maxValueLineWidth = w;
1442
- }
1873
+ ctx.fillText(lineToRender, valueStartX, textCenterY);
1874
+ const w = ctx.measureText(lineToRender).width;
1875
+ if (w > maxValueLineWidth)
1876
+ maxValueLineWidth = w;
1443
1877
  }
1444
1878
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1445
1879
  // Reset textBaseline về top cho các phần tiếp theo
1446
1880
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1447
1881
  const iconResult = {
1448
- height: iconImageHeight + lineGap,
1882
+ height: totalTextHeight + lineGap,
1449
1883
  // tổng width của cả label + value, dùng để canh icon image lệch sang phải
1450
1884
  lastLineWidth: labelWidth + maxValueLineWidth};
1451
1885
  // Kiểm tra xem phần icon image có vượt quá canvas không trước khi render
@@ -1495,7 +1929,7 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1495
1929
  const testLines = buildWrappedLines(ctx, testText, effectiveMaxWidth, x, cursorY, lineHeight, mockupBounds);
1496
1930
  testLines.length * lineHeight;
1497
1931
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1498
- const testTextWidth = Math.max(...testLines.map(line => ctx.measureText(line).width));
1932
+ const testTextWidth = Math.max(...testLines.map((line) => ctx.measureText(line).width));
1499
1933
  const textEndX = x + testTextWidth;
1500
1934
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1501
1935
  const swatchesStartX = textEndX + spacing;
@@ -1524,7 +1958,8 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1524
1958
  else {
1525
1959
  // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1526
1960
  swatchX = swatchesStartX;
1527
- swatchY = colorResult.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1961
+ swatchY =
1962
+ colorResult.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1528
1963
  // Render swatches (đã kiểm tra overflow ở trên)
1529
1964
  drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1530
1965
  // cursorY đã được cập nhật ở trên
@@ -1540,10 +1975,7 @@ const prepareExportCanvas = async (config, options = {}) => {
1540
1975
  current: new Map(),
1541
1976
  };
1542
1977
  // Load fonts and images in parallel
1543
- await Promise.all([
1544
- preloadFonts(config),
1545
- preloadImages(config, imageRefs)
1546
- ]);
1978
+ await Promise.all([preloadFonts(config), preloadImages(config, imageRefs)]);
1547
1979
  renderEmbroideryCanvas(canvas, config, { width, height }, imageRefs);
1548
1980
  if (!canvas.width || !canvas.height) {
1549
1981
  return null;