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.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,23 +565,48 @@ 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 - LAYOUT.PADDING;
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, không cộng padding ở đây
580
+ let testMeasureY = 0;
581
+ if (config.warning_message) {
582
+ const testWarningHeight = renderWarning(measureCtx, measureCanvas, config.warning_message, testScale);
583
+ testMeasureY += testWarningHeight;
584
+ }
585
+ if (config.message) {
586
+ const testMessageHeight = renderWarning(measureCtx, measureCanvas, config.message, testScale, 0, "", 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 (không gồm padding)
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);
583
610
  // Render warning & message with scaleFactor and get actual heights
584
611
  let actualWarningHeight = 0;
585
612
  let actualMessageHeight = 0;
@@ -592,7 +619,7 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
592
619
  );
593
620
  }
594
621
  // Calculate currentY: padding top + actual warning & message height (no spacing)
595
- let currentY = LAYOUT.PADDING * scaleFactor;
622
+ let currentY = LAYOUT.PADDING;
596
623
  if (config.warning_message && actualWarningHeight > 0) {
597
624
  currentY += actualWarningHeight;
598
625
  }
@@ -600,7 +627,7 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
600
627
  currentY += actualMessageHeight;
601
628
  }
602
629
  config.sides.forEach((side) => {
603
- const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor, imageRefs);
630
+ const sideHeight = renderSide(ctx, side, currentY, canvas.width, canvas.height, scaleFactor, imageRefs, mockupBounds);
604
631
  currentY += sideHeight + measureSpacing * scaleFactor;
605
632
  });
606
633
  };
@@ -678,10 +705,10 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1, offsetY = 0, prefi
678
705
  // Return the actual height of the warning (number of lines * lineHeight)
679
706
  return lines.length * lineHeight;
680
707
  };
681
- const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
708
+ const getMockupBounds = (canvas, imageRefs) => {
682
709
  const mockupImg = imageRefs.current.get("mockup");
683
710
  if (!mockupImg?.complete || !mockupImg.naturalWidth)
684
- return;
711
+ return null;
685
712
  const margin = LAYOUT.PADDING;
686
713
  const maxWidth = Math.min(1800, canvas.width * 0.375);
687
714
  const maxHeight = canvas.height * 0.375;
@@ -690,10 +717,38 @@ const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
690
717
  const height = Math.max(1, Math.floor(mockupImg.naturalHeight * scale));
691
718
  const x = canvas.width - margin - width;
692
719
  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
720
+ return { x, y, width, height };
721
+ };
722
+ // Helper function để tính maxWidth hiệu dụng dựa trên vị trí Y và mockupBounds
723
+ const getEffectiveMaxWidth = (x, y, lineHeight, originalMaxWidth, mockupBounds) => {
724
+ if (!mockupBounds)
725
+ return originalMaxWidth;
726
+ // Kiểm tra xem dòng text có nằm trong phạm vi Y của mockup không
727
+ const lineTopY = y;
728
+ const lineBottomY = y + lineHeight;
729
+ const overlapsY = lineTopY < mockupBounds.y + mockupBounds.height && lineBottomY > mockupBounds.y;
730
+ if (overlapsY) {
731
+ // Nếu overlap theo Y, giới hạn maxWidth để text không vượt quá mockup.x
732
+ const maxAllowedWidth = mockupBounds.x - x;
733
+ // Chỉ giới hạn nếu maxAllowedWidth > 0 và nhỏ hơn originalMaxWidth
734
+ if (maxAllowedWidth > 0 && maxAllowedWidth < originalMaxWidth) {
735
+ return maxAllowedWidth;
736
+ }
737
+ // Nếu x >= mockupBounds.x, text đã nằm sau mockup, không cần giới hạn
738
+ // Hoặc nếu maxAllowedWidth >= originalMaxWidth, không cần giới hạn
739
+ }
740
+ return originalMaxWidth;
695
741
  };
