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