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