696
- const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
742
+ const drawMockup = (ctx, canvas, imageRefs) => {
743
+ const mockupBounds = getMockupBounds(canvas, imageRefs);
744
+ if (!mockupBounds)
745
+ return;
746
+ const mockupImg = imageRefs.current.get("mockup");
747
+ if (!mockupImg)
748
+ return;
749
+ ctx.drawImage(mockupImg, mockupBounds.x, mockupBounds.y, mockupBounds.width, mockupBounds.height);
750
+ };
751
+ const renderSide = (ctx, side, startY, width, height, scaleFactor, imageRefs, mockupBounds = null) => {
697
752
  let currentY = startY;
698
753
  const padding = LAYOUT.PADDING * scaleFactor;
699
754
  const sideWidth = width - 2 * padding;
@@ -702,7 +757,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
702
757
  const headerFontSize = LAYOUT.HEADER_FONT_SIZE * scaleFactor;
703
758
  ctx.font = `bold ${headerFontSize}px ${LAYOUT.HEADER_FONT_FAMILY}`;
704
759
  ctx.fillStyle = LAYOUT.HEADER_COLOR;
705
- const headerResult = wrapText(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize);
760
+ const headerResult = wrapText(ctx, side.print_side.toUpperCase(), padding, currentY, sideWidth, headerFontSize, mockupBounds);
706
761
  // Draw underline
707
762
  const underlineY = headerResult.lastLineY + headerFontSize * LAYOUT.UNDERLINE_POSITION;
708
763
  ctx.strokeStyle = LAYOUT.HEADER_COLOR;
@@ -763,7 +818,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
763
818
  : { color: true };
764
819
  // Render uniform labels (when applicable)
765
820
  if (shouldShowUniformLabels) {
766
- currentY += renderUniformLabels(ctx, uniformProps, padding, currentY, sideWidth, scaleFactor, imageRefs, textPositions, uniformLabelFields);
821
+ currentY += renderUniformLabels(ctx, uniformProps, padding, currentY, sideWidth, scaleFactor, imageRefs, textPositions, uniformLabelFields, mockupBounds);
767
822
  }
768
823
  // Group text positions by common properties
769
824
  const textGroups = groupTextPositions(textPositions);
@@ -789,7 +844,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
789
844
  floral: !uniformProps.isUniform.floral,
790
845
  color: !uniformProps.isUniform.color,
791
846
  };
792
- const height = renderTextPosition(ctx, position, padding, currentY, sideWidth, textCounter, showLabels, scaleFactor, imageRefs);
847
+ const height = renderTextPosition(ctx, position, padding, currentY, sideWidth, textCounter, showLabels, scaleFactor, imageRefs, mockupBounds);
793
848
  if (height > 0) {
794
849
  currentY += height + LAYOUT.PADDING * scaleFactor;
795
850
  textCounter++;
@@ -810,7 +865,7 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
810
865
  iconColorValue !== null &&
811
866
  uniformProps.values.color === iconColorValue &&
812
867
  iconUsesSingleColor;
813
- currentY += renderIconPosition(ctx, position, padding, currentY, sideWidth, scaleFactor, imageRefs, { hideColor });
868
+ currentY += renderIconPosition(ctx, position, padding, currentY, sideWidth, scaleFactor, imageRefs, { hideColor }, mockupBounds);
814
869
  currentY += (LAYOUT.LINE_GAP / 3) * scaleFactor;
815
870
  }
816
871
  });
@@ -881,7 +936,7 @@ const computeUniformProperties = (textPositions, options) => {
881
936
  },
882
937
  };
883
938
  };
884
- const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, imageRefs, textPositions, fields) => {
939
+ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, imageRefs, textPositions, fields, mockupBounds = null) => {
885
940
  const { values } = uniformProps;
886
941
  const fontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
887
942
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
@@ -919,7 +974,7 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
919
974
  rendered++;
920
975
  }
921
976
  if (values.shape && values.shape !== "None" && shouldRenderField("shape")) {
922
- const result = wrapText(ctx, `Kiểu chữ: ${values.shape}`, x, cursorY, maxWidth, fontSize + lineGap);
977
+ const result = wrapText(ctx, `Kiểu chữ: ${values.shape}`, x, cursorY, maxWidth, fontSize + lineGap, mockupBounds);
923
978
  cursorY += result.height;
924
979
  rendered++;
925
980
  }
@@ -929,14 +984,20 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
929
984
  : [values.color];
930
985
  const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
931
986
  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);
987
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
988
+ const lineHeight = fontSize + lineGap;
989
+ const effectiveMaxWidth = mockupBounds
990
+ ? getEffectiveMaxWidth(x, cursorY, lineHeight, maxWidth, mockupBounds)
991
+ : maxWidth;
992
+ // Vẽ text với effectiveMaxWidth
993
+ const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, effectiveMaxWidth, lineHeight, mockupBounds);
934
994
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
995
+ // Sử dụng effectiveMaxWidth thay vì maxWidth
935
996
  const textEndX = x + Math.ceil(result.lastLineWidth);
