embroidery-qc-image 1.0.26 → 1.0.28

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
@@ -331,24 +331,44 @@ const preloadImages = async (config, imageRefs) => {
331
331
  return;
332
332
  await Promise.all(entries.map(({ url, cacheKey }) => loadImageAsync(url, imageRefs, cacheKey)));
333
333
  };
334
- const wrapText = (ctx, text, x, y, maxWidth, lineHeight) => {
334
+ const wrapText = (ctx, text, x, y, maxWidth, lineHeight, mockupBounds = null) => {
335
335
  const words = text.split(" ");
336
336
  const lines = [];
337
337
  let currentLine = words[0];
338
+ let currentY = y;
338
339
  for (let i = 1; i < words.length; i++) {
339
340
  const testLine = currentLine + " " + words[i];
340
- if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
341
+ // Tính maxWidth hiệu dụng cho dòng hiện tại
342
+ const effectiveMaxWidth = getEffectiveMaxWidth(x, currentY, lineHeight, maxWidth, mockupBounds);
343
+ const testWidth = ctx.measureText(testLine).width;
344
+ // Kiểm tra overlap với mockup
345
+ let shouldWrap = false;
346
+ if (testWidth > effectiveMaxWidth && currentLine.length > 0) {
347
+ shouldWrap = true;
348
+ }
349
+ if (shouldWrap) {
341
350
  lines.push(currentLine);
342
351
  currentLine = words[i];
352
+ currentY += lineHeight;
343
353
  }
344
354
  else {
345
355
  currentLine = testLine;
346
356
  }
347
357
  }
348
358
  lines.push(currentLine);
349
- let currentY = y;
359
+ currentY = y;
350
360
  lines.forEach((line) => {
351
- ctx.fillText(line, x, currentY);
361
+ // Tính maxWidth hiệu dụng cho từng dòng khi render
362
+ const effectiveMaxWidth = getEffectiveMaxWidth(x, currentY, lineHeight, maxWidth, mockupBounds);
363
+ // Nếu dòng quá dài, cắt bớt
364
+ let lineToRender = line;
365
+ if (ctx.measureText(line).width > effectiveMaxWidth) {
366
+ // Cắt từng ký tự cho đến khi vừa
367
+ while (ctx.measureText(lineToRender).width > effectiveMaxWidth && lineToRender.length > 0) {
368
+ lineToRender = lineToRender.slice(0, -1);
369
+ }
370
+ }
371
+ ctx.fillText(lineToRender, x, currentY);
352
372
  currentY += lineHeight;
353
373
  });
354
374
  return {
@@ -357,29 +377,46 @@ const wrapText = (ctx, text, x, y, maxWidth, lineHeight) => {
357
377
  lastLineY: y + (lines.length - 1) * lineHeight,
358
378
  };
359
379
  };
360
- const buildWrappedLines = (ctx, text, maxWidth) => {
380
+ const buildWrappedLines = (ctx, text, maxWidth, startX = 0, startY = 0, lineHeight = 0, mockupBounds = null) => {
361
381
  // Mỗi '\n' tương đương với một line break giống như khi wrap tự động.
362
382
  const segments = text.split("\n");
363
383
  const result = [];
384
+ let currentY = startY;
364
385
  segments.forEach((segment) => {
365
386
  const words = segment.split(" ").filter((word) => word.length > 0);
366
387
  if (words.length === 0) {
367
388
  // Nếu đoạn rỗng, thêm một dòng trống (break đúng 1 line)
368
389
  result.push("");
390
+ if (lineHeight > 0)
391
+ currentY += lineHeight;
369
392
  return;
370
393
  }
371
394
  let currentLine = words[0];
372
395
  for (let i = 1; i < words.length; i++) {
373
396
  const testLine = `${currentLine} ${words[i]}`;
374
- if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
397
+ // Tính maxWidth hiệu dụng cho dòng hiện tại
398
+ const effectiveMaxWidth = lineHeight > 0
399
+ ? getEffectiveMaxWidth(startX, currentY, lineHeight, maxWidth, mockupBounds)
400
+ : maxWidth;
401
+ const testWidth = ctx.measureText(testLine).width;
402
+ // Kiểm tra overlap với mockup
403
+ let shouldWrap = false;
404
+ if (testWidth > effectiveMaxWidth && currentLine.length > 0) {
405
+ shouldWrap = true;
406
+ }
407
+ if (shouldWrap) {
375
408
  result.push(currentLine);
376
409
  currentLine = words[i];
410
+ if (lineHeight > 0)
411
+ currentY += lineHeight;
377
412
  }
378
413
  else {
379
414
  currentLine = testLine;
380
415
  }
381
416
  }
382
417
  result.push(currentLine);
418
+ if (lineHeight > 0)
419
+ currentY += lineHeight;
383
420
  });
384
421
  return result.length ? result : [""];
385
422
  };
@@ -514,49 +551,14 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
514
551
  return;
515
552
  ctx.textAlign = LAYOUT.TEXT_ALIGN;
516
553
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
517
- // Calculate warning & message height (with scaleFactor = 1 for measurement)
518
- let warningHeight = 0;
519
- let warningLineHeight = 0;
520
- let warningLineCount = 0;
521
- if (config.warning_message) {
522
- const measureWarningCanvas = document.createElement("canvas");
523
- measureWarningCanvas.width = canvas.width;
524
- measureWarningCanvas.height = canvas.height;
525
- const measureWarningCtx = measureWarningCanvas.getContext("2d");
526
- if (measureWarningCtx) {
527
- measureWarningCtx.textAlign = "left";
528
- measureWarningCtx.textBaseline = "top";
529
- measureWarningCtx.font = `${LAYOUT.HEADER_FONT_SIZE * 0.7}px ${LAYOUT.FONT_FAMILY}`;
530
- const warningLines = buildWrappedLines(measureWarningCtx, `Note: ${config.warning_message.trim()}`, canvas.width - LAYOUT.PADDING * 4);
531
- warningLineCount = warningLines.length;
532
- warningLineHeight = LAYOUT.HEADER_FONT_SIZE * 0.7 + LAYOUT.LINE_GAP;
533
- warningHeight = warningLineCount * warningLineHeight + LAYOUT.PADDING;
534
- }
535
- }
536
- let messageHeight = 0;
537
- let messageLineHeight = 0;
538
- let messageLineCount = 0;
539
- if (config.message) {
540
- const measureMessageCanvas = document.createElement("canvas");
541
- measureMessageCanvas.width = canvas.width;
542
- measureMessageCanvas.height = canvas.height;
543
- const measureMessageCtx = measureMessageCanvas.getContext("2d");
544
- if (measureMessageCtx) {
545
- measureMessageCtx.textAlign = "left";
546
- measureMessageCtx.textBaseline = "top";
547
- measureMessageCtx.font = `${LAYOUT.HEADER_FONT_SIZE * 0.7}px ${LAYOUT.FONT_FAMILY}`;
548
- const messageLines = buildWrappedLines(measureMessageCtx, config.message.trim(), canvas.width - LAYOUT.PADDING * 4);
549
- messageLineCount = messageLines.length;
550
- messageLineHeight = LAYOUT.HEADER_FONT_SIZE * 0.7 + LAYOUT.LINE_GAP;
551
- messageHeight = messageLineCount * messageLineHeight + LAYOUT.PADDING;
552
- }
553
- }
554
554
  if (config.image_url) {
555
555
  const mockupImage = imageRefs.current.get(config.image_url);
556
556
  if (mockupImage) {
557
557
  imageRefs.current.set("mockup", mockupImage);
558
558
  }
559
559
  }
560
+ // Tính mockup bounds để tránh text overlap
561
+ const mockupBounds = getMockupBounds(canvas, imageRefs);
560
562
  const measureCanvas = document.createElement("canvas");
561
563
  measureCanvas.width = canvas.width;
562
564
  measureCanvas.height = canvas.height;
@@ -565,23 +567,48 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
565
567
  return;
566
568
  measureCtx.textAlign = LAYOUT.TEXT_ALIGN;
567
569
  measureCtx.textBaseline = LAYOUT.TEXT_BASELINE;
568
- let measureY = LAYOUT.PADDING;
569
570
  const measureSpacing = LAYOUT.ELEMENT_SPACING;
570
- // Add warning & message text height (without bottom padding, no spacing)
571
- if (config.warning_message) {
572
- const warningTextHeight = warningHeight - LAYOUT.PADDING;
573
- measureY += warningTextHeight;
574
- }
575
- if (config.message) {
576
- const messageTextHeight = messageHeight - LAYOUT.PADDING;
577
- measureY += messageTextHeight;
571
+ // Tìm scaleFactor tối ưu bằng binary search trong [0, 1]
572
+ const maxIterations = 12;
573
+ const epsilon = 0.001; // độ chính xác cho khoảng cách low-high
574
+ const contentHeight = canvas.height - LAYOUT.PADDING;
575
+ let low = 0;
576
+ let high = 1;
577
+ for (let i = 0; i < maxIterations; i++) {
578
+ const testScale = (low + high) / 2;
579
+ // (Không cần clear measureCanvas vì chỉ dùng để đo chiều cao, nhưng làm sạch cho dễ debug)
580
+ measureCtx.clearRect(0, 0, measureCanvas.width, measureCanvas.height);
581
+ // Đo warning & message đúng theo renderWarning, không cộng padding ở đây
582
+ let testMeasureY = 0;
583
+ if (config.warning_message) {
584
+ const testWarningHeight = renderWarning(measureCtx, measureCanvas, config.warning_message, testScale);
585
+ testMeasureY += testWarningHeight;
586
+ }
587
+ if (config.message) {
588
+ const testMessageHeight = renderWarning(measureCtx, measureCanvas, config.message, testScale, 0, "", DEFAULT_ERROR_COLOR);
589
+ testMeasureY += testMessageHeight;
590
+ }
591
+ // Đo lại chiều cao của các sides với scaleFactor, tiếp nối sau warning/message
592
+ config.sides.forEach((side) => {
593
+ const sideHeight = renderSide(measureCtx, side, testMeasureY, canvas.width, canvas.height, testScale, imageRefs, mockupBounds);
594
+ testMeasureY += sideHeight + measureSpacing * testScale;
595
+ });
596
+ // Tổng chiều cao content (không gồm padding)
597
+ const totalHeight = testMeasureY;
598
+ if (totalHeight > contentHeight) {
599
+ // Content đang cao hơn vùng cho phép -> giảm scale
600
+ high = testScale;
601
+ }
602
+ else {
603
+ // Content vẫn fit trong vùng cho phép -> có thể tăng scale
604
+ low = testScale;
605
+ }
606
+ if (high - low < epsilon) {
607
+ break;
608
+ }
578
609
  }
579
- config.sides.forEach((side) => {
580
- const sideHeight = renderSide(measureCtx, side, measureY, canvas.width, 1, imageRefs);
581
- measureY += sideHeight + measureSpacing;
582
- });
583
- const scaleFactor = Math.max(0.5, Math.min(1, (canvas.height - LAYOUT.PADDING - warningHeight - messageHeight) / measureY));
584
- drawMockupAndFlorals(ctx, canvas, imageRefs);
610
+ const scaleFactor = low;
611
+ drawMockup(ctx, canvas, imageRefs);
585
612
  // Render warning & message with scaleFactor and get actual heights
586
613
  let actualWarningHeight = 0;
587
614
  let actualMessageHeight = 0;
@@ -594,7 +621,7 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
594
621
  );
595
622
  }
596
623
  // Calculate currentY: padding top + actual warning & message height (no spacing)
597
- let currentY = LAYOUT.PADDING * scaleFactor;
624
+ let currentY = LAYOUT.PADDING;
598
625
  if (config.warning_message && actualWarningHeight > 0) {
599
626
  currentY += actualWarningHeight;
600
627
  }
@@ -602,7 +629,7 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
602
629
  currentY += actualMessageHeight;
603
630
  }
604
631
  config.sides.forEach((side) => {
605
- const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor, imageRefs);
632
+ const sideHeight = renderSide(ctx, side, currentY, canvas.width, canvas.height, scaleFactor, imageRefs, mockupBounds);
606
633
  currentY += sideHeight + measureSpacing * scaleFactor;
607
634
  });
