embroidery-qc-image 1.0.27 → 1.0.29

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,44 +567,63 @@ 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;
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, có bao gồm padding
582
+ let testMeasureY = LAYOUT.PADDING;
583
+ if (config.warning_message) {
584
+ const testWarningHeight = renderWarning(measureCtx, measureCanvas, config.warning_message, testScale, testMeasureY);
585
+ testMeasureY += testWarningHeight;
586
+ }
587
+ if (config.message) {
588
+ const testMessageHeight = renderWarning(measureCtx, measureCanvas, config.message, testScale, testMeasureY, "", 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
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);
612
+ // Calculate currentY: padding top + actual warning & message height (no spacing)
613
+ let currentY = LAYOUT.PADDING;
585
614
  // Render warning & message with scaleFactor and get actual heights
586
- let actualWarningHeight = 0;
587
- let actualMessageHeight = 0;
588
615
  if (config.warning_message) {
589
- actualWarningHeight = renderWarning(ctx, canvas, config.warning_message, scaleFactor);
616
+ const actualWarningHeight = renderWarning(ctx, canvas, config.warning_message, scaleFactor, currentY);
617
+ currentY += actualWarningHeight;
590
618
  }
591
619
  if (config.message) {
592
- actualMessageHeight = renderWarning(ctx, canvas, config.message, scaleFactor, actualWarningHeight, "", // message: không cần prefix "Note"
620
+ const actualMessageHeight = renderWarning(ctx, canvas, config.message, scaleFactor, currentY, "", // message: không cần prefix "Note"
593
621
  DEFAULT_ERROR_COLOR // message: hiển thị màu đỏ
594
622
  );
595
- }
596
- // Calculate currentY: padding top + actual warning & message height (no spacing)
597
- let currentY = LAYOUT.PADDING * scaleFactor;
598
- if (config.warning_message && actualWarningHeight > 0) {
599
- currentY += actualWarningHeight;
600
- }
601
- if (config.message && actualMessageHeight > 0) {
602
623
  currentY += actualMessageHeight;
603
624
  }
604
625
  config.sides.forEach((side) => {
605
- const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor, imageRefs);
626
+ const sideHeight = renderSide(ctx, side, currentY, canvas.width, canvas.height, scaleFactor, imageRefs, mockupBounds);
606
627
  currentY += sideHeight + measureSpacing * scaleFactor;
607
628
  });
608
629
  };
@@ -671,19 +692,18 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1, offsetY = 0, prefi
671
692
  lines = buildWrappedLines(ctx, sanitizedMessage, maxWidth);
672
693
  longestLineWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
673
694
  }
674
- const startY = LAYOUT.PADDING * scaleFactor + offsetY;
675
695
  lines.forEach((line, index) => {
676
- const y = startY + index * lineHeight;
696
+ const y = offsetY + index * lineHeight;
677
697
  ctx.fillText(line, leftX, y);
678
698
  });
679
699
  ctx.restore();
680
700
  // Return the actual height of the warning (number of lines * lineHeight)
681
701
  return lines.length * lineHeight;
682
702
  };
