embroidery-qc-image 1.0.25 → 1.0.27

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
@@ -48,7 +48,7 @@ const LAYOUT = {
48
48
  // Font sizes (base values, will be multiplied by scaleFactor)
49
49
  HEADER_FONT_SIZE: 220,
50
50
  TEXT_FONT_SIZE: 200,
51
- OTHER_FONT_SIZE: 180,
51
+ OTHER_FONT_SIZE: 160,
52
52
  // Colors
53
53
  HEADER_COLOR: "#000000",
54
54
  LABEL_COLOR: "#444444",
@@ -62,15 +62,10 @@ const LAYOUT = {
62
62
  SECTION_SPACING: 60,
63
63
  ELEMENT_SPACING: 100,
64
64
  SWATCH_SPACING: 25,
65
- FLORAL_SPACING: 100,
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
  // ============================================================================
@@ -388,6 +383,22 @@ const buildWrappedLines = (ctx, text, maxWidth) => {
388
383
  });
389
384
  return result.length ? result : [""];
390
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;
401
+ };
391
402
  const drawSwatches = (ctx, colors, startX, startY, swatchHeight, scaleFactor, imageRefs) => {
392
403
  let swatchX = startX;
393
404
  colors.forEach((color) => {
@@ -546,22 +557,6 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
546
557
  imageRefs.current.set("mockup", mockupImage);
547
558
  }
548
559
  }
549
- const floralAssets = [];
550
- const seenFlorals = new Set();
551
- config.sides.forEach((side) => {
552
- side.positions.forEach((position) => {
553
- if (position.type === "TEXT" && position.floral_pattern) {
554
- const url = getImageUrl("floral", position.floral_pattern);
555
- if (!seenFlorals.has(url)) {
556
- const img = imageRefs.current.get(url);
557
- if (img?.complete && img.naturalWidth > 0) {
558
- floralAssets.push(img);
559
- seenFlorals.add(url);
560
- }
561
- }
562
- }
563
- });
564
- });
565
560
  const measureCanvas = document.createElement("canvas");
566
561
  measureCanvas.width = canvas.width;
567
562
  measureCanvas.height = canvas.height;
@@ -586,7 +581,7 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
586
581
  measureY += sideHeight + measureSpacing;
587
582
  });
588
583
  const scaleFactor = Math.max(0.5, Math.min(1, (canvas.height - LAYOUT.PADDING - warningHeight - messageHeight) / measureY));
589
- drawMockupAndFlorals(ctx, canvas, floralAssets, imageRefs);
584
+ drawMockupAndFlorals(ctx, canvas, imageRefs);
590
585
  // Render warning & message with scaleFactor and get actual heights
591
586
  let actualWarningHeight = 0;
592
587
  let actualMessageHeight = 0;
@@ -685,7 +680,7 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1, offsetY = 0, prefi
685
680
  // Return the actual height of the warning (number of lines * lineHeight)
686
681
  return lines.length * lineHeight;
687
682
  };