936
997
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
937
998
  const swatchesStartX = textEndX + spacing;
938
999
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
939
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1000
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
940
1001
  let swatchX;
941
1002
  let swatchY;
942
1003
  if (shouldWrapSwatches) {
@@ -969,16 +1030,16 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
969
1030
  }
970
1031
  // Line height giống icon_image: floralH + lineGap
971
1032
  const floralLineHeight = floralH + lineGap;
972
- // Text align bottom: đặt text dưới cùng của dòng
973
- const textBottomY = cursorY + floralH;
1033
+ // Text align center: căn giữa theo chiều dọc trong block
1034
+ const textCenterY = cursorY + floralH / 2;
974
1035
  // Đo width trước khi vẽ
975
1036
  ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
976
1037
  const labelText = `Mẫu hoa: ${values.floral}`;
977
1038
  const labelWidth = ctx.measureText(labelText).width;
978
- // Vẽ text với textBaseline = bottom
979
- ctx.textBaseline = "bottom";
1039
+ // Vẽ text với textBaseline = middle để align center
1040
+ ctx.textBaseline = "middle";
980
1041
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
981
- ctx.fillText(labelText, x, textBottomY);
1042
+ ctx.fillText(labelText, x, textCenterY);
982
1043
  // Reset textBaseline về top cho các phần tiếp theo
983
1044
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
984
1045
  // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
@@ -992,13 +1053,13 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
992
1053
  if (shouldWrapFloral) {
993
1054
  // Không đủ chỗ, cho ảnh floral xuống dòng mới
994
1055
  floralX = x;
995
- floralY = textBottomY + lineGap;
1056
+ floralY = cursorY + floralH + lineGap;
996
1057
  cursorY += floralLineHeight;
997
1058
  }
998
1059
  else {
999
- // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1060
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng, align center
1000
1061
  floralX = floralStartX;
1001
- floralY = textBottomY - floralH; // Align bottom với text
1062
+ floralY = textCenterY - floralH / 2; // Align center với text
1002
1063
  cursorY += floralLineHeight;
1003
1064
  }
1004
1065
  // Vẽ ảnh floral
@@ -1013,19 +1074,17 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
1013
1074
  return cursorY - y;
1014
1075
  };