683
- const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
703
+ const getMockupBounds = (canvas, imageRefs) => {
684
704
  const mockupImg = imageRefs.current.get("mockup");
685
705
  if (!mockupImg?.complete || !mockupImg.naturalWidth)
686
- return;
706
+ return null;
687
707
  const margin = LAYOUT.PADDING;
688
708
  const maxWidth = Math.min(1800, canvas.width * 0.375);
689
709
  const maxHeight = canvas.height * 0.375;
@@ -692,10 +712,38 @@ const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
692
712
  const height = Math.max(1, Math.floor(mockupImg.naturalHeight * scale));
693
713
  const x = canvas.width - margin - width;
694
714
  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
715
+ return { x, y, width, height };
716
+ };
717
+ // Helper function để tính maxWidth hiệu dụng dựa trên vị trí Y và mockupBounds
718
+ const getEffectiveMaxWidth = (x, y, lineHeight, originalMaxWidth, mockupBounds) => {
719
+ if (!mockupBounds)
720
+ return originalMaxWidth;
721
+ // Kiểm tra xem dòng text có nằm trong phạm vi Y của mockup không
722
+ const lineTopY = y;
723
+ const lineBottomY = y + lineHeight;
724
+ const overlapsY = lineTopY < mockupBounds.y + mockupBounds.height && lineBottomY > mockupBounds.y;
725
+ if (overlapsY) {
726
+ // Nếu overlap theo Y, giới hạn maxWidth để text không vượt quá mockup.x
727
+ const maxAllowedWidth = mockupBounds.x - x;
728
+ // Chỉ giới hạn nếu maxAllowedWidth > 0 và nhỏ hơn originalMaxWidth
729
+ if (maxAllowedWidth > 0 && maxAllowedWidth < originalMaxWidth) {
730
+ return maxAllowedWidth;
731
+ }
732
+ // Nếu x >= mockupBounds.x, text đã nằm sau mockup, không cần giới hạn
733
+ // Hoặc nếu maxAllowedWidth >= originalMaxWidth, không cần giới hạn
734
+ }
735
+ return originalMaxWidth;
697
736
  };
698
- const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
737
+ const drawMockup = (ctx, canvas, imageRefs) => {
738
+ const mockupBounds = getMockupBounds(canvas, imageRefs);
739
+ if (!mockupBounds)
740
+ return;
741
+ const mockupImg = imageRefs.current.get("mockup");
742
+ if (!mockupImg)
743
+ return;
744
+ ctx.drawImage(mockupImg, mockupBounds.x, mockupBounds.y, mockupBounds.width, mockupBounds.height);
745
+ };
746
+ const renderSide = (ctx, side, startY, width, height, scaleFactor, imageRefs, mockupBounds = null) => {
699
747
  let currentY = startY;
700
748
  const padding = LAYOUT.PADDING * scaleFactor;
701
749
  const sideWidth = width - 2 * padding;
@@ -704,7 +752,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
704
752
  const headerFontSize = LAYOUT.HEADER_FONT_SIZE * scaleFactor;
705
753
  ctx.font = `bold ${headerFontSize}px ${LAYOUT.HEADER_FONT_FAMILY}`;
706
754
  ctx.fillStyle = LAYOUT.HEADER_COLOR;
707
- const headerResult = wrapText(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize);
755
+ const headerResult = wrapText(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize, mockupBounds);
708
756
  // Draw underline
709
757
  const underlineY = headerResult.lastLineY + headerFontSize * LAYOUT.UNDERLINE_POSITION;
710
758
  ctx.strokeStyle = LAYOUT.HEADER_COLOR;
@@ -765,7 +813,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
765
813
  : { color: true };
766
814
  // Render uniform labels (when applicable)
767
815
  if (shouldShowUniformLabels) {
768
- currentY += renderUniformLabels(ctx, uniformProps, padding, currentY, sideWidth, scaleFactor, imageRefs, textPositions, uniformLabelFields);
816
+ currentY += renderUniformLabels(ctx, uniformProps, padding, currentY, sideWidth, scaleFactor, imageRefs, textPositions, uniformLabelFields, mockupBounds);
769
817
  }
770
818
  // Group text positions by common properties
771
819
  const textGroups = groupTextPositions(textPositions);
@@ -791,7 +839,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
791
839
  floral: !uniformProps.isUniform.floral,
792
840
  color: !uniformProps.isUniform.color,
793
841
  };
794
- const height = renderTextPosition(ctx, position, padding, currentY, sideWidth, textCounter, showLabels, scaleFactor, imageRefs);
842
+ const height = renderTextPosition(ctx, position, padding, currentY, sideWidth, textCounter, showLabels, scaleFactor, imageRefs, mockupBounds);
795
843
  if (height > 0) {
796
844
  currentY += height + LAYOUT.PADDING * scaleFactor;
797
845
  textCounter++;
@@ -812,7 +860,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
812
860
  iconColorValue !== null &&
813
861
  uniformProps.values.color === iconColorValue &&
814
862
  iconUsesSingleColor;
815
- currentY += renderIconPosition(ctx, position, padding, currentY, sideWidth, scaleFactor, imageRefs, { hideColor });
863
+ currentY += renderIconPosition(ctx, position, padding, currentY, sideWidth, scaleFactor, imageRefs, { hideColor }, mockupBounds);
816
864
  currentY += (LAYOUT.LINE_GAP / 3) * scaleFactor;
817
865
  }