688
- const drawMockupAndFlorals = (ctx, canvas, floralAssets, imageRefs) => {
683
+ const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
689
684
  const mockupImg = imageRefs.current.get("mockup");
690
685
  if (!mockupImg?.complete || !mockupImg.naturalWidth)
691
686
  return;
@@ -698,19 +693,7 @@ const drawMockupAndFlorals = (ctx, canvas, floralAssets, imageRefs) => {
698
693
  const x = canvas.width - margin - width;
699
694
  const y = canvas.height - margin - height;
700
695
  ctx.drawImage(mockupImg, x, y, width, height);
701
- // Draw florals
702
- if (floralAssets.length > 0) {
703
- const floralH = Math.min(500, height);
704
- let currentX = x - LAYOUT.FLORAL_SPACING;
705
- for (let i = floralAssets.length - 1; i >= 0; i--) {
706
- const img = floralAssets[i];
707
- const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
708
- const w = Math.max(1, Math.floor(floralH * ratio));
709
- currentX -= w;
710
- ctx.drawImage(img, currentX, y + height - floralH, w, floralH);
711
- currentX -= LAYOUT.FLORAL_SPACING;
712
- }
713
- }
696
+ // Bỏ phần vẽ florals cạnh mockup vì đã hiển thị cạnh text rồi
714
697
  };
715
698
  const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
716
699
  let currentY = startY;
@@ -732,8 +715,33 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
732
715
  ctx.stroke();
733
716
  currentY += headerResult.height + LAYOUT.SECTION_SPACING * scaleFactor;
734
717
  ctx.restore();
735
- // Compute uniform properties
718
+ // Kiểm tra xem có phải trường hợp "không thêu gì" không
736
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
737
745
  const iconColorPositions = side.positions.filter((p) => p.type === "ICON" && (!p.layer_colors?.length || p.layer_colors.length === 1));
738
746
  const iconColorValues = iconColorPositions
739
747
  .map((p) => {
@@ -918,23 +926,87 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
918
926
  rendered++;
919
927
  }
920
928
  if (values.color && values.color !== "None" && shouldRenderField("color")) {
921
- const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
922
- const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, textMaxWidth, fontSize + lineGap);
923
- const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
924
- const swatchX = x +
925
- Math.ceil(result.lastLineWidth) +
926
- LAYOUT.ELEMENT_SPACING * scaleFactor;
927
- const swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
928
929
  const colors = values.color.includes(",")
929
930
  ? values.color.split(",").map((s) => s.trim())
930
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
+ }
931
956
  drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
932
- cursorY += result.height;
957
+ if (shouldWrapSwatches) {
958
+ cursorY += swatchH;
959
+ }
933
960
  rendered++;
934
961
  }
935
962
  if (values.floral && values.floral !== "None" && shouldRenderField("floral")) {
936
- const result = wrapText(ctx, `Mẫu hoa: ${values.floral}`, x, cursorY, maxWidth, fontSize + lineGap);
937
- 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
+ }
938
1010
  rendered++;
939
1011
  }
940
1012
  if (rendered > 0)
@@ -942,40 +1014,88 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
942
1014
  ctx.restore();
943
1015
  return cursorY - y;
944
1016
  };