1015
1076
  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) => {
1077
+ displayIndex, showLabels, scaleFactor, imageRefs, mockupBounds = null) => {
1017
1078
  ctx.save();
1018
1079
  const textFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
1019
1080
  const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1020
1081
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
1021
1082
  let currentY = y;
1022
- let drawnHeight = 0;
1023
1083
  // Chuẩn hóa xuống dòng:
1024
1084
  // - Hỗ trợ cả newline thật (\n) và chuỗi literal "\\n" từ JSON
1025
1085
  const normalizeNewlines = (text) => text
1026
1086
  .replace(/\r\n/g, "\n")
1027
- .replace(/\r/g, "\n")
1028
- .replace(/\\n/g, "\n");
1087
+ .replace(/\r/g, "\n");
1029
1088
  // Get display text (handle empty/null/undefined) sau khi normalize
1030
1089
  const rawOriginalText = position.text ?? "";
1031
1090
  const normalizedText = normalizeNewlines(rawOriginalText);
@@ -1050,37 +1109,52 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1050
1109
  const lines = rawText.split("\n");
1051
1110
  // Tính font-size hiệu dụng cho phần value sao cho:
1052
1111
  // - Không vượt quá availableWidth
1112
+ // - Tính đến mockup bounds cho từng dòng
1053
1113
  // - 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
1114
  let effectiveTextFontSize = textFontSize;
1065
1115
  if (!isEmptyText) {
1066
- const baseMaxWidth = measureMaxLineWidth(textFontSize);
1067
- if (baseMaxWidth > availableWidth) {
1068
- const shrinkRatio = availableWidth / baseMaxWidth;
1069
- effectiveTextFontSize = textFontSize * shrinkRatio;
1070
- }
1116
+ // Tính vị trí Y của từng dòng (textCenterY và lineY)
1117
+ const valueLineHeight = textFontSize;
1118
+ const textBlockHeight = lines.length * valueLineHeight;
1119
+ const textCenterY = currentY + textBlockHeight / 2;
1120
+ // Tính font size cần thiết cho từng dòng dựa trên effectiveMaxWidth của nó
1121
+ let minShrinkRatio = 1;
1122
+ ctx.font = `${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
1123
+ lines.forEach((line, idx) => {
1124
+ const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1125
+ // Tính effectiveMaxWidth cho dòng này
1126
+ const effectiveMaxWidth = mockupBounds
1127
+ ? getEffectiveMaxWidth(valueStartX, lineY, valueLineHeight, availableWidth, mockupBounds)
1128
+ : availableWidth;
1129
+ const lineWidth = ctx.measureText(line).width;
1130
+ if (lineWidth > effectiveMaxWidth) {
1131
+ // Cần thu nhỏ font cho dòng này
1132
+ const shrinkRatio = effectiveMaxWidth / lineWidth;
1133
+ if (shrinkRatio < minShrinkRatio) {
1134
+ minShrinkRatio = shrinkRatio;
1135
+ }
1136
+ }
1137
+ });
1138
+ effectiveTextFontSize = textFontSize * minShrinkRatio;
1071
1139
  }
1072
- // Vẽ phần value với font hiệu dụng, màu đỏ
1140
+ // Line height luôn theo Text label (textFontSize), không theo effectiveTextFontSize
1141
+ const valueLineHeight = textFontSize;
1142
+ const textBlockHeight = lines.length * valueLineHeight;
1143
+ // Text align center: căn giữa theo chiều dọc trong block
1144
+ const textCenterY = currentY + textBlockHeight / 2;
1145
+ // Vẽ phần value với font hiệu dụng, màu đỏ, align center
1146
+ ctx.textBaseline = "middle";
1073
1147
  ctx.font = `${effectiveTextFontSize}px ${LAYOUT.FONT_FAMILY}`;
1074
1148
  ctx.fillStyle = DEFAULT_ERROR_COLOR;
1075
- const valueLineHeight = effectiveTextFontSize; // giữ giống wrapText cũ (lineHeight = fontSize)
1076
- let localY = currentY;
1149
+ // Vẽ từ trên xuống: căn giữa mỗi dòng
1150
+ // 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
1077
1151
  lines.forEach((line, idx) => {
1078
- ctx.fillText(line, valueStartX, localY);
1079
- localY += valueLineHeight;
1152
+ const lineY = textCenterY - (lines.length - 1) / 2 * valueLineHeight + idx * valueLineHeight;
1153
+ ctx.fillText(line, valueStartX, lineY);
1080
1154
  });
1081
- const textBlockHeight = lines.length * valueLineHeight;
1155
+ // Reset textBaseline về top cho các phần tiếp theo
1156
+ ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1082
1157
  currentY += textBlockHeight;
1083
- drawnHeight += textBlockHeight;
1084
1158
  // Draw additional labels (skip when text is empty)
1085
1159
  if (!isEmptyText) {
1086
1160
  currentY += lineGap;
@@ -1089,9 +1163,8 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1089
1163
  // Lưu ý: phần dưới này vẫn có thể wrap theo maxWidth vì đây chỉ là label mô tả,
1090
1164
  // không phải nội dung Text chính cần giữ nguyên format.
1091
1165
  if (showLabels.shape && position.text_shape) {
1092
- const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap);
1166
+ const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap, mockupBounds);
1093
1167
  currentY += result.height;
1094
- drawnHeight += result.height;
1095
1168
  }
1096
1169
  if (showLabels.font && position.font) {
1097
1170
  // Render "Font: " với font mặc định
@@ -1113,7 +1186,6 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1113
1186
  // Tính toán height và di chuyển cursorY
1114
1187
  const lineHeight = otherFontSize + lineGap;
1115
1188
  currentY += lineHeight;
1116
- drawnHeight += lineHeight;
1117
1189
  }
1118
1190
  if (showLabels.color) {
1119
1191
  const colorValue = position.character_colors?.join(", ") || position.color;
@@ -1121,14 +1193,20 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1121
1193
  const colors = position.character_colors || [position.color];
1122
1194
  const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1123
1195
  const totalSwatchWidth = calculateSwatchesWidth(colors, swatchH, scaleFactor, imageRefs);
1124
- // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
1125
- const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, maxWidth, otherFontSize + lineGap);
1196
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
1197
+ const lineHeight = otherFontSize + lineGap;
1198
+ const effectiveMaxWidth = mockupBounds
1199
+ ? getEffectiveMaxWidth(x, currentY, lineHeight, maxWidth, mockupBounds)
1200
+ : maxWidth;
1201
+ // Vẽ text với effectiveMaxWidth
1202
+ const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, effectiveMaxWidth, lineHeight, mockupBounds);
1126
1203
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1204
+ // Sử dụng effectiveMaxWidth thay vì maxWidth
1127
1205
  const textEndX = x + Math.ceil(result.lastLineWidth);
1128
1206
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1129
1207
  const swatchesStartX = textEndX + spacing;
1130
1208
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
1131
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1209
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
1132
1210
  let swatchX;
1133
1211
  let swatchY;
1134
1212
  if (shouldWrapSwatches) {
@@ -1136,19 +1214,16 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1136
1214
  swatchX = x;
1137
1215
  swatchY = result.lastLineY + otherFontSize + lineGap;
1138
1216
  currentY += result.height + otherFontSize + lineGap;
1139
- drawnHeight += result.height + otherFontSize + lineGap;
1140
1217
  }
1141
1218
  else {
1142
1219
  // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1143
1220
  swatchX = swatchesStartX;
1144
1221
  swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1145
1222
  currentY += result.height;
1146
- drawnHeight += result.height;
1147
1223
  }
1148
1224
  drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1149
1225
  if (shouldWrapSwatches) {
1150
1226
  currentY += swatchH;
1151
- drawnHeight += swatchH;
1152
1227
  }
1153
1228
  }
1154
1229
  }
@@ -1164,16 +1239,16 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1164
1239
  }
1165
1240
  // Line height giống icon_image: floralH + lineGap
1166
1241
  const floralLineHeight = floralH + lineGap;
1167
- // Text align bottom: đặt text dưới cùng của dòng
1168
- const textBottomY = currentY + floralH;
1242
+ // Text align center: căn giữa theo chiều dọc trong block
1243
+ const textCenterY = currentY + floralH / 2;
1169
1244
  // Đo width trước khi vẽ
1170
1245
  ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1171
1246
  const labelText = `Mẫu hoa: ${position.floral_pattern}`;
1172
1247
  const labelWidth = ctx.measureText(labelText).width;
1173
- // Vẽ text với textBaseline = bottom
1174
- ctx.textBaseline = "bottom";
1248
+ // Vẽ text với textBaseline = middle để align center
1249
+ ctx.textBaseline = "middle";
1175
1250
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1176
- ctx.fillText(labelText, x, textBottomY);
1251
+ ctx.fillText(labelText, x, textCenterY);
1177
1252
  // Reset textBaseline về top cho các phần tiếp theo
1178
1253
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1179
1254
  // Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
@@ -1187,16 +1262,14 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1187
1262
  if (shouldWrapFloral) {
1188
1263
  // Không đủ chỗ, cho ảnh floral xuống dòng mới
1189
1264
  floralX = x;
1190
- floralY = textBottomY + lineGap;
1265
+ floralY = currentY + floralH + lineGap;
1191
1266
  currentY += floralLineHeight;
1192
- drawnHeight += floralLineHeight;
1193
1267
  }
1194
1268
  else {
1195
- // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
1269
+ // Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng, align center
1196
1270
  floralX = floralStartX;
1197
- floralY = textBottomY - floralH; // Align bottom với text
1271
+ floralY = textCenterY - floralH / 2; // Align center với text
1198
1272
  currentY += floralLineHeight;
1199
- drawnHeight += floralLineHeight;
1200
1273
  }
1201
1274
  // Vẽ ảnh floral
1202
1275
  if (floralImg?.complete && floralImg.naturalHeight > 0) {
@@ -1205,9 +1278,10 @@ displayIndex, showLabels, scaleFactor, imageRefs) => {
1205
1278
  }
1206
1279
  }
1207
1280
  ctx.restore();
1208
- return drawnHeight;
1281
+ // Trả về toàn bộ chiều cao đã sử dụng trong block này
1282
+ return currentY - y;
1209
1283
  };
1210
- const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs, options) => {
1284
+ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs, options, mockupBounds = null) => {
1211
1285
  // Dùng cùng font size với Text cho label và value icon
1212
1286
  const iconFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
1213
1287
  const lineGap = LAYOUT.LINE_GAP * scaleFactor;
@@ -1239,30 +1313,148 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1239
1313
  // Kiểm tra xem có icon_image không để tính height phù hợp
1240
1314
  const hasIconImage = position.icon_image && position.icon_image.trim().length > 0;
1241
1315
  const iconImageHeight = hasIconImage ? iconFontSize * 2 : iconFontSize;
1242
- // Text align bottom: đặt text ở dưới cùng của dòng
1243
- const textBottomY = cursorY + iconImageHeight;
1244
- // Đo width trước khi vẽ
1316
+ // Đo width của label
1245
1317
  ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1246
1318
  const labelWidth = ctx.measureText(iconLabel).width;
1247
- ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1248
- const valueText = ` ${iconValue}`;
1249
- const valueWidth = ctx.measureText(valueText).width;
1250
- // Vẽ text với textBaseline = bottom
1251
- ctx.textBaseline = "bottom";
1319
+ // 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
1320
+ // Tính kích thước icon_image nếu có để chừa khoảng trống
1321
+ let iconImageReservedWidth = 0;
1322
+ if (hasIconImage) {
1323
+ const iconUrl = getIconImageUrl(position);
1324
+ if (iconUrl) {
1325
+ const img = imageRefs.current.get(iconUrl);
1326
+ if (img?.complete && img.naturalHeight > 0) {
1327
+ const ratio = img.naturalWidth / img.naturalHeight;
1328
+ const iconHeight = iconFontSize * 2;
1329
+ const iconWidth = Math.max(1, Math.floor(iconHeight * ratio));
1330
+ iconImageReservedWidth = iconWidth + LAYOUT.ELEMENT_SPACING * scaleFactor;
1331
+ }
1332
+ }
1333
+ }
1334
+ // Tính available width cho icon value (trừ đi khoảng trống cho icon_image)
1335
+ const availableWidth = Math.max(1, maxWidth - labelWidth - iconImageReservedWidth);
1336
+ // Tính font-size hiệu dụng cho icon value
1337
+ // Giới hạn thu nhỏ tối đa 50% (tối thiểu = iconFontSize * 0.5)
1338
+ const MIN_ICON_VALUE_FONT_SIZE = iconFontSize * 0.5;
1339
+ const measureIconValueWidth = (fontSize) => {
1340
+ ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
1341
+ return ctx.measureText(` ${iconValue}`).width;
1342
+ };
1343
+ let effectiveIconValueFontSize = iconFontSize;
1344
+ const baseMaxWidth = measureIconValueWidth(iconFontSize);
1345
+ let needsWrap = false;
1346
+ if (baseMaxWidth > availableWidth) {
1347
+ const shrinkRatio = availableWidth / baseMaxWidth;
1348
+ effectiveIconValueFontSize = Math.max(MIN_ICON_VALUE_FONT_SIZE, iconFontSize * shrinkRatio);
1349
+ // Kiểm tra xem sau khi thu nhỏ đến 50% có vẫn overflow không
1350
+ const minMaxWidth = measureIconValueWidth(MIN_ICON_VALUE_FONT_SIZE);
1351
+ if (minMaxWidth > availableWidth) {
1352
+ // Vẫn overflow, cần dùng wrap text
1353
+ needsWrap = true;
1354
+ effectiveIconValueFontSize = MIN_ICON_VALUE_FONT_SIZE;
1355
+ }
1356
+ }
1357
+ // Tính line height và block height cho icon value
1358
+ const valueLineHeight = effectiveIconValueFontSize;
1359
+ let allWrappedLines = [];
1360
+ // Text align center: căn giữa theo chiều dọc trong block
1361
+ const textCenterY = cursorY + iconImageHeight / 2;
1362
+ const valueStartX = x + labelWidth;
1363
+ if (needsWrap) {
1364
+ // Dùng wrap text logic
1365
+ const wrappedLines = buildWrappedLines(ctx, iconValue, availableWidth, valueStartX, textCenterY, valueLineHeight, mockupBounds);
1366
+ allWrappedLines = wrappedLines;
1367
+ wrappedLines.length * valueLineHeight;
1368
+ }
1369
+ else {
1370
+ // Không cần wrap, chỉ một dòng
1371
+ allWrappedLines = [iconValue];
1372
+ }
1373
+ // Vẽ label với textBaseline = middle để align center với value
1374
+ ctx.textBaseline = "middle";
1252
1375
  ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1253
1376
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1254
- ctx.fillText(iconLabel, x, textBottomY);
1255
- ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
1377
+ ctx.fillText(iconLabel, x, textCenterY);
1378
+ // Vẽ icon value với align center
1379
+ ctx.font = `${effectiveIconValueFontSize}px ${LAYOUT.FONT_FAMILY}`;
1256
1380
  ctx.fillStyle = DEFAULT_ERROR_COLOR;
1257
- ctx.fillText(valueText, x + labelWidth, textBottomY);
1381
+ let maxValueLineWidth = 0;
1382
+ // Vẽ icon value, căn giữa theo chiều dọc
1383
+ if (needsWrap) {
1384
+ // Có nhiều dòng sau khi wrap
1385
+ allWrappedLines.forEach((line, index) => {
1386
+ const lineY = textCenterY - (allWrappedLines.length - 1) / 2 * valueLineHeight + index * valueLineHeight;
1387
+ // Kiểm tra overlap với mockup và điều chỉnh text nếu cần
1388
+ if (mockupBounds) {
1389
+ const effectiveMaxWidth = getEffectiveMaxWidth(valueStartX, lineY, valueLineHeight, availableWidth, mockupBounds);
1390
+ const lineWidth = ctx.measureText(line).width;
1391
+ if (lineWidth > effectiveMaxWidth) {
1392
+ // Cắt text cho đến khi vừa với effectiveMaxWidth
1393
+ let truncatedLine = line;
1394
+ while (ctx.measureText(truncatedLine).width > effectiveMaxWidth && truncatedLine.length > 0) {
1395
+ truncatedLine = truncatedLine.slice(0, -1);
1396
+ }
1397
+ ctx.fillText(truncatedLine, valueStartX, lineY);
1398
+ const w = ctx.measureText(truncatedLine).width;
1399
+ if (w > maxValueLineWidth)
1400
+ maxValueLineWidth = w;
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
+ else {
1410
+ ctx.fillText(line, valueStartX, lineY);
1411
+ const w = ctx.measureText(line).width;
1412
+ if (w > maxValueLineWidth)
1413
+ maxValueLineWidth = w;
1414
+ }
1415
+ });
1416
+ }
1417
+ else {
1418
+ // Chỉ một dòng, căn giữa
1419
+ const lineText = ` ${iconValue}`;
1420
+ // Kiểm tra overlap với mockup và điều chỉnh text nếu cần
1421
+ if (mockupBounds) {
1422
+ const effectiveMaxWidth = getEffectiveMaxWidth(valueStartX, textCenterY, valueLineHeight, availableWidth, mockupBounds);
1423
+ const lineWidth = ctx.measureText(lineText).width;
1424
+ if (lineWidth > effectiveMaxWidth) {
1425
+ // Cắt text cho đến khi vừa với effectiveMaxWidth
1426
+ let truncatedLine = lineText;
1427
+ while (ctx.measureText(truncatedLine).width > effectiveMaxWidth && truncatedLine.length > 0) {
1428
+ truncatedLine = truncatedLine.slice(0, -1);
1429
+ }
1430
+ ctx.fillText(truncatedLine, valueStartX, textCenterY);
1431
+ const w = ctx.measureText(truncatedLine).width;
1432
+ if (w > maxValueLineWidth)
1433
+ maxValueLineWidth = w;
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
+ }
1442
+ else {
1443
+ ctx.fillText(lineText, valueStartX, textCenterY);
1444
+ const w = ctx.measureText(lineText).width;
1445
+ if (w > maxValueLineWidth)
1446
+ maxValueLineWidth = w;
1447
+ }
1448
+ }
1258
1449
  ctx.fillStyle = LAYOUT.LABEL_COLOR;
1259
1450
  // Reset textBaseline về top cho các phần tiếp theo
1260
1451
  ctx.textBaseline = LAYOUT.TEXT_BASELINE;
1261
1452
  const iconResult = {
1262
1453
  height: iconImageHeight + lineGap,
1263
1454
  // tổng width của cả label + value, dùng để canh icon image lệch sang phải
1264
- lastLineWidth: labelWidth + valueWidth};
1265
- // Draw icon image
1455
+ lastLineWidth: labelWidth + maxValueLineWidth};
1456
+ // Kiểm tra xem phần icon image có vượt quá canvas không trước khi render
1457
+ // Draw icon image (không tự cắt theo canvasHeight, việc đảm bảo fit do scaleFactor xử lý)
1266
1458
  const iconUrl = getIconImageUrl(position);
1267
1459
  if (iconUrl) {
1268
1460
  const img = imageRefs.current.get(iconUrl);
@@ -1274,7 +1466,7 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1274
1466
  const iconX = x +
1275
1467
  Math.ceil(iconResult.lastLineWidth) +
1276
1468
  LAYOUT.ELEMENT_SPACING * scaleFactor;
1277
- const iconY = textBottomY - iconHeight; // Align bottom với text
1469
+ const iconY = textCenterY - iconHeight / 2; // Align center với text
1278
1470
  ctx.drawImage(img, iconX, iconY, swatchW, iconHeight);
1279
1471
  }
1280
1472
  }
@@ -1296,34 +1488,51 @@ const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRef
1296
1488
  const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
1297
1489
  const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
1298
1490
  const totalSwatchWidth = calculateSwatchesWidth(iconColors, swatchH, scaleFactor, imageRefs);
1299
- // Set font về OTHER_FONT_SIZE trước khi vẽ text "Màu chỉ:"
1491
+ // Tính effectiveMaxWidth cho dòng này để tránh mockup
1492
+ const lineHeight = otherFontSize + lineGap;
1493
+ const effectiveMaxWidth = mockupBounds
1494
+ ? getEffectiveMaxWidth(x, cursorY, lineHeight, maxWidth, mockupBounds)
1495
+ : maxWidth;
1496
+ // Tính toán trước để kiểm tra xem có đủ chỗ không
1497
+ // Đo chiều cao của text "Màu chỉ:" trước
1300
1498
  ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
1301
- ctx.fillStyle = LAYOUT.LABEL_COLOR;
1302
- // Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
1303
- const colorResult = wrapText(ctx, `Màu chỉ: ${iconColors.join(", ")}`, x, cursorY, maxWidth, otherFontSize + lineGap);
1499
+ const testText = `Màu chỉ: ${iconColors.join(", ")}`;
1500
+ const testLines = buildWrappedLines(ctx, testText, effectiveMaxWidth, x, cursorY, lineHeight, mockupBounds);
1501
+ testLines.length * lineHeight;
1304
1502
  // Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
1305
- const textEndX = x + Math.ceil(colorResult.lastLineWidth);
1503
+ const testTextWidth = Math.max(...testLines.map(line => ctx.measureText(line).width));
1504
+ const textEndX = x + testTextWidth;
1306
1505
  const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
1307
1506
  const swatchesStartX = textEndX + spacing;
1308
1507
  const swatchesEndX = swatchesStartX + totalSwatchWidth;
1309
- const shouldWrapSwatches = swatchesEndX > x + maxWidth;
1508
+ const shouldWrapSwatches = swatchesEndX > x + effectiveMaxWidth;
1509
+ if (shouldWrapSwatches) ;
1510
+ else {
1511
+ // Swatches trên cùng dòng với text
1512
+ cursorY + (testLines.length - 1) * lineHeight;
1513
+ }
1514
+ // Luôn render text và swatches; đảm bảo fit bằng scaleFactor ở tầng trên
1515
+ ctx.fillStyle = LAYOUT.LABEL_COLOR;
1516
+ const colorResult = wrapText(ctx, testText, x, cursorY, effectiveMaxWidth, lineHeight, mockupBounds);
1517
+ // Cập nhật cursorY sau khi render text
1518
+ cursorY += colorResult.height;
1310
1519
  let swatchX;
1311
1520
  let swatchY;
1312
1521
  if (shouldWrapSwatches) {
1313
1522
  // Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
1314
1523
  swatchX = x;
1315
- swatchY = colorResult.lastLineY + otherFontSize + lineGap;
1316
- cursorY += colorResult.height + otherFontSize + lineGap;
1524
+ swatchY = cursorY;
1525
+ // Render swatches (đã kiểm tra overflow ở trên)
1526
+ drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1527
+ cursorY = swatchY + swatchH;
1317
1528
  }
1318
1529
  else {
1319
1530
  // Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
1320
1531
  swatchX = swatchesStartX;
1321
1532
  swatchY = colorResult.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
1322
- cursorY += colorResult.height;
1323
- }
1324
- drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1325
- if (shouldWrapSwatches) {
1326
- cursorY += swatchH;
1533
+ // Render swatches (đã kiểm tra overflow ở trên)
1534
+ drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
1535
+ // cursorY đã được cập nhật ở trên
1327
1536
  }
1328
1537
  }
1329
1538
  ctx.restore();