608
635
  };
@@ -680,10 +707,10 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1, offsetY = 0, prefi
680
707
  // Return the actual height of the warning (number of lines * lineHeight)
681
708
  return lines.length * lineHeight;
682
709
  };
683
- const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
710
+ const getMockupBounds = (canvas, imageRefs) => {
684
711
  const mockupImg = imageRefs.current.get("mockup");
685
712
  if (!mockupImg?.complete || !mockupImg.naturalWidth)
686
- return;
713
+ return null;
687
714
  const margin = LAYOUT.PADDING;
688
715
  const maxWidth = Math.min(1800, canvas.width * 0.375);
689
716
  const maxHeight = canvas.height * 0.375;
@@ -692,10 +719,38 @@ const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
692
719
  const height = Math.max(1, Math.floor(mockupImg.naturalHeight * scale));
693
720
  const x = canvas.width - margin - width;
694
721
  const y = canvas.height - margin - height;
695
- ctx.drawImage(mockupImg, x, y, width, height);
696
- // Bỏ phần vẽ florals cạnh mockup vì đã hiển thị cạnh text rồi
722
+ return { x, y, width, height };
723
+ };
724
+ // Helper function để tính maxWidth hiệu dụng dựa trên vị trí Y và mockupBounds
725
+ const getEffectiveMaxWidth = (x, y, lineHeight, originalMaxWidth, mockupBounds) => {
726
+ if (!mockupBounds)
727
+ return originalMaxWidth;
728
+ // Kiểm tra xem dòng text có nằm trong phạm vi Y của mockup không
729
+ const lineTopY = y;
730
+ const lineBottomY = y + lineHeight;
731
+ const overlapsY = lineTopY < mockupBounds.y + mockupBounds.height && lineBottomY > mockupBounds.y;
732
+ if (overlapsY) {
733
+ // Nếu overlap theo Y, giới hạn maxWidth để text không vượt quá mockup.x
734
+ const maxAllowedWidth = mockupBounds.x - x;
735
+ // Chỉ giới hạn nếu maxAllowedWidth > 0 và nhỏ hơn originalMaxWidth
736
+ if (maxAllowedWidth > 0 && maxAllowedWidth < originalMaxWidth) {
737
+ return maxAllowedWidth;
738
+ }
739
+ // Nếu x >= mockupBounds.x, text đã nằm sau mockup, không cần giới hạn
740
+ // Hoặc nếu maxAllowedWidth >= originalMaxWidth, không cần giới hạn
741
+ }
742
+ return originalMaxWidth;
697
743
  };