945
- 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) => {
946
1019
  ctx.save();
947
1020
  const textFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
948
1021
  const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
949
1022
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
950
1023
  let currentY = y;
951
1024
  let drawnHeight = 0;
952
- // 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)
953
1042
  const textLabel = `Text ${displayIndex}: `;
954
1043
  ctx.font = `bold ${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
955
1044
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
956
1045
  const labelWidth = ctx.measureText(textLabel).width;
957
1046
  ctx.fillText(textLabel, x, currentY);
958
- const textMaxWidth = maxWidth - labelWidth;
959
- // Get display text (handle empty/null/undefined)
960
- const isEmptyText = !position.text || position.text.trim() === "";
961
- // Draw text content - dùng font mặc định và màu đỏ
962
- ctx.font = `${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
963
- ctx.fillStyle = DEFAULT_ERROR_COLOR;
964
- if (isEmptyText) {
965
- const textResult = wrapText(ctx, "(không có text)", x + labelWidth, currentY, textMaxWidth, textFontSize);
966
- currentY += textResult.height;
967
- drawnHeight += textResult.height;
968
- }
969
- else {
970
- const textResult = wrapText(ctx, position.text, x + labelWidth, currentY, textMaxWidth, textFontSize);
971
- currentY += textResult.height;
972
- 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
+ }
973
1073
  }
1074
+ // Line height luôn theo Text label (textFontSize), không theo effectiveTextFontSize
1075
+ const valueLineHeight = textFontSize;
1076
+ const textBlockHeight = lines.length * valueLineHeight;
1077
+ // Text align center: căn giữa theo chiều dọc trong block
1078
+ const textCenterY = currentY + textBlockHeight / 2;
1079
+ // Vẽ phần value với font hiệu dụng, màu đỏ, align center
1080
+ ctx.textBaseline = "middle";
1081
+ ctx.font = `${effectiveTextFontSize}px ${LAYOUT.FONT_FAMILY}`;
1082
+ ctx.fillStyle = DEFAULT_ERROR_COLOR;
1083
+ // Vẽ từ trên xuống: căn giữa mỗi dòng
1084
+ lines.forEach((line, idx) => {
1085
+ const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1086
+ ctx.fillText(line, valueStartX, lineY);
1087
+ });
1088
+ // Reset textBaseline về top cho các phần tiếp theo
1089
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1090
+ currentY += textBlockHeight;
1091
+ drawnHeight += textBlockHeight;
974
1092
  // Draw additional labels (skip when text is empty)
975
1093
  if (!isEmptyText) {
976
1094
  currentY += lineGap;
977
1095
  ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
978
1096
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1097
+ // Lưu ý: phần dưới này vẫn có thể wrap theo maxWidth vì đây chỉ là label mô tả,
1098
+ // không phải nội dung Text chính cần giữ nguyên format.
979
1099
  if (showLabels.shape && position.text_shape) {
980
1100
  const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap);
981
1101
  currentY += result.height;
@@ -1006,23 +1126,90 @@ const renderTextPosition = (ctx, position, x, y, maxWidth, displayIndex, showLab
1006
1126
  if (showLabels.color) {
1007
1127
  const colorValue = position.character_colors?.join(", ") || position.color;
1008
1128
  if (colorValue) {
1009
- const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
1010
- const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, textMaxWidth, otherFontSize + lineGap);
1011
- const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1012
- const swatchX = x +
1013
- Math.ceil(result.lastLineWidth) +
1014
- LAYOUT.ELEMENT_SPACING * scaleFactor;
1015
- const swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1016
1129
  const colors = position.character_colors || [position.color];
1130
+ const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1131
+ const totalSwatchWidth = calculateSwatchesWidth(colors, swatchH, scaleFactor, imageRefs);
1132
+ // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
1133
+ const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, maxWidth, otherFontSize + lineGap);
1134
+ // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1135
+ const textEndX = x + Math.ceil(result.lastLineWidth);
1136
+ const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1137
+ const swatchesStartX = textEndX + spacing;
1138
+ const swatchesEndX = swatchesStartX + totalSwatchWidth;
1139
+ const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1140
+ let swatchX;
1141
+ let swatchY;
1142
+ if (shouldWrapSwatches) {
1143
+ // Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
1144
+ swatchX = x;
1145
+ swatchY = result.lastLineY + otherFontSize + lineGap;
1146
+ currentY += result.height + otherFontSize + lineGap;
1147
+ drawnHeight += result.height + otherFontSize + lineGap;
1148
+ }
1149
+ else {
1150
+ // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1151
+ swatchX = swatchesStartX;
1152
+ swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1153
+ currentY += result.height;
1154
+ drawnHeight += result.height;
1155
+ }
1017
1156
  drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1018
- currentY += result.height;
1019
- drawnHeight += result.height;
1157
+ if (shouldWrapSwatches) {
1158
+ currentY += swatchH;
1159
+ drawnHeight += swatchH;
1160
+ }
1020
1161
  }
1021
1162
  }
1022
1163
  if (showLabels.floral && position.floral_pattern) {
1023
- const result = wrapText(ctx, `Mẫu hoa: ${position.floral_pattern}`, x, currentY, maxWidth, otherFontSize + lineGap);
1024
- currentY += result.height;
1025
- drawnHeight += result.height;
1164
+ const floralUrl = getImageUrl("floral", position.floral_pattern);
1165
+ const floralImg = imageRefs.current.get(floralUrl);
1166
+ // Tính kích thước ảnh floral (thêm 50% = 2.5x otherFontSize)
1167
+ const floralH = otherFontSize * 2.5;
1168
+ let totalFloralWidth = 0;
1169
+ if (floralImg?.complete && floralImg.naturalHeight > 0) {
1170
+ const ratio = floralImg.naturalWidth / floralImg.naturalHeight;
1171
+ totalFloralWidth = Math.max(1, Math.floor(floralH * ratio));
1172
+ }
1173
+ // Line height giống icon_image: floralH + lineGap
1174
+ const floralLineHeight = floralH + lineGap;
1175
+ // Text align bottom: đặt text ở dưới cùng của dòng
1176
+ const textBottomY = currentY + floralH;
1177
+ // Đo width trước khi vẽ
1178
+ ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1179
+ const labelText = `Mẫu hoa: ${position.floral_pattern}`;
1180
+ const labelWidth = ctx.measureText(labelText).width;
1181
+ // Vẽ text với textBaseline = bottom
1182
+ ctx.textBaseline = "bottom";
1183
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1184
+ ctx.fillText(labelText, x, textBottomY);
1185
+ // Reset textBaseline về top cho các phần tiếp theo
1186
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1187
+ // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
1188
+ const textEndX = x + labelWidth;
1189
+ const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1190
+ const floralStartX = textEndX + spacing;
1191
+ const floralEndX = floralStartX + totalFloralWidth;
1192
+ const shouldWrapFloral = floralEndX > x + maxWidth;
1193
+ let floralX;
1194
+ let floralY;
1195
+ if (shouldWrapFloral) {
1196
+ // Không đủ chỗ, cho ảnh floral xuống dòng mới
1197
+ floralX = x;
1198
+ floralY = textBottomY + lineGap;
1199
+ currentY += floralLineHeight;
1200
+ drawnHeight += floralLineHeight;
1201
+ }
1202
+ else {
1203
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1204
+ floralX = floralStartX;
1205
+ floralY = textBottomY - floralH; // Align bottom với text
1206
+ currentY += floralLineHeight;
1207
+ drawnHeight += floralLineHeight;
1208
+ }
1209
+ // Vẽ ảnh floral
1210
+ if (floralImg?.complete && floralImg.naturalHeight > 0) {
1211
+ ctx.drawImage(floralImg, floralX, floralY, totalFloralWidth, floralH);
1212
+ }
1026
1213
  }
1027
1214
  }
