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