698
- const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
744
+ const drawMockup = (ctx, canvas, imageRefs) => {
745
+ const mockupBounds = getMockupBounds(canvas, imageRefs);
746
+ if (!mockupBounds)
747
+ return;
748
+ const mockupImg = imageRefs.current.get("mockup");
749
+ if (!mockupImg)
750
+ return;
751
+ ctx.drawImage(mockupImg, mockupBounds.x, mockupBounds.y, mockupBounds.width, mockupBounds.height);
752
+ };
753
+ const renderSide = (ctx, side, startY, width, height, scaleFactor, imageRefs, mockupBounds = null) => {
699
754
  let currentY = startY;
700
755
  const padding = LAYOUT.PADDING * scaleFactor;
701
756
  const sideWidth = width - 2 * padding;
@@ -704,7 +759,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
704
759
  const headerFontSize = LAYOUT.HEADER_FONT_SIZE * scaleFactor;
705
760
  ctx.font = `bold ${headerFontSize}px ${LAYOUT.HEADER_FONT_FAMILY}`;
706
761
  ctx.fillStyle = LAYOUT.HEADER_COLOR;
707
- const headerResult = wrapText(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize);
762
+ const headerResult = wrapText(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize, mockupBounds);
708
763
  // Draw underline
709
764
  const underlineY = headerResult.lastLineY + headerFontSize * LAYOUT.UNDERLINE_POSITION;
710
765
  ctx.strokeStyle = LAYOUT.HEADER_COLOR;
@@ -765,7 +820,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
765
820
  : { color: true };
766
821
  // Render uniform labels (when applicable)
767
822
  if (shouldShowUniformLabels) {
768
- currentY += renderUniformLabels(ctx, uniformProps, padding, currentY, sideWidth, scaleFactor, imageRefs, textPositions, uniformLabelFields);
823
+ currentY += renderUniformLabels(ctx, uniformProps, padding, currentY, sideWidth, scaleFactor, imageRefs, textPositions, uniformLabelFields, mockupBounds);
769
824
  }
770
825
  // Group text positions by common properties
771
826
  const textGroups = groupTextPositions(textPositions);
@@ -791,7 +846,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
791
846
  floral: !uniformProps.isUniform.floral,
792
847
  color: !uniformProps.isUniform.color,
793
848
  };