1028
1215
  ctx.restore();
@@ -1057,36 +1244,119 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1057
1244
  // Fallback: hiển thị mã icon (ép sang string)
1058
1245
  iconValue = String(position.icon);
1059
1246
  }
1060
- // Vẽ label Icon: (bold, màu label mặc định)
1247
+ // Kiểm tra xem icon_image không để tính height phù hợp
1248
+ const hasIconImage = position.icon_image && position.icon_image.trim().length > 0;
1249
+ const iconImageHeight = hasIconImage ? iconFontSize * 2 : iconFontSize;
1250
+ // Đo width của label
1061
1251
  ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1062
- ctx.fillText(iconLabel, x, cursorY);
1063
1252
  const labelWidth = ctx.measureText(iconLabel).width;
1064
- // Vẽ value kế bên, font thường, màu đỏ giống text value
1065
- ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1253
+ // Chuẩn hóa xuống dòng cho icon_value (giống text value)
1254
+ const normalizeNewlines = (text) => text
1255
+ .replace(/\r\n/g, "\n")
1256
+ .replace(/\r/g, "\n")
1257
+ .replace(/\\n/g, "\n");
1258
+ const normalizedIconValue = normalizeNewlines(iconValue);
1259
+ const iconValueLines = normalizedIconValue.split("\n");
1260
+ // Tính kích thước icon_image nếu có để chừa khoảng trống
1261
+ let iconImageReservedWidth = 0;
1262
+ if (hasIconImage) {
1263
+ const iconUrl = getIconImageUrl(position);
1264
+ if (iconUrl) {
1265
+ const img = imageRefs.current.get(iconUrl);
1266
+ if (img?.complete && img.naturalHeight > 0) {
1267
+ const ratio = img.naturalWidth / img.naturalHeight;
1268
+ const iconHeight = iconFontSize * 2;
1269
+ const iconWidth = Math.max(1, Math.floor(iconHeight * ratio));
1270
+ iconImageReservedWidth = iconWidth + LAYOUT.ELEMENT_SPACING * scaleFactor;
1271
+ }
1272
+ }
1273
+ }
1274
+ // Tính available width cho icon value (trừ đi khoảng trống cho icon_image)
1275
+ const availableWidth = Math.max(1, maxWidth - labelWidth - iconImageReservedWidth);
1276
+ // Tính font-size hiệu dụng cho icon value
1277
+ // Giới hạn thu nhỏ tối đa 50% (tối thiểu = iconFontSize * 0.5)
1278
+ const MIN_ICON_VALUE_FONT_SIZE = iconFontSize * 0.5;
1279
+ const measureMaxLineWidth = (fontSize) => {
1280
+ ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
1281
+ let maxLineWidth = 0;
1282
+ iconValueLines.forEach((line) => {
1283
+ const w = ctx.measureText(` ${line}`).width;
1284
+ if (w > maxLineWidth)
1285
+ maxLineWidth = w;
1286
+ });
1287
+ return maxLineWidth;
1288
+ };
1289
+ let effectiveIconValueFontSize = iconFontSize;
1290
+ const baseMaxWidth = measureMaxLineWidth(iconFontSize);
1291
+ let needsWrap = false;
1292
+ if (baseMaxWidth > availableWidth) {
1293
+ const shrinkRatio = availableWidth / baseMaxWidth;
1294
+ effectiveIconValueFontSize = Math.max(MIN_ICON_VALUE_FONT_SIZE, iconFontSize * shrinkRatio);
1295
+ // Kiểm tra xem sau khi thu nhỏ đến 50% có vẫn overflow không
1296
+ const minMaxWidth = measureMaxLineWidth(MIN_ICON_VALUE_FONT_SIZE);
1297
+ if (minMaxWidth > availableWidth) {
1298
+ // Vẫn overflow, cần dùng wrap text
1299
+ needsWrap = true;
1300
+ effectiveIconValueFontSize = MIN_ICON_VALUE_FONT_SIZE;
1301
+ }
1302
+ }
1303
+ // Tính line height và block height cho icon value
1304
+ const valueLineHeight = effectiveIconValueFontSize;
1305
+ let allWrappedLines = [];
1306
+ if (needsWrap) {
1307
+ // Dùng wrap text logic: wrap tất cả các dòng trước
1308
+ iconValueLines.forEach((line) => {
1309
+ const wrappedLines = buildWrappedLines(ctx, line, availableWidth);
1310
+ allWrappedLines.push(...wrappedLines);
1311
+ });
1312
+ allWrappedLines.length * valueLineHeight;
1313
+ }
1314
+ else {
1315
+ allWrappedLines = iconValueLines;
1316
+ iconValueLines.length * valueLineHeight;
1317
+ }
1318
+ // Text align center: căn giữa theo chiều dọc trong block
1319
+ const textCenterY = cursorY + iconImageHeight / 2;
1320
+ // Vẽ label với textBaseline = middle để align center với value
1321
+ ctx.textBaseline = "middle";
1322
+ ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1323
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1324
+ ctx.fillText(iconLabel, x, textCenterY);
1325
+ const valueStartX = x + labelWidth;
1326
+ // Vẽ icon value với align center
1327
+ ctx.font = `${effectiveIconValueFontSize}px ${LAYOUT.FONT_FAMILY}`;
1066
1328
  ctx.fillStyle = DEFAULT_ERROR_COLOR;