818
866
  });
@@ -883,7 +931,7 @@ const computeUniformProperties = (textPositions, options) => {
883
931
  },
884
932
  };
885
933
  };
886
- const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, imageRefs, textPositions, fields) => {
934
+ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, imageRefs, textPositions, fields, mockupBounds = null) => {
887
935
  const { values } = uniformProps;
888
936
  const fontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
889
937
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
@@ -921,7 +969,7 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
921
969
  rendered++;
922
970
  }
923
971
  if (values.shape && values.shape !== "None" && shouldRenderField("shape")) {
924
- const result = wrapText(ctx, `Kiểu chữ: ${values.shape}`, x, cursorY, maxWidth, fontSize + lineGap);
972
+ const result = wrapText(ctx, `Kiểu chữ: ${values.shape}`, x, cursorY, maxWidth, fontSize + lineGap, mockupBounds);
925
973
  cursorY += result.height;
926
974
  rendered++;
927
975
  }
@@ -931,14 +979,20 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
931
979
  : [values.color];
932
980
  const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
933
981
  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);
982
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
983
+ const lineHeight = fontSize + lineGap;
984
+ const effectiveMaxWidth = mockupBounds
985
+ ? getEffectiveMaxWidth(x, cursorY, lineHeight, maxWidth, mockupBounds)
986
+ : maxWidth;
987
+ // Vẽ text với effectiveMaxWidth
988
+ const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, effectiveMaxWidth, lineHeight, mockupBounds);
936
989
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
990
+ // Sử dụng effectiveMaxWidth thay vì maxWidth
937
991
  const textEndX = x + Math.ceil(result.lastLineWidth);
938
992
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
939
993
  const swatchesStartX = textEndX + spacing;
940
994
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
941
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
995
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
942
996
  let swatchX;
943
997
  let swatchY;
944
998
  if (shouldWrapSwatches) {
@@ -971,16 +1025,16 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
971
1025
  }
972
1026
  // Line height giống icon_image: floralH + lineGap
973
1027
  const floralLineHeight = floralH + lineGap;
974
- // Text align bottom: đặt text dưới cùng của dòng
975
- const textBottomY = cursorY + floralH;
1028
+ // Text align center: căn giữa theo chiều dọc trong block
1029
+ const textCenterY = cursorY + floralH / 2;
976
1030
  // Đo width trước khi vẽ
977
1031
  ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
978
1032
  const labelText = `Mẫu hoa: ${values.floral}`;
979
1033
  const labelWidth = ctx.measureText(labelText).width;
980
- // Vẽ text với textBaseline = bottom
981
- ctx.textBaseline = "bottom";
1034
+ // Vẽ text với textBaseline = middle để align center
1035
+ ctx.textBaseline = "middle";
982
1036
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
983
- ctx.fillText(labelText, x, textBottomY);
1037
+ ctx.fillText(labelText, x, textCenterY);
984
1038
  // Reset textBaseline về top cho các phần tiếp theo
985
1039
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
986
1040
  // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
@@ -994,13 +1048,13 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
994
1048
  if (shouldWrapFloral) {
995
1049
  // Không đủ chỗ, cho ảnh floral xuống dòng mới
996
1050
  floralX = x;
997
- floralY = textBottomY + lineGap;
1051
+ floralY = cursorY + floralH + lineGap;
998
1052
  cursorY += floralLineHeight;
999
1053
  }
1000
1054
  else {
1001
- // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1055
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng, align center
1002
1056
  floralX = floralStartX;
1003
- floralY = textBottomY - floralH; // Align bottom với text
1057
+ floralY = textCenterY - floralH / 2; // Align center với text
1004
1058
  cursorY += floralLineHeight;
1005
1059
  }
1006
1060
  // Vẽ ảnh floral