794
- const height = renderTextPosition(ctx, position, padding, currentY, sideWidth, textCounter, showLabels, scaleFactor, imageRefs);
849
+ const height = renderTextPosition(ctx, position, padding, currentY, sideWidth, textCounter, showLabels, scaleFactor, imageRefs, mockupBounds);
795
850
  if (height > 0) {
796
851
  currentY += height + LAYOUT.PADDING * scaleFactor;
797
852
  textCounter++;
@@ -812,7 +867,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
812
867
  iconColorValue !== null &&
813
868
  uniformProps.values.color === iconColorValue &&
814
869
  iconUsesSingleColor;
815
- currentY += renderIconPosition(ctx, position, padding, currentY, sideWidth, scaleFactor, imageRefs, { hideColor });
870
+ currentY += renderIconPosition(ctx, position, padding, currentY, sideWidth, scaleFactor, imageRefs, { hideColor }, mockupBounds);
816
871
  currentY += (LAYOUT.LINE_GAP / 3) * scaleFactor;
817
872
  }
818
873
  });
@@ -883,7 +938,7 @@ const computeUniformProperties = (textPositions, options) => {
883
938
  },
884
939
  };
885
940
  };
886
- const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, imageRefs, textPositions, fields) => {
941
+ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, imageRefs, textPositions, fields, mockupBounds = null) => {
887
942
  const { values } = uniformProps;
888
943
  const fontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
889
944
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
@@ -921,7 +976,7 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
921
976
  rendered++;
922
977
  }
923
978
  if (values.shape && values.shape !== "None" && shouldRenderField("shape")) {
924
- const result = wrapText(ctx, `Kiểu chữ: ${values.shape}`, x, cursorY, maxWidth, fontSize + lineGap);
979
+ const result = wrapText(ctx, `Kiểu chữ: ${values.shape}`, x, cursorY, maxWidth, fontSize + lineGap, mockupBounds);
925
980
  cursorY += result.height;
926
981
  rendered++;
927
982
  }
@@ -931,14 +986,20 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
931
986
  : [values.color];
932
987
  const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
933
988
  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);
989
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
990
+ const lineHeight = fontSize + lineGap;
991
+ const effectiveMaxWidth = mockupBounds
992
+ ? getEffectiveMaxWidth(x, cursorY, lineHeight, maxWidth, mockupBounds)
993
+ : maxWidth;
994
+ // Vẽ text với effectiveMaxWidth
995
+ const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, effectiveMaxWidth, lineHeight, mockupBounds);
936
996
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
997
+ // Sử dụng effectiveMaxWidth thay vì maxWidth
937
998
  const textEndX = x + Math.ceil(result.lastLineWidth);
938
999
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
939
1000
  const swatchesStartX = textEndX + spacing;
940
1001
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
941
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1002
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
942
1003
  let swatchX;
943
1004
  let swatchY;
944
1005
  if (shouldWrapSwatches) {
@@ -971,16 +1032,16 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
971
1032
  }
972
1033
  // Line height giống icon_image: floralH + lineGap
973
1034
  const floralLineHeight = floralH + lineGap;
974
- // Text align bottom: đặt text dưới cùng của dòng
975
- const textBottomY = cursorY + floralH;
1035
+ // Text align center: căn giữa theo chiều dọc trong block
1036
+ const textCenterY = cursorY + floralH / 2;
976
1037
  // Đo width trước khi vẽ
977
1038
  ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
978
1039
  const labelText = `Mẫu hoa: ${values.floral}`;
979
1040
  const labelWidth = ctx.measureText(labelText).width;
980
- // Vẽ text với textBaseline = bottom
981
- ctx.textBaseline = "bottom";
1041
+ // Vẽ text với textBaseline = middle để align center
1042
+ ctx.textBaseline = "middle";
982
1043
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
983
- ctx.fillText(labelText, x, textBottomY);
1044
+ ctx.fillText(labelText, x, textCenterY);
984
1045
  // Reset textBaseline về top cho các phần tiếp theo
985
1046
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
986
1047
  // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
@@ -994,13 +1055,13 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
994
1055
  if (shouldWrapFloral) {
995
1056
  // Không đủ chỗ, cho ảnh floral xuống dòng mới
996
1057
  floralX = x;
997
- floralY = textBottomY + lineGap;
1058
+ floralY = cursorY + floralH + lineGap;
998
1059
  cursorY += floralLineHeight;
999
1060
  }
1000
1061
  else {
1001
- // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1062
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng, align center
1002
1063
  floralX = floralStartX;
1003
- floralY = textBottomY - floralH; // Align bottom với text
1064
+ floralY = textCenterY - floralH / 2; // Align center với text
1004
1065
  cursorY += floralLineHeight;
1005
1066
  }
1006
1067
  // Vẽ ảnh floral
@@ -1015,19 +1076,17 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
1015
1076
  return cursorY - y;
1016
1077
  };