1067
- const valueText = ` ${iconValue}`;
1068
- ctx.fillText(valueText, x + labelWidth, cursorY);
1069
- // Reset lại màu về label color cho các phần text tiếp theo (màu chỉ, v.v.)
1329
+ let maxValueLineWidth = 0;
1330
+ // Vẽ từng dòng, căn giữa theo chiều dọc
1331
+ allWrappedLines.forEach((line, index) => {
1332
+ const lineY = textCenterY - (allWrappedLines.length - 1) / 2 * valueLineHeight + index * valueLineHeight;
1333
+ const lineText = needsWrap ? line : ` ${line}`;
1334
+ ctx.fillText(lineText, valueStartX, lineY);
1335
+ const w = ctx.measureText(lineText).width;
1336
+ if (w > maxValueLineWidth)
1337
+ maxValueLineWidth = w;
1338
+ });
1070
1339
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1071
- const valueWidth = ctx.measureText(valueText).width;
1340
+ // Reset textBaseline về top cho các phần tiếp theo
1341
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1072
1342
  const iconResult = {
1073
- height: iconFontSize + lineGap,
1343
+ height: iconImageHeight + lineGap,
1074
1344
  // tổng width của cả label + value, dùng để canh icon image lệch sang phải
1075
- lastLineWidth: labelWidth + valueWidth,
1076
- lastLineY: cursorY,
1077
- };
1345
+ lastLineWidth: labelWidth + maxValueLineWidth};
1078
1346
  // Draw icon image
