html2canvas-pro 1.6.7 → 2.0.0

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.
Files changed (122) hide show
  1. package/README.md +1 -0
  2. package/demo/image-smoothing-demo.html +256 -0
  3. package/demo/refactoring-test.html +602 -0
  4. package/dist/html2canvas-pro.esm.js +2788 -1321
  5. package/dist/html2canvas-pro.esm.js.map +1 -1
  6. package/dist/html2canvas-pro.js +2791 -1320
  7. package/dist/html2canvas-pro.js.map +1 -1
  8. package/dist/html2canvas-pro.min.js +5 -4
  9. package/dist/lib/__tests__/index.js +8 -2
  10. package/dist/lib/__tests__/index.js.map +1 -1
  11. package/dist/lib/config.js +72 -0
  12. package/dist/lib/config.js.map +1 -0
  13. package/dist/lib/core/__tests__/cache-storage.js +6 -3
  14. package/dist/lib/core/__tests__/cache-storage.js.map +1 -1
  15. package/dist/lib/core/__tests__/cache-storage.test.js +158 -0
  16. package/dist/lib/core/__tests__/cache-storage.test.js.map +1 -0
  17. package/dist/lib/core/__tests__/validator.js +296 -0
  18. package/dist/lib/core/__tests__/validator.js.map +1 -0
  19. package/dist/lib/core/cache-storage.js +130 -11
  20. package/dist/lib/core/cache-storage.js.map +1 -1
  21. package/dist/lib/core/context.js +5 -2
  22. package/dist/lib/core/context.js.map +1 -1
  23. package/dist/lib/core/debugger.js +3 -0
  24. package/dist/lib/core/debugger.js.map +1 -1
  25. package/dist/lib/core/origin-checker.js +54 -0
  26. package/dist/lib/core/origin-checker.js.map +1 -0
  27. package/dist/lib/core/performance-monitor.js +208 -0
  28. package/dist/lib/core/performance-monitor.js.map +1 -0
  29. package/dist/lib/core/validator.js +501 -0
  30. package/dist/lib/core/validator.js.map +1 -0
  31. package/dist/lib/css/index.js +2 -0
  32. package/dist/lib/css/index.js.map +1 -1
  33. package/dist/lib/css/property-descriptors/__tests__/background-tests.js +7 -1
  34. package/dist/lib/css/property-descriptors/__tests__/background-tests.js.map +1 -1
  35. package/dist/lib/css/property-descriptors/__tests__/image-rendering-integration.test.js +142 -0
  36. package/dist/lib/css/property-descriptors/__tests__/image-rendering-integration.test.js.map +1 -0
  37. package/dist/lib/css/property-descriptors/__tests__/image-rendering-performance.test.js +167 -0
  38. package/dist/lib/css/property-descriptors/__tests__/image-rendering-performance.test.js.map +1 -0
  39. package/dist/lib/css/property-descriptors/__tests__/image-rendering.test.js +61 -0
  40. package/dist/lib/css/property-descriptors/__tests__/image-rendering.test.js.map +1 -0
  41. package/dist/lib/css/property-descriptors/image-rendering.js +34 -0
  42. package/dist/lib/css/property-descriptors/image-rendering.js.map +1 -0
  43. package/dist/lib/css/types/__tests__/image-tests.js +7 -1
  44. package/dist/lib/css/types/__tests__/image-tests.js.map +1 -1
  45. package/dist/lib/css/types/color-math.js +26 -0
  46. package/dist/lib/css/types/color-math.js.map +1 -0
  47. package/dist/lib/css/types/color-spaces/srgb.js +6 -6
  48. package/dist/lib/css/types/color-spaces/srgb.js.map +1 -1
  49. package/dist/lib/css/types/color-utilities.js +13 -22
  50. package/dist/lib/css/types/color-utilities.js.map +1 -1
  51. package/dist/lib/dom/__tests__/dom-normalizer.test.js +113 -0
  52. package/dist/lib/dom/__tests__/dom-normalizer.test.js.map +1 -0
  53. package/dist/lib/dom/__tests__/element-container.test.js +109 -0
  54. package/dist/lib/dom/__tests__/element-container.test.js.map +1 -0
  55. package/dist/lib/dom/document-cloner.js +3 -3
  56. package/dist/lib/dom/document-cloner.js.map +1 -1
  57. package/dist/lib/dom/dom-normalizer.js +80 -0
  58. package/dist/lib/dom/dom-normalizer.js.map +1 -0
  59. package/dist/lib/dom/element-container.js +32 -15
  60. package/dist/lib/dom/element-container.js.map +1 -1
  61. package/dist/lib/dom/node-parser.js +16 -20
  62. package/dist/lib/dom/node-parser.js.map +1 -1
  63. package/dist/lib/dom/node-type-guards.js +44 -0
  64. package/dist/lib/dom/node-type-guards.js.map +1 -0
  65. package/dist/lib/dom/replaced-elements/iframe-element-container.js +5 -4
  66. package/dist/lib/dom/replaced-elements/iframe-element-container.js.map +1 -1
  67. package/dist/lib/index.js +148 -41
  68. package/dist/lib/index.js.map +1 -1
  69. package/dist/lib/render/canvas/__tests__/background-renderer.test.js +65 -0
  70. package/dist/lib/render/canvas/__tests__/background-renderer.test.js.map +1 -0
  71. package/dist/lib/render/canvas/__tests__/border-renderer.test.js +23 -0
  72. package/dist/lib/render/canvas/__tests__/border-renderer.test.js.map +1 -0
  73. package/dist/lib/render/canvas/__tests__/effects-renderer.test.js +30 -0
  74. package/dist/lib/render/canvas/__tests__/effects-renderer.test.js.map +1 -0
  75. package/dist/lib/render/canvas/__tests__/text-renderer.test.js +63 -0
  76. package/dist/lib/render/canvas/__tests__/text-renderer.test.js.map +1 -0
  77. package/dist/lib/render/canvas/background-renderer.js +222 -0
  78. package/dist/lib/render/canvas/background-renderer.js.map +1 -0
  79. package/dist/lib/render/canvas/border-renderer.js +185 -0
  80. package/dist/lib/render/canvas/border-renderer.js.map +1 -0
  81. package/dist/lib/render/canvas/canvas-renderer.js +61 -689
  82. package/dist/lib/render/canvas/canvas-renderer.js.map +1 -1
  83. package/dist/lib/render/canvas/effects-renderer.js +89 -0
  84. package/dist/lib/render/canvas/effects-renderer.js.map +1 -0
  85. package/dist/lib/render/canvas/text-renderer.js +508 -0
  86. package/dist/lib/render/canvas/text-renderer.js.map +1 -0
  87. package/dist/lib/render/renderer-interface.js +3 -0
  88. package/dist/lib/render/renderer-interface.js.map +1 -0
  89. package/dist/types/config.d.ts +54 -0
  90. package/dist/types/core/__tests__/cache-storage.test.d.ts +1 -0
  91. package/dist/types/core/__tests__/validator.d.ts +1 -0
  92. package/dist/types/core/cache-storage.d.ts +42 -1
  93. package/dist/types/core/context.d.ts +5 -1
  94. package/dist/types/core/origin-checker.d.ts +33 -0
  95. package/dist/types/core/performance-monitor.d.ts +131 -0
  96. package/dist/types/core/validator.d.ts +132 -0
  97. package/dist/types/css/index.d.ts +2 -0
  98. package/dist/types/css/property-descriptors/__tests__/image-rendering-integration.test.d.ts +1 -0
  99. package/dist/types/css/property-descriptors/__tests__/image-rendering-performance.test.d.ts +1 -0
  100. package/dist/types/css/property-descriptors/__tests__/image-rendering.test.d.ts +1 -0
  101. package/dist/types/css/property-descriptors/image-rendering.d.ts +8 -0
  102. package/dist/types/css/types/color-math.d.ts +12 -0
  103. package/dist/types/css/types/color-utilities.d.ts +2 -3
  104. package/dist/types/dom/__tests__/dom-normalizer.test.d.ts +1 -0
  105. package/dist/types/dom/__tests__/element-container.test.d.ts +1 -0
  106. package/dist/types/dom/dom-normalizer.d.ts +43 -0
  107. package/dist/types/dom/element-container.d.ts +20 -1
  108. package/dist/types/dom/node-parser.d.ts +2 -7
  109. package/dist/types/dom/node-type-guards.d.ts +33 -0
  110. package/dist/types/dom/replaced-elements/iframe-element-container.d.ts +4 -1
  111. package/dist/types/index.d.ts +48 -3
  112. package/dist/types/render/canvas/__tests__/background-renderer.test.d.ts +1 -0
  113. package/dist/types/render/canvas/__tests__/border-renderer.test.d.ts +1 -0
  114. package/dist/types/render/canvas/__tests__/effects-renderer.test.d.ts +1 -0
  115. package/dist/types/render/canvas/__tests__/text-renderer.test.d.ts +1 -0
  116. package/dist/types/render/canvas/background-renderer.d.ts +87 -0
  117. package/dist/types/render/canvas/border-renderer.d.ts +67 -0
  118. package/dist/types/render/canvas/canvas-renderer.d.ts +19 -23
  119. package/dist/types/render/canvas/effects-renderer.d.ts +64 -0
  120. package/dist/types/render/canvas/text-renderer.d.ts +57 -0
  121. package/dist/types/render/renderer-interface.d.ts +26 -0
  122. package/package.json +2 -1
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * html2canvas-pro 1.6.7 <https://yorickshan.github.io/html2canvas-pro/>
2
+ * html2canvas-pro 2.0.0 <https://yorickshan.github.io/html2canvas-pro/>
3
3
  * Copyright (c) 2024-present yorickshan and html2canvas-pro contributors
4
4
  * Released under MIT License
5
5
  */
@@ -1499,6 +1499,97 @@ const isEndingTokenFor = (token, type) => {
1499
1499
  return type === 2 /* TokenType.LEFT_PARENTHESIS_TOKEN */ && token.type === 3 /* TokenType.RIGHT_PARENTHESIS_TOKEN */;
1500
1500
  };
1501
1501
 
1502
+ /**
1503
+ * Color mathematics utilities
1504
+ * Extracted to break circular dependency between srgb.ts and color-utilities.ts
1505
+ */
1506
+ /**
1507
+ * Clamp a value between min and max
1508
+ */
1509
+ const clamp = (value, min, max) => {
1510
+ return Math.min(Math.max(value, min), max);
1511
+ };
1512
+ /**
1513
+ * Multiply two 3x3 matrices
1514
+ */
1515
+ const multiplyMatrices = (A, B) => {
1516
+ return [
1517
+ A[0] * B[0] + A[1] * B[1] + A[2] * B[2],
1518
+ A[3] * B[0] + A[4] * B[1] + A[5] * B[2],
1519
+ A[6] * B[0] + A[7] * B[1] + A[8] * B[2]
1520
+ ];
1521
+ };
1522
+
1523
+ /**
1524
+ * SRGB related functions
1525
+ */
1526
+ /**
1527
+ * Convert XYZ to linear-light sRGB
1528
+ *
1529
+ * @param xyz
1530
+ */
1531
+ const xyz2rgbLinear = (xyz) => {
1532
+ return multiplyMatrices([
1533
+ 3.2409699419045226, -1.537383177570094, -0.4986107602930034, -0.9692436362808796, 1.8759675015077202,
1534
+ 0.04155505740717559, 0.05563007969699366, -0.20397695888897652, 1.0569715142428786
1535
+ ], xyz);
1536
+ };
1537
+ /**
1538
+ * Convert XYZ to linear-light sRGB
1539
+ *
1540
+ * @param xyz
1541
+ */
1542
+ const rgbLinear2xyz = (xyz) => {
1543
+ return multiplyMatrices([
1544
+ 0.41239079926595934, 0.357584339383878, 0.1804807884018343, 0.21263900587151027, 0.715168678767756,
1545
+ 0.07219231536073371, 0.01933081871559182, 0.11919477979462598, 0.9505321522496607
1546
+ ], xyz);
1547
+ };
1548
+ /**
1549
+ * Convert sRGB to RGB
1550
+ *
1551
+ * @param rgb
1552
+ */
1553
+ const srgbLinear2rgb = (rgb) => {
1554
+ return rgb.map((c) => {
1555
+ const sign = c < 0 ? -1 : 1, abs = Math.abs(c);
1556
+ return abs > 0.0031308 ? sign * (1.055 * abs ** (1 / 2.4) - 0.055) : 12.92 * c;
1557
+ });
1558
+ };
1559
+ /**
1560
+ * Convert RGB to sRGB
1561
+ *
1562
+ * @param rgb
1563
+ */
1564
+ const rgb2rgbLinear = (rgb) => {
1565
+ return rgb.map((c) => {
1566
+ const sign = c < 0 ? -1 : 1, abs = Math.abs(c);
1567
+ return abs <= 0.04045 ? c / 12.92 : sign * ((abs + 0.055) / 1.055) ** 2.4;
1568
+ });
1569
+ };
1570
+ /**
1571
+ * XYZ to SRGB
1572
+ *
1573
+ * @param args
1574
+ */
1575
+ const srgbFromXYZ = (args) => {
1576
+ const [r, g, b] = srgbLinear2rgb(xyz2rgbLinear([args[0], args[1], args[2]]));
1577
+ return [r, g, b, args[3]];
1578
+ };
1579
+ /**
1580
+ * XYZ to SRGB-Linear
1581
+ * @param args
1582
+ */
1583
+ const srgbLinearFromXYZ = (args) => {
1584
+ const [r, g, b] = xyz2rgbLinear([args[0], args[1], args[2]]);
1585
+ return [
1586
+ clamp(Math.round(r * 255), 0, 255),
1587
+ clamp(Math.round(g * 255), 0, 255),
1588
+ clamp(Math.round(b * 255), 0, 255),
1589
+ args[3]
1590
+ ];
1591
+ };
1592
+
1502
1593
  const isLength = (token) => token.type === 17 /* TokenType.NUMBER_TOKEN */ || token.type === 15 /* TokenType.DIMENSION_TOKEN */;
1503
1594
 
1504
1595
  const isLengthPercentage = (token) => token.type === 16 /* TokenType.PERCENTAGE_TOKEN */ || isLength(token);
@@ -1716,16 +1807,6 @@ const getTokenColorValue = (token, i) => {
1716
1807
  return 0;
1717
1808
  };
1718
1809
  const isRelativeTransform = (tokens) => (tokens[0].type === 20 /* TokenType.IDENT_TOKEN */ ? tokens[0].value : 'unknown') === 'from';
1719
- const clamp = (value, min, max) => {
1720
- return Math.min(Math.max(value, min), max);
1721
- };
1722
- const multiplyMatrices = (A, B) => {
1723
- return [
1724
- A[0] * B[0] + A[1] * B[1] + A[2] * B[2],
1725
- A[3] * B[0] + A[4] * B[1] + A[5] * B[2],
1726
- A[6] * B[0] + A[7] * B[1] + A[8] * B[2]
1727
- ];
1728
- };
1729
1810
  const packSrgb = (args) => {
1730
1811
  return pack(clamp(Math.round(args[0] * 255), 0, 255), clamp(Math.round(args[1] * 255), 0, 255), clamp(Math.round(args[2] * 255), 0, 255), clamp(args[3], 0, 1));
1731
1812
  };
@@ -2008,76 +2089,6 @@ const convertXyz50 = (args) => {
2008
2089
  return packXYZ([xyz[0], xyz[1], xyz[2], args[3]]);
2009
2090
  };
2010
2091
 
2011
- /**
2012
- * SRGB related functions
2013
- */
2014
- /**
2015
- * Convert XYZ to linear-light sRGB
2016
- *
2017
- * @param xyz
2018
- */
2019
- const xyz2rgbLinear = (xyz) => {
2020
- return multiplyMatrices([
2021
- 3.2409699419045226, -1.537383177570094, -0.4986107602930034, -0.9692436362808796, 1.8759675015077202,
2022
- 0.04155505740717559, 0.05563007969699366, -0.20397695888897652, 1.0569715142428786
2023
- ], xyz);
2024
- };
2025
- /**
2026
- * Convert XYZ to linear-light sRGB
2027
- *
2028
- * @param xyz
2029
- */
2030
- const rgbLinear2xyz = (xyz) => {
2031
- return multiplyMatrices([
2032
- 0.41239079926595934, 0.357584339383878, 0.1804807884018343, 0.21263900587151027, 0.715168678767756,
2033
- 0.07219231536073371, 0.01933081871559182, 0.11919477979462598, 0.9505321522496607
2034
- ], xyz);
2035
- };
2036
- /**
2037
- * Convert sRGB to RGB
2038
- *
2039
- * @param rgb
2040
- */
2041
- const srgbLinear2rgb = (rgb) => {
2042
- return rgb.map((c) => {
2043
- const sign = c < 0 ? -1 : 1, abs = Math.abs(c);
2044
- return abs > 0.0031308 ? sign * (1.055 * abs ** (1 / 2.4) - 0.055) : 12.92 * c;
2045
- });
2046
- };
2047
- /**
2048
- * Convert RGB to sRGB
2049
- *
2050
- * @param rgb
2051
- */
2052
- const rgb2rgbLinear = (rgb) => {
2053
- return rgb.map((c) => {
2054
- const sign = c < 0 ? -1 : 1, abs = Math.abs(c);
2055
- return abs <= 0.04045 ? c / 12.92 : sign * ((abs + 0.055) / 1.055) ** 2.4;
2056
- });
2057
- };
2058
- /**
2059
- * XYZ to SRGB
2060
- *
2061
- * @param args
2062
- */
2063
- const srgbFromXYZ = (args) => {
2064
- const [r, g, b] = srgbLinear2rgb(xyz2rgbLinear([args[0], args[1], args[2]]));
2065
- return [r, g, b, args[3]];
2066
- };
2067
- /**
2068
- * XYZ to SRGB-Linear
2069
- * @param args
2070
- */
2071
- const srgbLinearFromXYZ = (args) => {
2072
- const [r, g, b] = xyz2rgbLinear([args[0], args[1], args[2]]);
2073
- return [
2074
- clamp(Math.round(r * 255), 0, 255),
2075
- clamp(Math.round(g * 255), 0, 255),
2076
- clamp(Math.round(b * 255), 0, 255),
2077
- args[3]
2078
- ];
2079
- };
2080
-
2081
2092
  /**
2082
2093
  * Display-P3 related functions
2083
2094
  */
@@ -4553,6 +4564,37 @@ const textOverflow = {
4553
4564
  }
4554
4565
  };
4555
4566
 