1017
1078
  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) => {
1079
+ displayIndex, showLabels, scaleFactor, imageRefs, mockupBounds = null) => {
1019
1080
  ctx.save();
1020
1081
  const textFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
1021
1082
  const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1022
1083
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
1023
1084
  let currentY = y;
1024
- let drawnHeight = 0;
1025
1085
  // Chuẩn hóa xuống dòng:
1026
1086
  // - Hỗ trợ cả newline thật (\n) và chuỗi literal "\\n" từ JSON
1027
1087
  const normalizeNewlines = (text) => text
1028
1088
  .replace(/\r\n/g, "\n")
1029
- .replace(/\r/g, "\n")
1030
- .replace(/\\n/g, "\n");
1089
+ .replace(/\r/g, "\n");
1031
1090
  // Get display text (handle empty/null/undefined) sau khi normalize
1032
1091
  const rawOriginalText = position.text ?? "";
1033
1092
  const normalizedText = normalizeNewlines(rawOriginalText);
@@ -1052,37 +1111,52 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1052
1111
  const lines = rawText.split("\n");
1053
1112
  // Tính font-size hiệu dụng cho phần value sao cho:
1054
1113
  // - Không vượt quá availableWidth
1114
+ // - Tính đến mockup bounds cho từng dòng
1055
1115
  // - 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
1116
  let effectiveTextFontSize = textFontSize;
1067
1117
  if (!isEmptyText) {
1068
- const baseMaxWidth = measureMaxLineWidth(textFontSize);
1069
- if (baseMaxWidth > availableWidth) {
1070
- const shrinkRatio = availableWidth / baseMaxWidth;
1071
- effectiveTextFontSize = textFontSize * shrinkRatio;
1072
- }
1118
+ // Tính vị trí Y của từng dòng (textCenterY và lineY)
1119
+ const valueLineHeight = textFontSize;
1120
+ const textBlockHeight = lines.length * valueLineHeight;
1121
+ const textCenterY = currentY + textBlockHeight / 2;
1122
+ // Tính font size cần thiết cho từng dòng dựa trên effectiveMaxWidth của nó
1123
+ let minShrinkRatio = 1;
1124
+ ctx.font = `${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
1125
+ lines.forEach((line, idx) => {
1126
+ const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1127
+ // Tính effectiveMaxWidth cho dòng này
1128
+ const effectiveMaxWidth = mockupBounds
1129
+ ? getEffectiveMaxWidth(valueStartX, lineY, valueLineHeight, availableWidth, mockupBounds)
1130
+ : availableWidth;
1131
+ const lineWidth = ctx.measureText(line).width;
1132
+ if (lineWidth > effectiveMaxWidth) {
1133
+ // Cần thu nhỏ font cho dòng này
1134
+ const shrinkRatio = effectiveMaxWidth / lineWidth;
1135
+ if (shrinkRatio < minShrinkRatio) {
1136
+ minShrinkRatio = shrinkRatio;
1137
+ }
1138
+ }
1139
+ });
1140
+ effectiveTextFontSize = textFontSize * minShrinkRatio;
1073
1141
  }
1074
- // Vẽ phần value với font hiệu dụng, màu đỏ
1142
+ // Line height luôn theo Text label (textFontSize), không theo effectiveTextFontSize
1143
+ const valueLineHeight = textFontSize;
1144
+ const textBlockHeight = lines.length * valueLineHeight;
1145
+ // Text align center: căn giữa theo chiều dọc trong block
1146
+ const textCenterY = currentY + textBlockHeight / 2;
1147
+ // Vẽ phần value với font hiệu dụng, màu đỏ, align center
1148
+ ctx.textBaseline = "middle";
1075
1149
  ctx.font = `${effectiveTextFontSize}px ${LAYOUT.FONT_FAMILY}`;
1076
1150
  ctx.fillStyle = DEFAULT_ERROR_COLOR;
1077
- const valueLineHeight = effectiveTextFontSize; // giữ giống wrapText cũ (lineHeight = fontSize)
1078
- let localY = currentY;
1151
+ // Vẽ từ trên xuống: căn giữa mỗi dòng
1152
+ // 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
1079
1153
  lines.forEach((line, idx) => {
1080
- ctx.fillText(line, valueStartX, localY);
1081
- localY += valueLineHeight;
1154
+ const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1155
+ ctx.fillText(line, valueStartX, lineY);
1082
1156
  });
1083
- const textBlockHeight = lines.length * valueLineHeight;
1157
+ // Reset textBaseline về top cho các phần tiếp theo
1158
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1084
1159
  currentY += textBlockHeight;
1085
- drawnHeight += textBlockHeight;
1086
1160
  // Draw additional labels (skip when text is empty)
1087
1161
  if (!isEmptyText) {
1088
1162
  currentY += lineGap;
@@ -1091,9 +1165,8 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1091
1165
  // Lưu ý: phần dưới này vẫn có thể wrap theo maxWidth vì đây chỉ là label mô tả,
1092
1166
  // không phải nội dung Text chính cần giữ nguyên format.
1093
1167
  if (showLabels.shape && position.text_shape) {
1094
- const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap);
1168
+ const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap, mockupBounds);
1095
1169
  currentY += result.height;
1096
- drawnHeight += result.height;
1097
1170
  }
1098
1171
  if (showLabels.font && position.font) {
1099
1172
  // Render "Font: " với font mặc định
@@ -1115,7 +1188,6 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1115
1188
  // Tính toán height và di chuyển cursorY
1116
1189
  const lineHeight = otherFontSize + lineGap;
1117
1190
  currentY += lineHeight;
1118
- drawnHeight += lineHeight;
1119
1191
  }
1120
1192
  if (showLabels.color) {
1121
1193
  const colorValue = position.character_colors?.join(", ") || position.color;
@@ -1123,14 +1195,20 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1123
1195
  const colors = position.character_colors || [position.color];
1124
1196
  const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1125
1197
  const totalSwatchWidth = calculateSwatchesWidth(colors, swatchH, scaleFactor, imageRefs);
1126
- // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
1127
- const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, maxWidth, otherFontSize + lineGap);
1198
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
1199
+ const lineHeight = otherFontSize + lineGap;
1200
+ const effectiveMaxWidth = mockupBounds
1201
+ ? getEffectiveMaxWidth(x, currentY, lineHeight, maxWidth, mockupBounds)
1202
+ : maxWidth;
1203
+ // Vẽ text với effectiveMaxWidth
1204
+ const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, effectiveMaxWidth, lineHeight, mockupBounds);
1128
1205
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1206
+ // Sử dụng effectiveMaxWidth thay vì maxWidth
1129
1207
  const textEndX = x + Math.ceil(result.lastLineWidth);
1130
1208
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1131
1209
  const swatchesStartX = textEndX + spacing;
1132
1210
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
1133
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1211
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
1134
1212
  let swatchX;
1135
1213
  let swatchY;
1136
1214
  if (shouldWrapSwatches) {
@@ -1138,19 +1216,16 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1138
1216
  swatchX = x;
1139
1217
  swatchY = result.lastLineY + otherFontSize + lineGap;
1140
1218
  currentY += result.height + otherFontSize + lineGap;
1141
- drawnHeight += result.height + otherFontSize + lineGap;
1142
1219
  }
1143
1220
  else {
1144
1221
  // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1145
1222
  swatchX = swatchesStartX;
1146
1223
  swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1147
1224
  currentY += result.height;
1148
- drawnHeight += result.height;
1149
1225
  }
1150
1226
  drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1151
1227
  if (shouldWrapSwatches) {
1152
1228
  currentY += swatchH;
1153
- drawnHeight += swatchH;
1154
1229
  }
1155
1230
  }
1156
1231
  }
@@ -1166,16 +1241,16 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1166
1241
  }
1167
1242
  // Line height giống icon_image: floralH + lineGap
1168
1243
  const floralLineHeight = floralH + lineGap;
1169
- // Text align bottom: đặt text dưới cùng của dòng
1170
- const textBottomY = currentY + floralH;
1244
+ // Text align center: căn giữa theo chiều dọc trong block
1245
+ const textCenterY = currentY + floralH / 2;
1171
1246
  // Đo width trước khi vẽ
1172
1247
  ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1173
1248
  const labelText = `Mẫu hoa: ${position.floral_pattern}`;
1174
1249
  const labelWidth = ctx.measureText(labelText).width;
1175
- // Vẽ text với textBaseline = bottom
1176
- ctx.textBaseline = "bottom";
1250
+ // Vẽ text với textBaseline = middle để align center
1251
+ ctx.textBaseline = "middle";
1177
1252
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1178
- ctx.fillText(labelText, x, textBottomY);
1253
+ ctx.fillText(labelText, x, textCenterY);
1179
1254
  // Reset textBaseline về top cho các phần tiếp theo
1180
1255
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1181
1256
  // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
@@ -1189,16 +1264,14 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1189
1264
  if (shouldWrapFloral) {
1190
1265
  // Không đủ chỗ, cho ảnh floral xuống dòng mới
1191
1266
  floralX = x;
1192
- floralY = textBottomY + lineGap;
1267
+ floralY = currentY + floralH + lineGap;
1193
1268
  currentY += floralLineHeight;
1194
- drawnHeight += floralLineHeight;
1195
1269
  }
1196
1270
  else {
1197
- // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1271
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng, align center
1198
1272
  floralX = floralStartX;
1199
- floralY = textBottomY - floralH; // Align bottom với text
1273
+ floralY = textCenterY - floralH / 2; // Align center với text
1200
1274
  currentY += floralLineHeight;
1201
- drawnHeight += floralLineHeight;
1202
1275
  }
1203
1276
  // Vẽ ảnh floral
1204
1277
  if (floralImg?.complete && floralImg.naturalHeight > 0) {
@@ -1207,9 +1280,10 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1207
1280
  }
1208
1281
  }
1209
1282
  ctx.restore();
1210
- return drawnHeight;
1283
+ // Trả về toàn bộ chiều cao đã sử dụng trong block này
1284
+ return currentY - y;
1211
1285
  };
1212
- const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs, options) => {
1286
+ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs, options, mockupBounds = null) => {
1213
1287
  // Dùng cùng font size với Text cho label và value icon
1214
1288
  const iconFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
1215
1289
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
@@ -1241,30 +1315,148 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1241
1315
  // Kiểm tra xem có icon_image không để tính height phù hợp
1242
1316
  const hasIconImage = position.icon_image && position.icon_image.trim().length > 0;
1243
1317
  const iconImageHeight = hasIconImage ? iconFontSize * 2 : iconFontSize;
1244
- // Text align bottom: đặt text ở dưới cùng của dòng
1245
- const textBottomY = cursorY + iconImageHeight;
1246
- // Đo width trước khi vẽ
1318
+ // Đo width của label
1247
1319
  ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1248
1320
  const labelWidth = ctx.measureText(iconLabel).width;
1249
- ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1250
- const valueText = ` ${iconValue}`;
1251
- const valueWidth = ctx.measureText(valueText).width;
1252
- // Vẽ text với textBaseline = bottom
1253
- ctx.textBaseline = "bottom";
1321
+ // Icon value không cần chuẩn hóa xuống dòng, xử lý như một dòng text đơn giản
1322
+ // Tính kích thước icon_image nếu có để chừa khoảng trống
1323
+ let iconImageReservedWidth = 0;
1324
+ if (hasIconImage) {
1325
+ const iconUrl = getIconImageUrl(position);
1326
+ if (iconUrl) {
1327
+ const img = imageRefs.current.get(iconUrl);
1328
+ if (img?.complete && img.naturalHeight > 0) {
1329
+ const ratio = img.naturalWidth / img.naturalHeight;
1330
+ const iconHeight = iconFontSize * 2;
1331
+ const iconWidth = Math.max(1, Math.floor(iconHeight * ratio));
1332
+ iconImageReservedWidth = iconWidth + LAYOUT.ELEMENT_SPACING * scaleFactor;
1333
+ }
1334
+ }
1335
+ }
1336
+ // Tính available width cho icon value (trừ đi khoảng trống cho icon_image)
1337
+ const availableWidth = Math.max(1, maxWidth - labelWidth - iconImageReservedWidth);
1338
+ // Tính font-size hiệu dụng cho icon value
1339
+ // Giới hạn thu nhỏ tối đa 50% (tối thiểu = iconFontSize * 0.5)
1340
+ const MIN_ICON_VALUE_FONT_SIZE = iconFontSize * 0.5;
1341
+ const measureIconValueWidth = (fontSize) => {
1342
+ ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
1343
+ return ctx.measureText(` ${iconValue}`).width;
1344
+ };
1345
+ let effectiveIconValueFontSize = iconFontSize;
1346
+ const baseMaxWidth = measureIconValueWidth(iconFontSize);
1347
+ let needsWrap = false;
1348
+ if (baseMaxWidth > availableWidth) {
1349
+ const shrinkRatio = availableWidth / baseMaxWidth;
1350
+ effectiveIconValueFontSize = Math.max(MIN_ICON_VALUE_FONT_SIZE, iconFontSize * shrinkRatio);
1351
+ // Kiểm tra xem sau khi thu nhỏ đến 50% có vẫn overflow không
1352
+ const minMaxWidth = measureIconValueWidth(MIN_ICON_VALUE_FONT_SIZE);
1353
+ if (minMaxWidth > availableWidth) {
1354
+ // Vẫn overflow, cần dùng wrap text
1355
+ needsWrap = true;
1356
+ effectiveIconValueFontSize = MIN_ICON_VALUE_FONT_SIZE;
1357
+ }
1358
+ }
1359
+ // Tính line height và block height cho icon value
1360
+ const valueLineHeight = effectiveIconValueFontSize;
1361
+ let allWrappedLines = [];
1362
+ // Text align center: căn giữa theo chiều dọc trong block
1363
+ const textCenterY = cursorY + iconImageHeight / 2;
1364
+ const valueStartX = x + labelWidth;
1365
+ if (needsWrap) {
1366
+ // Dùng wrap text logic
1367
+ const wrappedLines = buildWrappedLines(ctx, iconValue, availableWidth, valueStartX, textCenterY, valueLineHeight, mockupBounds);
1368
+ allWrappedLines = wrappedLines;
1369
+ wrappedLines.length * valueLineHeight;
1370
+ }
1371
+ else {
1372
+ // Không cần wrap, chỉ một dòng
1373
+ allWrappedLines = [iconValue];
1374
+ }
1375
+ // Vẽ label với textBaseline = middle để align center với value
1376
+ ctx.textBaseline = "middle";
1254
1377
  ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1255
1378
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1256
- ctx.fillText(iconLabel, x, textBottomY);
1257
- ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1379
+ ctx.fillText(iconLabel, x, textCenterY);
1380
+ // Vẽ icon value với align center
1381
+ ctx.font = `${effectiveIconValueFontSize}px ${LAYOUT.FONT_FAMILY}`;
1258
1382
  ctx.fillStyle = DEFAULT_ERROR_COLOR;
1259
- ctx.fillText(valueText, x + labelWidth, textBottomY);
1383
+ let maxValueLineWidth = 0;
1384
+ // Vẽ icon value, căn giữa theo chiều dọc
1385
+ if (needsWrap) {
1386
+ // Có nhiều dòng sau khi wrap
1387
+ allWrappedLines.forEach((line, index) => {
1388
+ const lineY = textCenterY - (allWrappedLines.length - 1) / 2 * valueLineHeight + index * valueLineHeight;
1389
+ // Kiểm tra overlap với mockup và điều chỉnh text nếu cần
1390
+ if (mockupBounds) {
1391
+ const effectiveMaxWidth = getEffectiveMaxWidth(valueStartX, lineY, valueLineHeight, availableWidth, mockupBounds);
1392
+ const lineWidth = ctx.measureText(line).width;
1393
+ if (lineWidth > effectiveMaxWidth) {
1394
+ // Cắt text cho đến khi vừa với effectiveMaxWidth
1395
+ let truncatedLine = line;
1396
+ while (ctx.measureText(truncatedLine).width > effectiveMaxWidth && truncatedLine.length > 0) {
1397
+ truncatedLine = truncatedLine.slice(0, -1);
1398
+ }
1399
+ ctx.fillText(truncatedLine, valueStartX, lineY);
1400
+ const w = ctx.measureText(truncatedLine).width;
1401
+ if (w > maxValueLineWidth)
1402
+ maxValueLineWidth = w;
1403
+ }
1404
+ else {
1405
+ ctx.fillText(line, valueStartX, lineY);
1406
+ const w = ctx.measureText(line).width;
1407
+ if (w > maxValueLineWidth)
1408
+ maxValueLineWidth = w;
1409
+ }
1410
+ }
1411
+ else {
1412
+ ctx.fillText(line, valueStartX, lineY);
1413
+ const w = ctx.measureText(line).width;
1414
+ if (w > maxValueLineWidth)
1415
+ maxValueLineWidth = w;
1416
+ }
1417
+ });
1418
+ }
1419
+ else {
1420
+ // Chỉ một dòng, căn giữa
1421
+ const lineText = ` ${iconValue}`;
1422
+ // Kiểm tra overlap với mockup và điều chỉnh text nếu cần
1423
+ if (mockupBounds) {
1424
+ const effectiveMaxWidth = getEffectiveMaxWidth(valueStartX, textCenterY, valueLineHeight, availableWidth, mockupBounds);
1425
+ const lineWidth = ctx.measureText(lineText).width;
1426
+ if (lineWidth > effectiveMaxWidth) {
1427
+ // Cắt text cho đến khi vừa với effectiveMaxWidth
1428
+ let truncatedLine = lineText;
1429
+ while (ctx.measureText(truncatedLine).width > effectiveMaxWidth && truncatedLine.length > 0) {
1430
+ truncatedLine = truncatedLine.slice(0, -1);
1431
+ }
1432
+ ctx.fillText(truncatedLine, valueStartX, textCenterY);
1433
+ const w = ctx.measureText(truncatedLine).width;
1434
+ if (w > maxValueLineWidth)
1435
+ maxValueLineWidth = w;
1436
+ }
1437
+ else {
1438
+ ctx.fillText(lineText, valueStartX, textCenterY);
1439
+ const w = ctx.measureText(lineText).width;
1440
+ if (w > maxValueLineWidth)
1441
+ maxValueLineWidth = w;
1442
+ }
1443
+ }
1444
+ else {
1445
+ ctx.fillText(lineText, valueStartX, textCenterY);
1446
+ const w = ctx.measureText(lineText).width;
1447
+ if (w > maxValueLineWidth)
1448
+ maxValueLineWidth = w;
1449
+ }
1450
+ }
1260
1451
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1261
1452
  // Reset textBaseline về top cho các phần tiếp theo
1262
1453
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1263
1454
  const iconResult = {
1264
1455
  height: iconImageHeight + lineGap,
1265
1456
  // tổng width của cả label + value, dùng để canh icon image lệch sang phải
1266
- lastLineWidth: labelWidth + valueWidth};
1267
- // Draw icon image
1457
+ lastLineWidth: labelWidth + maxValueLineWidth};
1458
+ // Kiểm tra xem phần icon image có vượt quá canvas không trước khi render
1459
+ // Draw icon image (không tự cắt theo canvasHeight, việc đảm bảo fit do scaleFactor xử lý)
1268
1460
  const iconUrl = getIconImageUrl(position);
1269
1461
  if (iconUrl) {
1270
1462
  const img = imageRefs.current.get(iconUrl);
@@ -1276,7 +1468,7 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1276
1468
  const iconX = x +
1277
1469
  Math.ceil(iconResult.lastLineWidth) +
1278
1470
  LAYOUT.ELEMENT_SPACING * scaleFactor;
1279
- const iconY = textBottomY - iconHeight; // Align bottom với text
1471
+ const iconY = textCenterY - iconHeight / 2; // Align center với text
1280
1472
  ctx.drawImage(img, iconX, iconY, swatchW, iconHeight);
1281
1473
  }
1282
1474
  }
@@ -1298,34 +1490,51 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1298
1490
  const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1299
1491
  const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1300
1492
  const totalSwatchWidth = calculateSwatchesWidth(iconColors, swatchH, scaleFactor, imageRefs);
1301
- // Set font về OTHER_FONT_SIZE trước khi vẽ text "Màu chỉ:"
1493
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
1494
+ const lineHeight = otherFontSize + lineGap;
1495
+ const effectiveMaxWidth = mockupBounds
1496
+ ? getEffectiveMaxWidth(x, cursorY, lineHeight, maxWidth, mockupBounds)
1497
+ : maxWidth;
1498
+ // Tính toán trước để kiểm tra xem có đủ chỗ không
1499
+ // Đo chiều cao của text "Màu chỉ:" trước
1302
1500
  ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1303
- ctx.fillStyle = LAYOUT.LABEL_COLOR;
1304
- // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
1305
- const colorResult = wrapText(ctx, `Màu chỉ: ${iconColors.join(", ")}`, x, cursorY, maxWidth, otherFontSize + lineGap);
1501
+ const testText = `Màu chỉ: ${iconColors.join(", ")}`;
1502
+ const testLines = buildWrappedLines(ctx, testText, effectiveMaxWidth, x, cursorY, lineHeight, mockupBounds);
1503
+ testLines.length * lineHeight;
1306
1504
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1307
- const textEndX = x + Math.ceil(colorResult.lastLineWidth);
1505
+ const testTextWidth = Math.max(...testLines.map(line => ctx.measureText(line).width));
1506
+ const textEndX = x + testTextWidth;
1308
1507
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1309
1508
  const swatchesStartX = textEndX + spacing;
1310
1509
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
1311
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1510
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
1511
+ if (shouldWrapSwatches) ;
1512
+ else {
1513
+ // Swatches trên cùng dòng với text
1514
+ cursorY + (testLines.length - 1) * lineHeight;
1515
+ }
1516
+ // Luôn render text và swatches; đảm bảo fit bằng scaleFactor ở tầng trên
1517
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1518
+ const colorResult = wrapText(ctx, testText, x, cursorY, effectiveMaxWidth, lineHeight, mockupBounds);
1519
+ // Cập nhật cursorY sau khi render text
1520
+ cursorY += colorResult.height;
1312
1521
  let swatchX;
1313
1522
  let swatchY;
1314
1523
  if (shouldWrapSwatches) {
1315
1524
  // Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
1316
1525
  swatchX = x;
1317
- swatchY = colorResult.lastLineY + otherFontSize + lineGap;
1318
- cursorY += colorResult.height + otherFontSize + lineGap;
1526
+ swatchY = cursorY;
1527
+ // Render swatches (đã kiểm tra overflow ở trên)
1528
+ drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1529
+ cursorY = swatchY + swatchH;
1319
1530
  }
1320
1531
  else {
1321
1532
  // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1322
1533
  swatchX = swatchesStartX;
1323
1534
  swatchY = colorResult.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1324
- cursorY += colorResult.height;
1325
- }
1326
- drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1327
- if (shouldWrapSwatches) {
1328
- cursorY += swatchH;
1535
+ // Render swatches (đã kiểm tra overflow ở trên)
1536
+ drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1537
+ // cursorY đã được cập nhật ở trên
1329
1538
  }
1330
1539
  }
1331
1540
  ctx.restore();