1079
1347
  const iconUrl = getIconImageUrl(position);
1080
1348
  if (iconUrl) {
1081
1349
  const img = imageRefs.current.get(iconUrl);
1082
1350
  if (img?.complete && img.naturalHeight > 0) {
1083
1351
  const ratio = img.naturalWidth / img.naturalHeight;
1084
- const swatchW = Math.max(1, Math.floor(iconFontSize * ratio));
1352
+ // Nếu icon_image thì hiển thị to gấp đôi
1353
+ const iconHeight = hasIconImage ? iconFontSize * 2 : iconFontSize;
1354
+ const swatchW = Math.max(1, Math.floor(iconHeight * ratio));
1085
1355
  const iconX = x +
1086
1356
  Math.ceil(iconResult.lastLineWidth) +
1087
1357
  LAYOUT.ELEMENT_SPACING * scaleFactor;
1088
- const iconY = iconResult.lastLineY + Math.floor(iconFontSize / 2 - iconFontSize / 2);
1089
- ctx.drawImage(img, iconX, iconY, swatchW, iconFontSize);
1358
+ const iconY = textCenterY - iconHeight / 2; // Align center với text
1359
+ ctx.drawImage(img, iconX, iconY, swatchW, iconHeight);
1090
1360
  }
1091
1361
  }
1092
1362
  cursorY += iconResult.height;
@@ -1103,15 +1373,39 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1103
1373
  const hasMultiLayerColors = layerCount > 1;
1104
1374
  const shouldSkipColorSection = options?.hideColor && !hasMultiLayerColors;
1105
1375
  if (iconColors?.length && !shouldSkipColorSection) {
1106
- const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
1107
- const colorResult = wrapText(ctx, `Màu chỉ: ${iconColors.join(", ")}`, x, cursorY, textMaxWidth, iconFontSize + lineGap);
1108
- const swatchH = Math.floor(iconFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1109
- const swatchX = x +
1110
- Math.ceil(colorResult.lastLineWidth) +
1111
- LAYOUT.ELEMENT_SPACING * scaleFactor;
1112
- const swatchY = colorResult.lastLineY + Math.floor(iconFontSize / 2 - swatchH / 2);
1376
+ // Dòng "Màu chỉ:" của icon dùng OTHER_FONT_SIZE, không dùng iconFontSize
1377
+ const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1378
+ const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1379
+ const totalSwatchWidth = calculateSwatchesWidth(iconColors, swatchH, scaleFactor, imageRefs);
1380
+ // Set font về OTHER_FONT_SIZE trước khi vẽ text "Màu chỉ:"
1381
+ ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1382
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1383
+ // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
1384
+ const colorResult = wrapText(ctx, `Màu chỉ: ${iconColors.join(", ")}`, x, cursorY, maxWidth, otherFontSize + lineGap);
1385
+ // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1386
+ const textEndX = x + Math.ceil(colorResult.lastLineWidth);
1387
+ const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1388
+ const swatchesStartX = textEndX + spacing;
1389
+ const swatchesEndX = swatchesStartX + totalSwatchWidth;
1390
+ const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1391
+ let swatchX;
1392
+ let swatchY;
1393
+ if (shouldWrapSwatches) {
1394
+ // Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
1395
+ swatchX = x;
1396
+ swatchY = colorResult.lastLineY + otherFontSize + lineGap;
1397
+ cursorY += colorResult.height + otherFontSize + lineGap;
1398
+ }
1399
+ else {
1400
+ // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1401
+ swatchX = swatchesStartX;
1402
+ swatchY = colorResult.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1403
+ cursorY += colorResult.height;
1404
+ }
1113
1405
  drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1114
- cursorY += colorResult.height;
1406
+ if (shouldWrapSwatches) {
1407
+ cursorY += swatchH;
1408
+ }
1115
1409
  }
1116
1410
  ctx.restore();
1117
1411
  return cursorY - y;