4567
+ var IMAGE_RENDERING;
4568
+ (function (IMAGE_RENDERING) {
4569
+ IMAGE_RENDERING[IMAGE_RENDERING["AUTO"] = 0] = "AUTO";
4570
+ IMAGE_RENDERING[IMAGE_RENDERING["CRISP_EDGES"] = 1] = "CRISP_EDGES";
4571
+ IMAGE_RENDERING[IMAGE_RENDERING["PIXELATED"] = 2] = "PIXELATED";
4572
+ IMAGE_RENDERING[IMAGE_RENDERING["SMOOTH"] = 3] = "SMOOTH";
4573
+ })(IMAGE_RENDERING || (IMAGE_RENDERING = {}));
4574
+ const imageRendering = {
4575
+ name: 'image-rendering',
4576
+ initialValue: 'auto',
4577
+ prefix: false,
4578
+ type: 2 /* PropertyDescriptorParsingType.IDENT_VALUE */,
4579
+ parse: (_context, value) => {
4580
+ switch (value.toLowerCase()) {
4581
+ case 'crisp-edges':
4582
+ case '-webkit-crisp-edges':
4583
+ case '-moz-crisp-edges':
4584
+ return IMAGE_RENDERING.CRISP_EDGES;
4585
+ case 'pixelated':
4586
+ case '-webkit-optimize-contrast':
4587
+ return IMAGE_RENDERING.PIXELATED;
4588
+ case 'smooth':
4589
+ case 'high-quality':
4590
+ return IMAGE_RENDERING.SMOOTH;
4591
+ case 'auto':
4592
+ default:
4593
+ return IMAGE_RENDERING.AUTO;
4594
+ }
4595
+ }
4596
+ };
4597
+
4556
4598
  class CSSParsedDeclaration {
4557
4599
  constructor(context, declaration) {
4558
4600
  this.animationDuration = parse(context, duration, declaration.animationDuration);
@@ -4629,6 +4671,7 @@ class CSSParsedDeclaration {
4629
4671
  this.wordBreak = parse(context, wordBreak, declaration.wordBreak);
4630
4672
  this.zIndex = parse(context, zIndex, declaration.zIndex);
4631
4673
  this.objectFit = parse(context, objectFit, declaration.objectFit);
4674
+ this.imageRendering = parse(context, imageRendering, declaration.imageRendering);
4632
4675
  }
4633
4676
  isVisible() {
4634
4677
  return this.display > 0 && this.opacity > 0 && this.visibility === 0 /* VISIBILITY.VISIBLE */;
@@ -4705,8 +4748,45 @@ const parse = (context, descriptor, style) => {
4705
4748
  }
4706
4749
  };
4707
4750
 
4751
+ /**
4752
+ * DOM Node Type Guards
4753
+ * Extracted to break circular dependencies
4754
+ */
4755
+ /**
4756
+ * Check if node is an Element
4757
+ */
4758
+ const isElementNode = (node) => node.nodeType === Node.ELEMENT_NODE;
4759
+ /**
4760
+ * Check if node is a Text node
4761
+ */
4762
+ const isTextNode = (node) => node.nodeType === Node.TEXT_NODE;
4763
+ /**
4764
+ * Check if element is an SVG element
4765
+ */
4766
+ const isSVGElementNode = (element) => typeof element.className === 'object';
4767
+ /**
4768
+ * Check if node is an HTML element
4769
+ */
4770
+ const isHTMLElementNode = (node) => isElementNode(node) && typeof node.style !== 'undefined' && !isSVGElementNode(node);
4771
+ /**
4772
+ * Check if node is an LI element
4773
+ */
4774
+ const isLIElement = (node) => node.tagName === 'LI';
4775
+ /**
4776
+ * Check if node is an OL element
4777
+ */
4778
+ const isOLElement = (node) => node.tagName === 'OL';
4779
+ /**
4780
+ * Check if element is a custom element
4781
+ * Custom elements must have a hyphen and cannot be SVG elements
4782
+ */
4783
+ const isCustomElement = (element) => !isSVGElementNode(element) && element.tagName.indexOf('-') > 0;
4784
+
4708
4785
  const elementDebuggerAttribute = 'data-html2canvas-debug';
4709
4786
  const getElementDebugType = (element) => {
4787
+ if (typeof element.getAttribute !== 'function') {
4788
+ return 0 /* DebuggerType.NONE */;
4789
+ }
4710
4790
  const attribute = element.getAttribute(elementDebuggerAttribute);
4711
4791
  switch (attribute) {
4712
4792
  case 'all':
@@ -4726,8 +4806,83 @@ const isDebugging = (element, type) => {
4726
4806
  return elementType === 1 /* DebuggerType.ALL */ || type === elementType;
4727
4807
  };
4728
4808
 
4809
+ /**
4810
+ * DOM Normalizer
4811
+ * Handles DOM side effects that need to happen before rendering
4812
+ * Extracted from ElementContainer to follow SRP
4813
+ */
4814
+ /**
4815
+ * Normalize element styles for accurate rendering
4816
+ * This includes disabling animations and resetting transforms
4817
+ */
4818
+ class DOMNormalizer {
4819
+ /**
4820
+ * Normalize a single element and return original styles
4821
+ *
4822
+ * @param element - Element to normalize
4823
+ * @param styles - Parsed CSS styles
4824
+ * @returns Original styles map for restoration
4825
+ */
4826
+ static normalizeElement(element, styles) {
4827
+ const originalStyles = {};
4828
+ if (!isHTMLElementNode(element)) {
4829
+ return originalStyles;
4830
+ }
4831
+ // Disable animations to capture static state
4832
+ if (styles.animationDuration.some((duration) => duration > 0)) {
4833
+ originalStyles.animationDuration = element.style.animationDuration;
4834
+ element.style.animationDuration = '0s';
4835
+ }
4836
+ // Reset transform for accurate bounds calculation
4837
+ // getBoundingClientRect takes transforms into account
4838
+ if (styles.transform !== null) {
4839
+ originalStyles.transform = element.style.transform;
4840
+ element.style.transform = 'none';
4841
+ }
4842
+ // Reset rotate property similarly to transform
4843
+ if (styles.rotate !== null) {
4844
+ originalStyles.rotate = element.style.rotate;
4845
+ element.style.rotate = 'none';
4846
+ }
4847
+ return originalStyles;
4848
+ }
4849
+ /**
4850
+ * Normalize element and its descendants recursively
4851
+ *
4852
+ * @param element - Element to normalize
4853
+ * @param styles - Parsed CSS styles
4854
+ * @returns Original styles map for restoration
4855
+ */
4856
+ static normalizeTree(element, styles) {
4857
+ return this.normalizeElement(element, styles);
4858
+ // Could add recursive normalization here if needed
4859
+ // For now, only normalize the element itself
4860
+ }
4861
+ /**
4862
+ * Restore element styles after rendering
4863
+ *
4864
+ * @param element - Element to restore
4865
+ * @param originalStyles - Original styles to restore
4866
+ */
4867
+ static restoreElement(element, originalStyles) {
4868
+ if (!isHTMLElementNode(element)) {
4869
+ return;
4870
+ }
4871
+ // Restore each property that was saved
4872
+ if (originalStyles.animationDuration !== undefined) {
4873
+ element.style.animationDuration = originalStyles.animationDuration;
4874
+ }
4875
+ if (originalStyles.transform !== undefined) {
4876
+ element.style.transform = originalStyles.transform;
4877
+ }
4878
+ if (originalStyles.rotate !== undefined) {
4879
+ element.style.rotate = originalStyles.rotate;
4880
+ }
4881
+ }
4882
+ }
4883
+
4729
4884
  class ElementContainer {
4730
- constructor(context, element) {
4885
+ constructor(context, element, options = {}) {
4731
4886
  this.context = context;
4732
4887
  this.textNodes = [];
4733
4888
  this.elements = [];
@@ -4735,25 +4890,41 @@ class ElementContainer {
4735
4890
  if (isDebugging(element, 3 /* DebuggerType.PARSE */)) {
4736
4891
  debugger;
4737
4892
  }
4738
- this.styles = new CSSParsedDeclaration(context, window.getComputedStyle(element, null));
4739
- if (isHTMLElementNode(element)) {
4740
- if (this.styles.animationDuration.some((duration) => duration > 0)) {
4741
- element.style.animationDuration = '0s';
4742
- }
4743
- if (this.styles.transform !== null) {
4744
- // getBoundingClientRect takes transforms into account
4745
- element.style.transform = 'none';
4746
- }
4747
- if (this.styles.rotate !== null) {
4748
- // Handle rotate property similarly to transform
4749
- element.style.rotate = 'none';
4750
- }
4893
+ this.styles = new CSSParsedDeclaration(context, context.config.window.getComputedStyle(element, null));
4894
+ // Side effects moved to DOMNormalizer (can be disabled via options)
4895
+ const shouldNormalize = options.normalizeDom !== false; // Default: true
4896
+ if (shouldNormalize && isHTMLElementNode(element)) {
4897
+ this.originalStyles = DOMNormalizer.normalizeElement(element, this.styles);
4898
+ this.originalElement = element; // Save reference for restoration
4751
4899
  }
4752
4900
  this.bounds = parseBounds(this.context, element);
4753
4901
  if (isDebugging(element, 4 /* DebuggerType.RENDER */)) {
4754
4902
  this.flags |= 16 /* FLAGS.DEBUG_RENDER */;
4755
4903
  }
4756
4904
  }
4905
+ /**
4906
+ * Restore original element styles (if normalized)
4907
+ * Call this after rendering is complete to clean up DOM state
4908
+ */
4909
+ restore() {
4910
+ if (this.originalStyles && this.originalElement) {
4911
+ DOMNormalizer.restoreElement(this.originalElement, this.originalStyles);
4912
+ // Clear references to prevent memory leaks
4913
+ this.originalStyles = undefined;
4914
+ this.originalElement = undefined;
4915
+ }
4916
+ }
4917
+ /**
4918
+ * Recursively restore all elements in the tree
4919
+ * Call this on the root container after rendering is complete
4920
+ */
4921
+ restoreTree() {
4922
+ this.restore();
4923
+ // Recursively restore all child elements
4924
+ for (const child of this.elements) {
4925
+ child.restoreTree();
4926
+ }
4927
+ }
4757
4928
  }
4758
4929
 
4759
4930
  /*
@@ -5607,17 +5778,19 @@ class TextareaElementContainer extends ElementContainer {
5607
5778
  }
5608
5779
 
5609
5780
  class IFrameElementContainer extends ElementContainer {
5610
- constructor(context, iframe) {
5781
+ constructor(context, iframe, parseTreeFn) {
5611
5782
  super(context, iframe);
5612
5783
  this.src = iframe.src;
5613
5784
  this.width = parseInt(iframe.width, 10) || 0;
5614
5785
  this.height = parseInt(iframe.height, 10) || 0;
5615
5786
  this.backgroundColor = this.styles.backgroundColor;
5787
+ this.parseTreeFn = parseTreeFn;
5616
5788
  try {
5617
5789
  if (iframe.contentWindow &&
5618
5790
  iframe.contentWindow.document &&
5619
- iframe.contentWindow.document.documentElement) {
5620
- this.tree = parseTree(context, iframe.contentWindow.document.documentElement);
5791
+ iframe.contentWindow.document.documentElement &&
5792
+ this.parseTreeFn) {
5793
+ this.tree = this.parseTreeFn(context, iframe.contentWindow.document.documentElement);
5621
5794
  // http://www.w3.org/TR/css3-background/#special-backgrounds
5622
5795
  const documentBackgroundColor = iframe.contentWindow.document.documentElement
5623
5796
  ? parseColor(context, getComputedStyle(iframe.contentWindow.document.documentElement).backgroundColor)
@@ -5701,7 +5874,7 @@ const createContainer = (context, element) => {
5701
5874
  return new TextareaElementContainer(context, element);
5702
5875
  }
5703
5876
  if (isIFrameElement(element)) {
5704
- return new IFrameElementContainer(context, element);
5877
+ return new IFrameElementContainer(context, element, parseTree);
5705
5878
  }
5706
5879
  return new ElementContainer(context, element);
5707
5880
  };
@@ -5730,12 +5903,7 @@ const createsStackingContext = (styles) => {
5730
5903
  contains(styles.display, 536870912 /* DISPLAY.INLINE_GRID */) ||
5731
5904
  contains(styles.display, 134217728 /* DISPLAY.INLINE_TABLE */));
5732
5905
  };
5733
- const isTextNode = (node) => node.nodeType === Node.TEXT_NODE;
5734
- const isElementNode = (node) => node.nodeType === Node.ELEMENT_NODE;
5735
- const isHTMLElementNode = (node) => isElementNode(node) && typeof node.style !== 'undefined' && !isSVGElementNode(node);
5736
- const isSVGElementNode = (element) => typeof element.className === 'object';
5737
- const isLIElement = (node) => node.tagName === 'LI';
5738
- const isOLElement = (node) => node.tagName === 'OL';
5906
+ // Type guards moved to node-type-guards.ts and re-exported above
5739
5907
  const isInputElement = (node) => node.tagName === 'INPUT';
5740
5908
  const isHTMLElement = (node) => node.tagName === 'HTML';
5741
5909
  const isSVGElement = (node) => node.tagName === 'svg';
@@ -5750,7 +5918,7 @@ const isTextareaElement = (node) => node.tagName === 'TEXTAREA';
5750
5918
  const isSelectElement = (node) => node.tagName === 'SELECT';
5751
5919
  const isSlotElement = (node) => node.tagName === 'SLOT';
5752
5920
  // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
5753
- const isCustomElement = (node) => node.tagName.indexOf('-') > 0;
5921
+ // isCustomElement moved to node-type-guards.ts and re-exported above
5754
5922
 
5755
5923
  class CounterState {
5756
5924
  constructor() {
@@ -6158,7 +6326,7 @@ class DocumentCloner {
6158
6326
  toIFrame(ownerDocument, windowSize) {
6159
6327
  const iframe = createIFrameContainer(ownerDocument, windowSize, this.options.iframeContainer);
6160
6328
  if (!iframe.contentWindow) {
6161
- return Promise.reject(`Unable to find iframe window`);
6329
+ throw new Error('Unable to find iframe window');
6162
6330
  }
6163
6331
  const scrollX = ownerDocument.defaultView.pageXOffset;
6164
6332
  const scrollY = ownerDocument.defaultView.pageYOffset;
@@ -6180,7 +6348,7 @@ class DocumentCloner {
6180
6348
  const onclone = this.options.onclone;
6181
6349
  const referenceElement = this.clonedReferenceElement;
6182
6350
  if (typeof referenceElement === 'undefined') {
6183
- return Promise.reject(`Error finding the ${this.referenceElement.nodeName} in the cloned document`);
6351
+ throw new Error(`Error finding the ${this.referenceElement.nodeName} in the cloned document`);
6184
6352
  }
6185
6353
  if (documentClone.fonts && documentClone.fonts.ready) {
6186
6354
  await documentClone.fonts.ready;
@@ -6256,7 +6424,7 @@ class DocumentCloner {
6256
6424
  clone.loading = 'eager';
6257
6425
  }
6258
6426
  }
6259
- if (isCustomElement(clone)) {
6427
+ if (isCustomElement(clone) && !isSVGElementNode(clone)) {
6260
6428
  return this.createCustomElementClone(clone);
6261
6429
  }
6262
6430
  return clone;
@@ -6812,152 +6980,14 @@ const addBase = (targetELement, baseUri) => {
6812
6980
  headEle?.insertBefore(baseNode, headEle?.firstChild ?? null);
6813
6981
  };
6814
6982
 
6815
- class CacheStorage {
6816
- static getOrigin(url) {
6817
- const link = CacheStorage._link;
6818
- if (!link) {
6819
- return 'about:blank';
6820
- }
6821
- link.href = url;
6822
- link.href = link.href; // IE9, LOL! - http://jsfiddle.net/niklasvh/2e48b/
6823
- return link.protocol + link.hostname + link.port;
6824
- }
6825
- static isSameOrigin(src) {
6826
- return CacheStorage.getOrigin(src) === CacheStorage._origin;
6983
+ class Vector {
6984
+ constructor(x, y) {
6985
+ this.type = 0 /* PathType.VECTOR */;
6986
+ this.x = x;
6987
+ this.y = y;
6827
6988
  }
6828
- static setContext(window) {
6829
- CacheStorage._link = window.document.createElement('a');
6830
- CacheStorage._origin = CacheStorage.getOrigin(window.location.href);
6831
- }
6832
- }
6833
- CacheStorage._origin = 'about:blank';
6834
- class Cache {
6835
- constructor(context, _options) {
6836
- this.context = context;
6837
- this._options = _options;
6838
- this._cache = {};
6839
- }
6840
- addImage(src) {
6841
- const result = Promise.resolve();
6842
- if (this.has(src)) {
6843
- return result;
6844
- }
6845
- if (isBlobImage(src) || isRenderable(src)) {
6846
- (this._cache[src] = this.loadImage(src)).catch(() => {
6847
- // prevent unhandled rejection
6848
- });
6849
- return result;
6850
- }
6851
- return result;
6852
- }
6853
- match(src) {
6854
- return this._cache[src];
6855
- }
6856
- async loadImage(key) {
6857
- const isSameOrigin = typeof this._options.customIsSameOrigin === 'function'
6858
- ? await this._options.customIsSameOrigin(key, CacheStorage.isSameOrigin)
6859
- : CacheStorage.isSameOrigin(key);
6860
- const useCORS = !isInlineImage(key) && this._options.useCORS === true && FEATURES.SUPPORT_CORS_IMAGES && !isSameOrigin;
6861
- const useProxy = !isInlineImage(key) &&
6862
- !isSameOrigin &&
6863
- !isBlobImage(key) &&
6864
- typeof this._options.proxy === 'string' &&
6865
- FEATURES.SUPPORT_CORS_XHR &&
6866
- !useCORS;
6867
- if (!isSameOrigin &&
6868
- this._options.allowTaint === false &&
6869
- !isInlineImage(key) &&
6870
- !isBlobImage(key) &&
6871
- !useProxy &&
6872
- !useCORS) {
6873
- return;
6874
- }
6875
- let src = key;
6876
- if (useProxy) {
6877
- src = await this.proxy(src);
6878
- }
6879
- this.context.logger.debug(`Added image ${key.substring(0, 256)}`);
6880
- return await new Promise((resolve, reject) => {
6881
- const img = new Image();
6882
- img.onload = () => resolve(img);
6883
- img.onerror = reject;
6884
- //ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
6885
- if (isInlineBase64Image(src) || useCORS) {
6886
- img.crossOrigin = 'anonymous';
6887
- }
6888
- img.src = src;
6889
- if (img.complete === true) {
6890
- // Inline XML images may fail to parse, throwing an Error later on
6891
- setTimeout(() => resolve(img), 500);
6892
- }
6893
- if (this._options.imageTimeout > 0) {
6894
- setTimeout(() => reject(`Timed out (${this._options.imageTimeout}ms) loading image`), this._options.imageTimeout);
6895
- }
6896
- });
6897
- }
6898
- has(key) {
6899
- return typeof this._cache[key] !== 'undefined';
6900
- }
6901
- keys() {
6902
- return Promise.resolve(Object.keys(this._cache));
6903
- }
6904
- proxy(src) {
6905
- const proxy = this._options.proxy;
6906
- if (!proxy) {
6907
- throw new Error('No proxy defined');
6908
- }
6909
- const key = src.substring(0, 256);
6910
- return new Promise((resolve, reject) => {
6911
- const responseType = FEATURES.SUPPORT_RESPONSE_TYPE ? 'blob' : 'text';
6912
- const xhr = new XMLHttpRequest();
6913
- xhr.onload = () => {
6914
- if (xhr.status === 200) {
6915
- if (responseType === 'text') {
6916
- resolve(xhr.response);
6917
- }
6918
- else {
6919
- const reader = new FileReader();
6920
- reader.addEventListener('load', () => resolve(reader.result), false);
6921
- reader.addEventListener('error', (e) => reject(e), false);
6922
- reader.readAsDataURL(xhr.response);
6923
- }
6924
- }
6925
- else {
6926
- reject(`Failed to proxy resource ${key} with status code ${xhr.status}`);
6927
- }
6928
- };
6929
- xhr.onerror = reject;
6930
- const queryString = proxy.indexOf('?') > -1 ? '&' : '?';
6931
- xhr.open('GET', `${proxy}${queryString}url=${encodeURIComponent(src)}&responseType=${responseType}`);
6932
- if (responseType !== 'text' && xhr instanceof XMLHttpRequest) {
6933
- xhr.responseType = responseType;
6934
- }
6935
- if (this._options.imageTimeout) {
6936
- const timeout = this._options.imageTimeout;
6937
- xhr.timeout = timeout;
6938
- xhr.ontimeout = () => reject(`Timed out (${timeout}ms) proxying ${key}`);
6939
- }
6940
- xhr.send();
6941
- });
6942
- }
6943
- }
6944
- const INLINE_SVG = /^data:image\/svg\+xml/i;
6945
- const INLINE_BASE64 = /^data:image\/.*;base64,/i;
6946
- const INLINE_IMG = /^data:image\/.*/i;
6947
- const isRenderable = (src) => FEATURES.SUPPORT_SVG_DRAWING || !isSVG(src);
6948
- const isInlineImage = (src) => INLINE_IMG.test(src);
6949
- const isInlineBase64Image = (src) => INLINE_BASE64.test(src);
6950
- const isBlobImage = (src) => src.substr(0, 4) === 'blob';
6951
- const isSVG = (src) => src.substr(-3).toLowerCase() === 'svg' || INLINE_SVG.test(src);
6952
-
6953
- class Vector {
6954
- constructor(x, y) {
6955
- this.type = 0 /* PathType.VECTOR */;
6956
- this.x = x;
6957
- this.y = y;
6958
- }
6959
- add(deltaX, deltaY) {
6960
- return new Vector(this.x + deltaX, this.y + deltaY);
6989
+ add(deltaX, deltaY) {
6990
+ return new Vector(this.x + deltaX, this.y + deltaY);
6961
6991
  }
6962
6992
  }
6963
6993
 
@@ -7388,103 +7418,6 @@ const parseStackingContexts = (container) => {
7388
7418
  return root;
7389
7419
  };
7390
7420
 
7391
- const parsePathForBorder = (curves, borderSide) => {
7392
- switch (borderSide) {
7393
- case 0:
7394
- return createPathFromCurves(curves.topLeftBorderBox, curves.topLeftPaddingBox, curves.topRightBorderBox, curves.topRightPaddingBox);
7395
- case 1:
7396
- return createPathFromCurves(curves.topRightBorderBox, curves.topRightPaddingBox, curves.bottomRightBorderBox, curves.bottomRightPaddingBox);
7397
- case 2:
7398
- return createPathFromCurves(curves.bottomRightBorderBox, curves.bottomRightPaddingBox, curves.bottomLeftBorderBox, curves.bottomLeftPaddingBox);
7399
- case 3:
7400
- default:
7401
- return createPathFromCurves(curves.bottomLeftBorderBox, curves.bottomLeftPaddingBox, curves.topLeftBorderBox, curves.topLeftPaddingBox);
7402
- }
7403
- };
7404
- const parsePathForBorderDoubleOuter = (curves, borderSide) => {
7405
- switch (borderSide) {
7406
- case 0:
7407
- return createPathFromCurves(curves.topLeftBorderBox, curves.topLeftBorderDoubleOuterBox, curves.topRightBorderBox, curves.topRightBorderDoubleOuterBox);
7408
- case 1:
7409
- return createPathFromCurves(curves.topRightBorderBox, curves.topRightBorderDoubleOuterBox, curves.bottomRightBorderBox, curves.bottomRightBorderDoubleOuterBox);
7410
- case 2:
7411
- return createPathFromCurves(curves.bottomRightBorderBox, curves.bottomRightBorderDoubleOuterBox, curves.bottomLeftBorderBox, curves.bottomLeftBorderDoubleOuterBox);
7412
- case 3:
7413
- default:
7414
- return createPathFromCurves(curves.bottomLeftBorderBox, curves.bottomLeftBorderDoubleOuterBox, curves.topLeftBorderBox, curves.topLeftBorderDoubleOuterBox);
7415
- }
7416
- };
7417
- const parsePathForBorderDoubleInner = (curves, borderSide) => {
7418
- switch (borderSide) {
7419
- case 0:
7420
- return createPathFromCurves(curves.topLeftBorderDoubleInnerBox, curves.topLeftPaddingBox, curves.topRightBorderDoubleInnerBox, curves.topRightPaddingBox);
7421
- case 1:
7422
- return createPathFromCurves(curves.topRightBorderDoubleInnerBox, curves.topRightPaddingBox, curves.bottomRightBorderDoubleInnerBox, curves.bottomRightPaddingBox);
7423
- case 2:
7424
- return createPathFromCurves(curves.bottomRightBorderDoubleInnerBox, curves.bottomRightPaddingBox, curves.bottomLeftBorderDoubleInnerBox, curves.bottomLeftPaddingBox);
7425
- case 3:
7426
- default:
7427
- return createPathFromCurves(curves.bottomLeftBorderDoubleInnerBox, curves.bottomLeftPaddingBox, curves.topLeftBorderDoubleInnerBox, curves.topLeftPaddingBox);
7428
- }
7429
- };
7430
- const parsePathForBorderStroke = (curves, borderSide) => {
7431
- switch (borderSide) {
7432
- case 0:
7433
- return createStrokePathFromCurves(curves.topLeftBorderStroke, curves.topRightBorderStroke);
7434
- case 1:
7435
- return createStrokePathFromCurves(curves.topRightBorderStroke, curves.bottomRightBorderStroke);
7436
- case 2:
7437
- return createStrokePathFromCurves(curves.bottomRightBorderStroke, curves.bottomLeftBorderStroke);
7438
- case 3:
7439
- default:
7440
- return createStrokePathFromCurves(curves.bottomLeftBorderStroke, curves.topLeftBorderStroke);
7441
- }
7442
- };
7443
- const createStrokePathFromCurves = (outer1, outer2) => {
7444
- const path = [];
7445
- if (isBezierCurve(outer1)) {
7446
- path.push(outer1.subdivide(0.5, false));
7447
- }
7448
- else {
7449
- path.push(outer1);
7450
- }
7451
- if (isBezierCurve(outer2)) {
7452
- path.push(outer2.subdivide(0.5, true));
7453
- }
7454
- else {
7455
- path.push(outer2);
7456
- }
7457
- return path;
7458
- };
7459
- const createPathFromCurves = (outer1, inner1, outer2, inner2) => {
7460
- const path = [];
7461
- if (isBezierCurve(outer1)) {
7462
- path.push(outer1.subdivide(0.5, false));
7463
- }
7464
- else {
7465
- path.push(outer1);
7466
- }
7467
- if (isBezierCurve(outer2)) {
7468
- path.push(outer2.subdivide(0.5, true));
7469
- }
7470
- else {
7471
- path.push(outer2);
7472
- }
7473
- if (isBezierCurve(inner2)) {
7474
- path.push(inner2.subdivide(0.5, true).reverse());
7475
- }
7476
- else {
7477
- path.push(inner2);
7478
- }
7479
- if (isBezierCurve(inner1)) {
7480
- path.push(inner1.subdivide(0.5, false).reverse());
7481
- }
7482
- else {
7483
- path.push(inner1);
7484
- }
7485
- return path;
7486
- };
7487
-
7488
7421
  const paddingBox = (element) => {
7489
7422
  const bounds = element.bounds;
7490
7423
  const styles = element.styles;
@@ -7729,248 +7662,834 @@ class Renderer {
7729
7662
  }
7730
7663
  }
7731
7664
 
7732
- const MASK_OFFSET = 10000;
7733
- class CanvasRenderer extends Renderer {
7734
- constructor(context, options) {
7735
- super(context, options);
7736
- this._activeEffects = [];
7737
- this.canvas = options.canvas ? options.canvas : document.createElement('canvas');
7738
- this.ctx = this.canvas.getContext('2d');
7739
- if (!options.canvas) {
7740
- this.canvas.width = Math.floor(options.width * options.scale);
7741
- this.canvas.height = Math.floor(options.height * options.scale);
7742
- this.canvas.style.width = `${options.width}px`;
7743
- this.canvas.style.height = `${options.height}px`;
7744
- }
7745
- this.fontMetrics = new FontMetrics(document);
7746
- this.ctx.scale(this.options.scale, this.options.scale);
7747
- this.ctx.translate(-options.x, -options.y);
7748
- this.ctx.textBaseline = 'bottom';
7749
- this._activeEffects = [];
7750
- this.context.logger.debug(`Canvas renderer initialized (${options.width}x${options.height}) with scale ${options.scale}`);
7665
+ /**
7666
+ * Background Renderer
7667
+ *
7668
+ * Handles rendering of element backgrounds including:
7669
+ * - Background colors
7670
+ * - Background images (URL)
7671
+ * - Linear gradients
7672
+ * - Radial gradients
7673
+ * - Background patterns and repeats
7674
+ */
7675
+ /**
7676
+ * Background Renderer
7677
+ *
7678
+ * Specialized renderer for element backgrounds.
7679
+ * Extracted from CanvasRenderer to improve code organization and maintainability.
7680
+ */
7681
+ class BackgroundRenderer {
7682
+ constructor(deps) {
7683
+ this.ctx = deps.ctx;
7684
+ this.context = deps.context;
7685
+ this.canvas = deps.canvas;
7686
+ // Options stored in deps but not needed as instance property
7751
7687
  }
7752
- applyEffects(effects) {
7753
- while (this._activeEffects.length) {
7754
- this.popEffect();
7688
+ /**
7689
+ * Render background images for a container
7690
+ * Supports URL images, linear gradients, and radial gradients
7691
+ *
7692
+ * @param container - Element container with background styles
7693
+ */
7694
+ async renderBackgroundImage(container) {
7695
+ let index = container.styles.backgroundImage.length - 1;
7696
+ for (const backgroundImage of container.styles.backgroundImage.slice(0).reverse()) {
7697
+ if (backgroundImage.type === 0 /* CSSImageType.URL */) {
7698
+ await this.renderBackgroundURLImage(container, backgroundImage, index);
7699
+ }
7700
+ else if (isLinearGradient(backgroundImage)) {
7701
+ this.renderLinearGradient(container, backgroundImage, index);
7702
+ }
7703
+ else if (isRadialGradient(backgroundImage)) {
7704
+ this.renderRadialGradient(container, backgroundImage, index);
7705
+ }
7706
+ index--;
7755
7707
  }
7756
- effects.forEach((effect) => this.applyEffect(effect));
7757
7708
  }
7758
- applyEffect(effect) {
7759
- this.ctx.save();
7760
- if (isOpacityEffect(effect)) {
7761
- this.ctx.globalAlpha = effect.opacity;
7762
- }
7763
- if (isTransformEffect(effect)) {
7764
- this.ctx.translate(effect.offsetX, effect.offsetY);
7765
- this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
7766
- this.ctx.translate(-effect.offsetX, -effect.offsetY);
7767
- }
7768
- if (isClipEffect(effect)) {
7769
- this.path(effect.path);
7770
- this.ctx.clip();
7709
+ /**
7710
+ * Render a URL-based background image
7711
+ */
7712
+ async renderBackgroundURLImage(container, backgroundImage, index) {
7713
+ let image;
7714
+ const url = backgroundImage.url;
7715
+ try {
7716
+ image = await this.context.cache.match(url);
7771
7717
  }
7772
- this._activeEffects.push(effect);
7773
- }
7774
- popEffect() {
7775
- this._activeEffects.pop();
7776
- this.ctx.restore();
7777
- }
7778
- async renderStack(stack) {
7779
- const styles = stack.element.container.styles;
7780
- if (styles.isVisible()) {
7781
- await this.renderStackContent(stack);
7718
+ catch (e) {
7719
+ this.context.logger.error(`Error loading background-image ${url}`);
7720
+ }
7721
+ if (image) {
7722
+ const imageWidth = isNaN(image.width) || image.width === 0 ? 1 : image.width;
7723
+ const imageHeight = isNaN(image.height) || image.height === 0 ? 1 : image.height;
7724
+ const [path, x, y, width, height] = calculateBackgroundRendering(container, index, [
7725
+ imageWidth,
7726
+ imageHeight,
7727
+ imageWidth / imageHeight
7728
+ ]);
7729
+ const pattern = this.ctx.createPattern(this.resizeImage(image, width, height, container.styles.imageRendering), 'repeat');
7730
+ this.renderRepeat(path, pattern, x, y);
7782
7731
  }
7783
7732
  }
7784
- async renderNode(paint) {
7785
- if (contains(paint.container.flags, 16 /* FLAGS.DEBUG_RENDER */)) {
7786
- debugger;
7787
- }
7788
- if (paint.container.styles.isVisible()) {
7789
- await this.renderNodeBackgroundAndBorders(paint);
7790
- await this.renderNodeContent(paint);
7733
+ /**
7734
+ * Render a linear gradient background
7735
+ */
7736
+ renderLinearGradient(container, backgroundImage, index) {
7737
+ const [path, x, y, width, height] = calculateBackgroundRendering(container, index, [null, null, null]);
7738
+ const [lineLength, x0, x1, y0, y1] = calculateGradientDirection(backgroundImage.angle, width, height);
7739
+ const ownerDocument = this.canvas.ownerDocument ?? document;
7740
+ const canvas = ownerDocument.createElement('canvas');
7741
+ canvas.width = width;
7742
+ canvas.height = height;
7743
+ const ctx = canvas.getContext('2d');
7744
+ const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
7745
+ processColorStops(backgroundImage.stops, lineLength || 1).forEach((colorStop) => gradient.addColorStop(colorStop.stop, asString(colorStop.color)));
7746
+ ctx.fillStyle = gradient;
7747
+ ctx.fillRect(0, 0, width, height);
7748
+ if (width > 0 && height > 0) {
7749
+ const pattern = this.ctx.createPattern(canvas, 'repeat');
7750
+ this.renderRepeat(path, pattern, x, y);
7791
7751
  }
7792
7752
  }
7793
- renderTextWithLetterSpacing(text, letterSpacing, baseline) {
7794
- if (letterSpacing === 0) {
7795
- // Use alphabetic baseline for consistent text positioning across browsers
7796
- // Issue #129: text.bounds.top + text.bounds.height causes text to render too low
7797
- this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
7798
- }
7799
- else {
7800
- const letters = segmentGraphemes(text.text);
7801
- letters.reduce((left, letter) => {
7802
- this.ctx.fillText(letter, left, text.bounds.top + baseline);
7803
- return left + this.ctx.measureText(letter).width;
7804
- }, text.bounds.left);
7753
+ /**
7754
+ * Render a radial gradient background
7755
+ */
7756
+ renderRadialGradient(container, backgroundImage, index) {
7757
+ const [path, left, top, width, height] = calculateBackgroundRendering(container, index, [null, null, null]);
7758
+ const position = backgroundImage.position.length === 0 ? [FIFTY_PERCENT] : backgroundImage.position;
7759
+ const x = getAbsoluteValue(position[0], width);
7760
+ const y = getAbsoluteValue(position[position.length - 1], height);
7761
+ let [rx, ry] = calculateRadius(backgroundImage, x, y, width, height);
7762
+ // Handle edge case where radial gradient size is 0
7763
+ // Use a minimum value of 0.01 to ensure gradient is still rendered
7764
+ if (rx === 0 || ry === 0) {
7765
+ rx = Math.max(rx, 0.01);
7766
+ ry = Math.max(ry, 0.01);
7767
+ }
7768
+ if (rx > 0 && ry > 0) {
7769
+ const radialGradient = this.ctx.createRadialGradient(left + x, top + y, 0, left + x, top + y, rx);
7770
+ processColorStops(backgroundImage.stops, rx * 2).forEach((colorStop) => radialGradient.addColorStop(colorStop.stop, asString(colorStop.color)));
7771
+ this.path(path);
7772
+ this.ctx.fillStyle = radialGradient;
7773
+ if (rx !== ry) {
7774
+ // transforms for elliptical radial gradient
7775
+ const midX = container.bounds.left + 0.5 * container.bounds.width;
7776
+ const midY = container.bounds.top + 0.5 * container.bounds.height;
7777
+ const f = ry / rx;
7778
+ const invF = 1 / f;
7779
+ this.ctx.save();
7780
+ this.ctx.translate(midX, midY);
7781
+ this.ctx.transform(1, 0, 0, f, 0, 0);
7782
+ this.ctx.translate(-midX, -midY);
7783
+ this.ctx.fillRect(left, invF * (top - midY) + midY, width, height * invF);
7784
+ this.ctx.restore();
7785
+ }
7786
+ else {
7787
+ this.ctx.fill();
7788
+ }
7805
7789
  }
7806
7790
  }
7807
7791
  /**
7808
- * Helper method to render text with paint order support
7809
- * Reduces code duplication in line-clamp and normal rendering
7792
+ * Render a repeating pattern with offset
7793
+ *
7794
+ * @param path - Path to fill
7795
+ * @param pattern - Canvas pattern or gradient
7796
+ * @param offsetX - X offset for pattern
7797
+ * @param offsetY - Y offset for pattern
7810
7798
  */
7811
- renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers) {
7812
- paintOrderLayers.forEach((paintOrderLayer) => {
7813
- switch (paintOrderLayer) {
7814
- case 0 /* PAINT_ORDER_LAYER.FILL */:
7815
- this.ctx.fillStyle = asString(styles.color);
7816
- this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
7817
- break;
7818
- case 1 /* PAINT_ORDER_LAYER.STROKE */:
7819
- if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
7820
- this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
7821
- this.ctx.lineWidth = styles.webkitTextStrokeWidth;
7822
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
7823
- this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
7824
- }
7825
- break;
7826
- }
7827
- });
7799
+ renderRepeat(path, pattern, offsetX, offsetY) {
7800
+ this.path(path);
7801
+ this.ctx.fillStyle = pattern;
7802
+ this.ctx.translate(offsetX, offsetY);
7803
+ this.ctx.fill();
7804
+ this.ctx.translate(-offsetX, -offsetY);
7828
7805
  }
7829
- renderTextDecoration(bounds, styles) {
7830
- this.ctx.fillStyle = asString(styles.textDecorationColor || styles.color);
7831
- // Calculate decoration line thickness
7832
- let thickness = 1; // default
7833
- if (typeof styles.textDecorationThickness === 'number') {
7834
- thickness = styles.textDecorationThickness;
7806
+ /**
7807
+ * Resize an image to target dimensions
7808
+ *
7809
+ * @param image - Source image
7810
+ * @param width - Target width
7811
+ * @param height - Target height
7812
+ * @param imageRendering - CSS image-rendering property value
7813
+ * @returns Resized canvas or original image
7814
+ */
7815
+ resizeImage(image, width, height, imageRendering) {
7816
+ // https://github.com/niklasvh/html2canvas/pull/2911
7817
+ // if (image.width === width && image.height === height) {
7818
+ // return image;
7819
+ // }
7820
+ const ownerDocument = this.canvas.ownerDocument ?? document;
7821
+ const canvas = ownerDocument.createElement('canvas');
7822
+ canvas.width = Math.max(1, width);
7823
+ canvas.height = Math.max(1, height);
7824
+ const ctx = canvas.getContext('2d');
7825
+ // Apply image smoothing based on CSS image-rendering property
7826
+ if (imageRendering === IMAGE_RENDERING.PIXELATED || imageRendering === IMAGE_RENDERING.CRISP_EDGES) {
7827
+ this.context.logger.debug(`Disabling image smoothing for background image due to CSS image-rendering`);
7828
+ ctx.imageSmoothingEnabled = false;
7835
7829
  }
7836
- else if (styles.textDecorationThickness === 'from-font') {
7837
- // Use a reasonable default based on font size
7838
- thickness = Math.max(1, Math.floor(styles.fontSize.number * 0.05));
7830
+ else if (imageRendering === IMAGE_RENDERING.SMOOTH) {
7831
+ this.context.logger.debug(`Enabling image smoothing for background image due to CSS image-rendering: smooth`);
7832
+ ctx.imageSmoothingEnabled = true;
7839
7833
  }
7840
- // 'auto' uses default thickness of 1
7841
- // Calculate underline offset
7842
- let underlineOffset = 0;
7843
- if (typeof styles.textUnderlineOffset === 'number') {
7844
- // It's a pixel value
7845
- underlineOffset = styles.textUnderlineOffset;
7834
+ else {
7835
+ // AUTO: inherit from main renderer context
7836
+ ctx.imageSmoothingEnabled = this.ctx.imageSmoothingEnabled;
7846
7837
  }
7847
- // 'auto' uses default offset of 0
7848
- const decorationStyle = styles.textDecorationStyle;
7849
- styles.textDecorationLine.forEach((textDecorationLine) => {
7850
- let y = 0;
7851
- switch (textDecorationLine) {
7852
- case 1 /* TEXT_DECORATION_LINE.UNDERLINE */:
7853
- y = bounds.top + bounds.height - thickness + underlineOffset;
7854
- break;
7855
- case 2 /* TEXT_DECORATION_LINE.OVERLINE */:
7856
- y = bounds.top;
7857
- break;
7858
- case 3 /* TEXT_DECORATION_LINE.LINE_THROUGH */:
7859
- y = bounds.top + (bounds.height / 2 - thickness / 2);
7860
- break;
7861
- default:
7862
- return;
7863
- }
7864
- this.drawDecorationLine(bounds.left, y, bounds.width, thickness, decorationStyle);
7865
- });
7866
- }
7867
- drawDecorationLine(x, y, width, thickness, style) {
7868
- switch (style) {
7869
- case 0 /* TEXT_DECORATION_STYLE.SOLID */:
7870
- // Solid line (default)
7871
- this.ctx.fillRect(x, y, width, thickness);
7872
- break;
7873
- case 1 /* TEXT_DECORATION_STYLE.DOUBLE */:
7874
- // Double line
7875
- const gap = Math.max(1, thickness);
7876
- this.ctx.fillRect(x, y, width, thickness);
7877
- this.ctx.fillRect(x, y + thickness + gap, width, thickness);
7878
- break;
7879
- case 2 /* TEXT_DECORATION_STYLE.DOTTED */:
7880
- // Dotted line
7881
- this.ctx.save();
7882
- this.ctx.beginPath();
7883
- this.ctx.setLineDash([thickness, thickness * 2]);
7884
- this.ctx.lineWidth = thickness;
7885
- this.ctx.strokeStyle = this.ctx.fillStyle;
7886
- this.ctx.moveTo(x, y + thickness / 2);
7887
- this.ctx.lineTo(x + width, y + thickness / 2);
7888
- this.ctx.stroke();
7889
- this.ctx.restore();
7890
- break;
7891
- case 3 /* TEXT_DECORATION_STYLE.DASHED */:
7892
- // Dashed line
7893
- this.ctx.save();
7894
- this.ctx.beginPath();
7895
- this.ctx.setLineDash([thickness * 3, thickness * 2]);
7896
- this.ctx.lineWidth = thickness;
7897
- this.ctx.strokeStyle = this.ctx.fillStyle;
7898
- this.ctx.moveTo(x, y + thickness / 2);
7899
- this.ctx.lineTo(x + width, y + thickness / 2);
7900
- this.ctx.stroke();
7901
- this.ctx.restore();
7902
- break;
7903
- case 4 /* TEXT_DECORATION_STYLE.WAVY */:
7904
- // Wavy line (approximation using quadratic curves)
7905
- this.ctx.save();
7906
- this.ctx.beginPath();
7907
- this.ctx.lineWidth = thickness;
7908
- this.ctx.strokeStyle = this.ctx.fillStyle;
7909
- const amplitude = thickness * 2;
7910
- const wavelength = thickness * 4;
7911
- let currentX = x;
7912
- this.ctx.moveTo(currentX, y + thickness / 2);
7913
- while (currentX < x + width) {
7914
- const nextX = Math.min(currentX + wavelength / 2, x + width);
7915
- this.ctx.quadraticCurveTo(currentX + wavelength / 4, y + thickness / 2 - amplitude, nextX, y + thickness / 2);
7916
- currentX = nextX;
7917
- if (currentX < x + width) {
7918
- const nextX2 = Math.min(currentX + wavelength / 2, x + width);
7919
- this.ctx.quadraticCurveTo(currentX + wavelength / 4, y + thickness / 2 + amplitude, nextX2, y + thickness / 2);
7920
- currentX = nextX2;
7921
- }
7922
- }
7923
- this.ctx.stroke();
7924
- this.ctx.restore();
7925
- break;
7926
- default:
7927
- // Fallback to solid
7928
- this.ctx.fillRect(x, y, width, thickness);
7838
+ // Inherit quality setting
7839
+ if (this.ctx.imageSmoothingQuality) {
7840
+ ctx.imageSmoothingQuality = this.ctx.imageSmoothingQuality;
7929
7841
  }
7842
+ ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height);
7843
+ return canvas;
7930
7844
  }
7931
- // Helper method to truncate text and add ellipsis if needed
7932
- truncateTextWithEllipsis(text, maxWidth, letterSpacing) {
7933
- const ellipsis = '...';
7934
- const ellipsisWidth = this.ctx.measureText(ellipsis).width;
7935
- if (letterSpacing === 0) {
7936
- let truncated = text;
7937
- while (this.ctx.measureText(truncated).width + ellipsisWidth > maxWidth && truncated.length > 0) {
7938
- truncated = truncated.slice(0, -1);
7845
+ /**
7846
+ * Create a canvas path from path array
7847
+ *
7848
+ * @param paths - Array of path points
7849
+ */
7850
+ path(paths) {
7851
+ this.ctx.beginPath();
7852
+ this.formatPath(paths);
7853
+ this.ctx.closePath();
7854
+ }
7855
+ /**
7856
+ * Format path points into canvas path
7857
+ *
7858
+ * @param paths - Array of path points
7859
+ */
7860
+ formatPath(paths) {
7861
+ paths.forEach((point, index) => {
7862
+ const start = isBezierCurve(point) ? point.start : point;
7863
+ if (index === 0) {
7864
+ this.ctx.moveTo(start.x, start.y);
7939
7865
  }
7940
- return truncated + ellipsis;
7941
- }
7942
- else {
7943
- const letters = segmentGraphemes(text);
7944
- let width = ellipsisWidth;
7945
- let result = [];
7946
- for (const letter of letters) {
7947
- const letterWidth = this.ctx.measureText(letter).width + letterSpacing;
7948
- if (width + letterWidth > maxWidth) {
7949
- break;
7950
- }
7951
- result.push(letter);
7952
- width += letterWidth;
7866
+ else {
7867
+ this.ctx.lineTo(start.x, start.y);
7953
7868
  }
7954
- return result.join('') + ellipsis;
7955
- }
7956
- }
7957
- createFontStyle(styles) {
7958
- const fontVariant = styles.fontVariant
7959
- .filter((variant) => variant === 'normal' || variant === 'small-caps')
7960
- .join('');
7961
- const fontFamily = fixIOSSystemFonts(styles.fontFamily).join(', ');
7962
- const fontSize = isDimensionToken(styles.fontSize)
7963
- ? `${styles.fontSize.number}${styles.fontSize.unit}`
7964
- : `${styles.fontSize.number}px`;
7965
- return [
7966
- [styles.fontStyle, fontVariant, styles.fontWeight, fontSize, fontFamily].join(' '),
7967
- fontFamily,
7968
- fontSize
7969
- ];
7869
+ if (isBezierCurve(point)) {
7870
+ this.ctx.bezierCurveTo(point.startControl.x, point.startControl.y, point.endControl.x, point.endControl.y, point.end.x, point.end.y);
7871
+ }
7872
+ });
7970
7873
  }
7971
- async renderTextNode(text, styles, containerBounds) {
7972
- const [font] = this.createFontStyle(styles);
7973
- this.ctx.font = font;
7874
+ }
7875
+
7876
+ const parsePathForBorder = (curves, borderSide) => {
7877
+ switch (borderSide) {
7878
+ case 0:
7879
+ return createPathFromCurves(curves.topLeftBorderBox, curves.topLeftPaddingBox, curves.topRightBorderBox, curves.topRightPaddingBox);
7880
+ case 1:
7881
+ return createPathFromCurves(curves.topRightBorderBox, curves.topRightPaddingBox, curves.bottomRightBorderBox, curves.bottomRightPaddingBox);
7882
+ case 2:
7883
+ return createPathFromCurves(curves.bottomRightBorderBox, curves.bottomRightPaddingBox, curves.bottomLeftBorderBox, curves.bottomLeftPaddingBox);
7884
+ case 3:
7885
+ default:
7886
+ return createPathFromCurves(curves.bottomLeftBorderBox, curves.bottomLeftPaddingBox, curves.topLeftBorderBox, curves.topLeftPaddingBox);
7887
+ }
7888
+ };
7889
+ const parsePathForBorderDoubleOuter = (curves, borderSide) => {
7890
+ switch (borderSide) {
7891
+ case 0:
7892
+ return createPathFromCurves(curves.topLeftBorderBox, curves.topLeftBorderDoubleOuterBox, curves.topRightBorderBox, curves.topRightBorderDoubleOuterBox);
7893
+ case 1:
7894
+ return createPathFromCurves(curves.topRightBorderBox, curves.topRightBorderDoubleOuterBox, curves.bottomRightBorderBox, curves.bottomRightBorderDoubleOuterBox);
7895
+ case 2:
7896
+ return createPathFromCurves(curves.bottomRightBorderBox, curves.bottomRightBorderDoubleOuterBox, curves.bottomLeftBorderBox, curves.bottomLeftBorderDoubleOuterBox);
7897
+ case 3:
7898
+ default:
7899
+ return createPathFromCurves(curves.bottomLeftBorderBox, curves.bottomLeftBorderDoubleOuterBox, curves.topLeftBorderBox, curves.topLeftBorderDoubleOuterBox);
7900
+ }
7901
+ };
7902
+ const parsePathForBorderDoubleInner = (curves, borderSide) => {
7903
+ switch (borderSide) {
7904
+ case 0:
7905
+ return createPathFromCurves(curves.topLeftBorderDoubleInnerBox, curves.topLeftPaddingBox, curves.topRightBorderDoubleInnerBox, curves.topRightPaddingBox);
7906
+ case 1:
7907
+ return createPathFromCurves(curves.topRightBorderDoubleInnerBox, curves.topRightPaddingBox, curves.bottomRightBorderDoubleInnerBox, curves.bottomRightPaddingBox);
7908
+ case 2:
7909
+ return createPathFromCurves(curves.bottomRightBorderDoubleInnerBox, curves.bottomRightPaddingBox, curves.bottomLeftBorderDoubleInnerBox, curves.bottomLeftPaddingBox);
7910
+ case 3:
7911
+ default:
7912
+ return createPathFromCurves(curves.bottomLeftBorderDoubleInnerBox, curves.bottomLeftPaddingBox, curves.topLeftBorderDoubleInnerBox, curves.topLeftPaddingBox);
7913
+ }
7914
+ };
7915
+ const parsePathForBorderStroke = (curves, borderSide) => {
7916
+ switch (borderSide) {
7917
+ case 0:
7918
+ return createStrokePathFromCurves(curves.topLeftBorderStroke, curves.topRightBorderStroke);
7919
+ case 1:
7920
+ return createStrokePathFromCurves(curves.topRightBorderStroke, curves.bottomRightBorderStroke);
7921
+ case 2:
7922
+ return createStrokePathFromCurves(curves.bottomRightBorderStroke, curves.bottomLeftBorderStroke);
7923
+ case 3:
7924
+ default:
7925
+ return createStrokePathFromCurves(curves.bottomLeftBorderStroke, curves.topLeftBorderStroke);
7926
+ }
7927
+ };
7928
+ const createStrokePathFromCurves = (outer1, outer2) => {
7929
+ const path = [];
7930
+ if (isBezierCurve(outer1)) {
7931
+ path.push(outer1.subdivide(0.5, false));
7932
+ }
7933
+ else {
7934
+ path.push(outer1);
7935
+ }
7936
+ if (isBezierCurve(outer2)) {
7937
+ path.push(outer2.subdivide(0.5, true));
7938
+ }
7939
+ else {
7940
+ path.push(outer2);
7941
+ }
7942
+ return path;
7943
+ };
7944
+ const createPathFromCurves = (outer1, inner1, outer2, inner2) => {
7945
+ const path = [];
7946
+ if (isBezierCurve(outer1)) {
7947
+ path.push(outer1.subdivide(0.5, false));
7948
+ }
7949
+ else {
7950
+ path.push(outer1);
7951
+ }
7952
+ if (isBezierCurve(outer2)) {
7953
+ path.push(outer2.subdivide(0.5, true));
7954
+ }
7955
+ else {
7956
+ path.push(outer2);
7957
+ }
7958
+ if (isBezierCurve(inner2)) {
7959
+ path.push(inner2.subdivide(0.5, true).reverse());
7960
+ }
7961
+ else {
7962
+ path.push(inner2);
7963
+ }
7964
+ if (isBezierCurve(inner1)) {
7965
+ path.push(inner1.subdivide(0.5, false).reverse());
7966
+ }
7967
+ else {
7968
+ path.push(inner1);
7969
+ }
7970
+ return path;
7971
+ };
7972
+
7973
+ /**
7974
+ * Border Renderer
7975
+ *
7976
+ * Handles rendering of element borders including:
7977
+ * - Solid borders
7978
+ * - Double borders
7979
+ * - Dashed borders
7980
+ * - Dotted borders
7981
+ */
7982
+ /**
7983
+ * Border Renderer
7984
+ *
7985
+ * Specialized renderer for element borders.
7986
+ * Extracted from CanvasRenderer to improve code organization and maintainability.
7987
+ */
7988
+ class BorderRenderer {
7989
+ constructor(deps, pathCallbacks) {
7990
+ this.ctx = deps.ctx;
7991
+ this.pathCallbacks = pathCallbacks;
7992
+ }
7993
+ /**
7994
+ * Render a solid border
7995
+ *
7996
+ * @param color - Border color
7997
+ * @param side - Border side (0=top, 1=right, 2=bottom, 3=left)
7998
+ * @param curvePoints - Border curve points
7999
+ */
8000
+ async renderSolidBorder(color, side, curvePoints) {
8001
+ this.pathCallbacks.path(parsePathForBorder(curvePoints, side));
8002
+ this.ctx.fillStyle = asString(color);
8003
+ this.ctx.fill();
8004
+ }
8005
+ /**
8006
+ * Render a double border
8007
+ * Falls back to solid border if width is too small
8008
+ *
8009
+ * @param color - Border color
8010
+ * @param width - Border width
8011
+ * @param side - Border side (0=top, 1=right, 2=bottom, 3=left)
8012
+ * @param curvePoints - Border curve points
8013
+ */
8014
+ async renderDoubleBorder(color, width, side, curvePoints) {
8015
+ if (width < 3) {
8016
+ await this.renderSolidBorder(color, side, curvePoints);
8017
+ return;
8018
+ }
8019
+ const outerPaths = parsePathForBorderDoubleOuter(curvePoints, side);
8020
+ this.pathCallbacks.path(outerPaths);
8021
+ this.ctx.fillStyle = asString(color);
8022
+ this.ctx.fill();
8023
+ const innerPaths = parsePathForBorderDoubleInner(curvePoints, side);
8024
+ this.pathCallbacks.path(innerPaths);
8025
+ this.ctx.fill();
8026
+ }
8027
+ /**
8028
+ * Render a dashed or dotted border
8029
+ *
8030
+ * @param color - Border color
8031
+ * @param width - Border width
8032
+ * @param side - Border side (0=top, 1=right, 2=bottom, 3=left)
8033
+ * @param curvePoints - Border curve points
8034
+ * @param style - Border style (DASHED or DOTTED)
8035
+ */
8036
+ async renderDashedDottedBorder(color, width, side, curvePoints, style) {
8037
+ this.ctx.save();
8038
+ const strokePaths = parsePathForBorderStroke(curvePoints, side);
8039
+ const boxPaths = parsePathForBorder(curvePoints, side);
8040
+ if (style === 2 /* BORDER_STYLE.DASHED */) {
8041
+ this.pathCallbacks.path(boxPaths);
8042
+ this.ctx.clip();
8043
+ }
8044
+ // Extract start and end coordinates
8045
+ let startX, startY, endX, endY;
8046
+ if (isBezierCurve(boxPaths[0])) {
8047
+ startX = boxPaths[0].start.x;
8048
+ startY = boxPaths[0].start.y;
8049
+ }
8050
+ else {
8051
+ startX = boxPaths[0].x;
8052
+ startY = boxPaths[0].y;
8053
+ }
8054
+ if (isBezierCurve(boxPaths[1])) {
8055
+ endX = boxPaths[1].end.x;
8056
+ endY = boxPaths[1].end.y;
8057
+ }
8058
+ else {
8059
+ endX = boxPaths[1].x;
8060
+ endY = boxPaths[1].y;
8061
+ }
8062
+ // Calculate border length
8063
+ let length;
8064
+ if (side === 0 || side === 2) {
8065
+ length = Math.abs(startX - endX);
8066
+ }
8067
+ else {
8068
+ length = Math.abs(startY - endY);
8069
+ }
8070
+ this.ctx.beginPath();
8071
+ if (style === 3 /* BORDER_STYLE.DOTTED */) {
8072
+ this.pathCallbacks.formatPath(strokePaths);
8073
+ }
8074
+ else {
8075
+ this.pathCallbacks.formatPath(boxPaths.slice(0, 2));
8076
+ }
8077
+ // Calculate dash and space lengths
8078
+ let dashLength = width < 3 ? width * 3 : width * 2;
8079
+ let spaceLength = width < 3 ? width * 2 : width;
8080
+ if (style === 3 /* BORDER_STYLE.DOTTED */) {
8081
+ dashLength = width;
8082
+ spaceLength = width;
8083
+ }
8084
+ // Adjust dash pattern for border length
8085
+ let useLineDash = true;
8086
+ if (length <= dashLength * 2) {
8087
+ useLineDash = false;
8088
+ }
8089
+ else if (length <= dashLength * 2 + spaceLength) {
8090
+ const multiplier = length / (2 * dashLength + spaceLength);
8091
+ dashLength *= multiplier;
8092
+ spaceLength *= multiplier;
8093
+ }
8094
+ else {
8095
+ const numberOfDashes = Math.floor((length + spaceLength) / (dashLength + spaceLength));
8096
+ const minSpace = (length - numberOfDashes * dashLength) / (numberOfDashes - 1);
8097
+ const maxSpace = (length - (numberOfDashes + 1) * dashLength) / numberOfDashes;
8098
+ spaceLength =
8099
+ maxSpace <= 0 || Math.abs(spaceLength - minSpace) < Math.abs(spaceLength - maxSpace)
8100
+ ? minSpace
8101
+ : maxSpace;
8102
+ }
8103
+ // Apply line dash pattern
8104
+ if (useLineDash) {
8105
+ if (style === 3 /* BORDER_STYLE.DOTTED */) {
8106
+ this.ctx.setLineDash([0, dashLength + spaceLength]);
8107
+ }
8108
+ else {
8109
+ this.ctx.setLineDash([dashLength, spaceLength]);
8110
+ }
8111
+ }
8112
+ // Set line style and stroke
8113
+ if (style === 3 /* BORDER_STYLE.DOTTED */) {
8114
+ this.ctx.lineCap = 'round';
8115
+ this.ctx.lineWidth = width;
8116
+ }
8117
+ else {
8118
+ this.ctx.lineWidth = width * 2 + 1.1;
8119
+ }
8120
+ this.ctx.strokeStyle = asString(color);
8121
+ this.ctx.stroke();
8122
+ this.ctx.setLineDash([]);
8123
+ // Fill dashed round edge gaps
8124
+ if (style === 2 /* BORDER_STYLE.DASHED */) {
8125
+ if (isBezierCurve(boxPaths[0])) {
8126
+ const path1 = boxPaths[3];
8127
+ const path2 = boxPaths[0];
8128
+ this.ctx.beginPath();
8129
+ this.pathCallbacks.formatPath([
8130
+ new Vector(path1.end.x, path1.end.y),
8131
+ new Vector(path2.start.x, path2.start.y)
8132
+ ]);
8133
+ this.ctx.stroke();
8134
+ }
8135
+ if (isBezierCurve(boxPaths[1])) {
8136
+ const path1 = boxPaths[1];
8137
+ const path2 = boxPaths[2];
8138
+ this.ctx.beginPath();
8139
+ this.pathCallbacks.formatPath([
8140
+ new Vector(path1.end.x, path1.end.y),
8141
+ new Vector(path2.start.x, path2.start.y)
8142
+ ]);
8143
+ this.ctx.stroke();
8144
+ }
8145
+ }
8146
+ this.ctx.restore();
8147
+ }
8148
+ }
8149
+
8150
+ /**
8151
+ * Effects Renderer
8152
+ *
8153
+ * Handles rendering effects including:
8154
+ * - Opacity effects
8155
+ * - Transform effects (matrix transformations)
8156
+ * - Clip effects (clipping paths)
8157
+ */
8158
+ /**
8159
+ * Effects Renderer
8160
+ *
8161
+ * Manages rendering effects stack including opacity, transforms, and clipping.
8162
+ * Extracted from CanvasRenderer to improve code organization and maintainability.
8163
+ */
8164
+ class EffectsRenderer {
8165
+ constructor(deps, pathCallback) {
8166
+ this.activeEffects = [];
8167
+ this.ctx = deps.ctx;
8168
+ this.pathCallback = pathCallback;
8169
+ }
8170
+ /**
8171
+ * Apply multiple effects
8172
+ * Clears existing effects and applies new ones
8173
+ *
8174
+ * @param effects - Array of effects to apply
8175
+ */
8176
+ applyEffects(effects) {
8177
+ // Clear all existing effects
8178
+ while (this.activeEffects.length) {
8179
+ this.popEffect();
8180
+ }
8181
+ // Apply new effects
8182
+ effects.forEach((effect) => this.applyEffect(effect));
8183
+ }
8184
+ /**
8185
+ * Apply a single effect
8186
+ *
8187
+ * @param effect - Effect to apply
8188
+ */
8189
+ applyEffect(effect) {
8190
+ this.ctx.save();
8191
+ // Apply opacity effect
8192
+ if (isOpacityEffect(effect)) {
8193
+ this.ctx.globalAlpha = effect.opacity;
8194
+ }
8195
+ // Apply transform effect
8196
+ if (isTransformEffect(effect)) {
8197
+ this.ctx.translate(effect.offsetX, effect.offsetY);
8198
+ this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
8199
+ this.ctx.translate(-effect.offsetX, -effect.offsetY);
8200
+ }
8201
+ // Apply clip effect
8202
+ if (isClipEffect(effect)) {
8203
+ this.pathCallback.path(effect.path);
8204
+ this.ctx.clip();
8205
+ }
8206
+ this.activeEffects.push(effect);
8207
+ }
8208
+ /**
8209
+ * Remove the most recent effect
8210
+ * Restores the canvas state before the effect was applied
8211
+ */
8212
+ popEffect() {
8213
+ this.activeEffects.pop();
8214
+ this.ctx.restore();
8215
+ }
8216
+ /**
8217
+ * Get the current number of active effects
8218
+ *
8219
+ * @returns Number of active effects
8220
+ */
8221
+ getActiveEffectCount() {
8222
+ return this.activeEffects.length;
8223
+ }
8224
+ /**
8225
+ * Check if there are any active effects
8226
+ *
8227
+ * @returns True if there are active effects
8228
+ */
8229
+ hasActiveEffects() {
8230
+ return this.activeEffects.length > 0;
8231
+ }
8232
+ }
8233
+
8234
+ /**
8235
+ * Text Renderer
8236
+ *
8237
+ * Handles rendering of text content including:
8238
+ * - Text with letter spacing
8239
+ * - Text decorations (underline, overline, line-through)
8240
+ * - Text shadows
8241
+ * - Webkit line clamp
8242
+ * - Text overflow ellipsis
8243
+ * - Paint order (fill/stroke)
8244
+ * - Font styles
8245
+ */
8246
+ // iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
8247
+ const iOSBrokenFonts = ['-apple-system', 'system-ui'];
8248
+ /**
8249
+ * Detect iOS version from user agent
8250
+ * Returns null if not iOS or version cannot be determined
8251
+ */
8252
+ const getIOSVersion = () => {
8253
+ if (typeof navigator === 'undefined') {
8254
+ return null;
8255
+ }
8256
+ const userAgent = navigator.userAgent;
8257
+ // Check if it's iOS or iPadOS
8258
+ // iPadOS 13+ may identify as Macintosh, check for touch support
8259
+ const isIOS = /iPhone|iPad|iPod/.test(userAgent);
8260
+ const isIPadOS = /Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 1;
8261
+ if (!isIOS && !isIPadOS) {
8262
+ return null;
8263
+ }
8264
+ // Extract version number from various iOS user agent formats:
8265
+ // - "iPhone OS 15_0" or "iPhone OS 15_0_1"
8266
+ // - "CPU OS 15_0 like Mac OS X"
8267
+ // - "CPU iPhone OS 15_0 like Mac OS X"
8268
+ // - "Version/15.0" (for iPadOS)
8269
+ const patterns = [
8270
+ /(?:iPhone|CPU(?:\siPhone)?)\sOS\s(\d+)[\._](\d+)/, // iPhone OS, CPU OS, CPU iPhone OS
8271
+ /Version\/(\d+)\.(\d+)/ // Version/15.0 (iPadOS)
8272
+ ];
8273
+ for (const pattern of patterns) {
8274
+ const match = userAgent.match(pattern);
8275
+ if (match && match[1]) {
8276
+ return parseInt(match[1], 10);
8277
+ }
8278
+ }
8279
+ return null;
8280
+ };
8281
+ const fixIOSSystemFonts = (fontFamilies) => {
8282
+ const iosVersion = getIOSVersion();
8283
+ // On iOS 15.0 and 15.1, system fonts have rendering issues
8284
+ // Fixed in iOS 17+
8285
+ if (iosVersion !== null && iosVersion >= 15 && iosVersion < 17) {
8286
+ return fontFamilies.map((fontFamily) => iOSBrokenFonts.indexOf(fontFamily) !== -1
8287
+ ? `-apple-system, "Helvetica Neue", Arial, sans-serif`
8288
+ : fontFamily);
8289
+ }
8290
+ return fontFamilies;
8291
+ };
8292
+ /**
8293
+ * Text Renderer
8294
+ *
8295
+ * Specialized renderer for text content.
8296
+ * Extracted from CanvasRenderer to improve code organization and maintainability.
8297
+ */
8298
+ class TextRenderer {
8299
+ constructor(deps) {
8300
+ this.ctx = deps.ctx;
8301
+ // context stored but not used directly in this renderer
8302
+ this.options = deps.options;
8303
+ }
8304
+ /**
8305
+ * Render text with letter spacing
8306
+ * Public method used by list rendering
8307
+ */
8308
+ renderTextWithLetterSpacing(text, letterSpacing, baseline) {
8309
+ if (letterSpacing === 0) {
8310
+ // Use alphabetic baseline for consistent text positioning across browsers
8311
+ // Issue #129: text.bounds.top + text.bounds.height causes text to render too low
8312
+ this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
8313
+ }
8314
+ else {
8315
+ const letters = segmentGraphemes(text.text);
8316
+ letters.reduce((left, letter) => {
8317
+ this.ctx.fillText(letter, left, text.bounds.top + baseline);
8318
+ return left + this.ctx.measureText(letter).width;
8319
+ }, text.bounds.left);
8320
+ }
8321
+ }
8322
+ /**
8323
+ * Helper method to render text with paint order support
8324
+ * Reduces code duplication in line-clamp and normal rendering
8325
+ */
8326
+ renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers) {
8327
+ paintOrderLayers.forEach((paintOrderLayer) => {
8328
+ switch (paintOrderLayer) {
8329
+ case 0 /* PAINT_ORDER_LAYER.FILL */:
8330
+ this.ctx.fillStyle = asString(styles.color);
8331
+ this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
8332
+ break;
8333
+ case 1 /* PAINT_ORDER_LAYER.STROKE */:
8334
+ if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
8335
+ this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8336
+ this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8337
+ this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8338
+ this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
8339
+ }
8340
+ break;
8341
+ }
8342
+ });
8343
+ }
8344
+ renderTextDecoration(bounds, styles) {
8345
+ this.ctx.fillStyle = asString(styles.textDecorationColor || styles.color);
8346
+ // Calculate decoration line thickness
8347
+ let thickness = 1; // default
8348
+ if (typeof styles.textDecorationThickness === 'number') {
8349
+ thickness = styles.textDecorationThickness;
8350
+ }
8351
+ else if (styles.textDecorationThickness === 'from-font') {
8352
+ // Use a reasonable default based on font size
8353
+ thickness = Math.max(1, Math.floor(styles.fontSize.number * 0.05));
8354
+ }
8355
+ // 'auto' uses default thickness of 1
8356
+ // Calculate underline offset
8357
+ let underlineOffset = 0;
8358
+ if (typeof styles.textUnderlineOffset === 'number') {
8359
+ // It's a pixel value
8360
+ underlineOffset = styles.textUnderlineOffset;
8361
+ }
8362
+ // 'auto' uses default offset of 0
8363
+ const decorationStyle = styles.textDecorationStyle;
8364
+ styles.textDecorationLine.forEach((textDecorationLine) => {
8365
+ let y = 0;
8366
+ switch (textDecorationLine) {
8367
+ case 1 /* TEXT_DECORATION_LINE.UNDERLINE */:
8368
+ y = bounds.top + bounds.height - thickness + underlineOffset;
8369
+ break;
8370
+ case 2 /* TEXT_DECORATION_LINE.OVERLINE */:
8371
+ y = bounds.top;
8372
+ break;
8373
+ case 3 /* TEXT_DECORATION_LINE.LINE_THROUGH */:
8374
+ y = bounds.top + (bounds.height / 2 - thickness / 2);
8375
+ break;
8376
+ default:
8377
+ return;
8378
+ }
8379
+ this.drawDecorationLine(bounds.left, y, bounds.width, thickness, decorationStyle);
8380
+ });
8381
+ }
8382
+ drawDecorationLine(x, y, width, thickness, style) {
8383
+ switch (style) {
8384
+ case 0 /* TEXT_DECORATION_STYLE.SOLID */:
8385
+ // Solid line (default)
8386
+ this.ctx.fillRect(x, y, width, thickness);
8387
+ break;
8388
+ case 1 /* TEXT_DECORATION_STYLE.DOUBLE */:
8389
+ // Double line
8390
+ const gap = Math.max(1, thickness);
8391
+ this.ctx.fillRect(x, y, width, thickness);
8392
+ this.ctx.fillRect(x, y + thickness + gap, width, thickness);
8393
+ break;
8394
+ case 2 /* TEXT_DECORATION_STYLE.DOTTED */:
8395
+ // Dotted line
8396
+ this.ctx.save();
8397
+ this.ctx.beginPath();
8398
+ this.ctx.setLineDash([thickness, thickness * 2]);
8399
+ this.ctx.lineWidth = thickness;
8400
+ this.ctx.strokeStyle = this.ctx.fillStyle;
8401
+ this.ctx.moveTo(x, y + thickness / 2);
8402
+ this.ctx.lineTo(x + width, y + thickness / 2);
8403
+ this.ctx.stroke();
8404
+ this.ctx.restore();
8405
+ break;
8406
+ case 3 /* TEXT_DECORATION_STYLE.DASHED */:
8407
+ // Dashed line
8408
+ this.ctx.save();
8409
+ this.ctx.beginPath();
8410
+ this.ctx.setLineDash([thickness * 3, thickness * 2]);
8411
+ this.ctx.lineWidth = thickness;
8412
+ this.ctx.strokeStyle = this.ctx.fillStyle;
8413
+ this.ctx.moveTo(x, y + thickness / 2);
8414
+ this.ctx.lineTo(x + width, y + thickness / 2);
8415
+ this.ctx.stroke();
8416
+ this.ctx.restore();
8417
+ break;
8418
+ case 4 /* TEXT_DECORATION_STYLE.WAVY */:
8419
+ // Wavy line (approximation using quadratic curves)
8420
+ this.ctx.save();
8421
+ this.ctx.beginPath();
8422
+ this.ctx.lineWidth = thickness;
8423
+ this.ctx.strokeStyle = this.ctx.fillStyle;
8424
+ const amplitude = thickness * 2;
8425
+ const wavelength = thickness * 4;
8426
+ let currentX = x;
8427
+ this.ctx.moveTo(currentX, y + thickness / 2);
8428
+ while (currentX < x + width) {
8429
+ const nextX = Math.min(currentX + wavelength / 2, x + width);
8430
+ this.ctx.quadraticCurveTo(currentX + wavelength / 4, y + thickness / 2 - amplitude, nextX, y + thickness / 2);
8431
+ currentX = nextX;
8432
+ if (currentX < x + width) {
8433
+ const nextX2 = Math.min(currentX + wavelength / 2, x + width);
8434
+ this.ctx.quadraticCurveTo(currentX + wavelength / 4, y + thickness / 2 + amplitude, nextX2, y + thickness / 2);
8435
+ currentX = nextX2;
8436
+ }
8437
+ }
8438
+ this.ctx.stroke();
8439
+ this.ctx.restore();
8440
+ break;
8441
+ default:
8442
+ // Fallback to solid
8443
+ this.ctx.fillRect(x, y, width, thickness);
8444
+ }
8445
+ }
8446
+ // Helper method to truncate text and add ellipsis if needed
8447
+ truncateTextWithEllipsis(text, maxWidth, letterSpacing) {
8448
+ const ellipsis = '...';
8449
+ const ellipsisWidth = this.ctx.measureText(ellipsis).width;
8450
+ if (letterSpacing === 0) {
8451
+ let truncated = text;
8452
+ while (this.ctx.measureText(truncated).width + ellipsisWidth > maxWidth && truncated.length > 0) {
8453
+ truncated = truncated.slice(0, -1);
8454
+ }
8455
+ return truncated + ellipsis;
8456
+ }
8457
+ else {
8458
+ const letters = segmentGraphemes(text);
8459
+ let width = ellipsisWidth;
8460
+ let result = [];
8461
+ for (const letter of letters) {
8462
+ const letterWidth = this.ctx.measureText(letter).width + letterSpacing;
8463
+ if (width + letterWidth > maxWidth) {
8464
+ break;
8465
+ }
8466
+ result.push(letter);
8467
+ width += letterWidth;
8468
+ }
8469
+ return result.join('') + ellipsis;
8470
+ }
8471
+ }
8472
+ /**
8473
+ * Create font style array
8474
+ * Public method used by list rendering
8475
+ */
8476
+ createFontStyle(styles) {
8477
+ const fontVariant = styles.fontVariant
8478
+ .filter((variant) => variant === 'normal' || variant === 'small-caps')
8479
+ .join('');
8480
+ const fontFamily = fixIOSSystemFonts(styles.fontFamily).join(', ');
8481
+ const fontSize = isDimensionToken(styles.fontSize)
8482
+ ? `${styles.fontSize.number}${styles.fontSize.unit}`
8483
+ : `${styles.fontSize.number}px`;
8484
+ return [
8485
+ [styles.fontStyle, fontVariant, styles.fontWeight, fontSize, fontFamily].join(' '),
8486
+ fontFamily,
8487
+ fontSize
8488
+ ];
8489
+ }
8490
+ async renderTextNode(text, styles, containerBounds) {
8491
+ const [font] = this.createFontStyle(styles);
8492
+ this.ctx.font = font;
7974
8493
  this.ctx.direction = styles.direction === 1 /* DIRECTION.RTL */ ? 'rtl' : 'ltr';
7975
8494
  this.ctx.textAlign = 'left';
7976
8495
  this.ctx.textBaseline = 'alphabetic';
@@ -8136,846 +8655,1756 @@ class CanvasRenderer extends Renderer {
8136
8655
  }
8137
8656
  break;
8138
8657
  case 1 /* PAINT_ORDER_LAYER.STROKE */:
8139
- if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8658
+ if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8659
+ this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8660
+ this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8661
+ this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8662
+ if (styles.letterSpacing === 0) {
8663
+ this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8664
+ }
8665
+ else {
8666
+ const letters = segmentGraphemes(truncatedText);
8667
+ letters.reduce((left, letter) => {
8668
+ this.ctx.strokeText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8669
+ return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8670
+ }, firstBound.bounds.left);
8671
+ }
8672
+ }
8673
+ break;
8674
+ }
8675
+ });
8676
+ return;
8677
+ }
8678
+ // Normal rendering (no ellipsis needed)
8679
+ text.textBounds.forEach((text) => {
8680
+ paintOrder.forEach((paintOrderLayer) => {
8681
+ switch (paintOrderLayer) {
8682
+ case 0 /* PAINT_ORDER_LAYER.FILL */:
8683
+ this.ctx.fillStyle = asString(styles.color);
8684
+ this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8685
+ const textShadows = styles.textShadow;
8686
+ if (textShadows.length && text.text.trim().length) {
8687
+ textShadows
8688
+ .slice(0)
8689
+ .reverse()
8690
+ .forEach((textShadow) => {
8691
+ this.ctx.shadowColor = asString(textShadow.color);
8692
+ this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
8693
+ this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
8694
+ this.ctx.shadowBlur = textShadow.blur.number;
8695
+ this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8696
+ });
8697
+ this.ctx.shadowColor = '';
8698
+ this.ctx.shadowOffsetX = 0;
8699
+ this.ctx.shadowOffsetY = 0;
8700
+ this.ctx.shadowBlur = 0;
8701
+ }
8702
+ if (styles.textDecorationLine.length) {
8703
+ this.renderTextDecoration(text.bounds, styles);
8704
+ }
8705
+ break;
8706
+ case 1 /* PAINT_ORDER_LAYER.STROKE */:
8707
+ if (styles.webkitTextStrokeWidth && text.text.trim().length) {
8140
8708
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8141
8709
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8142
8710
  this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8711
+ // Issue #110: Use baseline (fontSize) for consistent positioning with fill
8712
+ // Previously used text.bounds.height which caused stroke to render too low
8713
+ const baseline = styles.fontSize.number;
8143
8714
  if (styles.letterSpacing === 0) {
8144
- this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8715
+ this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
8145
8716
  }
8146
8717
  else {
8147
- const letters = segmentGraphemes(truncatedText);
8718
+ const letters = segmentGraphemes(text.text);
8148
8719
  letters.reduce((left, letter) => {
8149
- this.ctx.strokeText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8150
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8151
- }, firstBound.bounds.left);
8720
+ this.ctx.strokeText(letter, left, text.bounds.top + baseline);
8721
+ return left + this.ctx.measureText(letter).width;
8722
+ }, text.bounds.left);
8152
8723
  }
8153
8724
  }
8725
+ this.ctx.strokeStyle = '';
8726
+ this.ctx.lineWidth = 0;
8727
+ this.ctx.lineJoin = 'miter';
8154
8728
  break;
8155
8729
  }
8156
8730
  });
8157
- return;
8731
+ });
8732
+ }
8733
+ }
8734
+
8735
+ const MASK_OFFSET = 10000;
8736
+ class CanvasRenderer extends Renderer {
8737
+ constructor(context, options) {
8738
+ super(context, options);
8739
+ this.canvas = options.canvas ? options.canvas : document.createElement('canvas');
8740
+ this.ctx = this.canvas.getContext('2d');
8741
+ if (!options.canvas) {
8742
+ this.canvas.width = Math.floor(options.width * options.scale);
8743
+ this.canvas.height = Math.floor(options.height * options.scale);
8744
+ this.canvas.style.width = `${options.width}px`;
8745
+ this.canvas.style.height = `${options.height}px`;
8746
+ }
8747
+ this.fontMetrics = new FontMetrics(document);
8748
+ this.ctx.scale(this.options.scale, this.options.scale);
8749
+ this.ctx.translate(-options.x, -options.y);
8750
+ this.ctx.textBaseline = 'bottom';
8751
+ // Set image smoothing options
8752
+ if (options.imageSmoothing !== undefined) {
8753
+ this.ctx.imageSmoothingEnabled = options.imageSmoothing;
8754
+ }
8755
+ if (options.imageSmoothingQuality) {
8756
+ this.ctx.imageSmoothingQuality = options.imageSmoothingQuality;
8757
+ }
8758
+ // Initialize specialized renderers
8759
+ this.backgroundRenderer = new BackgroundRenderer({
8760
+ ctx: this.ctx,
8761
+ context: this.context,
8762
+ canvas: this.canvas,
8763
+ options: {
8764
+ width: options.width,
8765
+ height: options.height,
8766
+ scale: options.scale
8767
+ }
8768
+ });
8769
+ this.borderRenderer = new BorderRenderer({ ctx: this.ctx }, {
8770
+ path: (paths) => this.path(paths),
8771
+ formatPath: (paths) => this.formatPath(paths)
8772
+ });
8773
+ this.effectsRenderer = new EffectsRenderer({ ctx: this.ctx }, { path: (paths) => this.path(paths) });
8774
+ this.textRenderer = new TextRenderer({
8775
+ ctx: this.ctx,
8776
+ context: this.context,
8777
+ options: { scale: options.scale }
8778
+ });
8779
+ this.context.logger.debug(`Canvas renderer initialized (${options.width}x${options.height}) with scale ${options.scale}`);
8780
+ }
8781
+ async renderStack(stack) {
8782
+ const styles = stack.element.container.styles;
8783
+ if (styles.isVisible()) {
8784
+ await this.renderStackContent(stack);
8785
+ }
8786
+ }
8787
+ async renderNode(paint) {
8788
+ if (contains(paint.container.flags, 16 /* FLAGS.DEBUG_RENDER */)) {
8789
+ debugger;
8790
+ }
8791
+ if (paint.container.styles.isVisible()) {
8792
+ await this.renderNodeBackgroundAndBorders(paint);
8793
+ await this.renderNodeContent(paint);
8794
+ }
8795
+ }
8796
+ /**
8797
+ * Helper method to render text with paint order support
8798
+ * Reduces code duplication in line-clamp and normal rendering
8799
+ */
8800
+ // Helper method to truncate text and add ellipsis if needed
8801
+ renderReplacedElement(container, curves, image) {
8802
+ const intrinsicWidth = image.naturalWidth || container.intrinsicWidth;
8803
+ const intrinsicHeight = image.naturalHeight || container.intrinsicHeight;
8804
+ if (image && intrinsicWidth > 0 && intrinsicHeight > 0) {
8805
+ const box = contentBox(container);
8806
+ const path = calculatePaddingBoxPath(curves);
8807
+ this.path(path);
8808
+ this.ctx.save();
8809
+ this.ctx.clip();
8810
+ let sx = 0, sy = 0, sw = intrinsicWidth, sh = intrinsicHeight, dx = box.left, dy = box.top, dw = box.width, dh = box.height;
8811
+ const { objectFit } = container.styles;
8812
+ const boxRatio = dw / dh;
8813
+ const imgRatio = sw / sh;
8814
+ if (objectFit === 2 /* OBJECT_FIT.CONTAIN */) {
8815
+ if (imgRatio > boxRatio) {
8816
+ dh = dw / imgRatio;
8817
+ dy += (box.height - dh) / 2;
8818
+ }
8819
+ else {
8820
+ dw = dh * imgRatio;
8821
+ dx += (box.width - dw) / 2;
8822
+ }
8823
+ }
8824
+ else if (objectFit === 4 /* OBJECT_FIT.COVER */) {
8825
+ if (imgRatio > boxRatio) {
8826
+ sw = sh * boxRatio;
8827
+ sx += (intrinsicWidth - sw) / 2;
8828
+ }
8829
+ else {
8830
+ sh = sw / boxRatio;
8831
+ sy += (intrinsicHeight - sh) / 2;
8832
+ }
8833
+ }
8834
+ else if (objectFit === 8 /* OBJECT_FIT.NONE */) {
8835
+ if (sw > dw) {
8836
+ sx += (sw - dw) / 2;
8837
+ sw = dw;
8838
+ }
8839
+ else {
8840
+ dx += (dw - sw) / 2;
8841
+ dw = sw;
8842
+ }
8843
+ if (sh > dh) {
8844
+ sy += (sh - dh) / 2;
8845
+ sh = dh;
8846
+ }
8847
+ else {
8848
+ dy += (dh - sh) / 2;
8849
+ dh = sh;
8850
+ }
8851
+ }
8852
+ else if (objectFit === 16 /* OBJECT_FIT.SCALE_DOWN */) {
8853
+ const containW = imgRatio > boxRatio ? dw : dh * imgRatio;
8854
+ const noneW = sw > dw ? sw : dw;
8855
+ if (containW < noneW) {
8856
+ if (imgRatio > boxRatio) {
8857
+ dh = dw / imgRatio;
8858
+ dy += (box.height - dh) / 2;
8859
+ }
8860
+ else {
8861
+ dw = dh * imgRatio;
8862
+ dx += (box.width - dw) / 2;
8863
+ }
8864
+ }
8865
+ else {
8866
+ if (sw > dw) {
8867
+ sx += (sw - dw) / 2;
8868
+ sw = dw;
8869
+ }
8870
+ else {
8871
+ dx += (dw - sw) / 2;
8872
+ dw = sw;
8873
+ }
8874
+ if (sh > dh) {
8875
+ sy += (sh - dh) / 2;
8876
+ sh = dh;
8877
+ }
8878
+ else {
8879
+ dy += (dh - sh) / 2;
8880
+ dh = sh;
8881
+ }
8882
+ }
8883
+ }
8884
+ this.ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
8885
+ this.ctx.restore();
8886
+ }
8887
+ }
8888
+ async renderNodeContent(paint) {
8889
+ this.effectsRenderer.applyEffects(paint.getEffects(4 /* EffectTarget.CONTENT */));
8890
+ const container = paint.container;
8891
+ const curves = paint.curves;
8892
+ const styles = container.styles;
8893
+ // Use content box for text overflow calculation (excludes padding and border)
8894
+ // This matches browser behavior where text-overflow uses the content width
8895
+ const textBounds = contentBox(container);
8896
+ for (const child of container.textNodes) {
8897
+ await this.textRenderer.renderTextNode(child, styles, textBounds);
8898
+ }
8899
+ if (container instanceof ImageElementContainer) {
8900
+ try {
8901
+ const image = await this.context.cache.match(container.src);
8902
+ // Apply image smoothing based on CSS image-rendering property and global options
8903
+ const prevSmoothing = this.ctx.imageSmoothingEnabled;
8904
+ // CSS image-rendering property overrides global settings
8905
+ if (styles.imageRendering === IMAGE_RENDERING.PIXELATED ||
8906
+ styles.imageRendering === IMAGE_RENDERING.CRISP_EDGES) {
8907
+ this.context.logger.debug(`Disabling image smoothing for ${container.src} due to CSS image-rendering: ${styles.imageRendering === IMAGE_RENDERING.PIXELATED ? 'pixelated' : 'crisp-edges'}`);
8908
+ this.ctx.imageSmoothingEnabled = false;
8909
+ }
8910
+ else if (styles.imageRendering === IMAGE_RENDERING.SMOOTH) {
8911
+ this.context.logger.debug(`Enabling image smoothing for ${container.src} due to CSS image-rendering: smooth`);
8912
+ this.ctx.imageSmoothingEnabled = true;
8913
+ }
8914
+ // IMAGE_RENDERING.AUTO: keep current global setting
8915
+ this.renderReplacedElement(container, curves, image);
8916
+ // Restore previous smoothing state
8917
+ this.ctx.imageSmoothingEnabled = prevSmoothing;
8918
+ }
8919
+ catch (e) {
8920
+ this.context.logger.error(`Error loading image ${container.src}`);
8921
+ }
8922
+ }
8923
+ if (container instanceof CanvasElementContainer) {
8924
+ this.renderReplacedElement(container, curves, container.canvas);
8925
+ }
8926
+ if (container instanceof SVGElementContainer) {
8927
+ try {
8928
+ const image = await this.context.cache.match(container.svg);
8929
+ this.renderReplacedElement(container, curves, image);
8930
+ }
8931
+ catch (e) {
8932
+ this.context.logger.error(`Error loading svg ${container.svg.substring(0, 255)}`);
8933
+ }
8934
+ }
8935
+ if (container instanceof IFrameElementContainer && container.tree) {
8936
+ const iframeRenderer = new CanvasRenderer(this.context, {
8937
+ scale: this.options.scale,
8938
+ backgroundColor: container.backgroundColor,
8939
+ x: 0,
8940
+ y: 0,
8941
+ width: container.width,
8942
+ height: container.height
8943
+ });
8944
+ const canvas = await iframeRenderer.render(container.tree);
8945
+ if (container.width && container.height) {
8946
+ this.ctx.drawImage(canvas, 0, 0, container.width, container.height, container.bounds.left, container.bounds.top, container.bounds.width, container.bounds.height);
8947
+ }
8948
+ }
8949
+ if (container instanceof InputElementContainer) {
8950
+ const size = Math.min(container.bounds.width, container.bounds.height);
8951
+ if (container.type === CHECKBOX) {
8952
+ if (container.checked) {
8953
+ this.ctx.save();
8954
+ this.path([
8955
+ new Vector(container.bounds.left + size * 0.39363, container.bounds.top + size * 0.79),
8956
+ new Vector(container.bounds.left + size * 0.16, container.bounds.top + size * 0.5549),
8957
+ new Vector(container.bounds.left + size * 0.27347, container.bounds.top + size * 0.44071),
8958
+ new Vector(container.bounds.left + size * 0.39694, container.bounds.top + size * 0.5649),
8959
+ new Vector(container.bounds.left + size * 0.72983, container.bounds.top + size * 0.23),
8960
+ new Vector(container.bounds.left + size * 0.84, container.bounds.top + size * 0.34085),
8961
+ new Vector(container.bounds.left + size * 0.39363, container.bounds.top + size * 0.79)
8962
+ ]);
8963
+ this.ctx.fillStyle = asString(INPUT_COLOR);
8964
+ this.ctx.fill();
8965
+ this.ctx.restore();
8966
+ }
8967
+ }
8968
+ else if (container.type === RADIO) {
8969
+ if (container.checked) {
8970
+ this.ctx.save();
8971
+ this.ctx.beginPath();
8972
+ this.ctx.arc(container.bounds.left + size / 2, container.bounds.top + size / 2, size / 4, 0, Math.PI * 2, true);
8973
+ this.ctx.fillStyle = asString(INPUT_COLOR);
8974
+ this.ctx.fill();
8975
+ this.ctx.restore();
8976
+ }
8977
+ }
8978
+ }
8979
+ if (isTextInputElement(container) && container.value.length) {
8980
+ const [font, fontFamily, fontSize] = this.textRenderer.createFontStyle(styles);
8981
+ const { baseline } = this.fontMetrics.getMetrics(fontFamily, fontSize);
8982
+ this.ctx.font = font;
8983
+ // Fix for Issue #92: Use placeholder color when rendering placeholder text
8984
+ const isPlaceholder = container instanceof InputElementContainer && container.isPlaceholder;
8985
+ this.ctx.fillStyle = isPlaceholder ? asString(PLACEHOLDER_COLOR) : asString(styles.color);
8986
+ this.ctx.textBaseline = 'alphabetic';
8987
+ this.ctx.textAlign = canvasTextAlign(container.styles.textAlign);
8988
+ const bounds = contentBox(container);
8989
+ let x = 0;
8990
+ switch (container.styles.textAlign) {
8991
+ case 1 /* TEXT_ALIGN.CENTER */:
8992
+ x += bounds.width / 2;
8993
+ break;
8994
+ case 2 /* TEXT_ALIGN.RIGHT */:
8995
+ x += bounds.width;
8996
+ break;
8997
+ }
8998
+ // Fix for Issue #92: Position text vertically centered in single-line input
8999
+ // Only apply vertical centering for InputElementContainer, not for textarea or select
9000
+ let verticalOffset = 0;
9001
+ if (container instanceof InputElementContainer) {
9002
+ const fontSizeValue = getAbsoluteValue(styles.fontSize, 0);
9003
+ verticalOffset = (bounds.height - fontSizeValue) / 2;
9004
+ }
9005
+ // Create text bounds with horizontal and vertical offsets
9006
+ // Height is not modified as it doesn't affect text rendering position
9007
+ const textBounds = bounds.add(x, verticalOffset, 0, 0);
9008
+ this.ctx.save();
9009
+ this.path([
9010
+ new Vector(bounds.left, bounds.top),
9011
+ new Vector(bounds.left + bounds.width, bounds.top),
9012
+ new Vector(bounds.left + bounds.width, bounds.top + bounds.height),
9013
+ new Vector(bounds.left, bounds.top + bounds.height)
9014
+ ]);
9015
+ this.ctx.clip();
9016
+ this.textRenderer.renderTextWithLetterSpacing(new TextBounds(container.value, textBounds), styles.letterSpacing, baseline);
9017
+ this.ctx.restore();
9018
+ this.ctx.textBaseline = 'alphabetic';
9019
+ this.ctx.textAlign = 'left';
8158
9020
  }
8159
- // Normal rendering (no ellipsis needed)
8160
- text.textBounds.forEach((text) => {
8161
- paintOrder.forEach((paintOrderLayer) => {
8162
- switch (paintOrderLayer) {
8163
- case 0 /* PAINT_ORDER_LAYER.FILL */:
8164
- this.ctx.fillStyle = asString(styles.color);
8165
- this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8166
- const textShadows = styles.textShadow;
8167
- if (textShadows.length && text.text.trim().length) {
8168
- textShadows
8169
- .slice(0)
8170
- .reverse()
8171
- .forEach((textShadow) => {
8172
- this.ctx.shadowColor = asString(textShadow.color);
8173
- this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
8174
- this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
8175
- this.ctx.shadowBlur = textShadow.blur.number;
8176
- this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8177
- });
8178
- this.ctx.shadowColor = '';
8179
- this.ctx.shadowOffsetX = 0;
8180
- this.ctx.shadowOffsetY = 0;
8181
- this.ctx.shadowBlur = 0;
8182
- }
8183
- if (styles.textDecorationLine.length) {
8184
- this.renderTextDecoration(text.bounds, styles);
8185
- }
8186
- break;
8187
- case 1 /* PAINT_ORDER_LAYER.STROKE */:
8188
- if (styles.webkitTextStrokeWidth && text.text.trim().length) {
8189
- this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8190
- this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8191
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8192
- // Issue #110: Use baseline (fontSize) for consistent positioning with fill
8193
- // Previously used text.bounds.height which caused stroke to render too low
8194
- const baseline = styles.fontSize.number;
8195
- if (styles.letterSpacing === 0) {
8196
- this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
8197
- }
8198
- else {
8199
- const letters = segmentGraphemes(text.text);
8200
- letters.reduce((left, letter) => {
8201
- this.ctx.strokeText(letter, left, text.bounds.top + baseline);
8202
- return left + this.ctx.measureText(letter).width;
8203
- }, text.bounds.left);
8204
- }
8205
- }
8206
- this.ctx.strokeStyle = '';
8207
- this.ctx.lineWidth = 0;
8208
- this.ctx.lineJoin = 'miter';
8209
- break;
9021
+ if (contains(container.styles.display, 2048 /* DISPLAY.LIST_ITEM */)) {
9022
+ if (container.styles.listStyleImage !== null) {
9023
+ const img = container.styles.listStyleImage;
9024
+ if (img.type === 0 /* CSSImageType.URL */) {
9025
+ let image;
9026
+ const url = img.url;
9027
+ try {
9028
+ image = await this.context.cache.match(url);
9029
+ this.ctx.drawImage(image, container.bounds.left - (image.width + 10), container.bounds.top);
9030
+ }
9031
+ catch (e) {
9032
+ this.context.logger.error(`Error loading list-style-image ${url}`);
9033
+ }
8210
9034
  }
8211
- });
9035
+ }
9036
+ else if (paint.listValue && container.styles.listStyleType !== -1 /* LIST_STYLE_TYPE.NONE */) {
9037
+ const [font] = this.textRenderer.createFontStyle(styles);
9038
+ this.ctx.font = font;
9039
+ this.ctx.fillStyle = asString(styles.color);
9040
+ this.ctx.textBaseline = 'middle';
9041
+ this.ctx.textAlign = 'right';
9042
+ const bounds = new Bounds(container.bounds.left, container.bounds.top + getAbsoluteValue(container.styles.paddingTop, container.bounds.width), container.bounds.width, computeLineHeight(styles.lineHeight, styles.fontSize.number) / 2 + 1);
9043
+ this.textRenderer.renderTextWithLetterSpacing(new TextBounds(paint.listValue, bounds), styles.letterSpacing, computeLineHeight(styles.lineHeight, styles.fontSize.number) / 2 + 2);
9044
+ this.ctx.textBaseline = 'bottom';
9045
+ this.ctx.textAlign = 'left';
9046
+ }
9047
+ }
9048
+ }
9049
+ async renderStackContent(stack) {
9050
+ if (contains(stack.element.container.flags, 16 /* FLAGS.DEBUG_RENDER */)) {
9051
+ debugger;
9052
+ }
9053
+ // https://www.w3.org/TR/css-position-3/#painting-order
9054
+ // 1. the background and borders of the element forming the stacking context.
9055
+ await this.renderNodeBackgroundAndBorders(stack.element);
9056
+ // 2. the child stacking contexts with negative stack levels (most negative first).
9057
+ for (const child of stack.negativeZIndex) {
9058
+ await this.renderStack(child);
9059
+ }
9060
+ // 3. For all its in-flow, non-positioned, block-level descendants in tree order:
9061
+ await this.renderNodeContent(stack.element);
9062
+ for (const child of stack.nonInlineLevel) {
9063
+ await this.renderNode(child);
9064
+ }
9065
+ // 4. All non-positioned floating descendants, in tree order. For each one of these,
9066
+ // treat the element as if it created a new stacking context, but any positioned descendants and descendants
9067
+ // which actually create a new stacking context should be considered part of the parent stacking context,
9068
+ // not this new one.
9069
+ for (const child of stack.nonPositionedFloats) {
9070
+ await this.renderStack(child);
9071
+ }
9072
+ // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
9073
+ for (const child of stack.nonPositionedInlineLevel) {
9074
+ await this.renderStack(child);
9075
+ }
9076
+ for (const child of stack.inlineLevel) {
9077
+ await this.renderNode(child);
9078
+ }
9079
+ // 6. All positioned, opacity or transform descendants, in tree order that fall into the following categories:
9080
+ // All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
9081
+ // For those with 'z-index: auto', treat the element as if it created a new stacking context,
9082
+ // but any positioned descendants and descendants which actually create a new stacking context should be
9083
+ // considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
9084
+ // treat the stacking context generated atomically.
9085
+ //
9086
+ // All opacity descendants with opacity less than 1
9087
+ //
9088
+ // All transform descendants with transform other than none
9089
+ for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
9090
+ await this.renderStack(child);
9091
+ }
9092
+ // 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
9093
+ // order (smallest first) then tree order.
9094
+ for (const child of stack.positiveZIndex) {
9095
+ await this.renderStack(child);
9096
+ }
9097
+ }
9098
+ mask(paths) {
9099
+ this.ctx.beginPath();
9100
+ this.ctx.moveTo(0, 0);
9101
+ // Use logical dimensions (options.width/height) instead of canvas pixel dimensions
9102
+ // because context has already been scaled by this.options.scale
9103
+ // Fix for Issue #126: Using canvas pixel dimensions causes broken output
9104
+ this.ctx.lineTo(this.options.width, 0);
9105
+ this.ctx.lineTo(this.options.width, this.options.height);
9106
+ this.ctx.lineTo(0, this.options.height);
9107
+ this.ctx.lineTo(0, 0);
9108
+ this.formatPath(paths.slice(0).reverse());
9109
+ this.ctx.closePath();
9110
+ }
9111
+ path(paths) {
9112
+ this.ctx.beginPath();
9113
+ this.formatPath(paths);
9114
+ this.ctx.closePath();
9115
+ }
9116
+ formatPath(paths) {
9117
+ paths.forEach((point, index) => {
9118
+ const start = isBezierCurve(point) ? point.start : point;
9119
+ if (index === 0) {
9120
+ this.ctx.moveTo(start.x, start.y);
9121
+ }
9122
+ else {
9123
+ this.ctx.lineTo(start.x, start.y);
9124
+ }
9125
+ if (isBezierCurve(point)) {
9126
+ this.ctx.bezierCurveTo(point.startControl.x, point.startControl.y, point.endControl.x, point.endControl.y, point.end.x, point.end.y);
9127
+ }
8212
9128
  });
8213
9129
  }
8214
- renderReplacedElement(container, curves, image) {
8215
- const intrinsicWidth = image.naturalWidth || container.intrinsicWidth;
8216
- const intrinsicHeight = image.naturalHeight || container.intrinsicHeight;
8217
- if (image && intrinsicWidth > 0 && intrinsicHeight > 0) {
8218
- const box = contentBox(container);
8219
- const path = calculatePaddingBoxPath(curves);
8220
- this.path(path);
9130
+ async renderNodeBackgroundAndBorders(paint) {
9131
+ this.effectsRenderer.applyEffects(paint.getEffects(2 /* EffectTarget.BACKGROUND_BORDERS */));
9132
+ const styles = paint.container.styles;
9133
+ const hasBackground = !isTransparent(styles.backgroundColor) || styles.backgroundImage.length;
9134
+ const borders = [
9135
+ { style: styles.borderTopStyle, color: styles.borderTopColor, width: styles.borderTopWidth },
9136
+ { style: styles.borderRightStyle, color: styles.borderRightColor, width: styles.borderRightWidth },
9137
+ { style: styles.borderBottomStyle, color: styles.borderBottomColor, width: styles.borderBottomWidth },
9138
+ { style: styles.borderLeftStyle, color: styles.borderLeftColor, width: styles.borderLeftWidth }
9139
+ ];
9140
+ const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(getBackgroundValueForIndex(styles.backgroundClip, 0), paint.curves);
9141
+ if (hasBackground || styles.boxShadow.length) {
8221
9142
  this.ctx.save();
9143
+ this.path(backgroundPaintingArea);
8222
9144
  this.ctx.clip();
8223
- let sx = 0, sy = 0, sw = intrinsicWidth, sh = intrinsicHeight, dx = box.left, dy = box.top, dw = box.width, dh = box.height;
8224
- const { objectFit } = container.styles;
8225
- const boxRatio = dw / dh;
8226
- const imgRatio = sw / sh;
8227
- if (objectFit === 2 /* OBJECT_FIT.CONTAIN */) {
8228
- if (imgRatio > boxRatio) {
8229
- dh = dw / imgRatio;
8230
- dy += (box.height - dh) / 2;
8231
- }
8232
- else {
8233
- dw = dh * imgRatio;
8234
- dx += (box.width - dw) / 2;
8235
- }
8236
- }
8237
- else if (objectFit === 4 /* OBJECT_FIT.COVER */) {
8238
- if (imgRatio > boxRatio) {
8239
- sw = sh * boxRatio;
8240
- sx += (intrinsicWidth - sw) / 2;
8241
- }
8242
- else {
8243
- sh = sw / boxRatio;
8244
- sy += (intrinsicHeight - sh) / 2;
8245
- }
9145
+ if (!isTransparent(styles.backgroundColor)) {
9146
+ this.ctx.fillStyle = asString(styles.backgroundColor);
9147
+ this.ctx.fill();
8246
9148
  }
8247
- else if (objectFit === 8 /* OBJECT_FIT.NONE */) {
8248
- if (sw > dw) {
8249
- sx += (sw - dw) / 2;
8250
- sw = dw;
9149
+ await this.backgroundRenderer.renderBackgroundImage(paint.container);
9150
+ this.ctx.restore();
9151
+ styles.boxShadow
9152
+ .slice(0)
9153
+ .reverse()
9154
+ .forEach((shadow) => {
9155
+ this.ctx.save();
9156
+ const borderBoxArea = calculateBorderBoxPath(paint.curves);
9157
+ const maskOffset = shadow.inset ? 0 : MASK_OFFSET;
9158
+ const shadowPaintingArea = transformPath(borderBoxArea, -maskOffset + (shadow.inset ? 1 : -1) * shadow.spread.number, (shadow.inset ? 1 : -1) * shadow.spread.number, shadow.spread.number * (shadow.inset ? -2 : 2), shadow.spread.number * (shadow.inset ? -2 : 2));
9159
+ if (shadow.inset) {
9160
+ this.path(borderBoxArea);
9161
+ this.ctx.clip();
9162
+ this.mask(shadowPaintingArea);
8251
9163
  }
8252
9164
  else {
8253
- dx += (dw - sw) / 2;
8254
- dw = sw;
9165
+ this.mask(borderBoxArea);
9166
+ this.ctx.clip();
9167
+ this.path(shadowPaintingArea);
8255
9168
  }
8256
- if (sh > dh) {
8257
- sy += (sh - dh) / 2;
8258
- sh = dh;
9169
+ this.ctx.shadowOffsetX = shadow.offsetX.number + maskOffset;
9170
+ this.ctx.shadowOffsetY = shadow.offsetY.number;
9171
+ this.ctx.shadowColor = asString(shadow.color);
9172
+ this.ctx.shadowBlur = shadow.blur.number;
9173
+ this.ctx.fillStyle = shadow.inset ? asString(shadow.color) : 'rgba(0,0,0,1)';
9174
+ this.ctx.fill();
9175
+ this.ctx.restore();
9176
+ });
9177
+ }
9178
+ let side = 0;
9179
+ for (const border of borders) {
9180
+ if (border.style !== 0 /* BORDER_STYLE.NONE */ && !isTransparent(border.color) && border.width > 0) {
9181
+ if (border.style === 2 /* BORDER_STYLE.DASHED */) {
9182
+ await this.borderRenderer.renderDashedDottedBorder(border.color, border.width, side, paint.curves, 2 /* BORDER_STYLE.DASHED */);
8259
9183
  }
8260
- else {
8261
- dy += (dh - sh) / 2;
8262
- dh = sh;
9184
+ else if (border.style === 3 /* BORDER_STYLE.DOTTED */) {
9185
+ await this.borderRenderer.renderDashedDottedBorder(border.color, border.width, side, paint.curves, 3 /* BORDER_STYLE.DOTTED */);
8263
9186
  }
8264
- }
8265
- else if (objectFit === 16 /* OBJECT_FIT.SCALE_DOWN */) {
8266
- const containW = imgRatio > boxRatio ? dw : dh * imgRatio;
8267
- const noneW = sw > dw ? sw : dw;
8268
- if (containW < noneW) {
8269
- if (imgRatio > boxRatio) {
8270
- dh = dw / imgRatio;
8271
- dy += (box.height - dh) / 2;
8272
- }
8273
- else {
8274
- dw = dh * imgRatio;
8275
- dx += (box.width - dw) / 2;
8276
- }
9187
+ else if (border.style === 4 /* BORDER_STYLE.DOUBLE */) {
9188
+ await this.borderRenderer.renderDoubleBorder(border.color, border.width, side, paint.curves);
8277
9189
  }
8278
9190
  else {
8279
- if (sw > dw) {
8280
- sx += (sw - dw) / 2;
8281
- sw = dw;
8282
- }
8283
- else {
8284
- dx += (dw - sw) / 2;
8285
- dw = sw;
8286
- }
8287
- if (sh > dh) {
8288
- sy += (sh - dh) / 2;
8289
- sh = dh;
8290
- }
8291
- else {
8292
- dy += (dh - sh) / 2;
8293
- dh = sh;
8294
- }
9191
+ await this.borderRenderer.renderSolidBorder(border.color, side, paint.curves);
8295
9192
  }
8296
9193
  }
8297
- this.ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
8298
- this.ctx.restore();
9194
+ side++;
8299
9195
  }
8300
9196
  }
8301
- async renderNodeContent(paint) {
8302
- this.applyEffects(paint.getEffects(4 /* EffectTarget.CONTENT */));
8303
- const container = paint.container;
8304
- const curves = paint.curves;
8305
- const styles = container.styles;
8306
- // Use content box for text overflow calculation (excludes padding and border)
8307
- // This matches browser behavior where text-overflow uses the content width
8308
- const textBounds = contentBox(container);
8309
- for (const child of container.textNodes) {
8310
- await this.renderTextNode(child, styles, textBounds);
8311
- }
8312
- if (container instanceof ImageElementContainer) {
8313
- try {
8314
- const image = await this.context.cache.match(container.src);
8315
- this.renderReplacedElement(container, curves, image);
8316
- }
8317
- catch (e) {
8318
- this.context.logger.error(`Error loading image ${container.src}`);
8319
- }
8320
- }
8321
- if (container instanceof CanvasElementContainer) {
8322
- this.renderReplacedElement(container, curves, container.canvas);
8323
- }
8324
- if (container instanceof SVGElementContainer) {
8325
- try {
8326
- const image = await this.context.cache.match(container.svg);
8327
- this.renderReplacedElement(container, curves, image);
8328
- }
8329
- catch (e) {
8330
- this.context.logger.error(`Error loading svg ${container.svg.substring(0, 255)}`);
8331
- }
8332
- }
8333
- if (container instanceof IFrameElementContainer && container.tree) {
8334
- const iframeRenderer = new CanvasRenderer(this.context, {
8335
- scale: this.options.scale,
8336
- backgroundColor: container.backgroundColor,
8337
- x: 0,
8338
- y: 0,
8339
- width: container.width,
8340
- height: container.height
8341
- });
8342
- const canvas = await iframeRenderer.render(container.tree);
8343
- if (container.width && container.height) {
8344
- this.ctx.drawImage(canvas, 0, 0, container.width, container.height, container.bounds.left, container.bounds.top, container.bounds.width, container.bounds.height);
8345
- }
9197
+ async render(element) {
9198
+ if (this.options.backgroundColor) {
9199
+ this.ctx.fillStyle = asString(this.options.backgroundColor);
9200
+ this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
8346
9201
  }
8347
- if (container instanceof InputElementContainer) {
8348
- const size = Math.min(container.bounds.width, container.bounds.height);
8349
- if (container.type === CHECKBOX) {
8350
- if (container.checked) {
8351
- this.ctx.save();
8352
- this.path([
8353
- new Vector(container.bounds.left + size * 0.39363, container.bounds.top + size * 0.79),
8354
- new Vector(container.bounds.left + size * 0.16, container.bounds.top + size * 0.5549),
8355
- new Vector(container.bounds.left + size * 0.27347, container.bounds.top + size * 0.44071),
8356
- new Vector(container.bounds.left + size * 0.39694, container.bounds.top + size * 0.5649),
8357
- new Vector(container.bounds.left + size * 0.72983, container.bounds.top + size * 0.23),
8358
- new Vector(container.bounds.left + size * 0.84, container.bounds.top + size * 0.34085),
8359
- new Vector(container.bounds.left + size * 0.39363, container.bounds.top + size * 0.79)
8360
- ]);
8361
- this.ctx.fillStyle = asString(INPUT_COLOR);
8362
- this.ctx.fill();
8363
- this.ctx.restore();
8364
- }
8365
- }
8366
- else if (container.type === RADIO) {
8367
- if (container.checked) {
8368
- this.ctx.save();
8369
- this.ctx.beginPath();
8370
- this.ctx.arc(container.bounds.left + size / 2, container.bounds.top + size / 2, size / 4, 0, Math.PI * 2, true);
8371
- this.ctx.fillStyle = asString(INPUT_COLOR);
8372
- this.ctx.fill();
8373
- this.ctx.restore();
8374
- }
8375
- }
9202
+ const stack = parseStackingContexts(element);
9203
+ await this.renderStack(stack);
9204
+ this.effectsRenderer.applyEffects([]);
9205
+ return this.canvas;
9206
+ }
9207
+ }
9208
+ const isTextInputElement = (container) => {
9209
+ if (container instanceof TextareaElementContainer) {
9210
+ return true;
9211
+ }
9212
+ else if (container instanceof SelectElementContainer) {
9213
+ return true;
9214
+ }
9215
+ else if (container instanceof InputElementContainer && container.type !== RADIO && container.type !== CHECKBOX) {
9216
+ return true;
9217
+ }
9218
+ return false;
9219
+ };
9220
+ const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
9221
+ switch (clip) {
9222
+ case 0 /* BACKGROUND_CLIP.BORDER_BOX */:
9223
+ return calculateBorderBoxPath(curves);
9224
+ case 2 /* BACKGROUND_CLIP.CONTENT_BOX */:
9225
+ return calculateContentBoxPath(curves);
9226
+ case 1 /* BACKGROUND_CLIP.PADDING_BOX */:
9227
+ default:
9228
+ return calculatePaddingBoxPath(curves);
9229
+ }
9230
+ };
9231
+ const canvasTextAlign = (textAlign) => {
9232
+ switch (textAlign) {
9233
+ case 1 /* TEXT_ALIGN.CENTER */:
9234
+ return 'center';
9235
+ case 2 /* TEXT_ALIGN.RIGHT */:
9236
+ return 'right';
9237
+ case 0 /* TEXT_ALIGN.LEFT */:
9238
+ default:
9239
+ return 'left';
9240
+ }
9241
+ };
9242
+
9243
+ class ForeignObjectRenderer extends Renderer {
9244
+ constructor(context, options) {
9245
+ super(context, options);
9246
+ this.canvas = options.canvas ? options.canvas : document.createElement('canvas');
9247
+ this.ctx = this.canvas.getContext('2d');
9248
+ this.options = options;
9249
+ this.canvas.width = Math.floor(options.width * options.scale);
9250
+ this.canvas.height = Math.floor(options.height * options.scale);
9251
+ this.canvas.style.width = `${options.width}px`;
9252
+ this.canvas.style.height = `${options.height}px`;
9253
+ this.ctx.scale(this.options.scale, this.options.scale);
9254
+ this.ctx.translate(-options.x, -options.y);
9255
+ this.context.logger.debug(`EXPERIMENTAL ForeignObject renderer initialized (${options.width}x${options.height} at ${options.x},${options.y}) with scale ${options.scale}`);
9256
+ }
9257
+ async render(element) {
9258
+ const svg = createForeignObjectSVG(this.options.width * this.options.scale, this.options.height * this.options.scale, this.options.scale, this.options.scale, element);
9259
+ const img = await loadSerializedSVG(svg);
9260
+ if (this.options.backgroundColor) {
9261
+ this.ctx.fillStyle = asString(this.options.backgroundColor);
9262
+ this.ctx.fillRect(0, 0, this.options.width * this.options.scale, this.options.height * this.options.scale);
8376
9263
  }
8377
- if (isTextInputElement(container) && container.value.length) {
8378
- const [font, fontFamily, fontSize] = this.createFontStyle(styles);
8379
- const { baseline } = this.fontMetrics.getMetrics(fontFamily, fontSize);
8380
- this.ctx.font = font;
8381
- // Fix for Issue #92: Use placeholder color when rendering placeholder text
8382
- const isPlaceholder = container instanceof InputElementContainer && container.isPlaceholder;
8383
- this.ctx.fillStyle = isPlaceholder ? asString(PLACEHOLDER_COLOR) : asString(styles.color);
8384
- this.ctx.textBaseline = 'alphabetic';
8385
- this.ctx.textAlign = canvasTextAlign(container.styles.textAlign);
8386
- const bounds = contentBox(container);
8387
- let x = 0;
8388
- switch (container.styles.textAlign) {
8389
- case 1 /* TEXT_ALIGN.CENTER */:
8390
- x += bounds.width / 2;
8391
- break;
8392
- case 2 /* TEXT_ALIGN.RIGHT */:
8393
- x += bounds.width;
8394
- break;
9264
+ this.ctx.drawImage(img, -this.options.x * this.options.scale, -this.options.y * this.options.scale);
9265
+ return this.canvas;
9266
+ }
9267
+ }
9268
+ const loadSerializedSVG = (svg) => new Promise((resolve, reject) => {
9269
+ const img = new Image();
9270
+ img.onload = () => {
9271
+ resolve(img);
9272
+ };
9273
+ img.onerror = reject;
9274
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
9275
+ });
9276
+
9277
+ class Logger {
9278
+ constructor({ id, enabled }) {
9279
+ this.id = id;
9280
+ this.enabled = enabled;
9281
+ this.start = Date.now();
9282
+ }
9283
+ debug(...args) {
9284
+ if (this.enabled) {
9285
+ // eslint-disable-next-line no-console
9286
+ if (typeof window !== 'undefined' && window.console && typeof console.debug === 'function') {
9287
+ // eslint-disable-next-line no-console
9288
+ console.debug(this.id, `${this.getTime()}ms`, ...args);
8395
9289
  }
8396
- // Fix for Issue #92: Position text vertically centered in single-line input
8397
- // Only apply vertical centering for InputElementContainer, not for textarea or select
8398
- let verticalOffset = 0;
8399
- if (container instanceof InputElementContainer) {
8400
- const fontSizeValue = getAbsoluteValue(styles.fontSize, 0);
8401
- verticalOffset = (bounds.height - fontSizeValue) / 2;
9290
+ else {
9291
+ this.info(...args);
8402
9292
  }
8403
- // Create text bounds with horizontal and vertical offsets
8404
- // Height is not modified as it doesn't affect text rendering position
8405
- const textBounds = bounds.add(x, verticalOffset, 0, 0);
8406
- this.ctx.save();
8407
- this.path([
8408
- new Vector(bounds.left, bounds.top),
8409
- new Vector(bounds.left + bounds.width, bounds.top),
8410
- new Vector(bounds.left + bounds.width, bounds.top + bounds.height),
8411
- new Vector(bounds.left, bounds.top + bounds.height)
8412
- ]);
8413
- this.ctx.clip();
8414
- this.renderTextWithLetterSpacing(new TextBounds(container.value, textBounds), styles.letterSpacing, baseline);
8415
- this.ctx.restore();
8416
- this.ctx.textBaseline = 'alphabetic';
8417
- this.ctx.textAlign = 'left';
8418
9293
  }
8419
- if (contains(container.styles.display, 2048 /* DISPLAY.LIST_ITEM */)) {
8420
- if (container.styles.listStyleImage !== null) {
8421
- const img = container.styles.listStyleImage;
8422
- if (img.type === 0 /* CSSImageType.URL */) {
8423
- let image;
8424
- const url = img.url;
8425
- try {
8426
- image = await this.context.cache.match(url);
8427
- this.ctx.drawImage(image, container.bounds.left - (image.width + 10), container.bounds.top);
8428
- }
8429
- catch (e) {
8430
- this.context.logger.error(`Error loading list-style-image ${url}`);
8431
- }
8432
- }
9294
+ }
9295
+ getTime() {
9296
+ return Date.now() - this.start;
9297
+ }
9298
+ info(...args) {
9299
+ if (this.enabled) {
9300
+ // eslint-disable-next-line no-console
9301
+ if (typeof window !== 'undefined' && window.console && typeof console.info === 'function') {
9302
+ // eslint-disable-next-line no-console
9303
+ console.info(this.id, `${this.getTime()}ms`, ...args);
8433
9304
  }
8434
- else if (paint.listValue && container.styles.listStyleType !== -1 /* LIST_STYLE_TYPE.NONE */) {
8435
- const [font] = this.createFontStyle(styles);
8436
- this.ctx.font = font;
8437
- this.ctx.fillStyle = asString(styles.color);
8438
- this.ctx.textBaseline = 'middle';
8439
- this.ctx.textAlign = 'right';
8440
- const bounds = new Bounds(container.bounds.left, container.bounds.top + getAbsoluteValue(container.styles.paddingTop, container.bounds.width), container.bounds.width, computeLineHeight(styles.lineHeight, styles.fontSize.number) / 2 + 1);
8441
- this.renderTextWithLetterSpacing(new TextBounds(paint.listValue, bounds), styles.letterSpacing, computeLineHeight(styles.lineHeight, styles.fontSize.number) / 2 + 2);
8442
- this.ctx.textBaseline = 'bottom';
8443
- this.ctx.textAlign = 'left';
9305
+ }
9306
+ }
9307
+ warn(...args) {
9308
+ if (this.enabled) {
9309
+ if (typeof window !== 'undefined' && window.console && typeof console.warn === 'function') {
9310
+ console.warn(this.id, `${this.getTime()}ms`, ...args);
9311
+ }
9312
+ else {
9313
+ this.info(...args);
8444
9314
  }
8445
9315
  }
8446
9316
  }
8447
- async renderStackContent(stack) {
8448
- if (contains(stack.element.container.flags, 16 /* FLAGS.DEBUG_RENDER */)) {
8449
- debugger;
9317
+ error(...args) {
9318
+ if (this.enabled) {
9319
+ if (typeof window !== 'undefined' && window.console && typeof console.error === 'function') {
9320
+ console.error(this.id, `${this.getTime()}ms`, ...args);
9321
+ }
9322
+ else {
9323
+ this.info(...args);
9324
+ }
8450
9325
  }
8451
- // https://www.w3.org/TR/css-position-3/#painting-order
8452
- // 1. the background and borders of the element forming the stacking context.
8453
- await this.renderNodeBackgroundAndBorders(stack.element);
8454
- // 2. the child stacking contexts with negative stack levels (most negative first).
8455
- for (const child of stack.negativeZIndex) {
8456
- await this.renderStack(child);
9326
+ }
9327
+ }
9328
+ Logger.instances = {};
9329
+
9330
+ class Cache {
9331
+ constructor(context, _options) {
9332
+ this.context = context;
9333
+ this._options = _options;
9334
+ this._cache = new Map();
9335
+ this._pendingOperations = new Map();
9336
+ // Default cache size: 100 items
9337
+ this.maxSize = _options.maxCacheSize ?? 100;
9338
+ if (this.maxSize < 1) {
9339
+ throw new Error('Cache maxSize must be at least 1');
8457
9340
  }
8458
- // 3. For all its in-flow, non-positioned, block-level descendants in tree order:
8459
- await this.renderNodeContent(stack.element);
8460
- for (const child of stack.nonInlineLevel) {
8461
- await this.renderNode(child);
9341
+ if (this.maxSize > 10000) {
9342
+ this.context.logger.warn(`Cache maxSize ${this.maxSize} is very large and may cause memory issues. ` +
9343
+ `Consider using a smaller value (recommended: 100-1000).`);
8462
9344
  }
8463
- // 4. All non-positioned floating descendants, in tree order. For each one of these,
8464
- // treat the element as if it created a new stacking context, but any positioned descendants and descendants
8465
- // which actually create a new stacking context should be considered part of the parent stacking context,
8466
- // not this new one.
8467
- for (const child of stack.nonPositionedFloats) {
8468
- await this.renderStack(child);
9345
+ }
9346
+ addImage(src) {
9347
+ // Wait for any pending operations on this key
9348
+ const pending = this._pendingOperations.get(src);
9349
+ if (pending) {
9350
+ return pending;
8469
9351
  }
8470
- // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
8471
- for (const child of stack.nonPositionedInlineLevel) {
8472
- await this.renderStack(child);
9352
+ if (this.has(src)) {
9353
+ // Update last accessed time
9354
+ const entry = this._cache.get(src);
9355
+ if (entry) {
9356
+ entry.lastAccessed = Date.now();
9357
+ }
9358
+ return Promise.resolve();
8473
9359
  }
8474
- for (const child of stack.inlineLevel) {
8475
- await this.renderNode(child);
9360
+ if (isBlobImage(src) || isRenderable(src)) {
9361
+ // Create a pending operation to ensure atomicity
9362
+ const operation = this._addImageInternal(src);
9363
+ this._pendingOperations.set(src, operation);
9364
+ operation.finally(() => {
9365
+ this._pendingOperations.delete(src);
9366
+ });
9367
+ return operation;
8476
9368
  }
8477
- // 6. All positioned, opacity or transform descendants, in tree order that fall into the following categories:
8478
- // All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
8479
- // For those with 'z-index: auto', treat the element as if it created a new stacking context,
8480
- // but any positioned descendants and descendants which actually create a new stacking context should be
8481
- // considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
8482
- // treat the stacking context generated atomically.
8483
- //
8484
- // All opacity descendants with opacity less than 1
8485
- //
8486
- // All transform descendants with transform other than none
8487
- for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
8488
- await this.renderStack(child);
9369
+ return Promise.resolve();
9370
+ }
9371
+ async _addImageInternal(src) {
9372
+ // Create image load promise with timeout protection
9373
+ const timeoutMs = this._options.imageTimeout ?? 15000;
9374
+ const timeoutPromise = new Promise((_, reject) => {
9375
+ setTimeout(() => {
9376
+ reject(new Error(`Image load timeout after ${timeoutMs}ms: ${src}`));
9377
+ }, timeoutMs);
9378
+ });
9379
+ // Race between image load and timeout
9380
+ const imageWithTimeout = Promise.race([this.loadImage(src), timeoutPromise]);
9381
+ // Handle errors to prevent unhandled rejections
9382
+ imageWithTimeout.catch((error) => {
9383
+ this.context.logger.error(`Failed to load image ${src}: ${error instanceof Error ? error.message : 'Unknown error'}`);
9384
+ });
9385
+ // Store the promise with timeout in cache
9386
+ this.set(src, imageWithTimeout);
9387
+ }
9388
+ match(src) {
9389
+ const entry = this._cache.get(src);
9390
+ if (entry) {
9391
+ // Update last accessed time on access
9392
+ entry.lastAccessed = Date.now();
9393
+ return entry.value;
8489
9394
  }
8490
- // 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
8491
- // order (smallest first) then tree order.
8492
- for (const child of stack.positiveZIndex) {
8493
- await this.renderStack(child);
9395
+ return undefined;
9396
+ }
9397
+ /**
9398
+ * Set a value in cache with LRU eviction
9399
+ */
9400
+ set(key, value) {
9401
+ // If key already exists, update it without eviction
9402
+ if (this._cache.has(key)) {
9403
+ const entry = this._cache.get(key);
9404
+ entry.value = value;
9405
+ entry.lastAccessed = Date.now();
9406
+ return;
9407
+ }
9408
+ // For new keys, check if we need to evict
9409
+ if (this._cache.size >= this.maxSize) {
9410
+ this.evictLRU();
8494
9411
  }
9412
+ this._cache.set(key, {
9413
+ value,
9414
+ lastAccessed: Date.now()
9415
+ });
8495
9416
  }
8496
- mask(paths) {
8497
- this.ctx.beginPath();
8498
- this.ctx.moveTo(0, 0);
8499
- // Use logical dimensions (options.width/height) instead of canvas pixel dimensions
8500
- // because context has already been scaled by this.options.scale
8501
- // Fix for Issue #126: Using canvas pixel dimensions causes broken output
8502
- this.ctx.lineTo(this.options.width, 0);
8503
- this.ctx.lineTo(this.options.width, this.options.height);
8504
- this.ctx.lineTo(0, this.options.height);
8505
- this.ctx.lineTo(0, 0);
8506
- this.formatPath(paths.slice(0).reverse());
8507
- this.ctx.closePath();
9417
+ /**
9418
+ * Evict least recently used entry
9419
+ */
9420
+ evictLRU() {
9421
+ let oldestKey = null;
9422
+ let oldestTime = Infinity;
9423
+ for (const [key, entry] of this._cache.entries()) {
9424
+ if (entry.lastAccessed < oldestTime) {
9425
+ oldestTime = entry.lastAccessed;
9426
+ oldestKey = key;
9427
+ }
9428
+ }
9429
+ if (oldestKey) {
9430
+ this._cache.delete(oldestKey);
9431
+ this.context.logger.debug(`Cache: Evicted LRU entry: ${oldestKey}`);
9432
+ }
8508
9433
  }
8509
- path(paths) {
8510
- this.ctx.beginPath();
8511
- this.formatPath(paths);
8512
- this.ctx.closePath();
9434
+ /**
9435
+ * Get cache size
9436
+ */
9437
+ size() {
9438
+ return this._cache.size;
8513
9439
  }
8514
- formatPath(paths) {
8515
- paths.forEach((point, index) => {
8516
- const start = isBezierCurve(point) ? point.start : point;
8517
- if (index === 0) {
8518
- this.ctx.moveTo(start.x, start.y);
9440
+ /**
9441
+ * Get max cache size
9442
+ */
9443
+ getMaxSize() {
9444
+ return this.maxSize;
9445
+ }
9446
+ /**
9447
+ * Clear all cache entries
9448
+ */
9449
+ clear() {
9450
+ this._cache.clear();
9451
+ }
9452
+ async loadImage(key) {
9453
+ const originChecker = this.context.originChecker;
9454
+ const defaultIsSameOrigin = (src) => originChecker.isSameOrigin(src);
9455
+ const isSameOrigin = typeof this._options.customIsSameOrigin === 'function'
9456
+ ? await this._options.customIsSameOrigin(key, defaultIsSameOrigin)
9457
+ : defaultIsSameOrigin(key);
9458
+ const useCORS = !isInlineImage(key) && this._options.useCORS === true && FEATURES.SUPPORT_CORS_IMAGES && !isSameOrigin;
9459
+ const useProxy = !isInlineImage(key) &&
9460
+ !isSameOrigin &&
9461
+ !isBlobImage(key) &&
9462
+ typeof this._options.proxy === 'string' &&
9463
+ FEATURES.SUPPORT_CORS_XHR &&
9464
+ !useCORS;
9465
+ if (!isSameOrigin &&
9466
+ this._options.allowTaint === false &&
9467
+ !isInlineImage(key) &&
9468
+ !isBlobImage(key) &&
9469
+ !useProxy &&
9470
+ !useCORS) {
9471
+ return;
9472
+ }
9473
+ let src = key;
9474
+ if (useProxy) {
9475
+ src = await this.proxy(src);
9476
+ }
9477
+ this.context.logger.debug(`Added image ${key.substring(0, 256)}`);
9478
+ return await new Promise((resolve, reject) => {
9479
+ const img = new Image();
9480
+ img.onload = () => resolve(img);
9481
+ img.onerror = reject;
9482
+ //ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
9483
+ if (isInlineBase64Image(src) || useCORS) {
9484
+ img.crossOrigin = 'anonymous';
8519
9485
  }
8520
- else {
8521
- this.ctx.lineTo(start.x, start.y);
9486
+ img.src = src;
9487
+ if (img.complete === true) {
9488
+ // Inline XML images may fail to parse, throwing an Error later on
9489
+ setTimeout(() => resolve(img), 500);
8522
9490
  }
8523
- if (isBezierCurve(point)) {
8524
- this.ctx.bezierCurveTo(point.startControl.x, point.startControl.y, point.endControl.x, point.endControl.y, point.end.x, point.end.y);
9491
+ if (this._options.imageTimeout > 0) {
9492
+ setTimeout(() => reject(`Timed out (${this._options.imageTimeout}ms) loading image`), this._options.imageTimeout);
9493
+ }
9494
+ });
9495
+ }
9496
+ has(key) {
9497
+ return this._cache.has(key);
9498
+ }
9499
+ keys() {
9500
+ return Promise.resolve(Object.keys(this._cache));
9501
+ }
9502
+ proxy(src) {
9503
+ const proxy = this._options.proxy;
9504
+ if (!proxy) {
9505
+ throw new Error('No proxy defined');
9506
+ }
9507
+ const key = src.substring(0, 256);
9508
+ return new Promise((resolve, reject) => {
9509
+ const responseType = FEATURES.SUPPORT_RESPONSE_TYPE ? 'blob' : 'text';
9510
+ const xhr = new XMLHttpRequest();
9511
+ xhr.onload = () => {
9512
+ if (xhr.status === 200) {
9513
+ if (responseType === 'text') {
9514
+ resolve(xhr.response);
9515
+ }
9516
+ else {
9517
+ const reader = new FileReader();
9518
+ reader.addEventListener('load', () => resolve(reader.result), false);
9519
+ reader.addEventListener('error', (e) => reject(e), false);
9520
+ reader.readAsDataURL(xhr.response);
9521
+ }
9522
+ }
9523
+ else {
9524
+ reject(`Failed to proxy resource ${key} with status code ${xhr.status}`);
9525
+ }
9526
+ };
9527
+ xhr.onerror = reject;
9528
+ const queryString = proxy.indexOf('?') > -1 ? '&' : '?';
9529
+ xhr.open('GET', `${proxy}${queryString}url=${encodeURIComponent(src)}&responseType=${responseType}`);
9530
+ if (responseType !== 'text' && xhr instanceof XMLHttpRequest) {
9531
+ xhr.responseType = responseType;
9532
+ }
9533
+ if (this._options.imageTimeout) {
9534
+ const timeout = this._options.imageTimeout;
9535
+ xhr.timeout = timeout;
9536
+ xhr.ontimeout = () => reject(`Timed out (${timeout}ms) proxying ${key}`);
8525
9537
  }
9538
+ xhr.send();
9539
+ });
9540
+ }
9541
+ }
9542
+ const INLINE_SVG = /^data:image\/svg\+xml/i;
9543
+ const INLINE_BASE64 = /^data:image\/.*;base64,/i;
9544
+ const INLINE_IMG = /^data:image\/.*/i;
9545
+ const isRenderable = (src) => FEATURES.SUPPORT_SVG_DRAWING || !isSVG(src);
9546
+ const isInlineImage = (src) => INLINE_IMG.test(src);
9547
+ const isInlineBase64Image = (src) => INLINE_BASE64.test(src);
9548
+ const isBlobImage = (src) => src.substr(0, 4) === 'blob';
9549
+ const isSVG = (src) => src.substr(-3).toLowerCase() === 'svg' || INLINE_SVG.test(src);
9550
+
9551
+ /**
9552
+ * Origin Checker
9553
+ *
9554
+ * Provides origin checking functionality without global static state.
9555
+ * Each instance maintains its own anchor element and origin reference.
9556
+ *
9557
+ * Replaces the static methods in CacheStorage with instance-based approach.
9558
+ */
9559
+ class OriginChecker {
9560
+ constructor(window) {
9561
+ if (!window || !window.document) {
9562
+ throw new Error('Valid window object required for OriginChecker');
9563
+ }
9564
+ if (!window.location || !window.location.href) {
9565
+ throw new Error('Window object must have valid location');
9566
+ }
9567
+ this.link = window.document.createElement('a');
9568
+ this.origin = this.getOrigin(window.location.href);
9569
+ }
9570
+ /**
9571
+ * Get the origin (protocol + hostname + port) of a URL
9572
+ *
9573
+ * @param url - URL to parse
9574
+ * @returns Origin string (e.g., "https://example.com:8080")
9575
+ */
9576
+ getOrigin(url) {
9577
+ this.link.href = url;
9578
+ // IE9 hack: accessing href twice to ensure it's properly parsed
9579
+ this.link.href = this.link.href;
9580
+ return this.link.protocol + this.link.hostname + this.link.port;
9581
+ }
9582
+ /**
9583
+ * Check if a URL is from the same origin as the context
9584
+ *
9585
+ * @param src - URL to check
9586
+ * @returns true if same origin, false otherwise
9587
+ */
9588
+ isSameOrigin(src) {
9589
+ return this.getOrigin(src) === this.origin;
9590
+ }
9591
+ /**
9592
+ * Get the current context origin
9593
+ *
9594
+ * @returns The origin of the context window
9595
+ */
9596
+ getContextOrigin() {
9597
+ return this.origin;
9598
+ }
9599
+ }
9600
+
9601
+ class Context {
9602
+ constructor(options, windowBounds, config) {
9603
+ this.windowBounds = windowBounds;
9604
+ this.instanceName = `#${Context.instanceCount++}`;
9605
+ this.config = config;
9606
+ this.logger = new Logger({ id: this.instanceName, enabled: options.logging });
9607
+ this.originChecker = new OriginChecker(config.window);
9608
+ this.cache = options.cache ?? config.cache ?? new Cache(this, options);
9609
+ }
9610
+ }
9611
+ Context.instanceCount = 1;
9612
+
9613
+ /**
9614
+ * Html2Canvas Configuration
9615
+ *
9616
+ * Manages configuration state for html2canvas rendering.
9617
+ * Eliminates the need for global static variables.
9618
+ */
9619
+ class Html2CanvasConfig {
9620
+ constructor(options = {}) {
9621
+ // Try to get window from options first, then fall back to global window
9622
+ this.window = options.window || (typeof window !== 'undefined' ? window : null);
9623
+ if (!this.window) {
9624
+ throw new Error('Window object is required but not available');
9625
+ }
9626
+ this.cspNonce = options.cspNonce;
9627
+ this.cache = options.cache;
9628
+ }
9629
+ /**
9630
+ * Create configuration from an element
9631
+ * Extracts window from element's owner document
9632
+ */
9633
+ static fromElement(element, options = {}) {
9634
+ const ownerDocument = element.ownerDocument;
9635
+ if (!ownerDocument) {
9636
+ throw new Error('Element is not attached to a document');
9637
+ }
9638
+ const defaultView = ownerDocument.defaultView;
9639
+ if (!defaultView) {
9640
+ throw new Error('Document is not attached to a window');
9641
+ }
9642
+ return new Html2CanvasConfig({
9643
+ window: defaultView,
9644
+ ...options
8526
9645
  });
8527
9646
  }
8528
- renderRepeat(path, pattern, offsetX, offsetY) {
8529
- this.path(path);
8530
- this.ctx.fillStyle = pattern;
8531
- this.ctx.translate(offsetX, offsetY);
8532
- this.ctx.fill();
8533
- this.ctx.translate(-offsetX, -offsetY);
9647
+ /**
9648
+ * Clone configuration with override options
9649
+ */
9650
+ clone(options = {}) {
9651
+ return new Html2CanvasConfig({
9652
+ window: options.window || this.window,
9653
+ cspNonce: options.cspNonce ?? this.cspNonce,
9654
+ cache: options.cache ?? this.cache
9655
+ });
8534
9656
  }
8535
- resizeImage(image, width, height) {
8536
- // https://github.com/niklasvh/html2canvas/pull/2911
8537
- // if (image.width === width && image.height === height) {
8538
- // return image;
8539
- // }
8540
- const ownerDocument = this.canvas.ownerDocument ?? document;
8541
- const canvas = ownerDocument.createElement('canvas');
8542
- canvas.width = Math.max(1, width);
8543
- canvas.height = Math.max(1, height);
8544
- const ctx = canvas.getContext('2d');
8545
- ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height);
8546
- return canvas;
9657
+ }
9658
+ /**
9659
+ * Set default configuration
9660
+ * @deprecated Pass configuration directly to html2canvas instead
9661
+ */
9662
+ function setDefaultConfig(config) {
9663
+ console.warn('[html2canvas-pro] setDefaultConfig is deprecated. Pass configuration to html2canvas directly.');
9664
+ }
9665
+
9666
+ /**
9667
+ * Input Validator
9668
+ *
9669
+ * Provides validation and sanitization for user inputs to prevent security vulnerabilities
9670
+ * including SSRF, XSS, and injection attacks.
9671
+ */
9672
+ /**
9673
+ * Input Validator
9674
+ *
9675
+ * Validates and sanitizes user inputs for security and correctness.
9676
+ */
9677
+ class Validator {
9678
+ constructor(config = {}) {
9679
+ this.config = {
9680
+ maxImageTimeout: 300000, // 5 minutes default
9681
+ allowDataUrls: true,
9682
+ ...config
9683
+ };
8547
9684
  }
8548
- async renderBackgroundImage(container) {
8549
- let index = container.styles.backgroundImage.length - 1;
8550
- for (const backgroundImage of container.styles.backgroundImage.slice(0).reverse()) {
8551
- if (backgroundImage.type === 0 /* CSSImageType.URL */) {
8552
- let image;
8553
- const url = backgroundImage.url;
8554
- try {
8555
- image = await this.context.cache.match(url);
8556
- }
8557
- catch (e) {
8558
- this.context.logger.error(`Error loading background-image ${url}`);
8559
- }
8560
- if (image) {
8561
- const imageWidth = isNaN(image.width) || image.width === 0 ? 1 : image.width;
8562
- const imageHeight = isNaN(image.height) || image.height === 0 ? 1 : image.height;
8563
- const [path, x, y, width, height] = calculateBackgroundRendering(container, index, [
8564
- imageWidth,
8565
- imageHeight,
8566
- imageWidth / imageHeight
8567
- ]);
8568
- const pattern = this.ctx.createPattern(this.resizeImage(image, width, height), 'repeat');
8569
- this.renderRepeat(path, pattern, x, y);
8570
- }
9685
+ /**
9686
+ * Validate a URL
9687
+ *
9688
+ * @param url - URL to validate
9689
+ * @param context - Context for validation (e.g., 'proxy', 'image')
9690
+ * @returns Validation result
9691
+ */
9692
+ validateUrl(url, context = 'general') {
9693
+ if (!url || typeof url !== 'string') {
9694
+ return {
9695
+ valid: false,
9696
+ error: 'URL must be a non-empty string'
9697
+ };
9698
+ }
9699
+ // Check for data URLs
9700
+ if (url.startsWith('data:')) {
9701
+ if (!this.config.allowDataUrls) {
9702
+ return {
9703
+ valid: false,
9704
+ error: 'Data URLs are not allowed'
9705
+ };
8571
9706
  }
8572
- else if (isLinearGradient(backgroundImage)) {
8573
- const [path, x, y, width, height] = calculateBackgroundRendering(container, index, [null, null, null]);
8574
- const [lineLength, x0, x1, y0, y1] = calculateGradientDirection(backgroundImage.angle, width, height);
8575
- const canvas = document.createElement('canvas');
8576
- canvas.width = width;
8577
- canvas.height = height;
8578
- const ctx = canvas.getContext('2d');
8579
- const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
8580
- processColorStops(backgroundImage.stops, lineLength || 1).forEach((colorStop) => gradient.addColorStop(colorStop.stop, asString(colorStop.color)));
8581
- ctx.fillStyle = gradient;
8582
- ctx.fillRect(0, 0, width, height);
8583
- if (width > 0 && height > 0) {
8584
- const pattern = this.ctx.createPattern(canvas, 'repeat');
8585
- this.renderRepeat(path, pattern, x, y);
9707
+ return { valid: true, sanitized: url };
9708
+ }
9709
+ // Check for blob URLs
9710
+ if (url.startsWith('blob:')) {
9711
+ return { valid: true, sanitized: url };
9712
+ }
9713
+ // Validate URL format
9714
+ try {
9715
+ const parsedUrl = new URL(url);
9716
+ // Only allow http and https protocols
9717
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
9718
+ return {
9719
+ valid: false,
9720
+ error: `Protocol ${parsedUrl.protocol} is not allowed. Only http and https are permitted.`
9721
+ };
9722
+ }
9723
+ // For proxy URLs, check domain whitelist
9724
+ if (context === 'proxy' && this.config.allowedProxyDomains && this.config.allowedProxyDomains.length > 0) {
9725
+ const hostname = parsedUrl.hostname.toLowerCase();
9726
+ const isAllowed = this.config.allowedProxyDomains.some((domain) => {
9727
+ const normalizedDomain = domain.toLowerCase();
9728
+ return hostname === normalizedDomain || hostname.endsWith('.' + normalizedDomain);
9729
+ });
9730
+ if (!isAllowed) {
9731
+ return {
9732
+ valid: false,
9733
+ error: `Proxy domain ${parsedUrl.hostname} is not in the allowed list`
9734
+ };
8586
9735
  }
8587
9736
  }
8588
- else if (isRadialGradient(backgroundImage)) {
8589
- const [path, left, top, width, height] = calculateBackgroundRendering(container, index, [
8590
- null,
8591
- null,
8592
- null
8593
- ]);
8594
- const position = backgroundImage.position.length === 0 ? [FIFTY_PERCENT] : backgroundImage.position;
8595
- const x = getAbsoluteValue(position[0], width);
8596
- const y = getAbsoluteValue(position[position.length - 1], height);
8597
- let [rx, ry] = calculateRadius(backgroundImage, x, y, width, height);
8598
- // Handle edge case where radial gradient size is 0
8599
- // Use a minimum value of 0.01 to ensure gradient is still rendered
8600
- if (rx === 0 || ry === 0) {
8601
- rx = Math.max(rx, 0.01);
8602
- ry = Math.max(ry, 0.01);
8603
- }
8604
- if (rx > 0 && ry > 0) {
8605
- const radialGradient = this.ctx.createRadialGradient(left + x, top + y, 0, left + x, top + y, rx);
8606
- processColorStops(backgroundImage.stops, rx * 2).forEach((colorStop) => radialGradient.addColorStop(colorStop.stop, asString(colorStop.color)));
8607
- this.path(path);
8608
- this.ctx.fillStyle = radialGradient;
8609
- if (rx !== ry) {
8610
- // transforms for elliptical radial gradient
8611
- const midX = container.bounds.left + 0.5 * container.bounds.width;
8612
- const midY = container.bounds.top + 0.5 * container.bounds.height;
8613
- const f = ry / rx;
8614
- const invF = 1 / f;
8615
- this.ctx.save();
8616
- this.ctx.translate(midX, midY);
8617
- this.ctx.transform(1, 0, 0, f, 0, 0);
8618
- this.ctx.translate(-midX, -midY);
8619
- this.ctx.fillRect(left, invF * (top - midY) + midY, width, height * invF);
8620
- this.ctx.restore();
9737
+ // Check for localhost/private IPs to prevent SSRF (skip when allowLocalhostProxy for dev/test)
9738
+ if (context === 'proxy') {
9739
+ if (!this.config.allowLocalhostProxy) {
9740
+ const hostname = parsedUrl.hostname.toLowerCase();
9741
+ // Check for localhost
9742
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
9743
+ return {
9744
+ valid: false,
9745
+ error: 'Localhost is not allowed for proxy URLs'
9746
+ };
8621
9747
  }
8622
- else {
8623
- this.ctx.fill();
9748
+ // For private IP ranges (simplified check)
9749
+ if (this.isPrivateIP(hostname)) {
9750
+ return {
9751
+ valid: false,
9752
+ error: 'Private IP addresses are not allowed for proxy URLs'
9753
+ };
9754
+ }
9755
+ // For link-local addresses
9756
+ if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) {
9757
+ return {
9758
+ valid: false,
9759
+ error: 'Link-local addresses are not allowed for proxy URLs'
9760
+ };
8624
9761
  }
8625
9762
  }
9763
+ // For proxy URLs, mark that runtime validation is recommended
9764
+ // to prevent DNS rebinding attacks
9765
+ return {
9766
+ valid: true,
9767
+ sanitized: url,
9768
+ requiresRuntimeCheck: true
9769
+ };
8626
9770
  }
8627
- index--;
9771
+ return { valid: true, sanitized: url };
8628
9772
  }
8629
- }
8630
- async renderSolidBorder(color, side, curvePoints) {
8631
- this.path(parsePathForBorder(curvePoints, side));
8632
- this.ctx.fillStyle = asString(color);
8633
- this.ctx.fill();
8634
- }
8635
- async renderDoubleBorder(color, width, side, curvePoints) {
8636
- if (width < 3) {
8637
- await this.renderSolidBorder(color, side, curvePoints);
8638
- return;
9773
+ catch (e) {
9774
+ return {
9775
+ valid: false,
9776
+ error: `Invalid URL format: ${e instanceof Error ? e.message : 'Unknown error'}`
9777
+ };
8639
9778
  }
8640
- const outerPaths = parsePathForBorderDoubleOuter(curvePoints, side);
8641
- this.path(outerPaths);
8642
- this.ctx.fillStyle = asString(color);
8643
- this.ctx.fill();
8644
- const innerPaths = parsePathForBorderDoubleInner(curvePoints, side);
8645
- this.path(innerPaths);
8646
- this.ctx.fill();
8647
9779
  }
8648
- async renderNodeBackgroundAndBorders(paint) {
8649
- this.applyEffects(paint.getEffects(2 /* EffectTarget.BACKGROUND_BORDERS */));
8650
- const styles = paint.container.styles;
8651
- const hasBackground = !isTransparent(styles.backgroundColor) || styles.backgroundImage.length;
8652
- const borders = [
8653
- { style: styles.borderTopStyle, color: styles.borderTopColor, width: styles.borderTopWidth },
8654
- { style: styles.borderRightStyle, color: styles.borderRightColor, width: styles.borderRightWidth },
8655
- { style: styles.borderBottomStyle, color: styles.borderBottomColor, width: styles.borderBottomWidth },
8656
- { style: styles.borderLeftStyle, color: styles.borderLeftColor, width: styles.borderLeftWidth }
9780
+ /**
9781
+ * Check if a hostname is a private IP address
9782
+ */
9783
+ isPrivateIP(hostname) {
9784
+ // IPv4 private ranges
9785
+ const privateIPv4Patterns = [
9786
+ /^0\./, // 0.0.0.0/8 (This network)
9787
+ /^10\./, // 10.0.0.0/8 (Private)
9788
+ /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // 100.64.0.0/10 (CGNAT)
9789
+ /^127\./, // 127.0.0.0/8 (Loopback)
9790
+ /^169\.254\./, // 169.254.0.0/16 (Link-local)
9791
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 (Private)
9792
+ /^192\.0\.0\./, // 192.0.0.0/24 (IETF Protocol Assignments)
9793
+ /^192\.0\.2\./, // 192.0.2.0/24 (TEST-NET-1)
9794
+ /^192\.168\./, // 192.168.0.0/16 (Private)
9795
+ /^198\.(1[8-9])\./, // 198.18.0.0/15 (Network benchmark)
9796
+ /^198\.51\.100\./, // 198.51.100.0/24 (TEST-NET-2)
9797
+ /^203\.0\.113\./, // 203.0.113.0/24 (TEST-NET-3)
9798
+ /^2(2[4-9]|3[0-9])\./, // 224.0.0.0/4 (Multicast)
9799
+ /^24[0-9]\./, // 240.0.0.0/4 (Reserved)
9800
+ /^255\.255\.255\.255$/ // 255.255.255.255/32 (Broadcast)
8657
9801
  ];
8658
- const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(getBackgroundValueForIndex(styles.backgroundClip, 0), paint.curves);
8659
- if (hasBackground || styles.boxShadow.length) {
8660
- this.ctx.save();
8661
- this.path(backgroundPaintingArea);
8662
- this.ctx.clip();
8663
- if (!isTransparent(styles.backgroundColor)) {
8664
- this.ctx.fillStyle = asString(styles.backgroundColor);
8665
- this.ctx.fill();
9802
+ // Check IPv4
9803
+ if (privateIPv4Patterns.some((pattern) => pattern.test(hostname))) {
9804
+ return true;
9805
+ }
9806
+ // IPv6 private ranges and special addresses
9807
+ if (hostname.includes(':')) {
9808
+ return this.isPrivateIPv6(hostname);
9809
+ }
9810
+ return false;
9811
+ }
9812
+ /**
9813
+ * Check if an IPv6 address is private or special
9814
+ * Handles compressed IPv6 addresses (e.g., ::1, fc00::1)
9815
+ */
9816
+ isPrivateIPv6(hostname) {
9817
+ const normalizedHost = hostname.toLowerCase().trim();
9818
+ // Remove square brackets if present (e.g., [::1])
9819
+ const addr = normalizedHost.replace(/^\[|\]$/g, '');
9820
+ // Remove zone ID if present (e.g., fe80::1%eth0)
9821
+ const addrWithoutZone = addr.split('%')[0];
9822
+ // Loopback ::1 (also matches 0:0:0:0:0:0:0:1)
9823
+ if (/^(0:){7}1$/.test(addrWithoutZone) || addrWithoutZone === '::1') {
9824
+ return true;
9825
+ }
9826
+ // Unspecified address :: (also matches 0:0:0:0:0:0:0:0)
9827
+ if (/^(0:){7}0$/.test(addrWithoutZone) || addrWithoutZone === '::') {
9828
+ return true;
9829
+ }
9830
+ // Expand :: compression to check prefixes
9831
+ // This handles cases like fc00::1, fe80::, etc.
9832
+ const expandedAddr = this.expandIPv6(addrWithoutZone);
9833
+ if (!expandedAddr) {
9834
+ // If we can't expand it, fall back to prefix matching
9835
+ return this.isPrivateIPv6Prefix(addrWithoutZone);
9836
+ }
9837
+ // fc00::/7 (Unique Local Address)
9838
+ // Check if first byte is in range fc00-fdff
9839
+ const firstByte = parseInt(expandedAddr.substring(0, 2), 16);
9840
+ if (firstByte >= 0xfc && firstByte <= 0xfd) {
9841
+ return true;
9842
+ }
9843
+ // fe80::/10 (Link-local)
9844
+ // First 10 bits should be 1111 1110 10
9845
+ if (firstByte === 0xfe) {
9846
+ const secondByte = parseInt(expandedAddr.substring(2, 4), 16);
9847
+ // Check if bits 11-12 are 10 (0x80-0xbf)
9848
+ if (secondByte >= 0x80 && secondByte <= 0xbf) {
9849
+ return true;
8666
9850
  }
8667
- await this.renderBackgroundImage(paint.container);
8668
- this.ctx.restore();
8669
- styles.boxShadow
8670
- .slice(0)
8671
- .reverse()
8672
- .forEach((shadow) => {
8673
- this.ctx.save();
8674
- const borderBoxArea = calculateBorderBoxPath(paint.curves);
8675
- const maskOffset = shadow.inset ? 0 : MASK_OFFSET;
8676
- const shadowPaintingArea = transformPath(borderBoxArea, -maskOffset + (shadow.inset ? 1 : -1) * shadow.spread.number, (shadow.inset ? 1 : -1) * shadow.spread.number, shadow.spread.number * (shadow.inset ? -2 : 2), shadow.spread.number * (shadow.inset ? -2 : 2));
8677
- if (shadow.inset) {
8678
- this.path(borderBoxArea);
8679
- this.ctx.clip();
8680
- this.mask(shadowPaintingArea);
8681
- }
8682
- else {
8683
- this.mask(borderBoxArea);
8684
- this.ctx.clip();
8685
- this.path(shadowPaintingArea);
8686
- }
8687
- this.ctx.shadowOffsetX = shadow.offsetX.number + maskOffset;
8688
- this.ctx.shadowOffsetY = shadow.offsetY.number;
8689
- this.ctx.shadowColor = asString(shadow.color);
8690
- this.ctx.shadowBlur = shadow.blur.number;
8691
- this.ctx.fillStyle = shadow.inset ? asString(shadow.color) : 'rgba(0,0,0,1)';
8692
- this.ctx.fill();
8693
- this.ctx.restore();
8694
- });
8695
9851
  }
8696
- let side = 0;
8697
- for (const border of borders) {
8698
- if (border.style !== 0 /* BORDER_STYLE.NONE */ && !isTransparent(border.color) && border.width > 0) {
8699
- if (border.style === 2 /* BORDER_STYLE.DASHED */) {
8700
- await this.renderDashedDottedBorder(border.color, border.width, side, paint.curves, 2 /* BORDER_STYLE.DASHED */);
8701
- }
8702
- else if (border.style === 3 /* BORDER_STYLE.DOTTED */) {
8703
- await this.renderDashedDottedBorder(border.color, border.width, side, paint.curves, 3 /* BORDER_STYLE.DOTTED */);
9852
+ // ff00::/8 (Multicast)
9853
+ if (firstByte === 0xff) {
9854
+ return true;
9855
+ }
9856
+ return false;
9857
+ }
9858
+ /**
9859
+ * Expand compressed IPv6 address to full form
9860
+ * e.g., "::1" -> "0000:0000:0000:0000:0000:0000:0000:0001"
9861
+ */
9862
+ expandIPv6(addr) {
9863
+ try {
9864
+ // Handle :: compression
9865
+ if (addr.includes('::')) {
9866
+ const parts = addr.split('::');
9867
+ if (parts.length > 2) {
9868
+ return null; // Invalid: more than one ::
8704
9869
  }
8705
- else if (border.style === 4 /* BORDER_STYLE.DOUBLE */) {
8706
- await this.renderDoubleBorder(border.color, border.width, side, paint.curves);
9870
+ const leftParts = parts[0] ? parts[0].split(':') : [];
9871
+ const rightParts = parts[1] ? parts[1].split(':') : [];
9872
+ const missingParts = 8 - leftParts.length - rightParts.length;
9873
+ if (missingParts < 0) {
9874
+ return null; // Invalid
8707
9875
  }
8708
- else {
8709
- await this.renderSolidBorder(border.color, side, paint.curves);
9876
+ const middleParts = Array(missingParts).fill('0000');
9877
+ const allParts = [...leftParts, ...middleParts, ...rightParts];
9878
+ return allParts.map((p) => p.padStart(4, '0')).join(':');
9879
+ }
9880
+ else {
9881
+ // No compression, just normalize
9882
+ const parts = addr.split(':');
9883
+ if (parts.length !== 8) {
9884
+ return null; // Invalid
8710
9885
  }
9886
+ return parts.map((p) => p.padStart(4, '0')).join(':');
8711
9887
  }
8712
- side++;
9888
+ }
9889
+ catch {
9890
+ return null;
8713
9891
  }
8714
9892
  }
8715
- async renderDashedDottedBorder(color, width, side, curvePoints, style) {
8716
- this.ctx.save();
8717
- const strokePaths = parsePathForBorderStroke(curvePoints, side);
8718
- const boxPaths = parsePathForBorder(curvePoints, side);
8719
- if (style === 2 /* BORDER_STYLE.DASHED */) {
8720
- this.path(boxPaths);
8721
- this.ctx.clip();
9893
+ /**
9894
+ * Fallback prefix matching for IPv6 when expansion fails
9895
+ */
9896
+ isPrivateIPv6Prefix(addr) {
9897
+ // fc00::/7 (Unique Local Address)
9898
+ if (/^fc[0-9a-f]{0,2}:?/i.test(addr) || /^fd[0-9a-f]{0,2}:?/i.test(addr)) {
9899
+ return true;
8722
9900
  }
8723
- let startX, startY, endX, endY;
8724
- if (isBezierCurve(boxPaths[0])) {
8725
- startX = boxPaths[0].start.x;
8726
- startY = boxPaths[0].start.y;
9901
+ // fe80::/10 (Link-local)
9902
+ if (/^fe[89ab][0-9a-f]:?/i.test(addr)) {
9903
+ return true;
8727
9904
  }
8728
- else {
8729
- startX = boxPaths[0].x;
8730
- startY = boxPaths[0].y;
9905
+ // ff00::/8 (Multicast)
9906
+ if (/^ff[0-9a-f]{0,2}:?/i.test(addr)) {
9907
+ return true;
8731
9908
  }
8732
- if (isBezierCurve(boxPaths[1])) {
8733
- endX = boxPaths[1].end.x;
8734
- endY = boxPaths[1].end.y;
9909
+ return false;
9910
+ }
9911
+ /**
9912
+ * Validate CSP nonce
9913
+ *
9914
+ * @param nonce - CSP nonce to validate
9915
+ * @returns Validation result
9916
+ */
9917
+ validateCspNonce(nonce) {
9918
+ if (!nonce || typeof nonce !== 'string') {
9919
+ return {
9920
+ valid: false,
9921
+ error: 'CSP nonce must be a non-empty string'
9922
+ };
8735
9923
  }
8736
- else {
8737
- endX = boxPaths[1].x;
8738
- endY = boxPaths[1].y;
9924
+ // Basic format validation - nonce should be base64-like
9925
+ // Typical format: base64 string, often 32+ characters
9926
+ if (nonce.length < 16) {
9927
+ return {
9928
+ valid: false,
9929
+ error: 'CSP nonce is too short (minimum 16 characters recommended)'
9930
+ };
8739
9931
  }
8740
- let length;
8741
- if (side === 0 || side === 2) {
8742
- length = Math.abs(startX - endX);
9932
+ // Check for suspicious characters
9933
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(nonce)) {
9934
+ return {
9935
+ valid: false,
9936
+ error: 'CSP nonce contains invalid characters'
9937
+ };
8743
9938
  }
8744
- else {
8745
- length = Math.abs(startY - endY);
9939
+ return { valid: true, sanitized: nonce };
9940
+ }
9941
+ /**
9942
+ * Validate image timeout
9943
+ *
9944
+ * @param timeout - Timeout in milliseconds
9945
+ * @returns Validation result
9946
+ */
9947
+ validateImageTimeout(timeout) {
9948
+ if (typeof timeout !== 'number' || isNaN(timeout)) {
9949
+ return {
9950
+ valid: false,
9951
+ error: 'Image timeout must be a number'
9952
+ };
8746
9953
  }
8747
- this.ctx.beginPath();
8748
- if (style === 3 /* BORDER_STYLE.DOTTED */) {
8749
- this.formatPath(strokePaths);
9954
+ if (timeout < 0) {
9955
+ return {
9956
+ valid: false,
9957
+ error: 'Image timeout cannot be negative'
9958
+ };
8750
9959
  }
8751
- else {
8752
- this.formatPath(boxPaths.slice(0, 2));
9960
+ if (this.config.maxImageTimeout && timeout > this.config.maxImageTimeout) {
9961
+ return {
9962
+ valid: false,
9963
+ error: `Image timeout ${timeout}ms exceeds maximum allowed ${this.config.maxImageTimeout}ms`
9964
+ };
8753
9965
  }
8754
- let dashLength = width < 3 ? width * 3 : width * 2;
8755
- let spaceLength = width < 3 ? width * 2 : width;
8756
- if (style === 3 /* BORDER_STYLE.DOTTED */) {
8757
- dashLength = width;
8758
- spaceLength = width;
9966
+ return { valid: true, sanitized: timeout };
9967
+ }
9968
+ /**
9969
+ * Validate window dimensions
9970
+ *
9971
+ * @param width - Window width
9972
+ * @param height - Window height
9973
+ * @returns Validation result
9974
+ */
9975
+ validateDimensions(width, height) {
9976
+ if (typeof width !== 'number' || typeof height !== 'number') {
9977
+ return {
9978
+ valid: false,
9979
+ error: 'Dimensions must be numbers'
9980
+ };
8759
9981
  }
8760
- let useLineDash = true;
8761
- if (length <= dashLength * 2) {
8762
- useLineDash = false;
9982
+ if (isNaN(width) || isNaN(height)) {
9983
+ return {
9984
+ valid: false,
9985
+ error: 'Dimensions cannot be NaN'
9986
+ };
8763
9987
  }
8764
- else if (length <= dashLength * 2 + spaceLength) {
8765
- const multiplier = length / (2 * dashLength + spaceLength);
8766
- dashLength *= multiplier;
8767
- spaceLength *= multiplier;
9988
+ if (width <= 0 || height <= 0) {
9989
+ return {
9990
+ valid: false,
9991
+ error: 'Dimensions must be positive'
9992
+ };
9993
+ }
9994
+ // Reasonable maximum to prevent memory issues
9995
+ const MAX_DIMENSION = 32767; // Common canvas limit
9996
+ if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
9997
+ return {
9998
+ valid: false,
9999
+ error: `Dimensions exceed maximum allowed (${MAX_DIMENSION}px)`
10000
+ };
10001
+ }
10002
+ return { valid: true, sanitized: { width, height } };
10003
+ }
10004
+ /**
10005
+ * Validate scale factor
10006
+ *
10007
+ * @param scale - Scale factor
10008
+ * @returns Validation result
10009
+ */
10010
+ validateScale(scale) {
10011
+ if (typeof scale !== 'number' || isNaN(scale)) {
10012
+ return {
10013
+ valid: false,
10014
+ error: 'Scale must be a number'
10015
+ };
10016
+ }
10017
+ if (scale <= 0) {
10018
+ return {
10019
+ valid: false,
10020
+ error: 'Scale must be positive'
10021
+ };
10022
+ }
10023
+ // Reasonable scale limits
10024
+ if (scale > 10) {
10025
+ return {
10026
+ valid: false,
10027
+ error: 'Scale factor too large (maximum 10x)'
10028
+ };
10029
+ }
10030
+ return { valid: true, sanitized: scale };
10031
+ }
10032
+ /**
10033
+ * Validate HTML element
10034
+ *
10035
+ * @param element - Element to validate
10036
+ * @returns Validation result
10037
+ */
10038
+ validateElement(element) {
10039
+ if (!element) {
10040
+ return {
10041
+ valid: false,
10042
+ error: 'Element is required'
10043
+ };
10044
+ }
10045
+ if (typeof element !== 'object') {
10046
+ return {
10047
+ valid: false,
10048
+ error: 'Element must be an object'
10049
+ };
10050
+ }
10051
+ // Accept real HTMLElement, or any element-like object with the minimal shape
10052
+ // required by the implementation (ownerDocument + defaultView) for backward
10053
+ // compatibility and test environments.
10054
+ if (typeof HTMLElement !== 'undefined' && element instanceof HTMLElement) {
10055
+ // Real DOM element
10056
+ if (!element.ownerDocument) {
10057
+ return { valid: false, error: 'Element must be attached to a document' };
10058
+ }
10059
+ return { valid: true };
10060
+ }
10061
+ // Duck-typing: accept object with ownerDocument and defaultView (minimal contract)
10062
+ if (!element.ownerDocument) {
10063
+ return {
10064
+ valid: false,
10065
+ error: 'Element must be attached to a document (ownerDocument required)'
10066
+ };
8768
10067
  }
8769
- else {
8770
- const numberOfDashes = Math.floor((length + spaceLength) / (dashLength + spaceLength));
8771
- const minSpace = (length - numberOfDashes * dashLength) / (numberOfDashes - 1);
8772
- const maxSpace = (length - (numberOfDashes + 1) * dashLength) / numberOfDashes;
8773
- spaceLength =
8774
- maxSpace <= 0 || Math.abs(spaceLength - minSpace) < Math.abs(spaceLength - maxSpace)
8775
- ? minSpace
8776
- : maxSpace;
10068
+ if (!element.ownerDocument.defaultView) {
10069
+ return {
10070
+ valid: false,
10071
+ error: 'Document must be attached to a window (ownerDocument.defaultView required)'
10072
+ };
8777
10073
  }
8778
- if (useLineDash) {
8779
- if (style === 3 /* BORDER_STYLE.DOTTED */) {
8780
- this.ctx.setLineDash([0, dashLength + spaceLength]);
10074
+ return { valid: true };
10075
+ }
10076
+ /**
10077
+ * Validate entire options object
10078
+ *
10079
+ * @param options - Options to validate
10080
+ * @returns Validation result with all errors
10081
+ */
10082
+ validateOptions(options) {
10083
+ const errors = [];
10084
+ // Validate proxy URL only when a non-empty string (allow null/undefined to mean "no proxy")
10085
+ const proxyUrl = options.proxy;
10086
+ if (proxyUrl !== undefined && proxyUrl !== null && typeof proxyUrl === 'string' && proxyUrl.length > 0) {
10087
+ const proxyResult = this.validateUrl(proxyUrl, 'proxy');
10088
+ if (!proxyResult.valid) {
10089
+ errors.push(`Proxy: ${proxyResult.error}`);
8781
10090
  }
8782
- else {
8783
- this.ctx.setLineDash([dashLength, spaceLength]);
10091
+ // Note: Proxy URLs are marked with requiresRuntimeCheck to prevent DNS rebinding
10092
+ // Consider implementing runtime IP validation in production environments
10093
+ }
10094
+ // Validate image timeout
10095
+ if (options.imageTimeout !== undefined) {
10096
+ const timeoutResult = this.validateImageTimeout(options.imageTimeout);
10097
+ if (!timeoutResult.valid) {
10098
+ errors.push(`Image timeout: ${timeoutResult.error}`);
8784
10099
  }
8785
10100
  }
8786
- if (style === 3 /* BORDER_STYLE.DOTTED */) {
8787
- this.ctx.lineCap = 'round';
8788
- this.ctx.lineWidth = width;
10101
+ // Validate dimensions
10102
+ if (options.width !== undefined || options.height !== undefined) {
10103
+ const width = options.width ?? 800;
10104
+ const height = options.height ?? 600;
10105
+ const dimensionsResult = this.validateDimensions(width, height);
10106
+ if (!dimensionsResult.valid) {
10107
+ errors.push(`Dimensions: ${dimensionsResult.error}`);
10108
+ }
8789
10109
  }
8790
- else {
8791
- this.ctx.lineWidth = width * 2 + 1.1;
10110
+ // Validate scale
10111
+ if (options.scale !== undefined) {
10112
+ const scaleResult = this.validateScale(options.scale);
10113
+ if (!scaleResult.valid) {
10114
+ errors.push(`Scale: ${scaleResult.error}`);
10115
+ }
8792
10116
  }
8793
- this.ctx.strokeStyle = asString(color);
8794
- this.ctx.stroke();
8795
- this.ctx.setLineDash([]);
8796
- // dashed round edge gap
8797
- if (style === 2 /* BORDER_STYLE.DASHED */) {
8798
- if (isBezierCurve(boxPaths[0])) {
8799
- const path1 = boxPaths[3];
8800
- const path2 = boxPaths[0];
8801
- this.ctx.beginPath();
8802
- this.formatPath([new Vector(path1.end.x, path1.end.y), new Vector(path2.start.x, path2.start.y)]);
8803
- this.ctx.stroke();
10117
+ // Validate CSP nonce
10118
+ if (options.cspNonce !== undefined) {
10119
+ const nonceResult = this.validateCspNonce(options.cspNonce);
10120
+ if (!nonceResult.valid) {
10121
+ errors.push(`CSP nonce: ${nonceResult.error}`);
8804
10122
  }
8805
- if (isBezierCurve(boxPaths[1])) {
8806
- const path1 = boxPaths[1];
8807
- const path2 = boxPaths[2];
8808
- this.ctx.beginPath();
8809
- this.formatPath([new Vector(path1.end.x, path1.end.y), new Vector(path2.start.x, path2.start.y)]);
8810
- this.ctx.stroke();
10123
+ }
10124
+ // Custom validation
10125
+ if (this.config.customValidator) {
10126
+ const customResult = this.config.customValidator(options, 'options');
10127
+ if (!customResult.valid) {
10128
+ errors.push(`Custom validation: ${customResult.error}`);
8811
10129
  }
8812
10130
  }
8813
- this.ctx.restore();
8814
- }
8815
- async render(element) {
8816
- if (this.options.backgroundColor) {
8817
- this.ctx.fillStyle = asString(this.options.backgroundColor);
8818
- this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
10131
+ if (errors.length > 0) {
10132
+ return {
10133
+ valid: false,
10134
+ error: errors.join('; ')
10135
+ };
8819
10136
  }
8820
- const stack = parseStackingContexts(element);
8821
- await this.renderStack(stack);
8822
- this.applyEffects([]);
8823
- return this.canvas;
10137
+ return { valid: true };
8824
10138
  }
8825
10139
  }
8826
- const isTextInputElement = (container) => {
8827
- if (container instanceof TextareaElementContainer) {
8828
- return true;
8829
- }
8830
- else if (container instanceof SelectElementContainer) {
8831
- return true;
8832
- }
8833
- else if (container instanceof InputElementContainer && container.type !== RADIO && container.type !== CHECKBOX) {
8834
- return true;
8835
- }
8836
- return false;
8837
- };
8838
- const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
8839
- switch (clip) {
8840
- case 0 /* BACKGROUND_CLIP.BORDER_BOX */:
8841
- return calculateBorderBoxPath(curves);
8842
- case 2 /* BACKGROUND_CLIP.CONTENT_BOX */:
8843
- return calculateContentBoxPath(curves);
8844
- case 1 /* BACKGROUND_CLIP.PADDING_BOX */:
8845
- default:
8846
- return calculatePaddingBoxPath(curves);
8847
- }
8848
- };
8849
- const canvasTextAlign = (textAlign) => {
8850
- switch (textAlign) {
8851
- case 1 /* TEXT_ALIGN.CENTER */:
8852
- return 'center';
8853
- case 2 /* TEXT_ALIGN.RIGHT */:
8854
- return 'right';
8855
- case 0 /* TEXT_ALIGN.LEFT */:
8856
- default:
8857
- return 'left';
8858
- }
8859
- };
8860
- // see https://github.com/niklasvh/html2canvas/pull/2645
8861
- const iOSBrokenFonts = ['-apple-system', 'system-ui'];
8862
- const fixIOSSystemFonts = (fontFamilies) => {
8863
- return /iPhone OS 15_(0|1)/.test(window.navigator.userAgent)
8864
- ? fontFamilies.filter((fontFamily) => iOSBrokenFonts.indexOf(fontFamily) === -1)
8865
- : fontFamilies;
8866
- };
8867
-
8868
- class ForeignObjectRenderer extends Renderer {
8869
- constructor(context, options) {
8870
- super(context, options);
8871
- this.canvas = options.canvas ? options.canvas : document.createElement('canvas');
8872
- this.ctx = this.canvas.getContext('2d');
8873
- this.options = options;
8874
- this.canvas.width = Math.floor(options.width * options.scale);
8875
- this.canvas.height = Math.floor(options.height * options.scale);
8876
- this.canvas.style.width = `${options.width}px`;
8877
- this.canvas.style.height = `${options.height}px`;
8878
- this.ctx.scale(this.options.scale, this.options.scale);
8879
- this.ctx.translate(-options.x, -options.y);
8880
- this.context.logger.debug(`EXPERIMENTAL ForeignObject renderer initialized (${options.width}x${options.height} at ${options.x},${options.y}) with scale ${options.scale}`);
8881
- }
8882
- async render(element) {
8883
- const svg = createForeignObjectSVG(this.options.width * this.options.scale, this.options.height * this.options.scale, this.options.scale, this.options.scale, element);
8884
- const img = await loadSerializedSVG(svg);
8885
- if (this.options.backgroundColor) {
8886
- this.ctx.fillStyle = asString(this.options.backgroundColor);
8887
- this.ctx.fillRect(0, 0, this.options.width * this.options.scale, this.options.height * this.options.scale);
8888
- }
8889
- this.ctx.drawImage(img, -this.options.x * this.options.scale, -this.options.y * this.options.scale);
8890
- return this.canvas;
8891
- }
10140
+ /**
10141
+ * Create a default validator instance
10142
+ */
10143
+ function createDefaultValidator(config = {}) {
10144
+ return new Validator({
10145
+ allowDataUrls: true,
10146
+ maxImageTimeout: 300000, // 5 minutes
10147
+ ...config
10148
+ });
8892
10149
  }
8893
- const loadSerializedSVG = (svg) => new Promise((resolve, reject) => {
8894
- const img = new Image();
8895
- img.onload = () => {
8896
- resolve(img);
8897
- };
8898
- img.onerror = reject;
8899
- img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
8900
- });
8901
10150
 
8902
- class Logger {
8903
- constructor({ id, enabled }) {
8904
- this.id = id;
10151
+ /**
10152
+ * Performance Monitor
10153
+ *
10154
+ * Tracks performance metrics throughout the rendering pipeline.
10155
+ * Provides insights into where time is spent during rendering.
10156
+ *
10157
+ * Usage:
10158
+ * ```typescript
10159
+ * const monitor = new PerformanceMonitor(context);
10160
+ *
10161
+ * monitor.start('clone');
10162
+ * await cloneDocument();
10163
+ * monitor.end('clone');
10164
+ *
10165
+ * const summary = monitor.getSummary();
10166
+ * ```
10167
+ */
10168
+ class PerformanceMonitor {
10169
+ constructor(context, enabled = true) {
10170
+ this.context = context;
10171
+ this.activeMetrics = new Map();
10172
+ this.completedMetrics = [];
8905
10173
  this.enabled = enabled;
8906
- this.start = Date.now();
10174
+ // Fallback for environments without performance.now()
10175
+ this.getTime =
10176
+ typeof performance !== 'undefined' && typeof performance.now === 'function'
10177
+ ? () => performance.now()
10178
+ : () => Date.now();
8907
10179
  }
8908
- debug(...args) {
8909
- if (this.enabled) {
8910
- // eslint-disable-next-line no-console
8911
- if (typeof window !== 'undefined' && window.console && typeof console.debug === 'function') {
8912
- // eslint-disable-next-line no-console
8913
- console.debug(this.id, `${this.getTime()}ms`, ...args);
8914
- }
8915
- else {
8916
- this.info(...args);
8917
- }
10180
+ /**
10181
+ * Start measuring a performance metric
10182
+ *
10183
+ * @param name - Unique name for this metric
10184
+ * @param metadata - Optional metadata to attach
10185
+ */
10186
+ start(name, metadata) {
10187
+ if (!this.enabled) {
10188
+ return;
8918
10189
  }
10190
+ if (this.activeMetrics.has(name)) {
10191
+ this.context?.logger.warn(`Performance metric '${name}' already started. Overwriting.`);
10192
+ }
10193
+ this.activeMetrics.set(name, {
10194
+ name,
10195
+ startTime: this.getTime(),
10196
+ metadata
10197
+ });
8919
10198
  }
8920
- getTime() {
8921
- return Date.now() - this.start;
10199
+ /**
10200
+ * End measuring a performance metric
10201
+ *
10202
+ * @param name - Name of the metric to end
10203
+ * @returns The completed metric, or undefined if not found
10204
+ */
10205
+ end(name) {
10206
+ if (!this.enabled) {
10207
+ return undefined;
10208
+ }
10209
+ const metric = this.activeMetrics.get(name);
10210
+ if (!metric) {
10211
+ this.context?.logger.warn(`Performance metric '${name}' not found. Was start() called?`);
10212
+ return undefined;
10213
+ }
10214
+ metric.endTime = this.getTime();
10215
+ metric.duration = metric.endTime - metric.startTime;
10216
+ this.completedMetrics.push(metric);
10217
+ this.activeMetrics.delete(name);
10218
+ this.context?.logger.debug(`⏱️ ${name}: ${metric.duration.toFixed(2)}ms`, metric.metadata);
10219
+ return metric;
8922
10220
  }
8923
- info(...args) {
8924
- if (this.enabled) {
8925
- // eslint-disable-next-line no-console
8926
- if (typeof window !== 'undefined' && window.console && typeof console.info === 'function') {
8927
- // eslint-disable-next-line no-console
8928
- console.info(this.id, `${this.getTime()}ms`, ...args);
8929
- }
10221
+ /**
10222
+ * Measure a synchronous function
10223
+ *
10224
+ * @param name - Name for this measurement
10225
+ * @param fn - Function to measure
10226
+ * @param metadata - Optional metadata
10227
+ * @returns The function's return value
10228
+ */
10229
+ measure(name, fn, metadata) {
10230
+ this.start(name, metadata);
10231
+ try {
10232
+ const result = fn();
10233
+ this.end(name);
10234
+ return result;
10235
+ }
10236
+ catch (error) {
10237
+ this.end(name);
10238
+ throw error;
8930
10239
  }
8931
10240
  }
8932
- warn(...args) {
8933
- if (this.enabled) {
8934
- if (typeof window !== 'undefined' && window.console && typeof console.warn === 'function') {
8935
- console.warn(this.id, `${this.getTime()}ms`, ...args);
8936
- }
8937
- else {
8938
- this.info(...args);
8939
- }
10241
+ /**
10242
+ * Measure an asynchronous function
10243
+ *
10244
+ * @param name - Name for this measurement
10245
+ * @param fn - Async function to measure
10246
+ * @param metadata - Optional metadata
10247
+ * @returns Promise resolving to the function's return value
10248
+ */
10249
+ async measureAsync(name, fn, metadata) {
10250
+ this.start(name, metadata);
10251
+ try {
10252
+ const result = await fn();
10253
+ this.end(name);
10254
+ return result;
10255
+ }
10256
+ catch (error) {
10257
+ this.end(name);
10258
+ throw error;
8940
10259
  }
8941
10260
  }
8942
- error(...args) {
8943
- if (this.enabled) {
8944
- if (typeof window !== 'undefined' && window.console && typeof console.error === 'function') {
8945
- console.error(this.id, `${this.getTime()}ms`, ...args);
8946
- }
8947
- else {
8948
- this.info(...args);
8949
- }
10261
+ /**
10262
+ * Get all completed metrics
10263
+ *
10264
+ * @returns Array of completed performance metrics
10265
+ */
10266
+ getMetrics() {
10267
+ return [...this.completedMetrics];
10268
+ }
10269
+ /**
10270
+ * Get a specific metric by name
10271
+ *
10272
+ * @param name - Metric name
10273
+ * @returns The metric, or undefined if not found
10274
+ */
10275
+ getMetric(name) {
10276
+ return this.completedMetrics.find((m) => m.name === name);
10277
+ }
10278
+ /**
10279
+ * Get performance summary
10280
+ *
10281
+ * @returns Aggregated performance data
10282
+ */
10283
+ getSummary() {
10284
+ const totalDuration = this.completedMetrics.reduce((sum, metric) => sum + (metric.duration || 0), 0);
10285
+ const breakdown = this.completedMetrics.map((metric) => ({
10286
+ name: metric.name,
10287
+ duration: metric.duration || 0,
10288
+ percentage: totalDuration > 0 ? (((metric.duration || 0) / totalDuration) * 100).toFixed(1) + '%' : '0%'
10289
+ }));
10290
+ return {
10291
+ totalDuration,
10292
+ metrics: this.getMetrics(),
10293
+ breakdown
10294
+ };
10295
+ }
10296
+ /**
10297
+ * Log performance summary to console
10298
+ */
10299
+ logSummary() {
10300
+ if (!this.enabled || this.completedMetrics.length === 0 || !this.context) {
10301
+ return;
8950
10302
  }
10303
+ const summary = this.getSummary();
10304
+ this.context.logger.info(`\n📊 Performance Summary (Total: ${summary.totalDuration.toFixed(2)}ms):`);
10305
+ summary.breakdown
10306
+ .sort((a, b) => b.duration - a.duration)
10307
+ .forEach((item) => {
10308
+ this.context.logger.info(` ${item.name.padEnd(20)} ${item.duration.toFixed(2).padStart(8)}ms ${item.percentage.padStart(6)}`);
10309
+ });
8951
10310
  }
8952
- }
8953
- Logger.instances = {};
8954
-
8955
- class Context {
8956
- constructor(options, windowBounds) {
8957
- this.windowBounds = windowBounds;
8958
- this.instanceName = `#${Context.instanceCount++}`;
8959
- this.logger = new Logger({ id: this.instanceName, enabled: options.logging });
8960
- this.cache = options.cache ?? new Cache(this, options);
10311
+ /**
10312
+ * Clear all metrics
10313
+ */
10314
+ clear() {
10315
+ this.activeMetrics.clear();
10316
+ this.completedMetrics.splice(0);
10317
+ }
10318
+ /**
10319
+ * Check if monitoring is enabled
10320
+ */
10321
+ isEnabled() {
10322
+ return this.enabled;
10323
+ }
10324
+ /**
10325
+ * Get active (uncompleted) metrics
10326
+ * Useful for debugging leaked measurements
10327
+ */
10328
+ getActiveMetrics() {
10329
+ return Array.from(this.activeMetrics.keys());
8961
10330
  }
8962
10331
  }
8963
- Context.instanceCount = 1;
8964
10332
 
8965
- let cspNonce;
8966
- const setCspNonce = (nonce) => {
8967
- cspNonce = nonce;
10333
+ /**
10334
+ * Main html2canvas function with improved configuration management
10335
+ *
10336
+ * @param element - Element to render
10337
+ * @param options - Rendering options
10338
+ * @param config - Optional configuration (for advanced use cases)
10339
+ * @returns Promise resolving to rendered canvas
10340
+ */
10341
+ const html2canvas = (element, options = {}, config) => {
10342
+ // Create configuration from element if not provided
10343
+ const finalConfig = config ||
10344
+ Html2CanvasConfig.fromElement(element, {
10345
+ cspNonce: options.cspNonce,
10346
+ cache: options.cache
10347
+ });
10348
+ return renderElement(element, options, finalConfig);
8968
10349
  };
8969
- const html2canvas = (element, options = {}) => {
8970
- return renderElement(element, options);
10350
+ /**
10351
+ * Set CSP nonce for inline styles
10352
+ * @deprecated Use options.cspNonce instead
10353
+ */
10354
+ const setCspNonce = (nonce) => {
10355
+ console.warn('[html2canvas-pro] setCspNonce is deprecated. ' +
10356
+ 'Pass cspNonce in options instead: html2canvas(element, { cspNonce: "..." })');
10357
+ // For backward compatibility, set default config
10358
+ if (typeof window !== 'undefined') {
10359
+ setDefaultConfig(new Html2CanvasConfig({ window, cspNonce: nonce }));
10360
+ }
8971
10361
  };
8972
10362
  html2canvas.setCspNonce = setCspNonce;
8973
- if (typeof window !== 'undefined') {
8974
- CacheStorage.setContext(window);
8975
- }
8976
- const renderElement = async (element, opts) => {
10363
+ /**
10364
+ * Coerce number-like option values for backward compatibility (e.g. string "2" from form/query).
10365
+ * Mutates opts in place; callers should avoid reusing the same options object if they rely on original types.
10366
+ */
10367
+ const coerceNumberOptions = (opts) => {
10368
+ const numKeys = [
10369
+ 'scale',
10370
+ 'width',
10371
+ 'height',
10372
+ 'imageTimeout',
10373
+ 'x',
10374
+ 'y',
10375
+ 'windowWidth',
10376
+ 'windowHeight',
10377
+ 'scrollX',
10378
+ 'scrollY'
10379
+ ];
10380
+ numKeys.forEach((key) => {
10381
+ const v = opts[key];
10382
+ if (v !== undefined && v !== null && typeof v !== 'number') {
10383
+ const n = Number(v);
10384
+ if (!Number.isNaN(n)) {
10385
+ opts[key] = n;
10386
+ }
10387
+ }
10388
+ });
10389
+ };
10390
+ const renderElement = async (element, opts, config) => {
10391
+ coerceNumberOptions(opts);
10392
+ // Input validation (unless explicitly skipped)
10393
+ if (!opts.skipValidation) {
10394
+ const validator = opts.validator || createDefaultValidator();
10395
+ // Validate element
10396
+ const elementValidation = validator.validateElement(element);
10397
+ if (!elementValidation.valid) {
10398
+ throw new Error(elementValidation.error);
10399
+ }
10400
+ // Validate options
10401
+ const optionsValidation = validator.validateOptions(opts);
10402
+ if (!optionsValidation.valid) {
10403
+ throw new Error(`Invalid options: ${optionsValidation.error}`);
10404
+ }
10405
+ }
8977
10406
  if (!element || typeof element !== 'object') {
8978
- return Promise.reject('Invalid element provided as first argument');
10407
+ throw new Error('Invalid element provided as first argument');
8979
10408
  }
8980
10409
  const ownerDocument = element.ownerDocument;
8981
10410
  if (!ownerDocument) {
@@ -8994,17 +10423,29 @@ const renderElement = async (element, opts) => {
8994
10423
  };
8995
10424
  const contextOptions = {
8996
10425
  logging: opts.logging ?? true,
8997
- cache: opts.cache,
10426
+ cache: opts.cache ?? config.cache,
8998
10427
  ...resourceOptions
8999
10428
  };
10429
+ // Fallbacks for minimal window (e.g. element-like mocks) so we don't get NaN
10430
+ const DEFAULT_WINDOW_WIDTH = 800;
10431
+ const DEFAULT_WINDOW_HEIGHT = 600;
10432
+ const DEFAULT_SCROLL = 0;
10433
+ const win = defaultView;
9000
10434
  const windowOptions = {
9001
- windowWidth: opts.windowWidth ?? defaultView.innerWidth,
9002
- windowHeight: opts.windowHeight ?? defaultView.innerHeight,
9003
- scrollX: opts.scrollX ?? defaultView.pageXOffset,
9004
- scrollY: opts.scrollY ?? defaultView.pageYOffset
10435
+ windowWidth: opts.windowWidth ?? win.innerWidth ?? DEFAULT_WINDOW_WIDTH,
10436
+ windowHeight: opts.windowHeight ?? win.innerHeight ?? DEFAULT_WINDOW_HEIGHT,
10437
+ scrollX: opts.scrollX ?? win.pageXOffset ?? DEFAULT_SCROLL,
10438
+ scrollY: opts.scrollY ?? win.pageYOffset ?? DEFAULT_SCROLL
9005
10439
  };
9006
10440
  const windowBounds = new Bounds(windowOptions.scrollX, windowOptions.scrollY, windowOptions.windowWidth, windowOptions.windowHeight);
9007
- const context = new Context(contextOptions, windowBounds);
10441
+ const context = new Context(contextOptions, windowBounds, config);
10442
+ // Initialize performance monitoring if enabled
10443
+ const performanceMonitoring = opts.enablePerformanceMonitoring ?? opts.logging ?? false;
10444
+ const perfMonitor = new PerformanceMonitor(context, performanceMonitoring);
10445
+ perfMonitor.start('total', {
10446
+ width: windowOptions.windowWidth,
10447
+ height: windowOptions.windowHeight
10448
+ });
9008
10449
  const foreignObjectRendering = opts.foreignObjectRendering ?? false;
9009
10450
  const cloneOptions = {
9010
10451
  allowTaint: opts.allowTaint ?? false,
@@ -9013,15 +10454,17 @@ const renderElement = async (element, opts) => {
9013
10454
  iframeContainer: opts.iframeContainer,
9014
10455
  inlineImages: foreignObjectRendering,
9015
10456
  copyStyles: foreignObjectRendering,
9016
- cspNonce
10457
+ cspNonce: opts.cspNonce ?? config.cspNonce
9017
10458
  };
9018
10459
  context.logger.debug(`Starting document clone with size ${windowBounds.width}x${windowBounds.height} scrolled to ${-windowBounds.left},${-windowBounds.top}`);
10460
+ perfMonitor.start('clone');
9019
10461
  const documentCloner = new DocumentCloner(context, element, cloneOptions);
9020
10462
  const clonedElement = documentCloner.clonedReferenceElement;
9021
10463
  if (!clonedElement) {
9022
- return Promise.reject(`Unable to find element in cloned iframe`);
10464
+ throw new Error('Unable to find element in cloned iframe');
9023
10465
  }
9024
10466
  const container = await documentCloner.toIFrame(ownerDocument, windowBounds);
10467
+ perfMonitor.end('clone');
9025
10468
  const { width, height, left, top } = isBodyElement(clonedElement) || isHTMLElement(clonedElement)
9026
10469
  ? parseDocumentSize(clonedElement.ownerDocument)
9027
10470
  : parseBounds(context, clonedElement);
@@ -9033,32 +10476,56 @@ const renderElement = async (element, opts) => {
9033
10476
  x: (opts.x ?? 0) + left,
9034
10477
  y: (opts.y ?? 0) + top,
9035
10478
  width: opts.width ?? Math.ceil(width),
9036
- height: opts.height ?? Math.ceil(height)
10479
+ height: opts.height ?? Math.ceil(height),
10480
+ imageSmoothing: opts.imageSmoothing,
10481
+ imageSmoothingQuality: opts.imageSmoothingQuality
9037
10482
  };
9038
10483
  let canvas;
9039
- if (foreignObjectRendering) {
9040
- context.logger.debug(`Document cloned, using foreign object rendering`);
9041
- const renderer = new ForeignObjectRenderer(context, renderOptions);
9042
- canvas = await renderer.render(clonedElement);
9043
- }
9044
- else {
9045
- context.logger.debug(`Document cloned, element located at ${left},${top} with size ${width}x${height} using computed rendering`);
9046
- context.logger.debug(`Starting DOM parsing`);
9047
- const root = parseTree(context, clonedElement);
9048
- if (backgroundColor === root.styles.backgroundColor) {
9049
- root.styles.backgroundColor = COLORS.TRANSPARENT;
10484
+ let root;
10485
+ try {
10486
+ if (foreignObjectRendering) {
10487
+ context.logger.debug(`Document cloned, using foreign object rendering`);
10488
+ perfMonitor.start('render-foreignobject');
10489
+ const renderer = new ForeignObjectRenderer(context, renderOptions);
10490
+ canvas = await renderer.render(clonedElement);
10491
+ perfMonitor.end('render-foreignobject');
10492
+ }
10493
+ else {
10494
+ context.logger.debug(`Document cloned, element located at ${left},${top} with size ${width}x${height} using computed rendering`);
10495
+ context.logger.debug(`Starting DOM parsing`);
10496
+ perfMonitor.start('parse');
10497
+ root = parseTree(context, clonedElement);
10498
+ perfMonitor.end('parse');
10499
+ if (backgroundColor === root.styles.backgroundColor) {
10500
+ root.styles.backgroundColor = COLORS.TRANSPARENT;
10501
+ }
10502
+ context.logger.debug(`Starting renderer for element at ${renderOptions.x},${renderOptions.y} with size ${renderOptions.width}x${renderOptions.height}`);
10503
+ perfMonitor.start('render');
10504
+ const renderer = new CanvasRenderer(context, renderOptions);
10505
+ canvas = await renderer.render(root);
10506
+ perfMonitor.end('render');
10507
+ }
10508
+ perfMonitor.start('cleanup');
10509
+ if (opts.removeContainer ?? true) {
10510
+ if (!DocumentCloner.destroy(container)) {
10511
+ context.logger.error(`Cannot detach cloned iframe as it is not in the DOM anymore`);
10512
+ }
10513
+ }
10514
+ perfMonitor.end('cleanup');
10515
+ perfMonitor.end('total');
10516
+ context.logger.debug(`Finished rendering`);
10517
+ // Log performance summary if monitoring is enabled
10518
+ if (performanceMonitoring) {
10519
+ perfMonitor.logSummary();
9050
10520
  }
9051
- context.logger.debug(`Starting renderer for element at ${renderOptions.x},${renderOptions.y} with size ${renderOptions.width}x${renderOptions.height}`);
9052
- const renderer = new CanvasRenderer(context, renderOptions);
9053
- canvas = await renderer.render(root);
10521
+ return canvas;
9054
10522
  }
9055
- if (opts.removeContainer ?? true) {
9056
- if (!DocumentCloner.destroy(container)) {
9057
- context.logger.error(`Cannot detach cloned iframe as it is not in the DOM anymore`);
10523
+ finally {
10524
+ // Restore DOM modifications (animations, transforms) in cloned document
10525
+ if (root) {
10526
+ root.restoreTree();
9058
10527
  }
9059
10528
  }
9060
- context.logger.debug(`Finished rendering`);
9061
- return canvas;
9062
10529
  };
9063
10530
  const parseBackgroundColor = (context, element, backgroundColorOverride) => {
9064
10531
  const ownerDocument = element.ownerDocument;
@@ -9083,5 +10550,5 @@ const parseBackgroundColor = (context, element, backgroundColorOverride) => {
9083
10550
  : defaultBackgroundColor;
9084
10551
  };
9085
10552
 
9086
- export { html2canvas as default, html2canvas, setCspNonce };
10553
+ export { Html2CanvasConfig, IMAGE_RENDERING, PerformanceMonitor, Validator, createDefaultValidator, html2canvas as default, html2canvas, setCspNonce };
9087
10554
  //# sourceMappingURL=html2canvas-pro.esm.js.map