@@ -1015,19 +1069,17 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
1015
1069
  return cursorY - y;
1016
1070
  };
1017
1071
  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) => {
1072
+ displayIndex, showLabels, scaleFactor, imageRefs, mockupBounds = null) => {
1019
1073
  ctx.save();
1020
1074
  const textFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
1021
1075
  const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1022
1076
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
1023
1077
  let currentY = y;
1024
- let drawnHeight = 0;
1025
1078
  // Chuẩn hóa xuống dòng:
1026
1079
  // - Hỗ trợ cả newline thật (\n) và chuỗi literal "\\n" từ JSON
1027
1080
  const normalizeNewlines = (text) => text
1028
1081
  .replace(/\r\n/g, "\n")
1029
- .replace(/\r/g, "\n")
1030
- .replace(/\\n/g, "\n");
1082
+ .replace(/\r/g, "\n");
1031
1083
  // Get display text (handle empty/null/undefined) sau khi normalize
1032
1084
  const rawOriginalText = position.text ?? "";
1033
1085
  const normalizedText = normalizeNewlines(rawOriginalText);
@@ -1052,24 +1104,33 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1052
1104
  const lines = rawText.split("\n");
1053
1105
  // Tính font-size hiệu dụng cho phần value sao cho:
1054
1106
  // - Không vượt quá availableWidth
1107
+ // - Tính đến mockup bounds cho từng dòng
1055
1108
  // - 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
1109
  let effectiveTextFontSize = textFontSize;
1067
1110
  if (!isEmptyText) {
1068
- const baseMaxWidth = measureMaxLineWidth(textFontSize);
1069
- if (baseMaxWidth > availableWidth) {
1070
- const shrinkRatio = availableWidth / baseMaxWidth;
1071
- effectiveTextFontSize = textFontSize * shrinkRatio;
1072
- }
1111
+ // Tính vị trí Y của từng dòng (textCenterY và lineY)
1112
+ const valueLineHeight = textFontSize;
1113
+ const textBlockHeight = lines.length * valueLineHeight;
1114
+ const textCenterY = currentY + textBlockHeight / 2;
1115
+ // Tính font size cần thiết cho từng dòng dựa trên effectiveMaxWidth của nó
1116
+ let minShrinkRatio = 1;
1117
+ ctx.font = `${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
1118
+ lines.forEach((line, idx) => {
1119
+ const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1120
+ // Tính effectiveMaxWidth cho dòng này
1121
+ const effectiveMaxWidth = mockupBounds
1122
+ ? getEffectiveMaxWidth(valueStartX, lineY, valueLineHeight, availableWidth, mockupBounds)
1123
+ : availableWidth;
1124
+ const lineWidth = ctx.measureText(line).width;
1125
+ if (lineWidth > effectiveMaxWidth) {
1126
+ // Cần thu nhỏ font cho dòng này
1127
+ const shrinkRatio = effectiveMaxWidth / lineWidth;
1128
+ if (shrinkRatio < minShrinkRatio) {
1129
+ minShrinkRatio = shrinkRatio;
1130
+ }
1131
+ }
1132
+ });
1133
+ effectiveTextFontSize = textFontSize * minShrinkRatio;
1073
1134
  }
1074
1135
  // Line height luôn theo Text label (textFontSize), không theo effectiveTextFontSize
1075
1136
  const valueLineHeight = textFontSize;
@@ -1081,6 +1142,7 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1081
1142
  ctx.font = `${effectiveTextFontSize}px ${LAYOUT.FONT_FAMILY}`;
1082
1143
  ctx.fillStyle = DEFAULT_ERROR_COLOR;
1083
1144
  // Vẽ từ trên xuống: căn giữa mỗi dòng
1145
+ // 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
1084
1146
  lines.forEach((line, idx) => {
1085
1147
  const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1086
1148
  ctx.fillText(line, valueStartX, lineY);
@@ -1088,7 +1150,6 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1088
1150
  // Reset textBaseline về top cho các phần tiếp theo
1089
1151
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1090
1152
  currentY += textBlockHeight;
1091
- drawnHeight += textBlockHeight;
1092
1153
  // Draw additional labels (skip when text is empty)
1093
1154
  if (!isEmptyText) {
1094
1155
  currentY += lineGap;
@@ -1097,9 +1158,8 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1097
1158
  // Lưu ý: phần dưới này vẫn có thể wrap theo maxWidth vì đây chỉ là label mô tả,
1098
1159
  // không phải nội dung Text chính cần giữ nguyên format.
1099
1160
  if (showLabels.shape && position.text_shape) {
1100
- const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap);
1161
+ const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap, mockupBounds);
1101
1162
  currentY += result.height;
1102
- drawnHeight += result.height;
1103
1163
  }
1104
1164
  if (showLabels.font && position.font) {
1105
1165
  // Render "Font: " với font mặc định
@@ -1121,7 +1181,6 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1121
1181
  // Tính toán height và di chuyển cursorY
1122
1182
  const lineHeight = otherFontSize + lineGap;
1123
1183
  currentY += lineHeight;
1124
- drawnHeight += lineHeight;
1125
1184
  }
1126
1185
  if (showLabels.color) {
1127
1186
  const colorValue = position.character_colors?.join(", ") || position.color;
@@ -1129,14 +1188,20 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1129
1188
  const colors = position.character_colors || [position.color];
1130
1189
  const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1131
1190
  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);
1191
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
1192
+ const lineHeight = otherFontSize + lineGap;
1193
+ const effectiveMaxWidth = mockupBounds
1194
+ ? getEffectiveMaxWidth(x, currentY, lineHeight, maxWidth, mockupBounds)
1195
+ : maxWidth;
1196
+ // Vẽ text với effectiveMaxWidth
1197
+ const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, effectiveMaxWidth, lineHeight, mockupBounds);
1134
1198
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1199
+ // Sử dụng effectiveMaxWidth thay vì maxWidth
1135
1200
  const textEndX = x + Math.ceil(result.lastLineWidth);
1136
1201
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1137
1202
  const swatchesStartX = textEndX + spacing;
1138
1203
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
1139
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1204
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
1140
1205
  let swatchX;
1141
1206
  let swatchY;
1142
1207
  if (shouldWrapSwatches) {
@@ -1144,19 +1209,16 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1144
1209
  swatchX = x;
1145
1210
  swatchY = result.lastLineY + otherFontSize + lineGap;
1146
1211
  currentY += result.height + otherFontSize + lineGap;
1147
- drawnHeight += result.height + otherFontSize + lineGap;
1148
1212
  }
1149
1213
  else {
1150
1214
  // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1151
1215
  swatchX = swatchesStartX;
1152
1216
  swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1153
1217
  currentY += result.height;
1154
- drawnHeight += result.height;
1155
1218
  }
1156
1219
  drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1157
1220
  if (shouldWrapSwatches) {
1158
1221
  currentY += swatchH;
1159
- drawnHeight += swatchH;
1160
1222
  }
1161
1223
  }
1162
1224
  }
@@ -1172,16 +1234,16 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1172
1234
  }
1173
1235
  // Line height giống icon_image: floralH + lineGap
1174
1236
  const floralLineHeight = floralH + lineGap;
1175
- // Text align bottom: đặt text dưới cùng của dòng
1176
- const textBottomY = currentY + floralH;
1237
+ // Text align center: căn giữa theo chiều dọc trong block
1238
+ const textCenterY = currentY + floralH / 2;
1177
1239
  // Đo width trước khi vẽ
1178
1240
  ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1179
1241
  const labelText = `Mẫu hoa: ${position.floral_pattern}`;
1180
1242
  const labelWidth = ctx.measureText(labelText).width;
1181
- // Vẽ text với textBaseline = bottom
1182
- ctx.textBaseline = "bottom";
1243
+ // Vẽ text với textBaseline = middle để align center
1244
+ ctx.textBaseline = "middle";
1183
1245
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1184
- ctx.fillText(labelText, x, textBottomY);
1246
+ ctx.fillText(labelText, x, textCenterY);
1185
1247
  // Reset textBaseline về top cho các phần tiếp theo
1186
1248
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1187
1249
  // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
@@ -1195,16 +1257,14 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1195
1257
  if (shouldWrapFloral) {
1196
1258
  // Không đủ chỗ, cho ảnh floral xuống dòng mới
1197
1259
  floralX = x;
1198
- floralY = textBottomY + lineGap;
1260
+ floralY = currentY + floralH + lineGap;
1199
1261
  currentY += floralLineHeight;
1200
- drawnHeight += floralLineHeight;
1201
1262
  }
1202
1263
  else {
1203
- // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1264
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng, align center
1204
1265
  floralX = floralStartX;
1205
- floralY = textBottomY - floralH; // Align bottom với text
1266
+ floralY = textCenterY - floralH / 2; // Align center với text
1206
1267
  currentY += floralLineHeight;
1207
- drawnHeight += floralLineHeight;
1208
1268
  }
1209
1269
  // Vẽ ảnh floral
1210
1270
  if (floralImg?.complete && floralImg.naturalHeight > 0) {
@@ -1213,9 +1273,10 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1213
1273
  }
1214
1274
  }
1215
1275
  ctx.restore();
1216
- return drawnHeight;
1276
+ // Trả về toàn bộ chiều cao đã sử dụng trong block này
1277
+ return currentY - y;
1217
1278
  };
1218
- const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs, options) => {
1279
+ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs, options, mockupBounds = null) => {
1219
1280
  // Dùng cùng font size với Text cho label và value icon
1220
1281
  const iconFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
1221
1282
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
@@ -1250,13 +1311,7 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1250
1311
  // Đo width của label
1251
1312
  ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1252
1313
  const labelWidth = ctx.measureText(iconLabel).width;
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");
1314
+ // Icon value không cần chuẩn hóa xuống dòng, xử như một dòng text đơn giản
1260
1315
  // Tính kích thước icon_image nếu có để chừa khoảng trống
1261
1316
  let iconImageReservedWidth = 0;
1262
1317
  if (hasIconImage) {
@@ -1276,24 +1331,18 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1276
1331
  // Tính font-size hiệu dụng cho icon value
1277
1332
  // Giới hạn thu nhỏ tối đa 50% (tối thiểu = iconFontSize * 0.5)
1278
1333
  const MIN_ICON_VALUE_FONT_SIZE = iconFontSize * 0.5;
1279
- const measureMaxLineWidth = (fontSize) => {
1334
+ const measureIconValueWidth = (fontSize) => {
1280
1335
  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;
1336
+ return ctx.measureText(` ${iconValue}`).width;
1288
1337
  };
1289
1338
  let effectiveIconValueFontSize = iconFontSize;
1290
- const baseMaxWidth = measureMaxLineWidth(iconFontSize);
1339
+ const baseMaxWidth = measureIconValueWidth(iconFontSize);
1291
1340
  let needsWrap = false;
1292
1341
  if (baseMaxWidth > availableWidth) {
1293
1342
  const shrinkRatio = availableWidth / baseMaxWidth;
1294
1343
  effectiveIconValueFontSize = Math.max(MIN_ICON_VALUE_FONT_SIZE, iconFontSize * shrinkRatio);
1295
1344
  // 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);
1345
+ const minMaxWidth = measureIconValueWidth(MIN_ICON_VALUE_FONT_SIZE);
1297
1346
  if (minMaxWidth > availableWidth) {
1298
1347
  // Vẫn overflow, cần dùng wrap text
1299
1348
  needsWrap = true;
@@ -1303,39 +1352,95 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1303
1352
  // Tính line height và block height cho icon value
1304
1353
  const valueLineHeight = effectiveIconValueFontSize;
1305
1354
  let allWrappedLines = [];
1355
+ // Text align center: căn giữa theo chiều dọc trong block
1356
+ const textCenterY = cursorY + iconImageHeight / 2;
1357
+ const valueStartX = x + labelWidth;
1306
1358
  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;
1359
+ // Dùng wrap text logic
1360
+ const wrappedLines = buildWrappedLines(ctx, iconValue, availableWidth, valueStartX, textCenterY, valueLineHeight, mockupBounds);
1361
+ allWrappedLines = wrappedLines;
1362
+ wrappedLines.length * valueLineHeight;
1313
1363
  }
1314
1364
  else {
1315
- allWrappedLines = iconValueLines;
1316
- iconValueLines.length * valueLineHeight;
1365
+ // Không cần wrap, chỉ một dòng
1366
+ allWrappedLines = [iconValue];
1317
1367
  }
1318
- // Text align center: căn giữa theo chiều dọc trong block
1319
- const textCenterY = cursorY + iconImageHeight / 2;
1320
1368
  // Vẽ label với textBaseline = middle để align center với value
1321
1369
  ctx.textBaseline = "middle";
1322
1370
  ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1323
1371
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1324
1372
  ctx.fillText(iconLabel, x, textCenterY);
1325
- const valueStartX = x + labelWidth;
1326
1373
  // Vẽ icon value với align center
1327
1374
  ctx.font = `${effectiveIconValueFontSize}px ${LAYOUT.FONT_FAMILY}`;
1328
1375
  ctx.fillStyle = DEFAULT_ERROR_COLOR;
1329
1376
  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
- });
1377
+ // Vẽ icon value, căn giữa theo chiều dọc
1378
+ if (needsWrap) {
1379
+ // nhiều dòng sau khi wrap
1380
+ allWrappedLines.forEach((line, index) => {
1381
+ const lineY = textCenterY - (allWrappedLines.length - 1) / 2 * valueLineHeight + index * valueLineHeight;
1382
+ // Kiểm tra overlap với mockup và điều chỉnh text nếu cần
1383
+ if (mockupBounds) {
1384
+ const effectiveMaxWidth = getEffectiveMaxWidth(valueStartX, lineY, valueLineHeight, availableWidth, mockupBounds);
1385
+ const lineWidth = ctx.measureText(line).width;
1386
+ if (lineWidth > effectiveMaxWidth) {
1387
+ // Cắt text cho đến khi vừa với effectiveMaxWidth
1388
+ let truncatedLine = line;
1389
+ while (ctx.measureText(truncatedLine).width > effectiveMaxWidth && truncatedLine.length > 0) {
1390
+ truncatedLine = truncatedLine.slice(0, -1);
1391
+ }
1392
+ ctx.fillText(truncatedLine, valueStartX, lineY);
1393
+ const w = ctx.measureText(truncatedLine).width;
1394
+ if (w > maxValueLineWidth)
1395
+ maxValueLineWidth = w;
1396
+ }
1397
+ else {
1398
+ ctx.fillText(line, valueStartX, lineY);
1399
+ const w = ctx.measureText(line).width;
1400
+ if (w > maxValueLineWidth)
1401
+ maxValueLineWidth = w;
1402
+ }
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
+ }
1412
+ else {
1413
+ // Chỉ một dòng, căn giữa
1414
+ const lineText = ` ${iconValue}`;
1415
+ // Kiểm tra overlap với mockup và điều chỉnh text nếu cần
1416
+ if (mockupBounds) {
1417
+ const effectiveMaxWidth = getEffectiveMaxWidth(valueStartX, textCenterY, valueLineHeight, availableWidth, mockupBounds);
1418
+ const lineWidth = ctx.measureText(lineText).width;
1419
+ if (lineWidth > effectiveMaxWidth) {
1420
+ // Cắt text cho đến khi vừa với effectiveMaxWidth
1421
+ let truncatedLine = lineText;
1422
+ while (ctx.measureText(truncatedLine).width > effectiveMaxWidth && truncatedLine.length > 0) {
1423
+ truncatedLine = truncatedLine.slice(0, -1);
1424
+ }
1425
+ ctx.fillText(truncatedLine, valueStartX, textCenterY);
1426
+ const w = ctx.measureText(truncatedLine).width;
1427
+ if (w > maxValueLineWidth)
1428
+ maxValueLineWidth = w;
1429
+ }
1430
+ else {
1431
+ ctx.fillText(lineText, valueStartX, textCenterY);
1432
+ const w = ctx.measureText(lineText).width;
1433
+ if (w > maxValueLineWidth)
1434
+ maxValueLineWidth = w;
1435
+ }
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
+ }
1339
1444
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1340
1445
  // Reset textBaseline về top cho các phần tiếp theo
1341
1446
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
@@ -1343,7 +1448,8 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1343
1448
  height: iconImageHeight + lineGap,
1344
1449
  // tổng width của cả label + value, dùng để canh icon image lệch sang phải
1345
1450
  lastLineWidth: labelWidth + maxValueLineWidth};
1346
- // Draw icon image
1451
+ // Kiểm tra xem phần icon image có vượt quá canvas không trước khi render
1452
+ // Draw icon image (không tự cắt theo canvasHeight, việc đảm bảo fit do scaleFactor xử lý)
1347
1453
  const iconUrl = getIconImageUrl(position);
1348
1454
  if (iconUrl) {
1349
1455
  const img = imageRefs.current.get(iconUrl);
@@ -1377,34 +1483,51 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1377
1483
  const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1378
1484
  const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1379
1485
  const totalSwatchWidth = calculateSwatchesWidth(iconColors, swatchH, scaleFactor, imageRefs);
1380
- // Set font về OTHER_FONT_SIZE trước khi vẽ text "Màu chỉ:"
1486
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
1487
+ const lineHeight = otherFontSize + lineGap;
1488
+ const effectiveMaxWidth = mockupBounds
1489
+ ? getEffectiveMaxWidth(x, cursorY, lineHeight, maxWidth, mockupBounds)
1490
+ : maxWidth;
1491
+ // Tính toán trước để kiểm tra xem có đủ chỗ không
1492
+ // Đo chiều cao của text "Màu chỉ:" trước
1381
1493
  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);
1494
+ const testText = `Màu chỉ: ${iconColors.join(", ")}`;
1495
+ const testLines = buildWrappedLines(ctx, testText, effectiveMaxWidth, x, cursorY, lineHeight, mockupBounds);
1496
+ testLines.length * lineHeight;
1385
1497
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1386
- const textEndX = x + Math.ceil(colorResult.lastLineWidth);
1498
+ const testTextWidth = Math.max(...testLines.map(line => ctx.measureText(line).width));
1499
+ const textEndX = x + testTextWidth;
1387
1500
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1388
1501
  const swatchesStartX = textEndX + spacing;
1389
1502
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
1390
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1503
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
1504
+ if (shouldWrapSwatches) ;
1505
+ else {
1506
+ // Swatches trên cùng dòng với text
1507
+ cursorY + (testLines.length - 1) * lineHeight;
1508
+ }
1509
+ // Luôn render text và swatches; đảm bảo fit bằng scaleFactor ở tầng trên
1510
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1511
+ const colorResult = wrapText(ctx, testText, x, cursorY, effectiveMaxWidth, lineHeight, mockupBounds);
1512
+ // Cập nhật cursorY sau khi render text
1513
+ cursorY += colorResult.height;
1391
1514
  let swatchX;
1392
1515
  let swatchY;
1393
1516
  if (shouldWrapSwatches) {
1394
1517
  // Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
1395
1518
  swatchX = x;
1396
- swatchY = colorResult.lastLineY + otherFontSize + lineGap;
1397
- cursorY += colorResult.height + otherFontSize + lineGap;
1519
+ swatchY = cursorY;
1520
+ // Render swatches (đã kiểm tra overflow ở trên)
1521
+ drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1522
+ cursorY = swatchY + swatchH;
1398
1523
  }
1399
1524
  else {
1400
1525
  // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1401
1526
  swatchX = swatchesStartX;
1402
1527
  swatchY = colorResult.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1403
- cursorY += colorResult.height;
1404
- }
1405
- drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1406
- if (shouldWrapSwatches) {
1407
- cursorY += swatchH;
1528
+ // Render swatches (đã kiểm tra overflow ở trên)
1529
+ drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1530
+ // cursorY đã được cập nhật ở trên
1408
1531
  }
1409
1532
  }
1410
1533
  ctx.restore();