html2canvas-pro 1.6.6 → 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 (123) 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 +2846 -1238
  5. package/dist/html2canvas-pro.esm.js.map +1 -1
  6. package/dist/html2canvas-pro.js +2849 -1237
  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 +152 -11
  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/document-cloner.d.ts +46 -0
  107. package/dist/types/dom/dom-normalizer.d.ts +43 -0
  108. package/dist/types/dom/element-container.d.ts +20 -1
  109. package/dist/types/dom/node-parser.d.ts +2 -7
  110. package/dist/types/dom/node-type-guards.d.ts +33 -0
  111. package/dist/types/dom/replaced-elements/iframe-element-container.d.ts +4 -1
  112. package/dist/types/index.d.ts +48 -3
  113. package/dist/types/render/canvas/__tests__/background-renderer.test.d.ts +1 -0
  114. package/dist/types/render/canvas/__tests__/border-renderer.test.d.ts +1 -0
  115. package/dist/types/render/canvas/__tests__/effects-renderer.test.d.ts +1 -0
  116. package/dist/types/render/canvas/__tests__/text-renderer.test.d.ts +1 -0
  117. package/dist/types/render/canvas/background-renderer.d.ts +87 -0
  118. package/dist/types/render/canvas/border-renderer.d.ts +67 -0
  119. package/dist/types/render/canvas/canvas-renderer.d.ts +19 -23
  120. package/dist/types/render/canvas/effects-renderer.d.ts +64 -0
  121. package/dist/types/render/canvas/text-renderer.d.ts +57 -0
  122. package/dist/types/render/renderer-interface.d.ts +26 -0
  123. package/package.json +2 -1
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * html2canvas-pro 1.6.6 <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
  */
@@ -1505,6 +1505,97 @@
1505
1505
  return type === 2 /* TokenType.LEFT_PARENTHESIS_TOKEN */ && token.type === 3 /* TokenType.RIGHT_PARENTHESIS_TOKEN */;
1506
1506
  };
1507
1507
 
1508
+ /**
1509
+ * Color mathematics utilities
1510
+ * Extracted to break circular dependency between srgb.ts and color-utilities.ts
1511
+ */
1512
+ /**
1513
+ * Clamp a value between min and max
1514
+ */
1515
+ const clamp = (value, min, max) => {
1516
+ return Math.min(Math.max(value, min), max);
1517
+ };
1518
+ /**
1519
+ * Multiply two 3x3 matrices
1520
+ */
1521
+ const multiplyMatrices = (A, B) => {
1522
+ return [
1523
+ A[0] * B[0] + A[1] * B[1] + A[2] * B[2],
1524
+ A[3] * B[0] + A[4] * B[1] + A[5] * B[2],
1525
+ A[6] * B[0] + A[7] * B[1] + A[8] * B[2]
1526
+ ];
1527
+ };
1528
+
1529
+ /**
1530
+ * SRGB related functions
1531
+ */
1532
+ /**
1533
+ * Convert XYZ to linear-light sRGB
1534
+ *
1535
+ * @param xyz
1536
+ */
1537
+ const xyz2rgbLinear = (xyz) => {
1538
+ return multiplyMatrices([
1539
+ 3.2409699419045226, -1.537383177570094, -0.4986107602930034, -0.9692436362808796, 1.8759675015077202,
1540
+ 0.04155505740717559, 0.05563007969699366, -0.20397695888897652, 1.0569715142428786
1541
+ ], xyz);
1542
+ };
1543
+ /**
1544
+ * Convert XYZ to linear-light sRGB
1545
+ *
1546
+ * @param xyz
1547
+ */
1548
+ const rgbLinear2xyz = (xyz) => {
1549
+ return multiplyMatrices([
1550
+ 0.41239079926595934, 0.357584339383878, 0.1804807884018343, 0.21263900587151027, 0.715168678767756,
1551
+ 0.07219231536073371, 0.01933081871559182, 0.11919477979462598, 0.9505321522496607
1552
+ ], xyz);
1553
+ };
1554
+ /**
1555
+ * Convert sRGB to RGB
1556
+ *
1557
+ * @param rgb
1558
+ */
1559
+ const srgbLinear2rgb = (rgb) => {
1560
+ return rgb.map((c) => {
1561
+ const sign = c < 0 ? -1 : 1, abs = Math.abs(c);
1562
+ return abs > 0.0031308 ? sign * (1.055 * abs ** (1 / 2.4) - 0.055) : 12.92 * c;
1563
+ });
1564
+ };
1565
+ /**
1566
+ * Convert RGB to sRGB
1567
+ *
1568
+ * @param rgb
1569
+ */
1570
+ const rgb2rgbLinear = (rgb) => {
1571
+ return rgb.map((c) => {
1572
+ const sign = c < 0 ? -1 : 1, abs = Math.abs(c);
1573
+ return abs <= 0.04045 ? c / 12.92 : sign * ((abs + 0.055) / 1.055) ** 2.4;
1574
+ });
1575
+ };
1576
+ /**
1577
+ * XYZ to SRGB
1578
+ *
1579
+ * @param args
1580
+ */
1581
+ const srgbFromXYZ = (args) => {
1582
+ const [r, g, b] = srgbLinear2rgb(xyz2rgbLinear([args[0], args[1], args[2]]));
1583
+ return [r, g, b, args[3]];
1584
+ };
1585
+ /**
1586
+ * XYZ to SRGB-Linear
1587
+ * @param args
1588
+ */
1589
+ const srgbLinearFromXYZ = (args) => {
1590
+ const [r, g, b] = xyz2rgbLinear([args[0], args[1], args[2]]);
1591
+ return [
1592
+ clamp(Math.round(r * 255), 0, 255),
1593
+ clamp(Math.round(g * 255), 0, 255),
1594
+ clamp(Math.round(b * 255), 0, 255),
1595
+ args[3]
1596
+ ];
1597
+ };
1598
+
1508
1599
  const isLength = (token) => token.type === 17 /* TokenType.NUMBER_TOKEN */ || token.type === 15 /* TokenType.DIMENSION_TOKEN */;
1509
1600
 
1510
1601
  const isLengthPercentage = (token) => token.type === 16 /* TokenType.PERCENTAGE_TOKEN */ || isLength(token);
@@ -1722,16 +1813,6 @@
1722
1813
  return 0;
1723
1814
  };
1724
1815
  const isRelativeTransform = (tokens) => (tokens[0].type === 20 /* TokenType.IDENT_TOKEN */ ? tokens[0].value : 'unknown') === 'from';
1725
- const clamp = (value, min, max) => {
1726
- return Math.min(Math.max(value, min), max);
1727
- };
1728
- const multiplyMatrices = (A, B) => {
1729
- return [
1730
- A[0] * B[0] + A[1] * B[1] + A[2] * B[2],
1731
- A[3] * B[0] + A[4] * B[1] + A[5] * B[2],
1732
- A[6] * B[0] + A[7] * B[1] + A[8] * B[2]
1733
- ];
1734
- };
1735
1816
  const packSrgb = (args) => {
1736
1817
  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));
1737
1818
  };
@@ -2014,76 +2095,6 @@
2014
2095
  return packXYZ([xyz[0], xyz[1], xyz[2], args[3]]);
2015
2096
  };
2016
2097
 
2017
- /**
2018
- * SRGB related functions
2019
- */
2020
- /**
2021
- * Convert XYZ to linear-light sRGB
2022
- *
2023
- * @param xyz
2024
- */
2025
- const xyz2rgbLinear = (xyz) => {
2026
- return multiplyMatrices([
2027
- 3.2409699419045226, -1.537383177570094, -0.4986107602930034, -0.9692436362808796, 1.8759675015077202,
2028
- 0.04155505740717559, 0.05563007969699366, -0.20397695888897652, 1.0569715142428786
2029
- ], xyz);
2030
- };
2031
- /**
2032
- * Convert XYZ to linear-light sRGB
2033
- *
2034
- * @param xyz
2035
- */
2036
- const rgbLinear2xyz = (xyz) => {
2037
- return multiplyMatrices([
2038
- 0.41239079926595934, 0.357584339383878, 0.1804807884018343, 0.21263900587151027, 0.715168678767756,
2039
- 0.07219231536073371, 0.01933081871559182, 0.11919477979462598, 0.9505321522496607
2040
- ], xyz);
2041
- };
2042
- /**
2043
- * Convert sRGB to RGB
2044
- *
2045
- * @param rgb
2046
- */
2047
- const srgbLinear2rgb = (rgb) => {
2048
- return rgb.map((c) => {
2049
- const sign = c < 0 ? -1 : 1, abs = Math.abs(c);
2050
- return abs > 0.0031308 ? sign * (1.055 * abs ** (1 / 2.4) - 0.055) : 12.92 * c;
2051
- });
2052
- };
2053
- /**
2054
- * Convert RGB to sRGB
2055
- *
2056
- * @param rgb
2057
- */
2058
- const rgb2rgbLinear = (rgb) => {
2059
- return rgb.map((c) => {
2060
- const sign = c < 0 ? -1 : 1, abs = Math.abs(c);
2061
- return abs <= 0.04045 ? c / 12.92 : sign * ((abs + 0.055) / 1.055) ** 2.4;
2062
- });
2063
- };
2064
- /**
2065
- * XYZ to SRGB
2066
- *
2067
- * @param args
2068
- */
2069
- const srgbFromXYZ = (args) => {
2070
- const [r, g, b] = srgbLinear2rgb(xyz2rgbLinear([args[0], args[1], args[2]]));
2071
- return [r, g, b, args[3]];
2072
- };
2073
- /**
2074
- * XYZ to SRGB-Linear
2075
- * @param args
2076
- */
2077
- const srgbLinearFromXYZ = (args) => {
2078
- const [r, g, b] = xyz2rgbLinear([args[0], args[1], args[2]]);
2079
- return [
2080
- clamp(Math.round(r * 255), 0, 255),
2081
- clamp(Math.round(g * 255), 0, 255),
2082
- clamp(Math.round(b * 255), 0, 255),
2083
- args[3]
2084
- ];
2085
- };
2086
-
2087
2098
  /**
2088
2099
  * Display-P3 related functions
2089
2100
  */
@@ -4559,6 +4570,37 @@
4559
4570
  }
4560
4571
  };
4561
4572
 
4573
+ exports.IMAGE_RENDERING = void 0;
4574
+ (function (IMAGE_RENDERING) {
4575
+ IMAGE_RENDERING[IMAGE_RENDERING["AUTO"] = 0] = "AUTO";
4576
+ IMAGE_RENDERING[IMAGE_RENDERING["CRISP_EDGES"] = 1] = "CRISP_EDGES";
4577
+ IMAGE_RENDERING[IMAGE_RENDERING["PIXELATED"] = 2] = "PIXELATED";
4578
+ IMAGE_RENDERING[IMAGE_RENDERING["SMOOTH"] = 3] = "SMOOTH";
4579
+ })(exports.IMAGE_RENDERING || (exports.IMAGE_RENDERING = {}));
4580
+ const imageRendering = {
4581
+ name: 'image-rendering',
4582
+ initialValue: 'auto',
4583
+ prefix: false,
4584
+ type: 2 /* PropertyDescriptorParsingType.IDENT_VALUE */,
4585
+ parse: (_context, value) => {
4586
+ switch (value.toLowerCase()) {
4587
+ case 'crisp-edges':
4588
+ case '-webkit-crisp-edges':
4589
+ case '-moz-crisp-edges':
4590
+ return exports.IMAGE_RENDERING.CRISP_EDGES;
4591
+ case 'pixelated':
4592
+ case '-webkit-optimize-contrast':
4593
+ return exports.IMAGE_RENDERING.PIXELATED;
4594
+ case 'smooth':
4595
+ case 'high-quality':
4596
+ return exports.IMAGE_RENDERING.SMOOTH;
4597
+ case 'auto':
4598
+ default:
4599
+ return exports.IMAGE_RENDERING.AUTO;
4600
+ }
4601
+ }
4602
+ };
4603
+
4562
4604
  class CSSParsedDeclaration {
4563
4605
  constructor(context, declaration) {
4564
4606
  this.animationDuration = parse(context, duration, declaration.animationDuration);
@@ -4635,6 +4677,7 @@
4635
4677
  this.wordBreak = parse(context, wordBreak, declaration.wordBreak);
4636
4678
  this.zIndex = parse(context, zIndex, declaration.zIndex);
4637
4679
  this.objectFit = parse(context, objectFit, declaration.objectFit);
4680
+ this.imageRendering = parse(context, imageRendering, declaration.imageRendering);
4638
4681
  }
4639
4682
  isVisible() {
4640
4683
  return this.display > 0 && this.opacity > 0 && this.visibility === 0 /* VISIBILITY.VISIBLE */;
@@ -4711,8 +4754,45 @@
4711
4754
  }
4712
4755
  };
4713
4756
 
4757
+ /**
4758
+ * DOM Node Type Guards
4759
+ * Extracted to break circular dependencies
4760
+ */
4761
+ /**
4762
+ * Check if node is an Element
4763
+ */
4764
+ const isElementNode = (node) => node.nodeType === Node.ELEMENT_NODE;
4765
+ /**
4766
+ * Check if node is a Text node
4767
+ */
4768
+ const isTextNode = (node) => node.nodeType === Node.TEXT_NODE;
4769
+ /**
4770
+ * Check if element is an SVG element
4771
+ */
4772
+ const isSVGElementNode = (element) => typeof element.className === 'object';
4773
+ /**
4774
+ * Check if node is an HTML element
4775
+ */
4776
+ const isHTMLElementNode = (node) => isElementNode(node) && typeof node.style !== 'undefined' && !isSVGElementNode(node);
4777
+ /**
4778
+ * Check if node is an LI element
4779
+ */
4780
+ const isLIElement = (node) => node.tagName === 'LI';
4781
+ /**
4782
+ * Check if node is an OL element
4783
+ */
4784
+ const isOLElement = (node) => node.tagName === 'OL';
4785
+ /**
4786
+ * Check if element is a custom element
4787
+ * Custom elements must have a hyphen and cannot be SVG elements
4788
+ */
4789
+ const isCustomElement = (element) => !isSVGElementNode(element) && element.tagName.indexOf('-') > 0;
4790
+
4714
4791
  const elementDebuggerAttribute = 'data-html2canvas-debug';
4715
4792
  const getElementDebugType = (element) => {
4793
+ if (typeof element.getAttribute !== 'function') {
4794
+ return 0 /* DebuggerType.NONE */;
4795
+ }
4716
4796
  const attribute = element.getAttribute(elementDebuggerAttribute);
4717
4797
  switch (attribute) {
4718
4798
  case 'all':
@@ -4732,8 +4812,83 @@
4732
4812
  return elementType === 1 /* DebuggerType.ALL */ || type === elementType;
4733
4813
  };
4734
4814
 
4815
+ /**
4816
+ * DOM Normalizer
4817
+ * Handles DOM side effects that need to happen before rendering
4818
+ * Extracted from ElementContainer to follow SRP
4819
+ */
4820
+ /**
4821
+ * Normalize element styles for accurate rendering
4822
+ * This includes disabling animations and resetting transforms
4823
+ */
4824
+ class DOMNormalizer {
4825
+ /**
4826
+ * Normalize a single element and return original styles
4827
+ *
4828
+ * @param element - Element to normalize
4829
+ * @param styles - Parsed CSS styles
4830
+ * @returns Original styles map for restoration
4831
+ */
4832
+ static normalizeElement(element, styles) {
4833
+ const originalStyles = {};
4834
+ if (!isHTMLElementNode(element)) {
4835
+ return originalStyles;
4836
+ }
4837
+ // Disable animations to capture static state
4838
+ if (styles.animationDuration.some((duration) => duration > 0)) {
4839
+ originalStyles.animationDuration = element.style.animationDuration;
4840
+ element.style.animationDuration = '0s';
4841
+ }
4842
+ // Reset transform for accurate bounds calculation
4843
+ // getBoundingClientRect takes transforms into account
4844
+ if (styles.transform !== null) {
4845
+ originalStyles.transform = element.style.transform;
4846
+ element.style.transform = 'none';
4847
+ }
4848
+ // Reset rotate property similarly to transform
4849
+ if (styles.rotate !== null) {
4850
+ originalStyles.rotate = element.style.rotate;
4851
+ element.style.rotate = 'none';
4852
+ }
4853
+ return originalStyles;
4854
+ }
4855
+ /**
4856
+ * Normalize element and its descendants recursively
4857
+ *
4858
+ * @param element - Element to normalize
4859
+ * @param styles - Parsed CSS styles
4860
+ * @returns Original styles map for restoration
4861
+ */
4862
+ static normalizeTree(element, styles) {
4863
+ return this.normalizeElement(element, styles);
4864
+ // Could add recursive normalization here if needed
4865
+ // For now, only normalize the element itself
4866
+ }
4867
+ /**
4868
+ * Restore element styles after rendering
4869
+ *
4870
+ * @param element - Element to restore
4871
+ * @param originalStyles - Original styles to restore
4872
+ */
4873
+ static restoreElement(element, originalStyles) {
4874
+ if (!isHTMLElementNode(element)) {
4875
+ return;
4876
+ }
4877
+ // Restore each property that was saved
4878
+ if (originalStyles.animationDuration !== undefined) {
4879
+ element.style.animationDuration = originalStyles.animationDuration;
4880
+ }
4881
+ if (originalStyles.transform !== undefined) {
4882
+ element.style.transform = originalStyles.transform;
4883
+ }
4884
+ if (originalStyles.rotate !== undefined) {
4885
+ element.style.rotate = originalStyles.rotate;
4886
+ }
4887
+ }
4888
+ }
4889
+
4735
4890
  class ElementContainer {
4736
- constructor(context, element) {
4891
+ constructor(context, element, options = {}) {
4737
4892
  this.context = context;
4738
4893
  this.textNodes = [];
4739
4894
  this.elements = [];
@@ -4741,25 +4896,41 @@
4741
4896
  if (isDebugging(element, 3 /* DebuggerType.PARSE */)) {
4742
4897
  debugger;
4743
4898
  }
4744
- this.styles = new CSSParsedDeclaration(context, window.getComputedStyle(element, null));
4745
- if (isHTMLElementNode(element)) {
4746
- if (this.styles.animationDuration.some((duration) => duration > 0)) {
4747
- element.style.animationDuration = '0s';
4748
- }
4749
- if (this.styles.transform !== null) {
4750
- // getBoundingClientRect takes transforms into account
4751
- element.style.transform = 'none';
4752
- }
4753
- if (this.styles.rotate !== null) {
4754
- // Handle rotate property similarly to transform
4755
- element.style.rotate = 'none';
4756
- }
4899
+ this.styles = new CSSParsedDeclaration(context, context.config.window.getComputedStyle(element, null));
4900
+ // Side effects moved to DOMNormalizer (can be disabled via options)
4901
+ const shouldNormalize = options.normalizeDom !== false; // Default: true
4902
+ if (shouldNormalize && isHTMLElementNode(element)) {
4903
+ this.originalStyles = DOMNormalizer.normalizeElement(element, this.styles);
4904
+ this.originalElement = element; // Save reference for restoration
4757
4905
  }
4758
4906
  this.bounds = parseBounds(this.context, element);
4759
4907
  if (isDebugging(element, 4 /* DebuggerType.RENDER */)) {
4760
4908
  this.flags |= 16 /* FLAGS.DEBUG_RENDER */;
4761
4909
  }
4762
4910
  }
4911
+ /**
4912
+ * Restore original element styles (if normalized)
4913
+ * Call this after rendering is complete to clean up DOM state
4914
+ */
4915
+ restore() {
4916
+ if (this.originalStyles && this.originalElement) {
4917
+ DOMNormalizer.restoreElement(this.originalElement, this.originalStyles);
4918
+ // Clear references to prevent memory leaks
4919
+ this.originalStyles = undefined;
4920
+ this.originalElement = undefined;
4921
+ }
4922
+ }
4923
+ /**
4924
+ * Recursively restore all elements in the tree
4925
+ * Call this on the root container after rendering is complete
4926
+ */
4927
+ restoreTree() {
4928
+ this.restore();
4929
+ // Recursively restore all child elements
4930
+ for (const child of this.elements) {
4931
+ child.restoreTree();
4932
+ }
4933
+ }
4763
4934
  }
4764
4935
 
4765
4936
  /*
@@ -5613,17 +5784,19 @@
5613
5784
  }
5614
5785
 
5615
5786
  class IFrameElementContainer extends ElementContainer {
5616
- constructor(context, iframe) {
5787
+ constructor(context, iframe, parseTreeFn) {
5617
5788
  super(context, iframe);
5618
5789
  this.src = iframe.src;
5619
5790
  this.width = parseInt(iframe.width, 10) || 0;
5620
5791
  this.height = parseInt(iframe.height, 10) || 0;
5621
5792
  this.backgroundColor = this.styles.backgroundColor;
5793
+ this.parseTreeFn = parseTreeFn;
5622
5794
  try {
5623
5795
  if (iframe.contentWindow &&
5624
5796
  iframe.contentWindow.document &&
5625
- iframe.contentWindow.document.documentElement) {
5626
- this.tree = parseTree(context, iframe.contentWindow.document.documentElement);
5797
+ iframe.contentWindow.document.documentElement &&
5798
+ this.parseTreeFn) {
5799
+ this.tree = this.parseTreeFn(context, iframe.contentWindow.document.documentElement);
5627
5800
  // http://www.w3.org/TR/css3-background/#special-backgrounds
5628
5801
  const documentBackgroundColor = iframe.contentWindow.document.documentElement
5629
5802
  ? parseColor(context, getComputedStyle(iframe.contentWindow.document.documentElement).backgroundColor)
@@ -5707,7 +5880,7 @@
5707
5880
  return new TextareaElementContainer(context, element);
5708
5881
  }
5709
5882
  if (isIFrameElement(element)) {
5710
- return new IFrameElementContainer(context, element);
5883
+ return new IFrameElementContainer(context, element, parseTree);
5711
5884
  }
5712
5885
  return new ElementContainer(context, element);
5713
5886
  };
@@ -5736,12 +5909,7 @@
5736
5909
  contains(styles.display, 536870912 /* DISPLAY.INLINE_GRID */) ||
5737
5910
  contains(styles.display, 134217728 /* DISPLAY.INLINE_TABLE */));
5738
5911
  };
5739
- const isTextNode = (node) => node.nodeType === Node.TEXT_NODE;
5740
- const isElementNode = (node) => node.nodeType === Node.ELEMENT_NODE;
5741
- const isHTMLElementNode = (node) => isElementNode(node) && typeof node.style !== 'undefined' && !isSVGElementNode(node);
5742
- const isSVGElementNode = (element) => typeof element.className === 'object';
5743
- const isLIElement = (node) => node.tagName === 'LI';
5744
- const isOLElement = (node) => node.tagName === 'OL';
5912
+ // Type guards moved to node-type-guards.ts and re-exported above
5745
5913
  const isInputElement = (node) => node.tagName === 'INPUT';
5746
5914
  const isHTMLElement = (node) => node.tagName === 'HTML';
5747
5915
  const isSVGElement = (node) => node.tagName === 'svg';
@@ -5756,7 +5924,7 @@
5756
5924
  const isSelectElement = (node) => node.tagName === 'SELECT';
5757
5925
  const isSlotElement = (node) => node.tagName === 'SLOT';
5758
5926
  // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
5759
- const isCustomElement = (node) => node.tagName.indexOf('-') > 0;
5927
+ // isCustomElement moved to node-type-guards.ts and re-exported above
5760
5928
 
5761
5929
  class CounterState {
5762
5930
  constructor() {
@@ -6164,7 +6332,7 @@
6164
6332
  toIFrame(ownerDocument, windowSize) {
6165
6333
  const iframe = createIFrameContainer(ownerDocument, windowSize, this.options.iframeContainer);
6166
6334
  if (!iframe.contentWindow) {
6167
- return Promise.reject(`Unable to find iframe window`);
6335
+ throw new Error('Unable to find iframe window');
6168
6336
  }
6169
6337
  const scrollX = ownerDocument.defaultView.pageXOffset;
6170
6338
  const scrollY = ownerDocument.defaultView.pageYOffset;
@@ -6186,7 +6354,7 @@
6186
6354
  const onclone = this.options.onclone;
6187
6355
  const referenceElement = this.clonedReferenceElement;
6188
6356
  if (typeof referenceElement === 'undefined') {
6189
- return Promise.reject(`Error finding the ${this.referenceElement.nodeName} in the cloned document`);
6357
+ throw new Error(`Error finding the ${this.referenceElement.nodeName} in the cloned document`);
6190
6358
  }
6191
6359
  if (documentClone.fonts && documentClone.fonts.ready) {
6192
6360
  await documentClone.fonts.ready;
@@ -6262,7 +6430,7 @@
6262
6430
  clone.loading = 'eager';
6263
6431
  }
6264
6432
  }
6265
- if (isCustomElement(clone)) {
6433
+ if (isCustomElement(clone) && !isSVGElementNode(clone)) {
6266
6434
  return this.createCustomElementClone(clone);
6267
6435
  }
6268
6436
  return clone;
@@ -6388,19 +6556,160 @@
6388
6556
  }
6389
6557
  }
6390
6558
  }
6391
- cloneChildNodes(node, clone, copyStyles) {
6392
- // Clone shadow DOM content if it exists
6393
- if (node.shadowRoot && clone.shadowRoot) {
6394
- for (let child = node.shadowRoot.firstChild; child; child = child.nextSibling) {
6395
- // Clone all shadow DOM children including <slot> elements
6396
- // The browser will automatically handle slot assignment
6397
- clone.shadowRoot.appendChild(this.cloneNode(child, copyStyles));
6398
- }
6399
- }
6400
- // Clone light DOM content (always, even if shadow DOM exists)
6401
- for (let child = node.firstChild; child; child = child.nextSibling) {
6402
- this.appendChildNode(clone, child, copyStyles);
6403
- }
6559
+ /**
6560
+ * Check if a child node should be cloned based on filtering rules
6561
+ * Filters out: scripts, ignored elements, and optionally styles
6562
+ */
6563
+ shouldCloneChild(child) {
6564
+ return (!isElementNode(child) ||
6565
+ (!isScriptElement(child) &&
6566
+ !child.hasAttribute(IGNORE_ATTRIBUTE) &&
6567
+ (typeof this.options.ignoreElements !== 'function' || !this.options.ignoreElements(child))));
6568
+ }
6569
+ /**
6570
+ * Check if a style element should be cloned based on copyStyles option
6571
+ */
6572
+ shouldCloneStyleElement(child) {
6573
+ return !this.options.copyStyles || !isElementNode(child) || !isStyleElement(child);
6574
+ }
6575
+ /**
6576
+ * Safely append a cloned child to a target, applying all filtering rules
6577
+ */
6578
+ safeAppendClonedChild(target, child, copyStyles) {
6579
+ if (this.shouldCloneChild(child) && this.shouldCloneStyleElement(child)) {
6580
+ target.appendChild(this.cloneNode(child, copyStyles));
6581
+ }
6582
+ }
6583
+ /**
6584
+ * Clone assigned nodes from a slot element to the target
6585
+ */
6586
+ cloneAssignedNodes(assignedNodes, target, copyStyles) {
6587
+ assignedNodes.forEach((node) => {
6588
+ this.safeAppendClonedChild(target, node, copyStyles);
6589
+ });
6590
+ }
6591
+ /**
6592
+ * Clone fallback content from a slot element when no nodes are assigned
6593
+ */
6594
+ cloneSlotFallbackContent(slot, target, copyStyles) {
6595
+ for (let child = slot.firstChild; child; child = child.nextSibling) {
6596
+ this.safeAppendClonedChild(target, child, copyStyles);
6597
+ }
6598
+ }
6599
+ /**
6600
+ * Handle cloning of a slot element, including assigned nodes or fallback content
6601
+ */
6602
+ cloneSlotElement(slot, targetShadowRoot, copyStyles) {
6603
+ if (!isSlotElement(slot)) {
6604
+ return;
6605
+ }
6606
+ const slotElement = slot;
6607
+ // Defensive check: ensure assignedNodes method exists
6608
+ if (typeof slotElement.assignedNodes !== 'function') {
6609
+ this.context.logger.warn('HTMLSlotElement.assignedNodes is not available', slot);
6610
+ this.cloneSlotFallbackContent(slot, targetShadowRoot, copyStyles);
6611
+ return;
6612
+ }
6613
+ const assignedNodes = slotElement.assignedNodes();
6614
+ // Defensive check: ensure assignedNodes returns an array
6615
+ if (!assignedNodes || !Array.isArray(assignedNodes)) {
6616
+ this.context.logger.warn('assignedNodes() did not return a valid array', slot);
6617
+ this.cloneSlotFallbackContent(slot, targetShadowRoot, copyStyles);
6618
+ return;
6619
+ }
6620
+ if (assignedNodes.length > 0) {
6621
+ // Clone assigned nodes
6622
+ this.cloneAssignedNodes(assignedNodes, targetShadowRoot, copyStyles);
6623
+ }
6624
+ else {
6625
+ // Clone fallback content
6626
+ this.cloneSlotFallbackContent(slot, targetShadowRoot, copyStyles);
6627
+ }
6628
+ }
6629
+ /**
6630
+ * Clone shadow DOM children to the target shadow root
6631
+ */
6632
+ cloneShadowDOMChildren(shadowRoot, targetShadowRoot, copyStyles) {
6633
+ for (let child = shadowRoot.firstChild; child; child = child.nextSibling) {
6634
+ if (isElementNode(child) && isSlotElement(child)) {
6635
+ // Handle slot elements specially
6636
+ this.cloneSlotElement(child, targetShadowRoot, copyStyles);
6637
+ }
6638
+ else {
6639
+ // Clone regular elements
6640
+ this.safeAppendClonedChild(targetShadowRoot, child, copyStyles);
6641
+ }
6642
+ }
6643
+ }
6644
+ /**
6645
+ * Clone light DOM children to the target element
6646
+ */
6647
+ cloneLightDOMChildren(node, clone, copyStyles) {
6648
+ for (let child = node.firstChild; child; child = child.nextSibling) {
6649
+ this.appendChildNode(clone, child, copyStyles);
6650
+ }
6651
+ }
6652
+ /**
6653
+ * Clone slot element as light DOM when shadow root creation failed
6654
+ */
6655
+ cloneSlotElementAsLightDOM(slot, clone, copyStyles) {
6656
+ if (!isSlotElement(slot)) {
6657
+ return;
6658
+ }
6659
+ const slotElement = slot;
6660
+ if (typeof slotElement.assignedNodes !== 'function') {
6661
+ // Fallback: clone slot's children
6662
+ for (let child = slot.firstChild; child; child = child.nextSibling) {
6663
+ this.appendChildNode(clone, child, copyStyles);
6664
+ }
6665
+ return;
6666
+ }
6667
+ const assignedNodes = slotElement.assignedNodes();
6668
+ if (assignedNodes && Array.isArray(assignedNodes) && assignedNodes.length > 0) {
6669
+ // Clone assigned nodes as light DOM
6670
+ assignedNodes.forEach((node) => this.appendChildNode(clone, node, copyStyles));
6671
+ }
6672
+ else {
6673
+ // Clone fallback content as light DOM
6674
+ for (let child = slot.firstChild; child; child = child.nextSibling) {
6675
+ this.appendChildNode(clone, child, copyStyles);
6676
+ }
6677
+ }
6678
+ }
6679
+ /**
6680
+ * Clone shadow DOM content as light DOM when shadow root creation failed
6681
+ * This is a fallback mechanism to ensure content is not lost
6682
+ */
6683
+ cloneShadowDOMAsLightDOM(shadowRoot, clone, copyStyles) {
6684
+ for (let child = shadowRoot.firstChild; child; child = child.nextSibling) {
6685
+ if (isElementNode(child) && isSlotElement(child)) {
6686
+ this.cloneSlotElementAsLightDOM(child, clone, copyStyles);
6687
+ }
6688
+ else {
6689
+ this.appendChildNode(clone, child, copyStyles);
6690
+ }
6691
+ }
6692
+ }
6693
+ /**
6694
+ * Clone child nodes from source element to clone element
6695
+ * Handles shadow DOM, slots, and light DOM appropriately
6696
+ */
6697
+ cloneChildNodes(node, clone, copyStyles) {
6698
+ if (node.shadowRoot && clone.shadowRoot) {
6699
+ // Both original and clone have shadow roots - clone shadow DOM content
6700
+ this.cloneShadowDOMChildren(node.shadowRoot, clone.shadowRoot, copyStyles);
6701
+ // Also clone light DOM (slot content sources)
6702
+ this.cloneLightDOMChildren(node, clone, copyStyles);
6703
+ }
6704
+ else if (node.shadowRoot && !clone.shadowRoot) {
6705
+ // Original has shadow root but clone doesn't (creation failed)
6706
+ // Fallback: clone shadow DOM content as light DOM to preserve content
6707
+ this.cloneShadowDOMAsLightDOM(node.shadowRoot, clone, copyStyles);
6708
+ }
6709
+ else {
6710
+ // No shadow DOM - just clone light DOM children
6711
+ this.cloneLightDOMChildren(node, clone, copyStyles);
6712
+ }
6404
6713
  }
6405
6714
  cloneNode(node, copyStyles) {
6406
6715
  if (isTextNode(node)) {
@@ -6677,144 +6986,6 @@
6677
6986
  headEle?.insertBefore(baseNode, headEle?.firstChild ?? null);
6678
6987
  };
6679
6988
 
6680
- class CacheStorage {
6681
- static getOrigin(url) {
6682
- const link = CacheStorage._link;
6683
- if (!link) {
6684
- return 'about:blank';
6685
- }
6686
- link.href = url;
6687
- link.href = link.href; // IE9, LOL! - http://jsfiddle.net/niklasvh/2e48b/
6688
- return link.protocol + link.hostname + link.port;
6689
- }
6690
- static isSameOrigin(src) {
6691
- return CacheStorage.getOrigin(src) === CacheStorage._origin;
6692
- }
6693
- static setContext(window) {
6694
- CacheStorage._link = window.document.createElement('a');
6695
- CacheStorage._origin = CacheStorage.getOrigin(window.location.href);
6696
- }
6697
- }
6698
- CacheStorage._origin = 'about:blank';
6699
- class Cache {
6700
- constructor(context, _options) {
6701
- this.context = context;
6702
- this._options = _options;
6703
- this._cache = {};
6704
- }
6705
- addImage(src) {
6706
- const result = Promise.resolve();
6707
- if (this.has(src)) {
6708
- return result;
6709
- }
6710
- if (isBlobImage(src) || isRenderable(src)) {
6711
- (this._cache[src] = this.loadImage(src)).catch(() => {
6712
- // prevent unhandled rejection
6713
- });
6714
- return result;
6715
- }
6716
- return result;
6717
- }
6718
- match(src) {
6719
- return this._cache[src];
6720
- }
6721
- async loadImage(key) {
6722
- const isSameOrigin = typeof this._options.customIsSameOrigin === 'function'
6723
- ? await this._options.customIsSameOrigin(key, CacheStorage.isSameOrigin)
6724
- : CacheStorage.isSameOrigin(key);
6725
- const useCORS = !isInlineImage(key) && this._options.useCORS === true && FEATURES.SUPPORT_CORS_IMAGES && !isSameOrigin;
6726
- const useProxy = !isInlineImage(key) &&
6727
- !isSameOrigin &&
6728
- !isBlobImage(key) &&
6729
- typeof this._options.proxy === 'string' &&
6730
- FEATURES.SUPPORT_CORS_XHR &&
6731
- !useCORS;
6732
- if (!isSameOrigin &&
6733
- this._options.allowTaint === false &&
6734
- !isInlineImage(key) &&
6735
- !isBlobImage(key) &&
6736
- !useProxy &&
6737
- !useCORS) {
6738
- return;
6739
- }
6740
- let src = key;
6741
- if (useProxy) {
6742
- src = await this.proxy(src);
6743
- }
6744
- this.context.logger.debug(`Added image ${key.substring(0, 256)}`);
6745
- return await new Promise((resolve, reject) => {
6746
- const img = new Image();
6747
- img.onload = () => resolve(img);
6748
- img.onerror = reject;
6749
- //ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
6750
- if (isInlineBase64Image(src) || useCORS) {
6751
- img.crossOrigin = 'anonymous';
6752
- }
6753
- img.src = src;
6754
- if (img.complete === true) {
6755
- // Inline XML images may fail to parse, throwing an Error later on
6756
- setTimeout(() => resolve(img), 500);
6757
- }
6758
- if (this._options.imageTimeout > 0) {
6759
- setTimeout(() => reject(`Timed out (${this._options.imageTimeout}ms) loading image`), this._options.imageTimeout);
6760
- }
6761
- });
6762
- }
6763
- has(key) {
6764
- return typeof this._cache[key] !== 'undefined';
6765
- }
6766
- keys() {
6767
- return Promise.resolve(Object.keys(this._cache));
6768
- }
6769
- proxy(src) {
6770
- const proxy = this._options.proxy;
6771
- if (!proxy) {
6772
- throw new Error('No proxy defined');
6773
- }
6774
- const key = src.substring(0, 256);
6775
- return new Promise((resolve, reject) => {
6776
- const responseType = FEATURES.SUPPORT_RESPONSE_TYPE ? 'blob' : 'text';
6777
- const xhr = new XMLHttpRequest();
6778
- xhr.onload = () => {
6779
- if (xhr.status === 200) {
6780
- if (responseType === 'text') {
6781
- resolve(xhr.response);
6782
- }
6783
- else {
6784
- const reader = new FileReader();
6785
- reader.addEventListener('load', () => resolve(reader.result), false);
6786
- reader.addEventListener('error', (e) => reject(e), false);
6787
- reader.readAsDataURL(xhr.response);
6788
- }
6789
- }
6790
- else {
6791
- reject(`Failed to proxy resource ${key} with status code ${xhr.status}`);
6792
- }
6793
- };
6794
- xhr.onerror = reject;
6795
- const queryString = proxy.indexOf('?') > -1 ? '&' : '?';
6796
- xhr.open('GET', `${proxy}${queryString}url=${encodeURIComponent(src)}&responseType=${responseType}`);
6797
- if (responseType !== 'text' && xhr instanceof XMLHttpRequest) {
6798
- xhr.responseType = responseType;
6799
- }
6800
- if (this._options.imageTimeout) {
6801
- const timeout = this._options.imageTimeout;
6802
- xhr.timeout = timeout;
6803
- xhr.ontimeout = () => reject(`Timed out (${timeout}ms) proxying ${key}`);
6804
- }
6805
- xhr.send();
6806
- });
6807
- }
6808
- }
6809
- const INLINE_SVG = /^data:image\/svg\+xml/i;
6810
- const INLINE_BASE64 = /^data:image\/.*;base64,/i;
6811
- const INLINE_IMG = /^data:image\/.*/i;
6812
- const isRenderable = (src) => FEATURES.SUPPORT_SVG_DRAWING || !isSVG(src);
6813
- const isInlineImage = (src) => INLINE_IMG.test(src);
6814
- const isInlineBase64Image = (src) => INLINE_BASE64.test(src);
6815
- const isBlobImage = (src) => src.substr(0, 4) === 'blob';
6816
- const isSVG = (src) => src.substr(-3).toLowerCase() === 'svg' || INLINE_SVG.test(src);
6817
-
6818
6989
  class Vector {
6819
6990
  constructor(x, y) {
6820
6991
  this.type = 0 /* PathType.VECTOR */;
@@ -7253,130 +7424,33 @@
7253
7424
  return root;
7254
7425
  };
7255
7426
 
7256
- const parsePathForBorder = (curves, borderSide) => {
7257
- switch (borderSide) {
7258
- case 0:
7259
- return createPathFromCurves(curves.topLeftBorderBox, curves.topLeftPaddingBox, curves.topRightBorderBox, curves.topRightPaddingBox);
7260
- case 1:
7261
- return createPathFromCurves(curves.topRightBorderBox, curves.topRightPaddingBox, curves.bottomRightBorderBox, curves.bottomRightPaddingBox);
7262
- case 2:
7263
- return createPathFromCurves(curves.bottomRightBorderBox, curves.bottomRightPaddingBox, curves.bottomLeftBorderBox, curves.bottomLeftPaddingBox);
7264
- case 3:
7265
- default:
7266
- return createPathFromCurves(curves.bottomLeftBorderBox, curves.bottomLeftPaddingBox, curves.topLeftBorderBox, curves.topLeftPaddingBox);
7267
- }
7427
+ const paddingBox = (element) => {
7428
+ const bounds = element.bounds;
7429
+ const styles = element.styles;
7430
+ return bounds.add(styles.borderLeftWidth, styles.borderTopWidth, -(styles.borderRightWidth + styles.borderLeftWidth), -(styles.borderTopWidth + styles.borderBottomWidth));
7268
7431
  };
7269
- const parsePathForBorderDoubleOuter = (curves, borderSide) => {
7270
- switch (borderSide) {
7271
- case 0:
7272
- return createPathFromCurves(curves.topLeftBorderBox, curves.topLeftBorderDoubleOuterBox, curves.topRightBorderBox, curves.topRightBorderDoubleOuterBox);
7273
- case 1:
7274
- return createPathFromCurves(curves.topRightBorderBox, curves.topRightBorderDoubleOuterBox, curves.bottomRightBorderBox, curves.bottomRightBorderDoubleOuterBox);
7275
- case 2:
7276
- return createPathFromCurves(curves.bottomRightBorderBox, curves.bottomRightBorderDoubleOuterBox, curves.bottomLeftBorderBox, curves.bottomLeftBorderDoubleOuterBox);
7277
- case 3:
7278
- default:
7279
- return createPathFromCurves(curves.bottomLeftBorderBox, curves.bottomLeftBorderDoubleOuterBox, curves.topLeftBorderBox, curves.topLeftBorderDoubleOuterBox);
7280
- }
7432
+ const contentBox = (element) => {
7433
+ const styles = element.styles;
7434
+ const bounds = element.bounds;
7435
+ const paddingLeft = getAbsoluteValue(styles.paddingLeft, bounds.width);
7436
+ const paddingRight = getAbsoluteValue(styles.paddingRight, bounds.width);
7437
+ const paddingTop = getAbsoluteValue(styles.paddingTop, bounds.width);
7438
+ const paddingBottom = getAbsoluteValue(styles.paddingBottom, bounds.width);
7439
+ return bounds.add(paddingLeft + styles.borderLeftWidth, paddingTop + styles.borderTopWidth, -(styles.borderRightWidth + styles.borderLeftWidth + paddingLeft + paddingRight), -(styles.borderTopWidth + styles.borderBottomWidth + paddingTop + paddingBottom));
7281
7440
  };
7282
- const parsePathForBorderDoubleInner = (curves, borderSide) => {
7283
- switch (borderSide) {
7284
- case 0:
7285
- return createPathFromCurves(curves.topLeftBorderDoubleInnerBox, curves.topLeftPaddingBox, curves.topRightBorderDoubleInnerBox, curves.topRightPaddingBox);
7286
- case 1:
7287
- return createPathFromCurves(curves.topRightBorderDoubleInnerBox, curves.topRightPaddingBox, curves.bottomRightBorderDoubleInnerBox, curves.bottomRightPaddingBox);
7288
- case 2:
7289
- return createPathFromCurves(curves.bottomRightBorderDoubleInnerBox, curves.bottomRightPaddingBox, curves.bottomLeftBorderDoubleInnerBox, curves.bottomLeftPaddingBox);
7290
- case 3:
7291
- default:
7292
- return createPathFromCurves(curves.bottomLeftBorderDoubleInnerBox, curves.bottomLeftPaddingBox, curves.topLeftBorderDoubleInnerBox, curves.topLeftPaddingBox);
7441
+
7442
+ const calculateBackgroundPositioningArea = (backgroundOrigin, element) => {
7443
+ if (backgroundOrigin === 0 /* BACKGROUND_ORIGIN.BORDER_BOX */) {
7444
+ return element.bounds;
7293
7445
  }
7294
- };
7295
- const parsePathForBorderStroke = (curves, borderSide) => {
7296
- switch (borderSide) {
7297
- case 0:
7298
- return createStrokePathFromCurves(curves.topLeftBorderStroke, curves.topRightBorderStroke);
7299
- case 1:
7300
- return createStrokePathFromCurves(curves.topRightBorderStroke, curves.bottomRightBorderStroke);
7301
- case 2:
7302
- return createStrokePathFromCurves(curves.bottomRightBorderStroke, curves.bottomLeftBorderStroke);
7303
- case 3:
7304
- default:
7305
- return createStrokePathFromCurves(curves.bottomLeftBorderStroke, curves.topLeftBorderStroke);
7446
+ if (backgroundOrigin === 2 /* BACKGROUND_ORIGIN.CONTENT_BOX */) {
7447
+ return contentBox(element);
7306
7448
  }
7449
+ return paddingBox(element);
7307
7450
  };
7308
- const createStrokePathFromCurves = (outer1, outer2) => {
7309
- const path = [];
7310
- if (isBezierCurve(outer1)) {
7311
- path.push(outer1.subdivide(0.5, false));
7312
- }
7313
- else {
7314
- path.push(outer1);
7315
- }
7316
- if (isBezierCurve(outer2)) {
7317
- path.push(outer2.subdivide(0.5, true));
7318
- }
7319
- else {
7320
- path.push(outer2);
7321
- }
7322
- return path;
7323
- };
7324
- const createPathFromCurves = (outer1, inner1, outer2, inner2) => {
7325
- const path = [];
7326
- if (isBezierCurve(outer1)) {
7327
- path.push(outer1.subdivide(0.5, false));
7328
- }
7329
- else {
7330
- path.push(outer1);
7331
- }
7332
- if (isBezierCurve(outer2)) {
7333
- path.push(outer2.subdivide(0.5, true));
7334
- }
7335
- else {
7336
- path.push(outer2);
7337
- }
7338
- if (isBezierCurve(inner2)) {
7339
- path.push(inner2.subdivide(0.5, true).reverse());
7340
- }
7341
- else {
7342
- path.push(inner2);
7343
- }
7344
- if (isBezierCurve(inner1)) {
7345
- path.push(inner1.subdivide(0.5, false).reverse());
7346
- }
7347
- else {
7348
- path.push(inner1);
7349
- }
7350
- return path;
7351
- };
7352
-
7353
- const paddingBox = (element) => {
7354
- const bounds = element.bounds;
7355
- const styles = element.styles;
7356
- return bounds.add(styles.borderLeftWidth, styles.borderTopWidth, -(styles.borderRightWidth + styles.borderLeftWidth), -(styles.borderTopWidth + styles.borderBottomWidth));
7357
- };
7358
- const contentBox = (element) => {
7359
- const styles = element.styles;
7360
- const bounds = element.bounds;
7361
- const paddingLeft = getAbsoluteValue(styles.paddingLeft, bounds.width);
7362
- const paddingRight = getAbsoluteValue(styles.paddingRight, bounds.width);
7363
- const paddingTop = getAbsoluteValue(styles.paddingTop, bounds.width);
7364
- const paddingBottom = getAbsoluteValue(styles.paddingBottom, bounds.width);
7365
- return bounds.add(paddingLeft + styles.borderLeftWidth, paddingTop + styles.borderTopWidth, -(styles.borderRightWidth + styles.borderLeftWidth + paddingLeft + paddingRight), -(styles.borderTopWidth + styles.borderBottomWidth + paddingTop + paddingBottom));
7366
- };
7367
-
7368
- const calculateBackgroundPositioningArea = (backgroundOrigin, element) => {
7369
- if (backgroundOrigin === 0 /* BACKGROUND_ORIGIN.BORDER_BOX */) {
7370
- return element.bounds;
7371
- }
7372
- if (backgroundOrigin === 2 /* BACKGROUND_ORIGIN.CONTENT_BOX */) {
7373
- return contentBox(element);
7374
- }
7375
- return paddingBox(element);
7376
- };
7377
- const calculateBackgroundPaintingArea = (backgroundClip, element) => {
7378
- if (backgroundClip === 0 /* BACKGROUND_CLIP.BORDER_BOX */) {
7379
- return element.bounds;
7451
+ const calculateBackgroundPaintingArea = (backgroundClip, element) => {
7452
+ if (backgroundClip === 0 /* BACKGROUND_CLIP.BORDER_BOX */) {
7453
+ return element.bounds;
7380
7454
  }
7381
7455
  if (backgroundClip === 2 /* BACKGROUND_CLIP.CONTENT_BOX */) {
7382
7456
  return contentBox(element);
@@ -7594,135 +7668,717 @@
7594
7668
  }
7595
7669
  }
7596
7670
 
7597
- const MASK_OFFSET = 10000;
7598
- class CanvasRenderer extends Renderer {
7599
- constructor(context, options) {
7600
- super(context, options);
7601
- this._activeEffects = [];
7602
- this.canvas = options.canvas ? options.canvas : document.createElement('canvas');
7603
- this.ctx = this.canvas.getContext('2d');
7604
- if (!options.canvas) {
7605
- this.canvas.width = Math.floor(options.width * options.scale);
7606
- this.canvas.height = Math.floor(options.height * options.scale);
7607
- this.canvas.style.width = `${options.width}px`;
7608
- this.canvas.style.height = `${options.height}px`;
7609
- }
7610
- this.fontMetrics = new FontMetrics(document);
7611
- this.ctx.scale(this.options.scale, this.options.scale);
7612
- this.ctx.translate(-options.x, -options.y);
7613
- this.ctx.textBaseline = 'bottom';
7614
- this._activeEffects = [];
7615
- this.context.logger.debug(`Canvas renderer initialized (${options.width}x${options.height}) with scale ${options.scale}`);
7671
+ /**
7672
+ * Background Renderer
7673
+ *
7674
+ * Handles rendering of element backgrounds including:
7675
+ * - Background colors
7676
+ * - Background images (URL)
7677
+ * - Linear gradients
7678
+ * - Radial gradients
7679
+ * - Background patterns and repeats
7680
+ */
7681
+ /**
7682
+ * Background Renderer
7683
+ *
7684
+ * Specialized renderer for element backgrounds.
7685
+ * Extracted from CanvasRenderer to improve code organization and maintainability.
7686
+ */
7687
+ class BackgroundRenderer {
7688
+ constructor(deps) {
7689
+ this.ctx = deps.ctx;
7690
+ this.context = deps.context;
7691
+ this.canvas = deps.canvas;
7692
+ // Options stored in deps but not needed as instance property
7616
7693
  }
7617
- applyEffects(effects) {
7618
- while (this._activeEffects.length) {
7619
- this.popEffect();
7694
+ /**
7695
+ * Render background images for a container
7696
+ * Supports URL images, linear gradients, and radial gradients
7697
+ *
7698
+ * @param container - Element container with background styles
7699
+ */
7700
+ async renderBackgroundImage(container) {
7701
+ let index = container.styles.backgroundImage.length - 1;
7702
+ for (const backgroundImage of container.styles.backgroundImage.slice(0).reverse()) {
7703
+ if (backgroundImage.type === 0 /* CSSImageType.URL */) {
7704
+ await this.renderBackgroundURLImage(container, backgroundImage, index);
7705
+ }
7706
+ else if (isLinearGradient(backgroundImage)) {
7707
+ this.renderLinearGradient(container, backgroundImage, index);
7708
+ }
7709
+ else if (isRadialGradient(backgroundImage)) {
7710
+ this.renderRadialGradient(container, backgroundImage, index);
7711
+ }
7712
+ index--;
7620
7713
  }
7621
- effects.forEach((effect) => this.applyEffect(effect));
7622
7714
  }
7623
- applyEffect(effect) {
7624
- this.ctx.save();
7625
- if (isOpacityEffect(effect)) {
7626
- this.ctx.globalAlpha = effect.opacity;
7627
- }
7628
- if (isTransformEffect(effect)) {
7629
- this.ctx.translate(effect.offsetX, effect.offsetY);
7630
- this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
7631
- this.ctx.translate(-effect.offsetX, -effect.offsetY);
7715
+ /**
7716
+ * Render a URL-based background image
7717
+ */
7718
+ async renderBackgroundURLImage(container, backgroundImage, index) {
7719
+ let image;
7720
+ const url = backgroundImage.url;
7721
+ try {
7722
+ image = await this.context.cache.match(url);
7632
7723
  }
7633
- if (isClipEffect(effect)) {
7634
- this.path(effect.path);
7635
- this.ctx.clip();
7724
+ catch (e) {
7725
+ this.context.logger.error(`Error loading background-image ${url}`);
7726
+ }
7727
+ if (image) {
7728
+ const imageWidth = isNaN(image.width) || image.width === 0 ? 1 : image.width;
7729
+ const imageHeight = isNaN(image.height) || image.height === 0 ? 1 : image.height;
7730
+ const [path, x, y, width, height] = calculateBackgroundRendering(container, index, [
7731
+ imageWidth,
7732
+ imageHeight,
7733
+ imageWidth / imageHeight
7734
+ ]);
7735
+ const pattern = this.ctx.createPattern(this.resizeImage(image, width, height, container.styles.imageRendering), 'repeat');
7736
+ this.renderRepeat(path, pattern, x, y);
7636
7737
  }
7637
- this._activeEffects.push(effect);
7638
- }
7639
- popEffect() {
7640
- this._activeEffects.pop();
7641
- this.ctx.restore();
7642
7738
  }
7643
- async renderStack(stack) {
7644
- const styles = stack.element.container.styles;
7645
- if (styles.isVisible()) {
7646
- await this.renderStackContent(stack);
7739
+ /**
7740
+ * Render a linear gradient background
7741
+ */
7742
+ renderLinearGradient(container, backgroundImage, index) {
7743
+ const [path, x, y, width, height] = calculateBackgroundRendering(container, index, [null, null, null]);
7744
+ const [lineLength, x0, x1, y0, y1] = calculateGradientDirection(backgroundImage.angle, width, height);
7745
+ const ownerDocument = this.canvas.ownerDocument ?? document;
7746
+ const canvas = ownerDocument.createElement('canvas');
7747
+ canvas.width = width;
7748
+ canvas.height = height;
7749
+ const ctx = canvas.getContext('2d');
7750
+ const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
7751
+ processColorStops(backgroundImage.stops, lineLength || 1).forEach((colorStop) => gradient.addColorStop(colorStop.stop, asString(colorStop.color)));
7752
+ ctx.fillStyle = gradient;
7753
+ ctx.fillRect(0, 0, width, height);
7754
+ if (width > 0 && height > 0) {
7755
+ const pattern = this.ctx.createPattern(canvas, 'repeat');
7756
+ this.renderRepeat(path, pattern, x, y);
7647
7757
  }
7648
7758
  }
7649
- async renderNode(paint) {
7650
- if (contains(paint.container.flags, 16 /* FLAGS.DEBUG_RENDER */)) {
7651
- debugger;
7652
- }
7653
- if (paint.container.styles.isVisible()) {
7654
- await this.renderNodeBackgroundAndBorders(paint);
7655
- await this.renderNodeContent(paint);
7759
+ /**
7760
+ * Render a radial gradient background
7761
+ */
7762
+ renderRadialGradient(container, backgroundImage, index) {
7763
+ const [path, left, top, width, height] = calculateBackgroundRendering(container, index, [null, null, null]);
7764
+ const position = backgroundImage.position.length === 0 ? [FIFTY_PERCENT] : backgroundImage.position;
7765
+ const x = getAbsoluteValue(position[0], width);
7766
+ const y = getAbsoluteValue(position[position.length - 1], height);
7767
+ let [rx, ry] = calculateRadius(backgroundImage, x, y, width, height);
7768
+ // Handle edge case where radial gradient size is 0
7769
+ // Use a minimum value of 0.01 to ensure gradient is still rendered
7770
+ if (rx === 0 || ry === 0) {
7771
+ rx = Math.max(rx, 0.01);
7772
+ ry = Math.max(ry, 0.01);
7773
+ }
7774
+ if (rx > 0 && ry > 0) {
7775
+ const radialGradient = this.ctx.createRadialGradient(left + x, top + y, 0, left + x, top + y, rx);
7776
+ processColorStops(backgroundImage.stops, rx * 2).forEach((colorStop) => radialGradient.addColorStop(colorStop.stop, asString(colorStop.color)));
7777
+ this.path(path);
7778
+ this.ctx.fillStyle = radialGradient;
7779
+ if (rx !== ry) {
7780
+ // transforms for elliptical radial gradient
7781
+ const midX = container.bounds.left + 0.5 * container.bounds.width;
7782
+ const midY = container.bounds.top + 0.5 * container.bounds.height;
7783
+ const f = ry / rx;
7784
+ const invF = 1 / f;
7785
+ this.ctx.save();
7786
+ this.ctx.translate(midX, midY);
7787
+ this.ctx.transform(1, 0, 0, f, 0, 0);
7788
+ this.ctx.translate(-midX, -midY);
7789
+ this.ctx.fillRect(left, invF * (top - midY) + midY, width, height * invF);
7790
+ this.ctx.restore();
7791
+ }
7792
+ else {
7793
+ this.ctx.fill();
7794
+ }
7656
7795
  }
7657
7796
  }
7658
- renderTextWithLetterSpacing(text, letterSpacing, baseline) {
7659
- if (letterSpacing === 0) {
7660
- // Use alphabetic baseline for consistent text positioning across browsers
7661
- // Issue #129: text.bounds.top + text.bounds.height causes text to render too low
7662
- this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
7797
+ /**
7798
+ * Render a repeating pattern with offset
7799
+ *
7800
+ * @param path - Path to fill
7801
+ * @param pattern - Canvas pattern or gradient
7802
+ * @param offsetX - X offset for pattern
7803
+ * @param offsetY - Y offset for pattern
7804
+ */
7805
+ renderRepeat(path, pattern, offsetX, offsetY) {
7806
+ this.path(path);
7807
+ this.ctx.fillStyle = pattern;
7808
+ this.ctx.translate(offsetX, offsetY);
7809
+ this.ctx.fill();
7810
+ this.ctx.translate(-offsetX, -offsetY);
7811
+ }
7812
+ /**
7813
+ * Resize an image to target dimensions
7814
+ *
7815
+ * @param image - Source image
7816
+ * @param width - Target width
7817
+ * @param height - Target height
7818
+ * @param imageRendering - CSS image-rendering property value
7819
+ * @returns Resized canvas or original image
7820
+ */
7821
+ resizeImage(image, width, height, imageRendering) {
7822
+ // https://github.com/niklasvh/html2canvas/pull/2911
7823
+ // if (image.width === width && image.height === height) {
7824
+ // return image;
7825
+ // }
7826
+ const ownerDocument = this.canvas.ownerDocument ?? document;
7827
+ const canvas = ownerDocument.createElement('canvas');
7828
+ canvas.width = Math.max(1, width);
7829
+ canvas.height = Math.max(1, height);
7830
+ const ctx = canvas.getContext('2d');
7831
+ // Apply image smoothing based on CSS image-rendering property
7832
+ if (imageRendering === exports.IMAGE_RENDERING.PIXELATED || imageRendering === exports.IMAGE_RENDERING.CRISP_EDGES) {
7833
+ this.context.logger.debug(`Disabling image smoothing for background image due to CSS image-rendering`);
7834
+ ctx.imageSmoothingEnabled = false;
7835
+ }
7836
+ else if (imageRendering === exports.IMAGE_RENDERING.SMOOTH) {
7837
+ this.context.logger.debug(`Enabling image smoothing for background image due to CSS image-rendering: smooth`);
7838
+ ctx.imageSmoothingEnabled = true;
7663
7839
  }
7664
7840
  else {
7665
- const letters = segmentGraphemes(text.text);
7666
- letters.reduce((left, letter) => {
7667
- this.ctx.fillText(letter, left, text.bounds.top + baseline);
7668
- return left + this.ctx.measureText(letter).width;
7669
- }, text.bounds.left);
7841
+ // AUTO: inherit from main renderer context
7842
+ ctx.imageSmoothingEnabled = this.ctx.imageSmoothingEnabled;
7670
7843
  }
7844
+ // Inherit quality setting
7845
+ if (this.ctx.imageSmoothingQuality) {
7846
+ ctx.imageSmoothingQuality = this.ctx.imageSmoothingQuality;
7847
+ }
7848
+ ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height);
7849
+ return canvas;
7671
7850
  }
7672
7851
  /**
7673
- * Helper method to render text with paint order support
7674
- * Reduces code duplication in line-clamp and normal rendering
7852
+ * Create a canvas path from path array
7853
+ *
7854
+ * @param paths - Array of path points
7675
7855
  */
7676
- renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers) {
7677
- paintOrderLayers.forEach((paintOrderLayer) => {
7678
- switch (paintOrderLayer) {
7679
- case 0 /* PAINT_ORDER_LAYER.FILL */:
7680
- this.ctx.fillStyle = asString(styles.color);
7681
- this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
7682
- break;
7683
- case 1 /* PAINT_ORDER_LAYER.STROKE */:
7684
- if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
7685
- this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
7686
- this.ctx.lineWidth = styles.webkitTextStrokeWidth;
7687
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
7688
- this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
7689
- }
7690
- break;
7856
+ path(paths) {
7857
+ this.ctx.beginPath();
7858
+ this.formatPath(paths);
7859
+ this.ctx.closePath();
7860
+ }
7861
+ /**
7862
+ * Format path points into canvas path
7863
+ *
7864
+ * @param paths - Array of path points
7865
+ */
7866
+ formatPath(paths) {
7867
+ paths.forEach((point, index) => {
7868
+ const start = isBezierCurve(point) ? point.start : point;
7869
+ if (index === 0) {
7870
+ this.ctx.moveTo(start.x, start.y);
7871
+ }
7872
+ else {
7873
+ this.ctx.lineTo(start.x, start.y);
7874
+ }
7875
+ if (isBezierCurve(point)) {
7876
+ this.ctx.bezierCurveTo(point.startControl.x, point.startControl.y, point.endControl.x, point.endControl.y, point.end.x, point.end.y);
7691
7877
  }
7692
7878
  });
7693
7879
  }
7694
- renderTextDecoration(bounds, styles) {
7695
- this.ctx.fillStyle = asString(styles.textDecorationColor || styles.color);
7696
- // Calculate decoration line thickness
7697
- let thickness = 1; // default
7698
- if (typeof styles.textDecorationThickness === 'number') {
7699
- thickness = styles.textDecorationThickness;
7700
- }
7701
- else if (styles.textDecorationThickness === 'from-font') {
7702
- // Use a reasonable default based on font size
7703
- thickness = Math.max(1, Math.floor(styles.fontSize.number * 0.05));
7704
- }
7705
- // 'auto' uses default thickness of 1
7706
- // Calculate underline offset
7707
- let underlineOffset = 0;
7708
- if (typeof styles.textUnderlineOffset === 'number') {
7709
- // It's a pixel value
7710
- underlineOffset = styles.textUnderlineOffset;
7711
- }
7712
- // 'auto' uses default offset of 0
7713
- const decorationStyle = styles.textDecorationStyle;
7714
- styles.textDecorationLine.forEach((textDecorationLine) => {
7715
- let y = 0;
7716
- switch (textDecorationLine) {
7717
- case 1 /* TEXT_DECORATION_LINE.UNDERLINE */:
7718
- y = bounds.top + bounds.height - thickness + underlineOffset;
7719
- break;
7720
- case 2 /* TEXT_DECORATION_LINE.OVERLINE */:
7721
- y = bounds.top;
7722
- break;
7723
- case 3 /* TEXT_DECORATION_LINE.LINE_THROUGH */:
7724
- y = bounds.top + (bounds.height / 2 - thickness / 2);
7725
- break;
7880
+ }
7881
+
7882
+ const parsePathForBorder = (curves, borderSide) => {
7883
+ switch (borderSide) {
7884
+ case 0:
7885
+ return createPathFromCurves(curves.topLeftBorderBox, curves.topLeftPaddingBox, curves.topRightBorderBox, curves.topRightPaddingBox);
7886
+ case 1:
7887
+ return createPathFromCurves(curves.topRightBorderBox, curves.topRightPaddingBox, curves.bottomRightBorderBox, curves.bottomRightPaddingBox);
7888
+ case 2:
7889
+ return createPathFromCurves(curves.bottomRightBorderBox, curves.bottomRightPaddingBox, curves.bottomLeftBorderBox, curves.bottomLeftPaddingBox);
7890
+ case 3:
7891
+ default:
7892
+ return createPathFromCurves(curves.bottomLeftBorderBox, curves.bottomLeftPaddingBox, curves.topLeftBorderBox, curves.topLeftPaddingBox);
7893
+ }
7894
+ };
7895
+ const parsePathForBorderDoubleOuter = (curves, borderSide) => {
7896
+ switch (borderSide) {
7897
+ case 0:
7898
+ return createPathFromCurves(curves.topLeftBorderBox, curves.topLeftBorderDoubleOuterBox, curves.topRightBorderBox, curves.topRightBorderDoubleOuterBox);
7899
+ case 1:
7900
+ return createPathFromCurves(curves.topRightBorderBox, curves.topRightBorderDoubleOuterBox, curves.bottomRightBorderBox, curves.bottomRightBorderDoubleOuterBox);
7901
+ case 2:
7902
+ return createPathFromCurves(curves.bottomRightBorderBox, curves.bottomRightBorderDoubleOuterBox, curves.bottomLeftBorderBox, curves.bottomLeftBorderDoubleOuterBox);
7903
+ case 3:
7904
+ default:
7905
+ return createPathFromCurves(curves.bottomLeftBorderBox, curves.bottomLeftBorderDoubleOuterBox, curves.topLeftBorderBox, curves.topLeftBorderDoubleOuterBox);
7906
+ }
7907
+ };
7908
+ const parsePathForBorderDoubleInner = (curves, borderSide) => {
7909
+ switch (borderSide) {
7910
+ case 0:
7911
+ return createPathFromCurves(curves.topLeftBorderDoubleInnerBox, curves.topLeftPaddingBox, curves.topRightBorderDoubleInnerBox, curves.topRightPaddingBox);
7912
+ case 1:
7913
+ return createPathFromCurves(curves.topRightBorderDoubleInnerBox, curves.topRightPaddingBox, curves.bottomRightBorderDoubleInnerBox, curves.bottomRightPaddingBox);
7914
+ case 2:
7915
+ return createPathFromCurves(curves.bottomRightBorderDoubleInnerBox, curves.bottomRightPaddingBox, curves.bottomLeftBorderDoubleInnerBox, curves.bottomLeftPaddingBox);
7916
+ case 3:
7917
+ default:
7918
+ return createPathFromCurves(curves.bottomLeftBorderDoubleInnerBox, curves.bottomLeftPaddingBox, curves.topLeftBorderDoubleInnerBox, curves.topLeftPaddingBox);
7919
+ }
7920
+ };
7921
+ const parsePathForBorderStroke = (curves, borderSide) => {
7922
+ switch (borderSide) {
7923
+ case 0:
7924
+ return createStrokePathFromCurves(curves.topLeftBorderStroke, curves.topRightBorderStroke);
7925
+ case 1:
7926
+ return createStrokePathFromCurves(curves.topRightBorderStroke, curves.bottomRightBorderStroke);
7927
+ case 2:
7928
+ return createStrokePathFromCurves(curves.bottomRightBorderStroke, curves.bottomLeftBorderStroke);
7929
+ case 3:
7930
+ default:
7931
+ return createStrokePathFromCurves(curves.bottomLeftBorderStroke, curves.topLeftBorderStroke);
7932
+ }
7933
+ };
7934
+ const createStrokePathFromCurves = (outer1, outer2) => {
7935
+ const path = [];
7936
+ if (isBezierCurve(outer1)) {
7937
+ path.push(outer1.subdivide(0.5, false));
7938
+ }
7939
+ else {
7940
+ path.push(outer1);
7941
+ }
7942
+ if (isBezierCurve(outer2)) {
7943
+ path.push(outer2.subdivide(0.5, true));
7944
+ }
7945
+ else {
7946
+ path.push(outer2);
7947
+ }
7948
+ return path;
7949
+ };
7950
+ const createPathFromCurves = (outer1, inner1, outer2, inner2) => {
7951
+ const path = [];
7952
+ if (isBezierCurve(outer1)) {
7953
+ path.push(outer1.subdivide(0.5, false));
7954
+ }
7955
+ else {
7956
+ path.push(outer1);
7957
+ }
7958
+ if (isBezierCurve(outer2)) {
7959
+ path.push(outer2.subdivide(0.5, true));
7960
+ }
7961
+ else {
7962
+ path.push(outer2);
7963
+ }
7964
+ if (isBezierCurve(inner2)) {
7965
+ path.push(inner2.subdivide(0.5, true).reverse());
7966
+ }
7967
+ else {
7968
+ path.push(inner2);
7969
+ }
7970
+ if (isBezierCurve(inner1)) {
7971
+ path.push(inner1.subdivide(0.5, false).reverse());
7972
+ }
7973
+ else {
7974
+ path.push(inner1);
7975
+ }
7976
+ return path;
7977
+ };
7978
+
7979
+ /**
7980
+ * Border Renderer
7981
+ *
7982
+ * Handles rendering of element borders including:
7983
+ * - Solid borders
7984
+ * - Double borders
7985
+ * - Dashed borders
7986
+ * - Dotted borders
7987
+ */
7988
+ /**
7989
+ * Border Renderer
7990
+ *
7991
+ * Specialized renderer for element borders.
7992
+ * Extracted from CanvasRenderer to improve code organization and maintainability.
7993
+ */
7994
+ class BorderRenderer {
7995
+ constructor(deps, pathCallbacks) {
7996
+ this.ctx = deps.ctx;
7997
+ this.pathCallbacks = pathCallbacks;
7998
+ }
7999
+ /**
8000
+ * Render a solid border
8001
+ *
8002
+ * @param color - Border color
8003
+ * @param side - Border side (0=top, 1=right, 2=bottom, 3=left)
8004
+ * @param curvePoints - Border curve points
8005
+ */
8006
+ async renderSolidBorder(color, side, curvePoints) {
8007
+ this.pathCallbacks.path(parsePathForBorder(curvePoints, side));
8008
+ this.ctx.fillStyle = asString(color);
8009
+ this.ctx.fill();
8010
+ }
8011
+ /**
8012
+ * Render a double border
8013
+ * Falls back to solid border if width is too small
8014
+ *
8015
+ * @param color - Border color
8016
+ * @param width - Border width
8017
+ * @param side - Border side (0=top, 1=right, 2=bottom, 3=left)
8018
+ * @param curvePoints - Border curve points
8019
+ */
8020
+ async renderDoubleBorder(color, width, side, curvePoints) {
8021
+ if (width < 3) {
8022
+ await this.renderSolidBorder(color, side, curvePoints);
8023
+ return;
8024
+ }
8025
+ const outerPaths = parsePathForBorderDoubleOuter(curvePoints, side);
8026
+ this.pathCallbacks.path(outerPaths);
8027
+ this.ctx.fillStyle = asString(color);
8028
+ this.ctx.fill();
8029
+ const innerPaths = parsePathForBorderDoubleInner(curvePoints, side);
8030
+ this.pathCallbacks.path(innerPaths);
8031
+ this.ctx.fill();
8032
+ }
8033
+ /**
8034
+ * Render a dashed or dotted border
8035
+ *
8036
+ * @param color - Border color
8037
+ * @param width - Border width
8038
+ * @param side - Border side (0=top, 1=right, 2=bottom, 3=left)
8039
+ * @param curvePoints - Border curve points
8040
+ * @param style - Border style (DASHED or DOTTED)
8041
+ */
8042
+ async renderDashedDottedBorder(color, width, side, curvePoints, style) {
8043
+ this.ctx.save();
8044
+ const strokePaths = parsePathForBorderStroke(curvePoints, side);
8045
+ const boxPaths = parsePathForBorder(curvePoints, side);
8046
+ if (style === 2 /* BORDER_STYLE.DASHED */) {
8047
+ this.pathCallbacks.path(boxPaths);
8048
+ this.ctx.clip();
8049
+ }
8050
+ // Extract start and end coordinates
8051
+ let startX, startY, endX, endY;
8052
+ if (isBezierCurve(boxPaths[0])) {
8053
+ startX = boxPaths[0].start.x;
8054
+ startY = boxPaths[0].start.y;
8055
+ }
8056
+ else {
8057
+ startX = boxPaths[0].x;
8058
+ startY = boxPaths[0].y;
8059
+ }
8060
+ if (isBezierCurve(boxPaths[1])) {
8061
+ endX = boxPaths[1].end.x;
8062
+ endY = boxPaths[1].end.y;
8063
+ }
8064
+ else {
8065
+ endX = boxPaths[1].x;
8066
+ endY = boxPaths[1].y;
8067
+ }
8068
+ // Calculate border length
8069
+ let length;
8070
+ if (side === 0 || side === 2) {
8071
+ length = Math.abs(startX - endX);
8072
+ }
8073
+ else {
8074
+ length = Math.abs(startY - endY);
8075
+ }
8076
+ this.ctx.beginPath();
8077
+ if (style === 3 /* BORDER_STYLE.DOTTED */) {
8078
+ this.pathCallbacks.formatPath(strokePaths);
8079
+ }
8080
+ else {
8081
+ this.pathCallbacks.formatPath(boxPaths.slice(0, 2));
8082
+ }
8083
+ // Calculate dash and space lengths
8084
+ let dashLength = width < 3 ? width * 3 : width * 2;
8085
+ let spaceLength = width < 3 ? width * 2 : width;
8086
+ if (style === 3 /* BORDER_STYLE.DOTTED */) {
8087
+ dashLength = width;
8088
+ spaceLength = width;
8089
+ }
8090
+ // Adjust dash pattern for border length
8091
+ let useLineDash = true;
8092
+ if (length <= dashLength * 2) {
8093
+ useLineDash = false;
8094
+ }
8095
+ else if (length <= dashLength * 2 + spaceLength) {
8096
+ const multiplier = length / (2 * dashLength + spaceLength);
8097
+ dashLength *= multiplier;
8098
+ spaceLength *= multiplier;
8099
+ }
8100
+ else {
8101
+ const numberOfDashes = Math.floor((length + spaceLength) / (dashLength + spaceLength));
8102
+ const minSpace = (length - numberOfDashes * dashLength) / (numberOfDashes - 1);
8103
+ const maxSpace = (length - (numberOfDashes + 1) * dashLength) / numberOfDashes;
8104
+ spaceLength =
8105
+ maxSpace <= 0 || Math.abs(spaceLength - minSpace) < Math.abs(spaceLength - maxSpace)
8106
+ ? minSpace
8107
+ : maxSpace;
8108
+ }
8109
+ // Apply line dash pattern
8110
+ if (useLineDash) {
8111
+ if (style === 3 /* BORDER_STYLE.DOTTED */) {
8112
+ this.ctx.setLineDash([0, dashLength + spaceLength]);
8113
+ }
8114
+ else {
8115
+ this.ctx.setLineDash([dashLength, spaceLength]);
8116
+ }
8117
+ }
8118
+ // Set line style and stroke
8119
+ if (style === 3 /* BORDER_STYLE.DOTTED */) {
8120
+ this.ctx.lineCap = 'round';
8121
+ this.ctx.lineWidth = width;
8122
+ }
8123
+ else {
8124
+ this.ctx.lineWidth = width * 2 + 1.1;
8125
+ }
8126
+ this.ctx.strokeStyle = asString(color);
8127
+ this.ctx.stroke();
8128
+ this.ctx.setLineDash([]);
8129
+ // Fill dashed round edge gaps
8130
+ if (style === 2 /* BORDER_STYLE.DASHED */) {
8131
+ if (isBezierCurve(boxPaths[0])) {
8132
+ const path1 = boxPaths[3];
8133
+ const path2 = boxPaths[0];
8134
+ this.ctx.beginPath();
8135
+ this.pathCallbacks.formatPath([
8136
+ new Vector(path1.end.x, path1.end.y),
8137
+ new Vector(path2.start.x, path2.start.y)
8138
+ ]);
8139
+ this.ctx.stroke();
8140
+ }
8141
+ if (isBezierCurve(boxPaths[1])) {
8142
+ const path1 = boxPaths[1];
8143
+ const path2 = boxPaths[2];
8144
+ this.ctx.beginPath();
8145
+ this.pathCallbacks.formatPath([
8146
+ new Vector(path1.end.x, path1.end.y),
8147
+ new Vector(path2.start.x, path2.start.y)
8148
+ ]);
8149
+ this.ctx.stroke();
8150
+ }
8151
+ }
8152
+ this.ctx.restore();
8153
+ }
8154
+ }
8155
+
8156
+ /**
8157
+ * Effects Renderer
8158
+ *
8159
+ * Handles rendering effects including:
8160
+ * - Opacity effects
8161
+ * - Transform effects (matrix transformations)
8162
+ * - Clip effects (clipping paths)
8163
+ */
8164
+ /**
8165
+ * Effects Renderer
8166
+ *
8167
+ * Manages rendering effects stack including opacity, transforms, and clipping.
8168
+ * Extracted from CanvasRenderer to improve code organization and maintainability.
8169
+ */
8170
+ class EffectsRenderer {
8171
+ constructor(deps, pathCallback) {
8172
+ this.activeEffects = [];
8173
+ this.ctx = deps.ctx;
8174
+ this.pathCallback = pathCallback;
8175
+ }
8176
+ /**
8177
+ * Apply multiple effects
8178
+ * Clears existing effects and applies new ones
8179
+ *
8180
+ * @param effects - Array of effects to apply
8181
+ */
8182
+ applyEffects(effects) {
8183
+ // Clear all existing effects
8184
+ while (this.activeEffects.length) {
8185
+ this.popEffect();
8186
+ }
8187
+ // Apply new effects
8188
+ effects.forEach((effect) => this.applyEffect(effect));
8189
+ }
8190
+ /**
8191
+ * Apply a single effect
8192
+ *
8193
+ * @param effect - Effect to apply
8194
+ */
8195
+ applyEffect(effect) {
8196
+ this.ctx.save();
8197
+ // Apply opacity effect
8198
+ if (isOpacityEffect(effect)) {
8199
+ this.ctx.globalAlpha = effect.opacity;
8200
+ }
8201
+ // Apply transform effect
8202
+ if (isTransformEffect(effect)) {
8203
+ this.ctx.translate(effect.offsetX, effect.offsetY);
8204
+ this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
8205
+ this.ctx.translate(-effect.offsetX, -effect.offsetY);
8206
+ }
8207
+ // Apply clip effect
8208
+ if (isClipEffect(effect)) {
8209
+ this.pathCallback.path(effect.path);
8210
+ this.ctx.clip();
8211
+ }
8212
+ this.activeEffects.push(effect);
8213
+ }
8214
+ /**
8215
+ * Remove the most recent effect
8216
+ * Restores the canvas state before the effect was applied
8217
+ */
8218
+ popEffect() {
8219
+ this.activeEffects.pop();
8220
+ this.ctx.restore();
8221
+ }
8222
+ /**
8223
+ * Get the current number of active effects
8224
+ *
8225
+ * @returns Number of active effects
8226
+ */
8227
+ getActiveEffectCount() {
8228
+ return this.activeEffects.length;
8229
+ }
8230
+ /**
8231
+ * Check if there are any active effects
8232
+ *
8233
+ * @returns True if there are active effects
8234
+ */
8235
+ hasActiveEffects() {
8236
+ return this.activeEffects.length > 0;
8237
+ }
8238
+ }
8239
+
8240
+ /**
8241
+ * Text Renderer
8242
+ *
8243
+ * Handles rendering of text content including:
8244
+ * - Text with letter spacing
8245
+ * - Text decorations (underline, overline, line-through)
8246
+ * - Text shadows
8247
+ * - Webkit line clamp
8248
+ * - Text overflow ellipsis
8249
+ * - Paint order (fill/stroke)
8250
+ * - Font styles
8251
+ */
8252
+ // iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
8253
+ const iOSBrokenFonts = ['-apple-system', 'system-ui'];
8254
+ /**
8255
+ * Detect iOS version from user agent
8256
+ * Returns null if not iOS or version cannot be determined
8257
+ */
8258
+ const getIOSVersion = () => {
8259
+ if (typeof navigator === 'undefined') {
8260
+ return null;
8261
+ }
8262
+ const userAgent = navigator.userAgent;
8263
+ // Check if it's iOS or iPadOS
8264
+ // iPadOS 13+ may identify as Macintosh, check for touch support
8265
+ const isIOS = /iPhone|iPad|iPod/.test(userAgent);
8266
+ const isIPadOS = /Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 1;
8267
+ if (!isIOS && !isIPadOS) {
8268
+ return null;
8269
+ }
8270
+ // Extract version number from various iOS user agent formats:
8271
+ // - "iPhone OS 15_0" or "iPhone OS 15_0_1"
8272
+ // - "CPU OS 15_0 like Mac OS X"
8273
+ // - "CPU iPhone OS 15_0 like Mac OS X"
8274
+ // - "Version/15.0" (for iPadOS)
8275
+ const patterns = [
8276
+ /(?:iPhone|CPU(?:\siPhone)?)\sOS\s(\d+)[\._](\d+)/, // iPhone OS, CPU OS, CPU iPhone OS
8277
+ /Version\/(\d+)\.(\d+)/ // Version/15.0 (iPadOS)
8278
+ ];
8279
+ for (const pattern of patterns) {
8280
+ const match = userAgent.match(pattern);
8281
+ if (match && match[1]) {
8282
+ return parseInt(match[1], 10);
8283
+ }
8284
+ }
8285
+ return null;
8286
+ };
8287
+ const fixIOSSystemFonts = (fontFamilies) => {
8288
+ const iosVersion = getIOSVersion();
8289
+ // On iOS 15.0 and 15.1, system fonts have rendering issues
8290
+ // Fixed in iOS 17+
8291
+ if (iosVersion !== null && iosVersion >= 15 && iosVersion < 17) {
8292
+ return fontFamilies.map((fontFamily) => iOSBrokenFonts.indexOf(fontFamily) !== -1
8293
+ ? `-apple-system, "Helvetica Neue", Arial, sans-serif`
8294
+ : fontFamily);
8295
+ }
8296
+ return fontFamilies;
8297
+ };
8298
+ /**
8299
+ * Text Renderer
8300
+ *
8301
+ * Specialized renderer for text content.
8302
+ * Extracted from CanvasRenderer to improve code organization and maintainability.
8303
+ */
8304
+ class TextRenderer {
8305
+ constructor(deps) {
8306
+ this.ctx = deps.ctx;
8307
+ // context stored but not used directly in this renderer
8308
+ this.options = deps.options;
8309
+ }
8310
+ /**
8311
+ * Render text with letter spacing
8312
+ * Public method used by list rendering
8313
+ */
8314
+ renderTextWithLetterSpacing(text, letterSpacing, baseline) {
8315
+ if (letterSpacing === 0) {
8316
+ // Use alphabetic baseline for consistent text positioning across browsers
8317
+ // Issue #129: text.bounds.top + text.bounds.height causes text to render too low
8318
+ this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
8319
+ }
8320
+ else {
8321
+ const letters = segmentGraphemes(text.text);
8322
+ letters.reduce((left, letter) => {
8323
+ this.ctx.fillText(letter, left, text.bounds.top + baseline);
8324
+ return left + this.ctx.measureText(letter).width;
8325
+ }, text.bounds.left);
8326
+ }
8327
+ }
8328
+ /**
8329
+ * Helper method to render text with paint order support
8330
+ * Reduces code duplication in line-clamp and normal rendering
8331
+ */
8332
+ renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers) {
8333
+ paintOrderLayers.forEach((paintOrderLayer) => {
8334
+ switch (paintOrderLayer) {
8335
+ case 0 /* PAINT_ORDER_LAYER.FILL */:
8336
+ this.ctx.fillStyle = asString(styles.color);
8337
+ this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
8338
+ break;
8339
+ case 1 /* PAINT_ORDER_LAYER.STROKE */:
8340
+ if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
8341
+ this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8342
+ this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8343
+ this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8344
+ this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
8345
+ }
8346
+ break;
8347
+ }
8348
+ });
8349
+ }
8350
+ renderTextDecoration(bounds, styles) {
8351
+ this.ctx.fillStyle = asString(styles.textDecorationColor || styles.color);
8352
+ // Calculate decoration line thickness
8353
+ let thickness = 1; // default
8354
+ if (typeof styles.textDecorationThickness === 'number') {
8355
+ thickness = styles.textDecorationThickness;
8356
+ }
8357
+ else if (styles.textDecorationThickness === 'from-font') {
8358
+ // Use a reasonable default based on font size
8359
+ thickness = Math.max(1, Math.floor(styles.fontSize.number * 0.05));
8360
+ }
8361
+ // 'auto' uses default thickness of 1
8362
+ // Calculate underline offset
8363
+ let underlineOffset = 0;
8364
+ if (typeof styles.textUnderlineOffset === 'number') {
8365
+ // It's a pixel value
8366
+ underlineOffset = styles.textUnderlineOffset;
8367
+ }
8368
+ // 'auto' uses default offset of 0
8369
+ const decorationStyle = styles.textDecorationStyle;
8370
+ styles.textDecorationLine.forEach((textDecorationLine) => {
8371
+ let y = 0;
8372
+ switch (textDecorationLine) {
8373
+ case 1 /* TEXT_DECORATION_LINE.UNDERLINE */:
8374
+ y = bounds.top + bounds.height - thickness + underlineOffset;
8375
+ break;
8376
+ case 2 /* TEXT_DECORATION_LINE.OVERLINE */:
8377
+ y = bounds.top;
8378
+ break;
8379
+ case 3 /* TEXT_DECORATION_LINE.LINE_THROUGH */:
8380
+ y = bounds.top + (bounds.height / 2 - thickness / 2);
8381
+ break;
7726
8382
  default:
7727
8383
  return;
7728
8384
  }
@@ -7819,6 +8475,10 @@
7819
8475
  return result.join('') + ellipsis;
7820
8476
  }
7821
8477
  }
8478
+ /**
8479
+ * Create font style array
8480
+ * Public method used by list rendering
8481
+ */
7822
8482
  createFontStyle(styles) {
7823
8483
  const fontVariant = styles.fontVariant
7824
8484
  .filter((variant) => variant === 'normal' || variant === 'small-caps')
@@ -8001,846 +8661,1756 @@
8001
8661
  }
8002
8662
  break;
8003
8663
  case 1 /* PAINT_ORDER_LAYER.STROKE */:
8004
- if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8664
+ if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8665
+ this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8666
+ this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8667
+ this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8668
+ if (styles.letterSpacing === 0) {
8669
+ this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8670
+ }
8671
+ else {
8672
+ const letters = segmentGraphemes(truncatedText);
8673
+ letters.reduce((left, letter) => {
8674
+ this.ctx.strokeText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8675
+ return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8676
+ }, firstBound.bounds.left);
8677
+ }
8678
+ }
8679
+ break;
8680
+ }
8681
+ });
8682
+ return;
8683
+ }
8684
+ // Normal rendering (no ellipsis needed)
8685
+ text.textBounds.forEach((text) => {
8686
+ paintOrder.forEach((paintOrderLayer) => {
8687
+ switch (paintOrderLayer) {
8688
+ case 0 /* PAINT_ORDER_LAYER.FILL */:
8689
+ this.ctx.fillStyle = asString(styles.color);
8690
+ this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8691
+ const textShadows = styles.textShadow;
8692
+ if (textShadows.length && text.text.trim().length) {
8693
+ textShadows
8694
+ .slice(0)
8695
+ .reverse()
8696
+ .forEach((textShadow) => {
8697
+ this.ctx.shadowColor = asString(textShadow.color);
8698
+ this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
8699
+ this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
8700
+ this.ctx.shadowBlur = textShadow.blur.number;
8701
+ this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8702
+ });
8703
+ this.ctx.shadowColor = '';
8704
+ this.ctx.shadowOffsetX = 0;
8705
+ this.ctx.shadowOffsetY = 0;
8706
+ this.ctx.shadowBlur = 0;
8707
+ }
8708
+ if (styles.textDecorationLine.length) {
8709
+ this.renderTextDecoration(text.bounds, styles);
8710
+ }
8711
+ break;
8712
+ case 1 /* PAINT_ORDER_LAYER.STROKE */:
8713
+ if (styles.webkitTextStrokeWidth && text.text.trim().length) {
8005
8714
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8006
8715
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8007
8716
  this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8717
+ // Issue #110: Use baseline (fontSize) for consistent positioning with fill
8718
+ // Previously used text.bounds.height which caused stroke to render too low
8719
+ const baseline = styles.fontSize.number;
8008
8720
  if (styles.letterSpacing === 0) {
8009
- this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8721
+ this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
8010
8722
  }
8011
8723
  else {
8012
- const letters = segmentGraphemes(truncatedText);
8724
+ const letters = segmentGraphemes(text.text);
8013
8725
  letters.reduce((left, letter) => {
8014
- this.ctx.strokeText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8015
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8016
- }, firstBound.bounds.left);
8726
+ this.ctx.strokeText(letter, left, text.bounds.top + baseline);
8727
+ return left + this.ctx.measureText(letter).width;
8728
+ }, text.bounds.left);
8017
8729
  }
8018
8730
  }
8731
+ this.ctx.strokeStyle = '';
8732
+ this.ctx.lineWidth = 0;
8733
+ this.ctx.lineJoin = 'miter';
8019
8734
  break;
8020
8735
  }
8021
8736
  });
8022
- return;
8737
+ });
8738
+ }
8739
+ }
8740
+
8741
+ const MASK_OFFSET = 10000;
8742
+ class CanvasRenderer extends Renderer {
8743
+ constructor(context, options) {
8744
+ super(context, options);
8745
+ this.canvas = options.canvas ? options.canvas : document.createElement('canvas');
8746
+ this.ctx = this.canvas.getContext('2d');
8747
+ if (!options.canvas) {
8748
+ this.canvas.width = Math.floor(options.width * options.scale);
8749
+ this.canvas.height = Math.floor(options.height * options.scale);
8750
+ this.canvas.style.width = `${options.width}px`;
8751
+ this.canvas.style.height = `${options.height}px`;
8752
+ }
8753
+ this.fontMetrics = new FontMetrics(document);
8754
+ this.ctx.scale(this.options.scale, this.options.scale);
8755
+ this.ctx.translate(-options.x, -options.y);
8756
+ this.ctx.textBaseline = 'bottom';
8757
+ // Set image smoothing options
8758
+ if (options.imageSmoothing !== undefined) {
8759
+ this.ctx.imageSmoothingEnabled = options.imageSmoothing;
8760
+ }
8761
+ if (options.imageSmoothingQuality) {
8762
+ this.ctx.imageSmoothingQuality = options.imageSmoothingQuality;
8763
+ }
8764
+ // Initialize specialized renderers
8765
+ this.backgroundRenderer = new BackgroundRenderer({
8766
+ ctx: this.ctx,
8767
+ context: this.context,
8768
+ canvas: this.canvas,
8769
+ options: {
8770
+ width: options.width,
8771
+ height: options.height,
8772
+ scale: options.scale
8773
+ }
8774
+ });
8775
+ this.borderRenderer = new BorderRenderer({ ctx: this.ctx }, {
8776
+ path: (paths) => this.path(paths),
8777
+ formatPath: (paths) => this.formatPath(paths)
8778
+ });
8779
+ this.effectsRenderer = new EffectsRenderer({ ctx: this.ctx }, { path: (paths) => this.path(paths) });
8780
+ this.textRenderer = new TextRenderer({
8781
+ ctx: this.ctx,
8782
+ context: this.context,
8783
+ options: { scale: options.scale }
8784
+ });
8785
+ this.context.logger.debug(`Canvas renderer initialized (${options.width}x${options.height}) with scale ${options.scale}`);
8786
+ }
8787
+ async renderStack(stack) {
8788
+ const styles = stack.element.container.styles;
8789
+ if (styles.isVisible()) {
8790
+ await this.renderStackContent(stack);
8791
+ }
8792
+ }
8793
+ async renderNode(paint) {
8794
+ if (contains(paint.container.flags, 16 /* FLAGS.DEBUG_RENDER */)) {
8795
+ debugger;
8796
+ }
8797
+ if (paint.container.styles.isVisible()) {
8798
+ await this.renderNodeBackgroundAndBorders(paint);
8799
+ await this.renderNodeContent(paint);
8800
+ }
8801
+ }
8802
+ /**
8803
+ * Helper method to render text with paint order support
8804
+ * Reduces code duplication in line-clamp and normal rendering
8805
+ */
8806
+ // Helper method to truncate text and add ellipsis if needed
8807
+ renderReplacedElement(container, curves, image) {
8808
+ const intrinsicWidth = image.naturalWidth || container.intrinsicWidth;
8809
+ const intrinsicHeight = image.naturalHeight || container.intrinsicHeight;
8810
+ if (image && intrinsicWidth > 0 && intrinsicHeight > 0) {
8811
+ const box = contentBox(container);
8812
+ const path = calculatePaddingBoxPath(curves);
8813
+ this.path(path);
8814
+ this.ctx.save();
8815
+ this.ctx.clip();
8816
+ let sx = 0, sy = 0, sw = intrinsicWidth, sh = intrinsicHeight, dx = box.left, dy = box.top, dw = box.width, dh = box.height;
8817
+ const { objectFit } = container.styles;
8818
+ const boxRatio = dw / dh;
8819
+ const imgRatio = sw / sh;
8820
+ if (objectFit === 2 /* OBJECT_FIT.CONTAIN */) {
8821
+ if (imgRatio > boxRatio) {
8822
+ dh = dw / imgRatio;
8823
+ dy += (box.height - dh) / 2;
8824
+ }
8825
+ else {
8826
+ dw = dh * imgRatio;
8827
+ dx += (box.width - dw) / 2;
8828
+ }
8829
+ }
8830
+ else if (objectFit === 4 /* OBJECT_FIT.COVER */) {
8831
+ if (imgRatio > boxRatio) {
8832
+ sw = sh * boxRatio;
8833
+ sx += (intrinsicWidth - sw) / 2;
8834
+ }
8835
+ else {
8836
+ sh = sw / boxRatio;
8837
+ sy += (intrinsicHeight - sh) / 2;
8838
+ }
8839
+ }
8840
+ else if (objectFit === 8 /* OBJECT_FIT.NONE */) {
8841
+ if (sw > dw) {
8842
+ sx += (sw - dw) / 2;
8843
+ sw = dw;
8844
+ }
8845
+ else {
8846
+ dx += (dw - sw) / 2;
8847
+ dw = sw;
8848
+ }
8849
+ if (sh > dh) {
8850
+ sy += (sh - dh) / 2;
8851
+ sh = dh;
8852
+ }
8853
+ else {
8854
+ dy += (dh - sh) / 2;
8855
+ dh = sh;
8856
+ }
8857
+ }
8858
+ else if (objectFit === 16 /* OBJECT_FIT.SCALE_DOWN */) {
8859
+ const containW = imgRatio > boxRatio ? dw : dh * imgRatio;
8860
+ const noneW = sw > dw ? sw : dw;
8861
+ if (containW < noneW) {
8862
+ if (imgRatio > boxRatio) {
8863
+ dh = dw / imgRatio;
8864
+ dy += (box.height - dh) / 2;
8865
+ }
8866
+ else {
8867
+ dw = dh * imgRatio;
8868
+ dx += (box.width - dw) / 2;
8869
+ }
8870
+ }
8871
+ else {
8872
+ if (sw > dw) {
8873
+ sx += (sw - dw) / 2;
8874
+ sw = dw;
8875
+ }
8876
+ else {
8877
+ dx += (dw - sw) / 2;
8878
+ dw = sw;
8879
+ }
8880
+ if (sh > dh) {
8881
+ sy += (sh - dh) / 2;
8882
+ sh = dh;
8883
+ }
8884
+ else {
8885
+ dy += (dh - sh) / 2;
8886
+ dh = sh;
8887
+ }
8888
+ }
8889
+ }
8890
+ this.ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
8891
+ this.ctx.restore();
8892
+ }
8893
+ }
8894
+ async renderNodeContent(paint) {
8895
+ this.effectsRenderer.applyEffects(paint.getEffects(4 /* EffectTarget.CONTENT */));
8896
+ const container = paint.container;
8897
+ const curves = paint.curves;
8898
+ const styles = container.styles;
8899
+ // Use content box for text overflow calculation (excludes padding and border)
8900
+ // This matches browser behavior where text-overflow uses the content width
8901
+ const textBounds = contentBox(container);
8902
+ for (const child of container.textNodes) {
8903
+ await this.textRenderer.renderTextNode(child, styles, textBounds);
8904
+ }
8905
+ if (container instanceof ImageElementContainer) {
8906
+ try {
8907
+ const image = await this.context.cache.match(container.src);
8908
+ // Apply image smoothing based on CSS image-rendering property and global options
8909
+ const prevSmoothing = this.ctx.imageSmoothingEnabled;
8910
+ // CSS image-rendering property overrides global settings
8911
+ if (styles.imageRendering === exports.IMAGE_RENDERING.PIXELATED ||
8912
+ styles.imageRendering === exports.IMAGE_RENDERING.CRISP_EDGES) {
8913
+ this.context.logger.debug(`Disabling image smoothing for ${container.src} due to CSS image-rendering: ${styles.imageRendering === exports.IMAGE_RENDERING.PIXELATED ? 'pixelated' : 'crisp-edges'}`);
8914
+ this.ctx.imageSmoothingEnabled = false;
8915
+ }
8916
+ else if (styles.imageRendering === exports.IMAGE_RENDERING.SMOOTH) {
8917
+ this.context.logger.debug(`Enabling image smoothing for ${container.src} due to CSS image-rendering: smooth`);
8918
+ this.ctx.imageSmoothingEnabled = true;
8919
+ }
8920
+ // IMAGE_RENDERING.AUTO: keep current global setting
8921
+ this.renderReplacedElement(container, curves, image);
8922
+ // Restore previous smoothing state
8923
+ this.ctx.imageSmoothingEnabled = prevSmoothing;
8924
+ }
8925
+ catch (e) {
8926
+ this.context.logger.error(`Error loading image ${container.src}`);
8927
+ }
8928
+ }
8929
+ if (container instanceof CanvasElementContainer) {
8930
+ this.renderReplacedElement(container, curves, container.canvas);
8931
+ }
8932
+ if (container instanceof SVGElementContainer) {
8933
+ try {
8934
+ const image = await this.context.cache.match(container.svg);
8935
+ this.renderReplacedElement(container, curves, image);
8936
+ }
8937
+ catch (e) {
8938
+ this.context.logger.error(`Error loading svg ${container.svg.substring(0, 255)}`);
8939
+ }
8940
+ }
8941
+ if (container instanceof IFrameElementContainer && container.tree) {
8942
+ const iframeRenderer = new CanvasRenderer(this.context, {
8943
+ scale: this.options.scale,
8944
+ backgroundColor: container.backgroundColor,
8945
+ x: 0,
8946
+ y: 0,
8947
+ width: container.width,
8948
+ height: container.height
8949
+ });
8950
+ const canvas = await iframeRenderer.render(container.tree);
8951
+ if (container.width && container.height) {
8952
+ this.ctx.drawImage(canvas, 0, 0, container.width, container.height, container.bounds.left, container.bounds.top, container.bounds.width, container.bounds.height);
8953
+ }
8954
+ }
8955
+ if (container instanceof InputElementContainer) {
8956
+ const size = Math.min(container.bounds.width, container.bounds.height);
8957
+ if (container.type === CHECKBOX) {
8958
+ if (container.checked) {
8959
+ this.ctx.save();
8960
+ this.path([
8961
+ new Vector(container.bounds.left + size * 0.39363, container.bounds.top + size * 0.79),
8962
+ new Vector(container.bounds.left + size * 0.16, container.bounds.top + size * 0.5549),
8963
+ new Vector(container.bounds.left + size * 0.27347, container.bounds.top + size * 0.44071),
8964
+ new Vector(container.bounds.left + size * 0.39694, container.bounds.top + size * 0.5649),
8965
+ new Vector(container.bounds.left + size * 0.72983, container.bounds.top + size * 0.23),
8966
+ new Vector(container.bounds.left + size * 0.84, container.bounds.top + size * 0.34085),
8967
+ new Vector(container.bounds.left + size * 0.39363, container.bounds.top + size * 0.79)
8968
+ ]);
8969
+ this.ctx.fillStyle = asString(INPUT_COLOR);
8970
+ this.ctx.fill();
8971
+ this.ctx.restore();
8972
+ }
8973
+ }
8974
+ else if (container.type === RADIO) {
8975
+ if (container.checked) {
8976
+ this.ctx.save();
8977
+ this.ctx.beginPath();
8978
+ this.ctx.arc(container.bounds.left + size / 2, container.bounds.top + size / 2, size / 4, 0, Math.PI * 2, true);
8979
+ this.ctx.fillStyle = asString(INPUT_COLOR);
8980
+ this.ctx.fill();
8981
+ this.ctx.restore();
8982
+ }
8983
+ }
8984
+ }
8985
+ if (isTextInputElement(container) && container.value.length) {
8986
+ const [font, fontFamily, fontSize] = this.textRenderer.createFontStyle(styles);
8987
+ const { baseline } = this.fontMetrics.getMetrics(fontFamily, fontSize);
8988
+ this.ctx.font = font;
8989
+ // Fix for Issue #92: Use placeholder color when rendering placeholder text
8990
+ const isPlaceholder = container instanceof InputElementContainer && container.isPlaceholder;
8991
+ this.ctx.fillStyle = isPlaceholder ? asString(PLACEHOLDER_COLOR) : asString(styles.color);
8992
+ this.ctx.textBaseline = 'alphabetic';
8993
+ this.ctx.textAlign = canvasTextAlign(container.styles.textAlign);
8994
+ const bounds = contentBox(container);
8995
+ let x = 0;
8996
+ switch (container.styles.textAlign) {
8997
+ case 1 /* TEXT_ALIGN.CENTER */:
8998
+ x += bounds.width / 2;
8999
+ break;
9000
+ case 2 /* TEXT_ALIGN.RIGHT */:
9001
+ x += bounds.width;
9002
+ break;
9003
+ }
9004
+ // Fix for Issue #92: Position text vertically centered in single-line input
9005
+ // Only apply vertical centering for InputElementContainer, not for textarea or select
9006
+ let verticalOffset = 0;
9007
+ if (container instanceof InputElementContainer) {
9008
+ const fontSizeValue = getAbsoluteValue(styles.fontSize, 0);
9009
+ verticalOffset = (bounds.height - fontSizeValue) / 2;
9010
+ }
9011
+ // Create text bounds with horizontal and vertical offsets
9012
+ // Height is not modified as it doesn't affect text rendering position
9013
+ const textBounds = bounds.add(x, verticalOffset, 0, 0);
9014
+ this.ctx.save();
9015
+ this.path([
9016
+ new Vector(bounds.left, bounds.top),
9017
+ new Vector(bounds.left + bounds.width, bounds.top),
9018
+ new Vector(bounds.left + bounds.width, bounds.top + bounds.height),
9019
+ new Vector(bounds.left, bounds.top + bounds.height)
9020
+ ]);
9021
+ this.ctx.clip();
9022
+ this.textRenderer.renderTextWithLetterSpacing(new TextBounds(container.value, textBounds), styles.letterSpacing, baseline);
9023
+ this.ctx.restore();
9024
+ this.ctx.textBaseline = 'alphabetic';
9025
+ this.ctx.textAlign = 'left';
8023
9026
  }
8024
- // Normal rendering (no ellipsis needed)
8025
- text.textBounds.forEach((text) => {
8026
- paintOrder.forEach((paintOrderLayer) => {
8027
- switch (paintOrderLayer) {
8028
- case 0 /* PAINT_ORDER_LAYER.FILL */:
8029
- this.ctx.fillStyle = asString(styles.color);
8030
- this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8031
- const textShadows = styles.textShadow;
8032
- if (textShadows.length && text.text.trim().length) {
8033
- textShadows
8034
- .slice(0)
8035
- .reverse()
8036
- .forEach((textShadow) => {
8037
- this.ctx.shadowColor = asString(textShadow.color);
8038
- this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
8039
- this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
8040
- this.ctx.shadowBlur = textShadow.blur.number;
8041
- this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8042
- });
8043
- this.ctx.shadowColor = '';
8044
- this.ctx.shadowOffsetX = 0;
8045
- this.ctx.shadowOffsetY = 0;
8046
- this.ctx.shadowBlur = 0;
8047
- }
8048
- if (styles.textDecorationLine.length) {
8049
- this.renderTextDecoration(text.bounds, styles);
8050
- }
8051
- break;
8052
- case 1 /* PAINT_ORDER_LAYER.STROKE */:
8053
- if (styles.webkitTextStrokeWidth && text.text.trim().length) {
8054
- this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8055
- this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8056
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8057
- // Issue #110: Use baseline (fontSize) for consistent positioning with fill
8058
- // Previously used text.bounds.height which caused stroke to render too low
8059
- const baseline = styles.fontSize.number;
8060
- if (styles.letterSpacing === 0) {
8061
- this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
8062
- }
8063
- else {
8064
- const letters = segmentGraphemes(text.text);
8065
- letters.reduce((left, letter) => {
8066
- this.ctx.strokeText(letter, left, text.bounds.top + baseline);
8067
- return left + this.ctx.measureText(letter).width;
8068
- }, text.bounds.left);
8069
- }
8070
- }
8071
- this.ctx.strokeStyle = '';
8072
- this.ctx.lineWidth = 0;
8073
- this.ctx.lineJoin = 'miter';
8074
- break;
9027
+ if (contains(container.styles.display, 2048 /* DISPLAY.LIST_ITEM */)) {
9028
+ if (container.styles.listStyleImage !== null) {
9029
+ const img = container.styles.listStyleImage;
9030
+ if (img.type === 0 /* CSSImageType.URL */) {
9031
+ let image;
9032
+ const url = img.url;
9033
+ try {
9034
+ image = await this.context.cache.match(url);
9035
+ this.ctx.drawImage(image, container.bounds.left - (image.width + 10), container.bounds.top);
9036
+ }
9037
+ catch (e) {
9038
+ this.context.logger.error(`Error loading list-style-image ${url}`);
9039
+ }
8075
9040
  }
8076
- });
9041
+ }
9042
+ else if (paint.listValue && container.styles.listStyleType !== -1 /* LIST_STYLE_TYPE.NONE */) {
9043
+ const [font] = this.textRenderer.createFontStyle(styles);
9044
+ this.ctx.font = font;
9045
+ this.ctx.fillStyle = asString(styles.color);
9046
+ this.ctx.textBaseline = 'middle';
9047
+ this.ctx.textAlign = 'right';
9048
+ 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);
9049
+ this.textRenderer.renderTextWithLetterSpacing(new TextBounds(paint.listValue, bounds), styles.letterSpacing, computeLineHeight(styles.lineHeight, styles.fontSize.number) / 2 + 2);
9050
+ this.ctx.textBaseline = 'bottom';
9051
+ this.ctx.textAlign = 'left';
9052
+ }
9053
+ }
9054
+ }
9055
+ async renderStackContent(stack) {
9056
+ if (contains(stack.element.container.flags, 16 /* FLAGS.DEBUG_RENDER */)) {
9057
+ debugger;
9058
+ }
9059
+ // https://www.w3.org/TR/css-position-3/#painting-order
9060
+ // 1. the background and borders of the element forming the stacking context.
9061
+ await this.renderNodeBackgroundAndBorders(stack.element);
9062
+ // 2. the child stacking contexts with negative stack levels (most negative first).
9063
+ for (const child of stack.negativeZIndex) {
9064
+ await this.renderStack(child);
9065
+ }
9066
+ // 3. For all its in-flow, non-positioned, block-level descendants in tree order:
9067
+ await this.renderNodeContent(stack.element);
9068
+ for (const child of stack.nonInlineLevel) {
9069
+ await this.renderNode(child);
9070
+ }
9071
+ // 4. All non-positioned floating descendants, in tree order. For each one of these,
9072
+ // treat the element as if it created a new stacking context, but any positioned descendants and descendants
9073
+ // which actually create a new stacking context should be considered part of the parent stacking context,
9074
+ // not this new one.
9075
+ for (const child of stack.nonPositionedFloats) {
9076
+ await this.renderStack(child);
9077
+ }
9078
+ // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
9079
+ for (const child of stack.nonPositionedInlineLevel) {
9080
+ await this.renderStack(child);
9081
+ }
9082
+ for (const child of stack.inlineLevel) {
9083
+ await this.renderNode(child);
9084
+ }
9085
+ // 6. All positioned, opacity or transform descendants, in tree order that fall into the following categories:
9086
+ // All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
9087
+ // For those with 'z-index: auto', treat the element as if it created a new stacking context,
9088
+ // but any positioned descendants and descendants which actually create a new stacking context should be
9089
+ // considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
9090
+ // treat the stacking context generated atomically.
9091
+ //
9092
+ // All opacity descendants with opacity less than 1
9093
+ //
9094
+ // All transform descendants with transform other than none
9095
+ for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
9096
+ await this.renderStack(child);
9097
+ }
9098
+ // 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
9099
+ // order (smallest first) then tree order.
9100
+ for (const child of stack.positiveZIndex) {
9101
+ await this.renderStack(child);
9102
+ }
9103
+ }
9104
+ mask(paths) {
9105
+ this.ctx.beginPath();
9106
+ this.ctx.moveTo(0, 0);
9107
+ // Use logical dimensions (options.width/height) instead of canvas pixel dimensions
9108
+ // because context has already been scaled by this.options.scale
9109
+ // Fix for Issue #126: Using canvas pixel dimensions causes broken output
9110
+ this.ctx.lineTo(this.options.width, 0);
9111
+ this.ctx.lineTo(this.options.width, this.options.height);
9112
+ this.ctx.lineTo(0, this.options.height);
9113
+ this.ctx.lineTo(0, 0);
9114
+ this.formatPath(paths.slice(0).reverse());
9115
+ this.ctx.closePath();
9116
+ }
9117
+ path(paths) {
9118
+ this.ctx.beginPath();
9119
+ this.formatPath(paths);
9120
+ this.ctx.closePath();
9121
+ }
9122
+ formatPath(paths) {
9123
+ paths.forEach((point, index) => {
9124
+ const start = isBezierCurve(point) ? point.start : point;
9125
+ if (index === 0) {
9126
+ this.ctx.moveTo(start.x, start.y);
9127
+ }
9128
+ else {
9129
+ this.ctx.lineTo(start.x, start.y);
9130
+ }
9131
+ if (isBezierCurve(point)) {
9132
+ this.ctx.bezierCurveTo(point.startControl.x, point.startControl.y, point.endControl.x, point.endControl.y, point.end.x, point.end.y);
9133
+ }
8077
9134
  });
8078
9135
  }
8079
- renderReplacedElement(container, curves, image) {
8080
- const intrinsicWidth = image.naturalWidth || container.intrinsicWidth;
8081
- const intrinsicHeight = image.naturalHeight || container.intrinsicHeight;
8082
- if (image && intrinsicWidth > 0 && intrinsicHeight > 0) {
8083
- const box = contentBox(container);
8084
- const path = calculatePaddingBoxPath(curves);
8085
- this.path(path);
9136
+ async renderNodeBackgroundAndBorders(paint) {
9137
+ this.effectsRenderer.applyEffects(paint.getEffects(2 /* EffectTarget.BACKGROUND_BORDERS */));
9138
+ const styles = paint.container.styles;
9139
+ const hasBackground = !isTransparent(styles.backgroundColor) || styles.backgroundImage.length;
9140
+ const borders = [
9141
+ { style: styles.borderTopStyle, color: styles.borderTopColor, width: styles.borderTopWidth },
9142
+ { style: styles.borderRightStyle, color: styles.borderRightColor, width: styles.borderRightWidth },
9143
+ { style: styles.borderBottomStyle, color: styles.borderBottomColor, width: styles.borderBottomWidth },
9144
+ { style: styles.borderLeftStyle, color: styles.borderLeftColor, width: styles.borderLeftWidth }
9145
+ ];
9146
+ const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(getBackgroundValueForIndex(styles.backgroundClip, 0), paint.curves);
9147
+ if (hasBackground || styles.boxShadow.length) {
8086
9148
  this.ctx.save();
9149
+ this.path(backgroundPaintingArea);
8087
9150
  this.ctx.clip();
8088
- let sx = 0, sy = 0, sw = intrinsicWidth, sh = intrinsicHeight, dx = box.left, dy = box.top, dw = box.width, dh = box.height;
8089
- const { objectFit } = container.styles;
8090
- const boxRatio = dw / dh;
8091
- const imgRatio = sw / sh;
8092
- if (objectFit === 2 /* OBJECT_FIT.CONTAIN */) {
8093
- if (imgRatio > boxRatio) {
8094
- dh = dw / imgRatio;
8095
- dy += (box.height - dh) / 2;
8096
- }
8097
- else {
8098
- dw = dh * imgRatio;
8099
- dx += (box.width - dw) / 2;
8100
- }
8101
- }
8102
- else if (objectFit === 4 /* OBJECT_FIT.COVER */) {
8103
- if (imgRatio > boxRatio) {
8104
- sw = sh * boxRatio;
8105
- sx += (intrinsicWidth - sw) / 2;
8106
- }
8107
- else {
8108
- sh = sw / boxRatio;
8109
- sy += (intrinsicHeight - sh) / 2;
8110
- }
9151
+ if (!isTransparent(styles.backgroundColor)) {
9152
+ this.ctx.fillStyle = asString(styles.backgroundColor);
9153
+ this.ctx.fill();
8111
9154
  }
8112
- else if (objectFit === 8 /* OBJECT_FIT.NONE */) {
8113
- if (sw > dw) {
8114
- sx += (sw - dw) / 2;
8115
- sw = dw;
9155
+ await this.backgroundRenderer.renderBackgroundImage(paint.container);
9156
+ this.ctx.restore();
9157
+ styles.boxShadow
9158
+ .slice(0)
9159
+ .reverse()
9160
+ .forEach((shadow) => {
9161
+ this.ctx.save();
9162
+ const borderBoxArea = calculateBorderBoxPath(paint.curves);
9163
+ const maskOffset = shadow.inset ? 0 : MASK_OFFSET;
9164
+ 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));
9165
+ if (shadow.inset) {
9166
+ this.path(borderBoxArea);
9167
+ this.ctx.clip();
9168
+ this.mask(shadowPaintingArea);
8116
9169
  }
8117
9170
  else {
8118
- dx += (dw - sw) / 2;
8119
- dw = sw;
9171
+ this.mask(borderBoxArea);
9172
+ this.ctx.clip();
9173
+ this.path(shadowPaintingArea);
8120
9174
  }
8121
- if (sh > dh) {
8122
- sy += (sh - dh) / 2;
8123
- sh = dh;
9175
+ this.ctx.shadowOffsetX = shadow.offsetX.number + maskOffset;
9176
+ this.ctx.shadowOffsetY = shadow.offsetY.number;
9177
+ this.ctx.shadowColor = asString(shadow.color);
9178
+ this.ctx.shadowBlur = shadow.blur.number;
9179
+ this.ctx.fillStyle = shadow.inset ? asString(shadow.color) : 'rgba(0,0,0,1)';
9180
+ this.ctx.fill();
9181
+ this.ctx.restore();
9182
+ });
9183
+ }
9184
+ let side = 0;
9185
+ for (const border of borders) {
9186
+ if (border.style !== 0 /* BORDER_STYLE.NONE */ && !isTransparent(border.color) && border.width > 0) {
9187
+ if (border.style === 2 /* BORDER_STYLE.DASHED */) {
9188
+ await this.borderRenderer.renderDashedDottedBorder(border.color, border.width, side, paint.curves, 2 /* BORDER_STYLE.DASHED */);
8124
9189
  }
8125
- else {
8126
- dy += (dh - sh) / 2;
8127
- dh = sh;
9190
+ else if (border.style === 3 /* BORDER_STYLE.DOTTED */) {
9191
+ await this.borderRenderer.renderDashedDottedBorder(border.color, border.width, side, paint.curves, 3 /* BORDER_STYLE.DOTTED */);
8128
9192
  }
8129
- }
8130
- else if (objectFit === 16 /* OBJECT_FIT.SCALE_DOWN */) {
8131
- const containW = imgRatio > boxRatio ? dw : dh * imgRatio;
8132
- const noneW = sw > dw ? sw : dw;
8133
- if (containW < noneW) {
8134
- if (imgRatio > boxRatio) {
8135
- dh = dw / imgRatio;
8136
- dy += (box.height - dh) / 2;
8137
- }
8138
- else {
8139
- dw = dh * imgRatio;
8140
- dx += (box.width - dw) / 2;
8141
- }
9193
+ else if (border.style === 4 /* BORDER_STYLE.DOUBLE */) {
9194
+ await this.borderRenderer.renderDoubleBorder(border.color, border.width, side, paint.curves);
8142
9195
  }
8143
9196
  else {
8144
- if (sw > dw) {
8145
- sx += (sw - dw) / 2;
8146
- sw = dw;
8147
- }
8148
- else {
8149
- dx += (dw - sw) / 2;
8150
- dw = sw;
8151
- }
8152
- if (sh > dh) {
8153
- sy += (sh - dh) / 2;
8154
- sh = dh;
8155
- }
8156
- else {
8157
- dy += (dh - sh) / 2;
8158
- dh = sh;
8159
- }
9197
+ await this.borderRenderer.renderSolidBorder(border.color, side, paint.curves);
8160
9198
  }
8161
9199
  }
8162
- this.ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
8163
- this.ctx.restore();
9200
+ side++;
8164
9201
  }
8165
9202
  }
8166
- async renderNodeContent(paint) {
8167
- this.applyEffects(paint.getEffects(4 /* EffectTarget.CONTENT */));
8168
- const container = paint.container;
8169
- const curves = paint.curves;
8170
- const styles = container.styles;
8171
- // Use content box for text overflow calculation (excludes padding and border)
8172
- // This matches browser behavior where text-overflow uses the content width
8173
- const textBounds = contentBox(container);
8174
- for (const child of container.textNodes) {
8175
- await this.renderTextNode(child, styles, textBounds);
8176
- }
8177
- if (container instanceof ImageElementContainer) {
8178
- try {
8179
- const image = await this.context.cache.match(container.src);
8180
- this.renderReplacedElement(container, curves, image);
8181
- }
8182
- catch (e) {
8183
- this.context.logger.error(`Error loading image ${container.src}`);
8184
- }
8185
- }
8186
- if (container instanceof CanvasElementContainer) {
8187
- this.renderReplacedElement(container, curves, container.canvas);
8188
- }
8189
- if (container instanceof SVGElementContainer) {
8190
- try {
8191
- const image = await this.context.cache.match(container.svg);
8192
- this.renderReplacedElement(container, curves, image);
8193
- }
8194
- catch (e) {
8195
- this.context.logger.error(`Error loading svg ${container.svg.substring(0, 255)}`);
8196
- }
8197
- }
8198
- if (container instanceof IFrameElementContainer && container.tree) {
8199
- const iframeRenderer = new CanvasRenderer(this.context, {
8200
- scale: this.options.scale,
8201
- backgroundColor: container.backgroundColor,
8202
- x: 0,
8203
- y: 0,
8204
- width: container.width,
8205
- height: container.height
8206
- });
8207
- const canvas = await iframeRenderer.render(container.tree);
8208
- if (container.width && container.height) {
8209
- this.ctx.drawImage(canvas, 0, 0, container.width, container.height, container.bounds.left, container.bounds.top, container.bounds.width, container.bounds.height);
8210
- }
9203
+ async render(element) {
9204
+ if (this.options.backgroundColor) {
9205
+ this.ctx.fillStyle = asString(this.options.backgroundColor);
9206
+ this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
8211
9207
  }
8212
- if (container instanceof InputElementContainer) {
8213
- const size = Math.min(container.bounds.width, container.bounds.height);
8214
- if (container.type === CHECKBOX) {
8215
- if (container.checked) {
8216
- this.ctx.save();
8217
- this.path([
8218
- new Vector(container.bounds.left + size * 0.39363, container.bounds.top + size * 0.79),
8219
- new Vector(container.bounds.left + size * 0.16, container.bounds.top + size * 0.5549),
8220
- new Vector(container.bounds.left + size * 0.27347, container.bounds.top + size * 0.44071),
8221
- new Vector(container.bounds.left + size * 0.39694, container.bounds.top + size * 0.5649),
8222
- new Vector(container.bounds.left + size * 0.72983, container.bounds.top + size * 0.23),
8223
- new Vector(container.bounds.left + size * 0.84, container.bounds.top + size * 0.34085),
8224
- new Vector(container.bounds.left + size * 0.39363, container.bounds.top + size * 0.79)
8225
- ]);
8226
- this.ctx.fillStyle = asString(INPUT_COLOR);
8227
- this.ctx.fill();
8228
- this.ctx.restore();
8229
- }
8230
- }
8231
- else if (container.type === RADIO) {
8232
- if (container.checked) {
8233
- this.ctx.save();
8234
- this.ctx.beginPath();
8235
- this.ctx.arc(container.bounds.left + size / 2, container.bounds.top + size / 2, size / 4, 0, Math.PI * 2, true);
8236
- this.ctx.fillStyle = asString(INPUT_COLOR);
8237
- this.ctx.fill();
8238
- this.ctx.restore();
8239
- }
8240
- }
9208
+ const stack = parseStackingContexts(element);
9209
+ await this.renderStack(stack);
9210
+ this.effectsRenderer.applyEffects([]);
9211
+ return this.canvas;
9212
+ }
9213
+ }
9214
+ const isTextInputElement = (container) => {
9215
+ if (container instanceof TextareaElementContainer) {
9216
+ return true;
9217
+ }
9218
+ else if (container instanceof SelectElementContainer) {
9219
+ return true;
9220
+ }
9221
+ else if (container instanceof InputElementContainer && container.type !== RADIO && container.type !== CHECKBOX) {
9222
+ return true;
9223
+ }
9224
+ return false;
9225
+ };
9226
+ const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
9227
+ switch (clip) {
9228
+ case 0 /* BACKGROUND_CLIP.BORDER_BOX */:
9229
+ return calculateBorderBoxPath(curves);
9230
+ case 2 /* BACKGROUND_CLIP.CONTENT_BOX */:
9231
+ return calculateContentBoxPath(curves);
9232
+ case 1 /* BACKGROUND_CLIP.PADDING_BOX */:
9233
+ default:
9234
+ return calculatePaddingBoxPath(curves);
9235
+ }
9236
+ };
9237
+ const canvasTextAlign = (textAlign) => {
9238
+ switch (textAlign) {
9239
+ case 1 /* TEXT_ALIGN.CENTER */:
9240
+ return 'center';
9241
+ case 2 /* TEXT_ALIGN.RIGHT */:
9242
+ return 'right';
9243
+ case 0 /* TEXT_ALIGN.LEFT */:
9244
+ default:
9245
+ return 'left';
9246
+ }
9247
+ };
9248
+
9249
+ class ForeignObjectRenderer extends Renderer {
9250
+ constructor(context, options) {
9251
+ super(context, options);
9252
+ this.canvas = options.canvas ? options.canvas : document.createElement('canvas');
9253
+ this.ctx = this.canvas.getContext('2d');
9254
+ this.options = options;
9255
+ this.canvas.width = Math.floor(options.width * options.scale);
9256
+ this.canvas.height = Math.floor(options.height * options.scale);
9257
+ this.canvas.style.width = `${options.width}px`;
9258
+ this.canvas.style.height = `${options.height}px`;
9259
+ this.ctx.scale(this.options.scale, this.options.scale);
9260
+ this.ctx.translate(-options.x, -options.y);
9261
+ this.context.logger.debug(`EXPERIMENTAL ForeignObject renderer initialized (${options.width}x${options.height} at ${options.x},${options.y}) with scale ${options.scale}`);
9262
+ }
9263
+ async render(element) {
9264
+ const svg = createForeignObjectSVG(this.options.width * this.options.scale, this.options.height * this.options.scale, this.options.scale, this.options.scale, element);
9265
+ const img = await loadSerializedSVG(svg);
9266
+ if (this.options.backgroundColor) {
9267
+ this.ctx.fillStyle = asString(this.options.backgroundColor);
9268
+ this.ctx.fillRect(0, 0, this.options.width * this.options.scale, this.options.height * this.options.scale);
8241
9269
  }
8242
- if (isTextInputElement(container) && container.value.length) {
8243
- const [font, fontFamily, fontSize] = this.createFontStyle(styles);
8244
- const { baseline } = this.fontMetrics.getMetrics(fontFamily, fontSize);
8245
- this.ctx.font = font;
8246
- // Fix for Issue #92: Use placeholder color when rendering placeholder text
8247
- const isPlaceholder = container instanceof InputElementContainer && container.isPlaceholder;
8248
- this.ctx.fillStyle = isPlaceholder ? asString(PLACEHOLDER_COLOR) : asString(styles.color);
8249
- this.ctx.textBaseline = 'alphabetic';
8250
- this.ctx.textAlign = canvasTextAlign(container.styles.textAlign);
8251
- const bounds = contentBox(container);
8252
- let x = 0;
8253
- switch (container.styles.textAlign) {
8254
- case 1 /* TEXT_ALIGN.CENTER */:
8255
- x += bounds.width / 2;
8256
- break;
8257
- case 2 /* TEXT_ALIGN.RIGHT */:
8258
- x += bounds.width;
8259
- break;
9270
+ this.ctx.drawImage(img, -this.options.x * this.options.scale, -this.options.y * this.options.scale);
9271
+ return this.canvas;
9272
+ }
9273
+ }
9274
+ const loadSerializedSVG = (svg) => new Promise((resolve, reject) => {
9275
+ const img = new Image();
9276
+ img.onload = () => {
9277
+ resolve(img);
9278
+ };
9279
+ img.onerror = reject;
9280
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
9281
+ });
9282
+
9283
+ class Logger {
9284
+ constructor({ id, enabled }) {
9285
+ this.id = id;
9286
+ this.enabled = enabled;
9287
+ this.start = Date.now();
9288
+ }
9289
+ debug(...args) {
9290
+ if (this.enabled) {
9291
+ // eslint-disable-next-line no-console
9292
+ if (typeof window !== 'undefined' && window.console && typeof console.debug === 'function') {
9293
+ // eslint-disable-next-line no-console
9294
+ console.debug(this.id, `${this.getTime()}ms`, ...args);
8260
9295
  }
8261
- // Fix for Issue #92: Position text vertically centered in single-line input
8262
- // Only apply vertical centering for InputElementContainer, not for textarea or select
8263
- let verticalOffset = 0;
8264
- if (container instanceof InputElementContainer) {
8265
- const fontSizeValue = getAbsoluteValue(styles.fontSize, 0);
8266
- verticalOffset = (bounds.height - fontSizeValue) / 2;
9296
+ else {
9297
+ this.info(...args);
8267
9298
  }
8268
- // Create text bounds with horizontal and vertical offsets
8269
- // Height is not modified as it doesn't affect text rendering position
8270
- const textBounds = bounds.add(x, verticalOffset, 0, 0);
8271
- this.ctx.save();
8272
- this.path([
8273
- new Vector(bounds.left, bounds.top),
8274
- new Vector(bounds.left + bounds.width, bounds.top),
8275
- new Vector(bounds.left + bounds.width, bounds.top + bounds.height),
8276
- new Vector(bounds.left, bounds.top + bounds.height)
8277
- ]);
8278
- this.ctx.clip();
8279
- this.renderTextWithLetterSpacing(new TextBounds(container.value, textBounds), styles.letterSpacing, baseline);
8280
- this.ctx.restore();
8281
- this.ctx.textBaseline = 'alphabetic';
8282
- this.ctx.textAlign = 'left';
8283
9299
  }
8284
- if (contains(container.styles.display, 2048 /* DISPLAY.LIST_ITEM */)) {
8285
- if (container.styles.listStyleImage !== null) {
8286
- const img = container.styles.listStyleImage;
8287
- if (img.type === 0 /* CSSImageType.URL */) {
8288
- let image;
8289
- const url = img.url;
8290
- try {
8291
- image = await this.context.cache.match(url);
8292
- this.ctx.drawImage(image, container.bounds.left - (image.width + 10), container.bounds.top);
8293
- }
8294
- catch (e) {
8295
- this.context.logger.error(`Error loading list-style-image ${url}`);
8296
- }
8297
- }
9300
+ }
9301
+ getTime() {
9302
+ return Date.now() - this.start;
9303
+ }
9304
+ info(...args) {
9305
+ if (this.enabled) {
9306
+ // eslint-disable-next-line no-console
9307
+ if (typeof window !== 'undefined' && window.console && typeof console.info === 'function') {
9308
+ // eslint-disable-next-line no-console
9309
+ console.info(this.id, `${this.getTime()}ms`, ...args);
8298
9310
  }
8299
- else if (paint.listValue && container.styles.listStyleType !== -1 /* LIST_STYLE_TYPE.NONE */) {
8300
- const [font] = this.createFontStyle(styles);
8301
- this.ctx.font = font;
8302
- this.ctx.fillStyle = asString(styles.color);
8303
- this.ctx.textBaseline = 'middle';
8304
- this.ctx.textAlign = 'right';
8305
- 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);
8306
- this.renderTextWithLetterSpacing(new TextBounds(paint.listValue, bounds), styles.letterSpacing, computeLineHeight(styles.lineHeight, styles.fontSize.number) / 2 + 2);
8307
- this.ctx.textBaseline = 'bottom';
8308
- this.ctx.textAlign = 'left';
9311
+ }
9312
+ }
9313
+ warn(...args) {
9314
+ if (this.enabled) {
9315
+ if (typeof window !== 'undefined' && window.console && typeof console.warn === 'function') {
9316
+ console.warn(this.id, `${this.getTime()}ms`, ...args);
9317
+ }
9318
+ else {
9319
+ this.info(...args);
8309
9320
  }
8310
9321
  }
8311
9322
  }
8312
- async renderStackContent(stack) {
8313
- if (contains(stack.element.container.flags, 16 /* FLAGS.DEBUG_RENDER */)) {
8314
- debugger;
9323
+ error(...args) {
9324
+ if (this.enabled) {
9325
+ if (typeof window !== 'undefined' && window.console && typeof console.error === 'function') {
9326
+ console.error(this.id, `${this.getTime()}ms`, ...args);
9327
+ }
9328
+ else {
9329
+ this.info(...args);
9330
+ }
8315
9331
  }
8316
- // https://www.w3.org/TR/css-position-3/#painting-order
8317
- // 1. the background and borders of the element forming the stacking context.
8318
- await this.renderNodeBackgroundAndBorders(stack.element);
8319
- // 2. the child stacking contexts with negative stack levels (most negative first).
8320
- for (const child of stack.negativeZIndex) {
8321
- await this.renderStack(child);
9332
+ }
9333
+ }
9334
+ Logger.instances = {};
9335
+
9336
+ class Cache {
9337
+ constructor(context, _options) {
9338
+ this.context = context;
9339
+ this._options = _options;
9340
+ this._cache = new Map();
9341
+ this._pendingOperations = new Map();
9342
+ // Default cache size: 100 items
9343
+ this.maxSize = _options.maxCacheSize ?? 100;
9344
+ if (this.maxSize < 1) {
9345
+ throw new Error('Cache maxSize must be at least 1');
8322
9346
  }
8323
- // 3. For all its in-flow, non-positioned, block-level descendants in tree order:
8324
- await this.renderNodeContent(stack.element);
8325
- for (const child of stack.nonInlineLevel) {
8326
- await this.renderNode(child);
9347
+ if (this.maxSize > 10000) {
9348
+ this.context.logger.warn(`Cache maxSize ${this.maxSize} is very large and may cause memory issues. ` +
9349
+ `Consider using a smaller value (recommended: 100-1000).`);
8327
9350
  }
8328
- // 4. All non-positioned floating descendants, in tree order. For each one of these,
8329
- // treat the element as if it created a new stacking context, but any positioned descendants and descendants
8330
- // which actually create a new stacking context should be considered part of the parent stacking context,
8331
- // not this new one.
8332
- for (const child of stack.nonPositionedFloats) {
8333
- await this.renderStack(child);
9351
+ }
9352
+ addImage(src) {
9353
+ // Wait for any pending operations on this key
9354
+ const pending = this._pendingOperations.get(src);
9355
+ if (pending) {
9356
+ return pending;
8334
9357
  }
8335
- // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
8336
- for (const child of stack.nonPositionedInlineLevel) {
8337
- await this.renderStack(child);
9358
+ if (this.has(src)) {
9359
+ // Update last accessed time
9360
+ const entry = this._cache.get(src);
9361
+ if (entry) {
9362
+ entry.lastAccessed = Date.now();
9363
+ }
9364
+ return Promise.resolve();
8338
9365
  }
8339
- for (const child of stack.inlineLevel) {
8340
- await this.renderNode(child);
9366
+ if (isBlobImage(src) || isRenderable(src)) {
9367
+ // Create a pending operation to ensure atomicity
9368
+ const operation = this._addImageInternal(src);
9369
+ this._pendingOperations.set(src, operation);
9370
+ operation.finally(() => {
9371
+ this._pendingOperations.delete(src);
9372
+ });
9373
+ return operation;
8341
9374
  }
8342
- // 6. All positioned, opacity or transform descendants, in tree order that fall into the following categories:
8343
- // All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
8344
- // For those with 'z-index: auto', treat the element as if it created a new stacking context,
8345
- // but any positioned descendants and descendants which actually create a new stacking context should be
8346
- // considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
8347
- // treat the stacking context generated atomically.
8348
- //
8349
- // All opacity descendants with opacity less than 1
8350
- //
8351
- // All transform descendants with transform other than none
8352
- for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
8353
- await this.renderStack(child);
9375
+ return Promise.resolve();
9376
+ }
9377
+ async _addImageInternal(src) {
9378
+ // Create image load promise with timeout protection
9379
+ const timeoutMs = this._options.imageTimeout ?? 15000;
9380
+ const timeoutPromise = new Promise((_, reject) => {
9381
+ setTimeout(() => {
9382
+ reject(new Error(`Image load timeout after ${timeoutMs}ms: ${src}`));
9383
+ }, timeoutMs);
9384
+ });
9385
+ // Race between image load and timeout
9386
+ const imageWithTimeout = Promise.race([this.loadImage(src), timeoutPromise]);
9387
+ // Handle errors to prevent unhandled rejections
9388
+ imageWithTimeout.catch((error) => {
9389
+ this.context.logger.error(`Failed to load image ${src}: ${error instanceof Error ? error.message : 'Unknown error'}`);
9390
+ });
9391
+ // Store the promise with timeout in cache
9392
+ this.set(src, imageWithTimeout);
9393
+ }
9394
+ match(src) {
9395
+ const entry = this._cache.get(src);
9396
+ if (entry) {
9397
+ // Update last accessed time on access
9398
+ entry.lastAccessed = Date.now();
9399
+ return entry.value;
8354
9400
  }
8355
- // 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
8356
- // order (smallest first) then tree order.
8357
- for (const child of stack.positiveZIndex) {
8358
- await this.renderStack(child);
9401
+ return undefined;
9402
+ }
9403
+ /**
9404
+ * Set a value in cache with LRU eviction
9405
+ */
9406
+ set(key, value) {
9407
+ // If key already exists, update it without eviction
9408
+ if (this._cache.has(key)) {
9409
+ const entry = this._cache.get(key);
9410
+ entry.value = value;
9411
+ entry.lastAccessed = Date.now();
9412
+ return;
9413
+ }
9414
+ // For new keys, check if we need to evict
9415
+ if (this._cache.size >= this.maxSize) {
9416
+ this.evictLRU();
8359
9417
  }
9418
+ this._cache.set(key, {
9419
+ value,
9420
+ lastAccessed: Date.now()
9421
+ });
8360
9422
  }
8361
- mask(paths) {
8362
- this.ctx.beginPath();
8363
- this.ctx.moveTo(0, 0);
8364
- // Use logical dimensions (options.width/height) instead of canvas pixel dimensions
8365
- // because context has already been scaled by this.options.scale
8366
- // Fix for Issue #126: Using canvas pixel dimensions causes broken output
8367
- this.ctx.lineTo(this.options.width, 0);
8368
- this.ctx.lineTo(this.options.width, this.options.height);
8369
- this.ctx.lineTo(0, this.options.height);
8370
- this.ctx.lineTo(0, 0);
8371
- this.formatPath(paths.slice(0).reverse());
8372
- this.ctx.closePath();
9423
+ /**
9424
+ * Evict least recently used entry
9425
+ */
9426
+ evictLRU() {
9427
+ let oldestKey = null;
9428
+ let oldestTime = Infinity;
9429
+ for (const [key, entry] of this._cache.entries()) {
9430
+ if (entry.lastAccessed < oldestTime) {
9431
+ oldestTime = entry.lastAccessed;
9432
+ oldestKey = key;
9433
+ }
9434
+ }
9435
+ if (oldestKey) {
9436
+ this._cache.delete(oldestKey);
9437
+ this.context.logger.debug(`Cache: Evicted LRU entry: ${oldestKey}`);
9438
+ }
8373
9439
  }
8374
- path(paths) {
8375
- this.ctx.beginPath();
8376
- this.formatPath(paths);
8377
- this.ctx.closePath();
9440
+ /**
9441
+ * Get cache size
9442
+ */
9443
+ size() {
9444
+ return this._cache.size;
8378
9445
  }
8379
- formatPath(paths) {
8380
- paths.forEach((point, index) => {
8381
- const start = isBezierCurve(point) ? point.start : point;
8382
- if (index === 0) {
8383
- this.ctx.moveTo(start.x, start.y);
9446
+ /**
9447
+ * Get max cache size
9448
+ */
9449
+ getMaxSize() {
9450
+ return this.maxSize;
9451
+ }
9452
+ /**
9453
+ * Clear all cache entries
9454
+ */
9455
+ clear() {
9456
+ this._cache.clear();
9457
+ }
9458
+ async loadImage(key) {
9459
+ const originChecker = this.context.originChecker;
9460
+ const defaultIsSameOrigin = (src) => originChecker.isSameOrigin(src);
9461
+ const isSameOrigin = typeof this._options.customIsSameOrigin === 'function'
9462
+ ? await this._options.customIsSameOrigin(key, defaultIsSameOrigin)
9463
+ : defaultIsSameOrigin(key);
9464
+ const useCORS = !isInlineImage(key) && this._options.useCORS === true && FEATURES.SUPPORT_CORS_IMAGES && !isSameOrigin;
9465
+ const useProxy = !isInlineImage(key) &&
9466
+ !isSameOrigin &&
9467
+ !isBlobImage(key) &&
9468
+ typeof this._options.proxy === 'string' &&
9469
+ FEATURES.SUPPORT_CORS_XHR &&
9470
+ !useCORS;
9471
+ if (!isSameOrigin &&
9472
+ this._options.allowTaint === false &&
9473
+ !isInlineImage(key) &&
9474
+ !isBlobImage(key) &&
9475
+ !useProxy &&
9476
+ !useCORS) {
9477
+ return;
9478
+ }
9479
+ let src = key;
9480
+ if (useProxy) {
9481
+ src = await this.proxy(src);
9482
+ }
9483
+ this.context.logger.debug(`Added image ${key.substring(0, 256)}`);
9484
+ return await new Promise((resolve, reject) => {
9485
+ const img = new Image();
9486
+ img.onload = () => resolve(img);
9487
+ img.onerror = reject;
9488
+ //ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
9489
+ if (isInlineBase64Image(src) || useCORS) {
9490
+ img.crossOrigin = 'anonymous';
8384
9491
  }
8385
- else {
8386
- this.ctx.lineTo(start.x, start.y);
9492
+ img.src = src;
9493
+ if (img.complete === true) {
9494
+ // Inline XML images may fail to parse, throwing an Error later on
9495
+ setTimeout(() => resolve(img), 500);
8387
9496
  }
8388
- if (isBezierCurve(point)) {
8389
- this.ctx.bezierCurveTo(point.startControl.x, point.startControl.y, point.endControl.x, point.endControl.y, point.end.x, point.end.y);
9497
+ if (this._options.imageTimeout > 0) {
9498
+ setTimeout(() => reject(`Timed out (${this._options.imageTimeout}ms) loading image`), this._options.imageTimeout);
9499
+ }
9500
+ });
9501
+ }
9502
+ has(key) {
9503
+ return this._cache.has(key);
9504
+ }
9505
+ keys() {
9506
+ return Promise.resolve(Object.keys(this._cache));
9507
+ }
9508
+ proxy(src) {
9509
+ const proxy = this._options.proxy;
9510
+ if (!proxy) {
9511
+ throw new Error('No proxy defined');
9512
+ }
9513
+ const key = src.substring(0, 256);
9514
+ return new Promise((resolve, reject) => {
9515
+ const responseType = FEATURES.SUPPORT_RESPONSE_TYPE ? 'blob' : 'text';
9516
+ const xhr = new XMLHttpRequest();
9517
+ xhr.onload = () => {
9518
+ if (xhr.status === 200) {
9519
+ if (responseType === 'text') {
9520
+ resolve(xhr.response);
9521
+ }
9522
+ else {
9523
+ const reader = new FileReader();
9524
+ reader.addEventListener('load', () => resolve(reader.result), false);
9525
+ reader.addEventListener('error', (e) => reject(e), false);
9526
+ reader.readAsDataURL(xhr.response);
9527
+ }
9528
+ }
9529
+ else {
9530
+ reject(`Failed to proxy resource ${key} with status code ${xhr.status}`);
9531
+ }
9532
+ };
9533
+ xhr.onerror = reject;
9534
+ const queryString = proxy.indexOf('?') > -1 ? '&' : '?';
9535
+ xhr.open('GET', `${proxy}${queryString}url=${encodeURIComponent(src)}&responseType=${responseType}`);
9536
+ if (responseType !== 'text' && xhr instanceof XMLHttpRequest) {
9537
+ xhr.responseType = responseType;
9538
+ }
9539
+ if (this._options.imageTimeout) {
9540
+ const timeout = this._options.imageTimeout;
9541
+ xhr.timeout = timeout;
9542
+ xhr.ontimeout = () => reject(`Timed out (${timeout}ms) proxying ${key}`);
8390
9543
  }
9544
+ xhr.send();
9545
+ });
9546
+ }
9547
+ }
9548
+ const INLINE_SVG = /^data:image\/svg\+xml/i;
9549
+ const INLINE_BASE64 = /^data:image\/.*;base64,/i;
9550
+ const INLINE_IMG = /^data:image\/.*/i;
9551
+ const isRenderable = (src) => FEATURES.SUPPORT_SVG_DRAWING || !isSVG(src);
9552
+ const isInlineImage = (src) => INLINE_IMG.test(src);
9553
+ const isInlineBase64Image = (src) => INLINE_BASE64.test(src);
9554
+ const isBlobImage = (src) => src.substr(0, 4) === 'blob';
9555
+ const isSVG = (src) => src.substr(-3).toLowerCase() === 'svg' || INLINE_SVG.test(src);
9556
+
9557
+ /**
9558
+ * Origin Checker
9559
+ *
9560
+ * Provides origin checking functionality without global static state.
9561
+ * Each instance maintains its own anchor element and origin reference.
9562
+ *
9563
+ * Replaces the static methods in CacheStorage with instance-based approach.
9564
+ */
9565
+ class OriginChecker {
9566
+ constructor(window) {
9567
+ if (!window || !window.document) {
9568
+ throw new Error('Valid window object required for OriginChecker');
9569
+ }
9570
+ if (!window.location || !window.location.href) {
9571
+ throw new Error('Window object must have valid location');
9572
+ }
9573
+ this.link = window.document.createElement('a');
9574
+ this.origin = this.getOrigin(window.location.href);
9575
+ }
9576
+ /**
9577
+ * Get the origin (protocol + hostname + port) of a URL
9578
+ *
9579
+ * @param url - URL to parse
9580
+ * @returns Origin string (e.g., "https://example.com:8080")
9581
+ */
9582
+ getOrigin(url) {
9583
+ this.link.href = url;
9584
+ // IE9 hack: accessing href twice to ensure it's properly parsed
9585
+ this.link.href = this.link.href;
9586
+ return this.link.protocol + this.link.hostname + this.link.port;
9587
+ }
9588
+ /**
9589
+ * Check if a URL is from the same origin as the context
9590
+ *
9591
+ * @param src - URL to check
9592
+ * @returns true if same origin, false otherwise
9593
+ */
9594
+ isSameOrigin(src) {
9595
+ return this.getOrigin(src) === this.origin;
9596
+ }
9597
+ /**
9598
+ * Get the current context origin
9599
+ *
9600
+ * @returns The origin of the context window
9601
+ */
9602
+ getContextOrigin() {
9603
+ return this.origin;
9604
+ }
9605
+ }
9606
+
9607
+ class Context {
9608
+ constructor(options, windowBounds, config) {
9609
+ this.windowBounds = windowBounds;
9610
+ this.instanceName = `#${Context.instanceCount++}`;
9611
+ this.config = config;
9612
+ this.logger = new Logger({ id: this.instanceName, enabled: options.logging });
9613
+ this.originChecker = new OriginChecker(config.window);
9614
+ this.cache = options.cache ?? config.cache ?? new Cache(this, options);
9615
+ }
9616
+ }
9617
+ Context.instanceCount = 1;
9618
+
9619
+ /**
9620
+ * Html2Canvas Configuration
9621
+ *
9622
+ * Manages configuration state for html2canvas rendering.
9623
+ * Eliminates the need for global static variables.
9624
+ */
9625
+ class Html2CanvasConfig {
9626
+ constructor(options = {}) {
9627
+ // Try to get window from options first, then fall back to global window
9628
+ this.window = options.window || (typeof window !== 'undefined' ? window : null);
9629
+ if (!this.window) {
9630
+ throw new Error('Window object is required but not available');
9631
+ }
9632
+ this.cspNonce = options.cspNonce;
9633
+ this.cache = options.cache;
9634
+ }
9635
+ /**
9636
+ * Create configuration from an element
9637
+ * Extracts window from element's owner document
9638
+ */
9639
+ static fromElement(element, options = {}) {
9640
+ const ownerDocument = element.ownerDocument;
9641
+ if (!ownerDocument) {
9642
+ throw new Error('Element is not attached to a document');
9643
+ }
9644
+ const defaultView = ownerDocument.defaultView;
9645
+ if (!defaultView) {
9646
+ throw new Error('Document is not attached to a window');
9647
+ }
9648
+ return new Html2CanvasConfig({
9649
+ window: defaultView,
9650
+ ...options
8391
9651
  });
8392
9652
  }
8393
- renderRepeat(path, pattern, offsetX, offsetY) {
8394
- this.path(path);
8395
- this.ctx.fillStyle = pattern;
8396
- this.ctx.translate(offsetX, offsetY);
8397
- this.ctx.fill();
8398
- this.ctx.translate(-offsetX, -offsetY);
9653
+ /**
9654
+ * Clone configuration with override options
9655
+ */
9656
+ clone(options = {}) {
9657
+ return new Html2CanvasConfig({
9658
+ window: options.window || this.window,
9659
+ cspNonce: options.cspNonce ?? this.cspNonce,
9660
+ cache: options.cache ?? this.cache
9661
+ });
8399
9662
  }
8400
- resizeImage(image, width, height) {
8401
- // https://github.com/niklasvh/html2canvas/pull/2911
8402
- // if (image.width === width && image.height === height) {
8403
- // return image;
8404
- // }
8405
- const ownerDocument = this.canvas.ownerDocument ?? document;
8406
- const canvas = ownerDocument.createElement('canvas');
8407
- canvas.width = Math.max(1, width);
8408
- canvas.height = Math.max(1, height);
8409
- const ctx = canvas.getContext('2d');
8410
- ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height);
8411
- return canvas;
9663
+ }
9664
+ /**
9665
+ * Set default configuration
9666
+ * @deprecated Pass configuration directly to html2canvas instead
9667
+ */
9668
+ function setDefaultConfig(config) {
9669
+ console.warn('[html2canvas-pro] setDefaultConfig is deprecated. Pass configuration to html2canvas directly.');
9670
+ }
9671
+
9672
+ /**
9673
+ * Input Validator
9674
+ *
9675
+ * Provides validation and sanitization for user inputs to prevent security vulnerabilities
9676
+ * including SSRF, XSS, and injection attacks.
9677
+ */
9678
+ /**
9679
+ * Input Validator
9680
+ *
9681
+ * Validates and sanitizes user inputs for security and correctness.
9682
+ */
9683
+ class Validator {
9684
+ constructor(config = {}) {
9685
+ this.config = {
9686
+ maxImageTimeout: 300000, // 5 minutes default
9687
+ allowDataUrls: true,
9688
+ ...config
9689
+ };
8412
9690
  }
8413
- async renderBackgroundImage(container) {
8414
- let index = container.styles.backgroundImage.length - 1;
8415
- for (const backgroundImage of container.styles.backgroundImage.slice(0).reverse()) {
8416
- if (backgroundImage.type === 0 /* CSSImageType.URL */) {
8417
- let image;
8418
- const url = backgroundImage.url;
8419
- try {
8420
- image = await this.context.cache.match(url);
8421
- }
8422
- catch (e) {
8423
- this.context.logger.error(`Error loading background-image ${url}`);
8424
- }
8425
- if (image) {
8426
- const imageWidth = isNaN(image.width) || image.width === 0 ? 1 : image.width;
8427
- const imageHeight = isNaN(image.height) || image.height === 0 ? 1 : image.height;
8428
- const [path, x, y, width, height] = calculateBackgroundRendering(container, index, [
8429
- imageWidth,
8430
- imageHeight,
8431
- imageWidth / imageHeight
8432
- ]);
8433
- const pattern = this.ctx.createPattern(this.resizeImage(image, width, height), 'repeat');
8434
- this.renderRepeat(path, pattern, x, y);
8435
- }
9691
+ /**
9692
+ * Validate a URL
9693
+ *
9694
+ * @param url - URL to validate
9695
+ * @param context - Context for validation (e.g., 'proxy', 'image')
9696
+ * @returns Validation result
9697
+ */
9698
+ validateUrl(url, context = 'general') {
9699
+ if (!url || typeof url !== 'string') {
9700
+ return {
9701
+ valid: false,
9702
+ error: 'URL must be a non-empty string'
9703
+ };
9704
+ }
9705
+ // Check for data URLs
9706
+ if (url.startsWith('data:')) {
9707
+ if (!this.config.allowDataUrls) {
9708
+ return {
9709
+ valid: false,
9710
+ error: 'Data URLs are not allowed'
9711
+ };
8436
9712
  }
8437
- else if (isLinearGradient(backgroundImage)) {
8438
- const [path, x, y, width, height] = calculateBackgroundRendering(container, index, [null, null, null]);
8439
- const [lineLength, x0, x1, y0, y1] = calculateGradientDirection(backgroundImage.angle, width, height);
8440
- const canvas = document.createElement('canvas');
8441
- canvas.width = width;
8442
- canvas.height = height;
8443
- const ctx = canvas.getContext('2d');
8444
- const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
8445
- processColorStops(backgroundImage.stops, lineLength || 1).forEach((colorStop) => gradient.addColorStop(colorStop.stop, asString(colorStop.color)));
8446
- ctx.fillStyle = gradient;
8447
- ctx.fillRect(0, 0, width, height);
8448
- if (width > 0 && height > 0) {
8449
- const pattern = this.ctx.createPattern(canvas, 'repeat');
8450
- this.renderRepeat(path, pattern, x, y);
9713
+ return { valid: true, sanitized: url };
9714
+ }
9715
+ // Check for blob URLs
9716
+ if (url.startsWith('blob:')) {
9717
+ return { valid: true, sanitized: url };
9718
+ }
9719
+ // Validate URL format
9720
+ try {
9721
+ const parsedUrl = new URL(url);
9722
+ // Only allow http and https protocols
9723
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
9724
+ return {
9725
+ valid: false,
9726
+ error: `Protocol ${parsedUrl.protocol} is not allowed. Only http and https are permitted.`
9727
+ };
9728
+ }
9729
+ // For proxy URLs, check domain whitelist
9730
+ if (context === 'proxy' && this.config.allowedProxyDomains && this.config.allowedProxyDomains.length > 0) {
9731
+ const hostname = parsedUrl.hostname.toLowerCase();
9732
+ const isAllowed = this.config.allowedProxyDomains.some((domain) => {
9733
+ const normalizedDomain = domain.toLowerCase();
9734
+ return hostname === normalizedDomain || hostname.endsWith('.' + normalizedDomain);
9735
+ });
9736
+ if (!isAllowed) {
9737
+ return {
9738
+ valid: false,
9739
+ error: `Proxy domain ${parsedUrl.hostname} is not in the allowed list`
9740
+ };
8451
9741
  }
8452
9742
  }
8453
- else if (isRadialGradient(backgroundImage)) {
8454
- const [path, left, top, width, height] = calculateBackgroundRendering(container, index, [
8455
- null,
8456
- null,
8457
- null
8458
- ]);
8459
- const position = backgroundImage.position.length === 0 ? [FIFTY_PERCENT] : backgroundImage.position;
8460
- const x = getAbsoluteValue(position[0], width);
8461
- const y = getAbsoluteValue(position[position.length - 1], height);
8462
- let [rx, ry] = calculateRadius(backgroundImage, x, y, width, height);
8463
- // Handle edge case where radial gradient size is 0
8464
- // Use a minimum value of 0.01 to ensure gradient is still rendered
8465
- if (rx === 0 || ry === 0) {
8466
- rx = Math.max(rx, 0.01);
8467
- ry = Math.max(ry, 0.01);
8468
- }
8469
- if (rx > 0 && ry > 0) {
8470
- const radialGradient = this.ctx.createRadialGradient(left + x, top + y, 0, left + x, top + y, rx);
8471
- processColorStops(backgroundImage.stops, rx * 2).forEach((colorStop) => radialGradient.addColorStop(colorStop.stop, asString(colorStop.color)));
8472
- this.path(path);
8473
- this.ctx.fillStyle = radialGradient;
8474
- if (rx !== ry) {
8475
- // transforms for elliptical radial gradient
8476
- const midX = container.bounds.left + 0.5 * container.bounds.width;
8477
- const midY = container.bounds.top + 0.5 * container.bounds.height;
8478
- const f = ry / rx;
8479
- const invF = 1 / f;
8480
- this.ctx.save();
8481
- this.ctx.translate(midX, midY);
8482
- this.ctx.transform(1, 0, 0, f, 0, 0);
8483
- this.ctx.translate(-midX, -midY);
8484
- this.ctx.fillRect(left, invF * (top - midY) + midY, width, height * invF);
8485
- this.ctx.restore();
9743
+ // Check for localhost/private IPs to prevent SSRF (skip when allowLocalhostProxy for dev/test)
9744
+ if (context === 'proxy') {
9745
+ if (!this.config.allowLocalhostProxy) {
9746
+ const hostname = parsedUrl.hostname.toLowerCase();
9747
+ // Check for localhost
9748
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
9749
+ return {
9750
+ valid: false,
9751
+ error: 'Localhost is not allowed for proxy URLs'
9752
+ };
8486
9753
  }
8487
- else {
8488
- this.ctx.fill();
9754
+ // For private IP ranges (simplified check)
9755
+ if (this.isPrivateIP(hostname)) {
9756
+ return {
9757
+ valid: false,
9758
+ error: 'Private IP addresses are not allowed for proxy URLs'
9759
+ };
9760
+ }
9761
+ // For link-local addresses
9762
+ if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) {
9763
+ return {
9764
+ valid: false,
9765
+ error: 'Link-local addresses are not allowed for proxy URLs'
9766
+ };
8489
9767
  }
8490
9768
  }
9769
+ // For proxy URLs, mark that runtime validation is recommended
9770
+ // to prevent DNS rebinding attacks
9771
+ return {
9772
+ valid: true,
9773
+ sanitized: url,
9774
+ requiresRuntimeCheck: true
9775
+ };
8491
9776
  }
8492
- index--;
9777
+ return { valid: true, sanitized: url };
8493
9778
  }
8494
- }
8495
- async renderSolidBorder(color, side, curvePoints) {
8496
- this.path(parsePathForBorder(curvePoints, side));
8497
- this.ctx.fillStyle = asString(color);
8498
- this.ctx.fill();
8499
- }
8500
- async renderDoubleBorder(color, width, side, curvePoints) {
8501
- if (width < 3) {
8502
- await this.renderSolidBorder(color, side, curvePoints);
8503
- return;
9779
+ catch (e) {
9780
+ return {
9781
+ valid: false,
9782
+ error: `Invalid URL format: ${e instanceof Error ? e.message : 'Unknown error'}`
9783
+ };
8504
9784
  }
8505
- const outerPaths = parsePathForBorderDoubleOuter(curvePoints, side);
8506
- this.path(outerPaths);
8507
- this.ctx.fillStyle = asString(color);
8508
- this.ctx.fill();
8509
- const innerPaths = parsePathForBorderDoubleInner(curvePoints, side);
8510
- this.path(innerPaths);
8511
- this.ctx.fill();
8512
9785
  }
8513
- async renderNodeBackgroundAndBorders(paint) {
8514
- this.applyEffects(paint.getEffects(2 /* EffectTarget.BACKGROUND_BORDERS */));
8515
- const styles = paint.container.styles;
8516
- const hasBackground = !isTransparent(styles.backgroundColor) || styles.backgroundImage.length;
8517
- const borders = [
8518
- { style: styles.borderTopStyle, color: styles.borderTopColor, width: styles.borderTopWidth },
8519
- { style: styles.borderRightStyle, color: styles.borderRightColor, width: styles.borderRightWidth },
8520
- { style: styles.borderBottomStyle, color: styles.borderBottomColor, width: styles.borderBottomWidth },
8521
- { style: styles.borderLeftStyle, color: styles.borderLeftColor, width: styles.borderLeftWidth }
9786
+ /**
9787
+ * Check if a hostname is a private IP address
9788
+ */
9789
+ isPrivateIP(hostname) {
9790
+ // IPv4 private ranges
9791
+ const privateIPv4Patterns = [
9792
+ /^0\./, // 0.0.0.0/8 (This network)
9793
+ /^10\./, // 10.0.0.0/8 (Private)
9794
+ /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // 100.64.0.0/10 (CGNAT)
9795
+ /^127\./, // 127.0.0.0/8 (Loopback)
9796
+ /^169\.254\./, // 169.254.0.0/16 (Link-local)
9797
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 (Private)
9798
+ /^192\.0\.0\./, // 192.0.0.0/24 (IETF Protocol Assignments)
9799
+ /^192\.0\.2\./, // 192.0.2.0/24 (TEST-NET-1)
9800
+ /^192\.168\./, // 192.168.0.0/16 (Private)
9801
+ /^198\.(1[8-9])\./, // 198.18.0.0/15 (Network benchmark)
9802
+ /^198\.51\.100\./, // 198.51.100.0/24 (TEST-NET-2)
9803
+ /^203\.0\.113\./, // 203.0.113.0/24 (TEST-NET-3)
9804
+ /^2(2[4-9]|3[0-9])\./, // 224.0.0.0/4 (Multicast)
9805
+ /^24[0-9]\./, // 240.0.0.0/4 (Reserved)
9806
+ /^255\.255\.255\.255$/ // 255.255.255.255/32 (Broadcast)
8522
9807
  ];
8523
- const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(getBackgroundValueForIndex(styles.backgroundClip, 0), paint.curves);
8524
- if (hasBackground || styles.boxShadow.length) {
8525
- this.ctx.save();
8526
- this.path(backgroundPaintingArea);
8527
- this.ctx.clip();
8528
- if (!isTransparent(styles.backgroundColor)) {
8529
- this.ctx.fillStyle = asString(styles.backgroundColor);
8530
- this.ctx.fill();
9808
+ // Check IPv4
9809
+ if (privateIPv4Patterns.some((pattern) => pattern.test(hostname))) {
9810
+ return true;
9811
+ }
9812
+ // IPv6 private ranges and special addresses
9813
+ if (hostname.includes(':')) {
9814
+ return this.isPrivateIPv6(hostname);
9815
+ }
9816
+ return false;
9817
+ }
9818
+ /**
9819
+ * Check if an IPv6 address is private or special
9820
+ * Handles compressed IPv6 addresses (e.g., ::1, fc00::1)
9821
+ */
9822
+ isPrivateIPv6(hostname) {
9823
+ const normalizedHost = hostname.toLowerCase().trim();
9824
+ // Remove square brackets if present (e.g., [::1])
9825
+ const addr = normalizedHost.replace(/^\[|\]$/g, '');
9826
+ // Remove zone ID if present (e.g., fe80::1%eth0)
9827
+ const addrWithoutZone = addr.split('%')[0];
9828
+ // Loopback ::1 (also matches 0:0:0:0:0:0:0:1)
9829
+ if (/^(0:){7}1$/.test(addrWithoutZone) || addrWithoutZone === '::1') {
9830
+ return true;
9831
+ }
9832
+ // Unspecified address :: (also matches 0:0:0:0:0:0:0:0)
9833
+ if (/^(0:){7}0$/.test(addrWithoutZone) || addrWithoutZone === '::') {
9834
+ return true;
9835
+ }
9836
+ // Expand :: compression to check prefixes
9837
+ // This handles cases like fc00::1, fe80::, etc.
9838
+ const expandedAddr = this.expandIPv6(addrWithoutZone);
9839
+ if (!expandedAddr) {
9840
+ // If we can't expand it, fall back to prefix matching
9841
+ return this.isPrivateIPv6Prefix(addrWithoutZone);
9842
+ }
9843
+ // fc00::/7 (Unique Local Address)
9844
+ // Check if first byte is in range fc00-fdff
9845
+ const firstByte = parseInt(expandedAddr.substring(0, 2), 16);
9846
+ if (firstByte >= 0xfc && firstByte <= 0xfd) {
9847
+ return true;
9848
+ }
9849
+ // fe80::/10 (Link-local)
9850
+ // First 10 bits should be 1111 1110 10
9851
+ if (firstByte === 0xfe) {
9852
+ const secondByte = parseInt(expandedAddr.substring(2, 4), 16);
9853
+ // Check if bits 11-12 are 10 (0x80-0xbf)
9854
+ if (secondByte >= 0x80 && secondByte <= 0xbf) {
9855
+ return true;
8531
9856
  }
8532
- await this.renderBackgroundImage(paint.container);
8533
- this.ctx.restore();
8534
- styles.boxShadow
8535
- .slice(0)
8536
- .reverse()
8537
- .forEach((shadow) => {
8538
- this.ctx.save();
8539
- const borderBoxArea = calculateBorderBoxPath(paint.curves);
8540
- const maskOffset = shadow.inset ? 0 : MASK_OFFSET;
8541
- 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));
8542
- if (shadow.inset) {
8543
- this.path(borderBoxArea);
8544
- this.ctx.clip();
8545
- this.mask(shadowPaintingArea);
8546
- }
8547
- else {
8548
- this.mask(borderBoxArea);
8549
- this.ctx.clip();
8550
- this.path(shadowPaintingArea);
8551
- }
8552
- this.ctx.shadowOffsetX = shadow.offsetX.number + maskOffset;
8553
- this.ctx.shadowOffsetY = shadow.offsetY.number;
8554
- this.ctx.shadowColor = asString(shadow.color);
8555
- this.ctx.shadowBlur = shadow.blur.number;
8556
- this.ctx.fillStyle = shadow.inset ? asString(shadow.color) : 'rgba(0,0,0,1)';
8557
- this.ctx.fill();
8558
- this.ctx.restore();
8559
- });
8560
9857
  }
8561
- let side = 0;
8562
- for (const border of borders) {
8563
- if (border.style !== 0 /* BORDER_STYLE.NONE */ && !isTransparent(border.color) && border.width > 0) {
8564
- if (border.style === 2 /* BORDER_STYLE.DASHED */) {
8565
- await this.renderDashedDottedBorder(border.color, border.width, side, paint.curves, 2 /* BORDER_STYLE.DASHED */);
8566
- }
8567
- else if (border.style === 3 /* BORDER_STYLE.DOTTED */) {
8568
- await this.renderDashedDottedBorder(border.color, border.width, side, paint.curves, 3 /* BORDER_STYLE.DOTTED */);
9858
+ // ff00::/8 (Multicast)
9859
+ if (firstByte === 0xff) {
9860
+ return true;
9861
+ }
9862
+ return false;
9863
+ }
9864
+ /**
9865
+ * Expand compressed IPv6 address to full form
9866
+ * e.g., "::1" -> "0000:0000:0000:0000:0000:0000:0000:0001"
9867
+ */
9868
+ expandIPv6(addr) {
9869
+ try {
9870
+ // Handle :: compression
9871
+ if (addr.includes('::')) {
9872
+ const parts = addr.split('::');
9873
+ if (parts.length > 2) {
9874
+ return null; // Invalid: more than one ::
8569
9875
  }
8570
- else if (border.style === 4 /* BORDER_STYLE.DOUBLE */) {
8571
- await this.renderDoubleBorder(border.color, border.width, side, paint.curves);
9876
+ const leftParts = parts[0] ? parts[0].split(':') : [];
9877
+ const rightParts = parts[1] ? parts[1].split(':') : [];
9878
+ const missingParts = 8 - leftParts.length - rightParts.length;
9879
+ if (missingParts < 0) {
9880
+ return null; // Invalid
8572
9881
  }
8573
- else {
8574
- await this.renderSolidBorder(border.color, side, paint.curves);
9882
+ const middleParts = Array(missingParts).fill('0000');
9883
+ const allParts = [...leftParts, ...middleParts, ...rightParts];
9884
+ return allParts.map((p) => p.padStart(4, '0')).join(':');
9885
+ }
9886
+ else {
9887
+ // No compression, just normalize
9888
+ const parts = addr.split(':');
9889
+ if (parts.length !== 8) {
9890
+ return null; // Invalid
8575
9891
  }
9892
+ return parts.map((p) => p.padStart(4, '0')).join(':');
8576
9893
  }
8577
- side++;
9894
+ }
9895
+ catch {
9896
+ return null;
8578
9897
  }
8579
9898
  }
8580
- async renderDashedDottedBorder(color, width, side, curvePoints, style) {
8581
- this.ctx.save();
8582
- const strokePaths = parsePathForBorderStroke(curvePoints, side);
8583
- const boxPaths = parsePathForBorder(curvePoints, side);
8584
- if (style === 2 /* BORDER_STYLE.DASHED */) {
8585
- this.path(boxPaths);
8586
- this.ctx.clip();
9899
+ /**
9900
+ * Fallback prefix matching for IPv6 when expansion fails
9901
+ */
9902
+ isPrivateIPv6Prefix(addr) {
9903
+ // fc00::/7 (Unique Local Address)
9904
+ if (/^fc[0-9a-f]{0,2}:?/i.test(addr) || /^fd[0-9a-f]{0,2}:?/i.test(addr)) {
9905
+ return true;
8587
9906
  }
8588
- let startX, startY, endX, endY;
8589
- if (isBezierCurve(boxPaths[0])) {
8590
- startX = boxPaths[0].start.x;
8591
- startY = boxPaths[0].start.y;
9907
+ // fe80::/10 (Link-local)
9908
+ if (/^fe[89ab][0-9a-f]:?/i.test(addr)) {
9909
+ return true;
8592
9910
  }
8593
- else {
8594
- startX = boxPaths[0].x;
8595
- startY = boxPaths[0].y;
9911
+ // ff00::/8 (Multicast)
9912
+ if (/^ff[0-9a-f]{0,2}:?/i.test(addr)) {
9913
+ return true;
8596
9914
  }
8597
- if (isBezierCurve(boxPaths[1])) {
8598
- endX = boxPaths[1].end.x;
8599
- endY = boxPaths[1].end.y;
9915
+ return false;
9916
+ }
9917
+ /**
9918
+ * Validate CSP nonce
9919
+ *
9920
+ * @param nonce - CSP nonce to validate
9921
+ * @returns Validation result
9922
+ */
9923
+ validateCspNonce(nonce) {
9924
+ if (!nonce || typeof nonce !== 'string') {
9925
+ return {
9926
+ valid: false,
9927
+ error: 'CSP nonce must be a non-empty string'
9928
+ };
8600
9929
  }
8601
- else {
8602
- endX = boxPaths[1].x;
8603
- endY = boxPaths[1].y;
9930
+ // Basic format validation - nonce should be base64-like
9931
+ // Typical format: base64 string, often 32+ characters
9932
+ if (nonce.length < 16) {
9933
+ return {
9934
+ valid: false,
9935
+ error: 'CSP nonce is too short (minimum 16 characters recommended)'
9936
+ };
8604
9937
  }
8605
- let length;
8606
- if (side === 0 || side === 2) {
8607
- length = Math.abs(startX - endX);
9938
+ // Check for suspicious characters
9939
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(nonce)) {
9940
+ return {
9941
+ valid: false,
9942
+ error: 'CSP nonce contains invalid characters'
9943
+ };
8608
9944
  }
8609
- else {
8610
- length = Math.abs(startY - endY);
9945
+ return { valid: true, sanitized: nonce };
9946
+ }
9947
+ /**
9948
+ * Validate image timeout
9949
+ *
9950
+ * @param timeout - Timeout in milliseconds
9951
+ * @returns Validation result
9952
+ */
9953
+ validateImageTimeout(timeout) {
9954
+ if (typeof timeout !== 'number' || isNaN(timeout)) {
9955
+ return {
9956
+ valid: false,
9957
+ error: 'Image timeout must be a number'
9958
+ };
8611
9959
  }
8612
- this.ctx.beginPath();
8613
- if (style === 3 /* BORDER_STYLE.DOTTED */) {
8614
- this.formatPath(strokePaths);
9960
+ if (timeout < 0) {
9961
+ return {
9962
+ valid: false,
9963
+ error: 'Image timeout cannot be negative'
9964
+ };
8615
9965
  }
8616
- else {
8617
- this.formatPath(boxPaths.slice(0, 2));
9966
+ if (this.config.maxImageTimeout && timeout > this.config.maxImageTimeout) {
9967
+ return {
9968
+ valid: false,
9969
+ error: `Image timeout ${timeout}ms exceeds maximum allowed ${this.config.maxImageTimeout}ms`
9970
+ };
8618
9971
  }
8619
- let dashLength = width < 3 ? width * 3 : width * 2;
8620
- let spaceLength = width < 3 ? width * 2 : width;
8621
- if (style === 3 /* BORDER_STYLE.DOTTED */) {
8622
- dashLength = width;
8623
- spaceLength = width;
9972
+ return { valid: true, sanitized: timeout };
9973
+ }
9974
+ /**
9975
+ * Validate window dimensions
9976
+ *
9977
+ * @param width - Window width
9978
+ * @param height - Window height
9979
+ * @returns Validation result
9980
+ */
9981
+ validateDimensions(width, height) {
9982
+ if (typeof width !== 'number' || typeof height !== 'number') {
9983
+ return {
9984
+ valid: false,
9985
+ error: 'Dimensions must be numbers'
9986
+ };
8624
9987
  }
8625
- let useLineDash = true;
8626
- if (length <= dashLength * 2) {
8627
- useLineDash = false;
9988
+ if (isNaN(width) || isNaN(height)) {
9989
+ return {
9990
+ valid: false,
9991
+ error: 'Dimensions cannot be NaN'
9992
+ };
8628
9993
  }
8629
- else if (length <= dashLength * 2 + spaceLength) {
8630
- const multiplier = length / (2 * dashLength + spaceLength);
8631
- dashLength *= multiplier;
8632
- spaceLength *= multiplier;
9994
+ if (width <= 0 || height <= 0) {
9995
+ return {
9996
+ valid: false,
9997
+ error: 'Dimensions must be positive'
9998
+ };
9999
+ }
10000
+ // Reasonable maximum to prevent memory issues
10001
+ const MAX_DIMENSION = 32767; // Common canvas limit
10002
+ if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
10003
+ return {
10004
+ valid: false,
10005
+ error: `Dimensions exceed maximum allowed (${MAX_DIMENSION}px)`
10006
+ };
10007
+ }
10008
+ return { valid: true, sanitized: { width, height } };
10009
+ }
10010
+ /**
10011
+ * Validate scale factor
10012
+ *
10013
+ * @param scale - Scale factor
10014
+ * @returns Validation result
10015
+ */
10016
+ validateScale(scale) {
10017
+ if (typeof scale !== 'number' || isNaN(scale)) {
10018
+ return {
10019
+ valid: false,
10020
+ error: 'Scale must be a number'
10021
+ };
10022
+ }
10023
+ if (scale <= 0) {
10024
+ return {
10025
+ valid: false,
10026
+ error: 'Scale must be positive'
10027
+ };
10028
+ }
10029
+ // Reasonable scale limits
10030
+ if (scale > 10) {
10031
+ return {
10032
+ valid: false,
10033
+ error: 'Scale factor too large (maximum 10x)'
10034
+ };
10035
+ }
10036
+ return { valid: true, sanitized: scale };
10037
+ }
10038
+ /**
10039
+ * Validate HTML element
10040
+ *
10041
+ * @param element - Element to validate
10042
+ * @returns Validation result
10043
+ */
10044
+ validateElement(element) {
10045
+ if (!element) {
10046
+ return {
10047
+ valid: false,
10048
+ error: 'Element is required'
10049
+ };
10050
+ }
10051
+ if (typeof element !== 'object') {
10052
+ return {
10053
+ valid: false,
10054
+ error: 'Element must be an object'
10055
+ };
10056
+ }
10057
+ // Accept real HTMLElement, or any element-like object with the minimal shape
10058
+ // required by the implementation (ownerDocument + defaultView) for backward
10059
+ // compatibility and test environments.
10060
+ if (typeof HTMLElement !== 'undefined' && element instanceof HTMLElement) {
10061
+ // Real DOM element
10062
+ if (!element.ownerDocument) {
10063
+ return { valid: false, error: 'Element must be attached to a document' };
10064
+ }
10065
+ return { valid: true };
10066
+ }
10067
+ // Duck-typing: accept object with ownerDocument and defaultView (minimal contract)
10068
+ if (!element.ownerDocument) {
10069
+ return {
10070
+ valid: false,
10071
+ error: 'Element must be attached to a document (ownerDocument required)'
10072
+ };
8633
10073
  }
8634
- else {
8635
- const numberOfDashes = Math.floor((length + spaceLength) / (dashLength + spaceLength));
8636
- const minSpace = (length - numberOfDashes * dashLength) / (numberOfDashes - 1);
8637
- const maxSpace = (length - (numberOfDashes + 1) * dashLength) / numberOfDashes;
8638
- spaceLength =
8639
- maxSpace <= 0 || Math.abs(spaceLength - minSpace) < Math.abs(spaceLength - maxSpace)
8640
- ? minSpace
8641
- : maxSpace;
10074
+ if (!element.ownerDocument.defaultView) {
10075
+ return {
10076
+ valid: false,
10077
+ error: 'Document must be attached to a window (ownerDocument.defaultView required)'
10078
+ };
8642
10079
  }
8643
- if (useLineDash) {
8644
- if (style === 3 /* BORDER_STYLE.DOTTED */) {
8645
- this.ctx.setLineDash([0, dashLength + spaceLength]);
10080
+ return { valid: true };
10081
+ }
10082
+ /**
10083
+ * Validate entire options object
10084
+ *
10085
+ * @param options - Options to validate
10086
+ * @returns Validation result with all errors
10087
+ */
10088
+ validateOptions(options) {
10089
+ const errors = [];
10090
+ // Validate proxy URL only when a non-empty string (allow null/undefined to mean "no proxy")
10091
+ const proxyUrl = options.proxy;
10092
+ if (proxyUrl !== undefined && proxyUrl !== null && typeof proxyUrl === 'string' && proxyUrl.length > 0) {
10093
+ const proxyResult = this.validateUrl(proxyUrl, 'proxy');
10094
+ if (!proxyResult.valid) {
10095
+ errors.push(`Proxy: ${proxyResult.error}`);
8646
10096
  }
8647
- else {
8648
- this.ctx.setLineDash([dashLength, spaceLength]);
10097
+ // Note: Proxy URLs are marked with requiresRuntimeCheck to prevent DNS rebinding
10098
+ // Consider implementing runtime IP validation in production environments
10099
+ }
10100
+ // Validate image timeout
10101
+ if (options.imageTimeout !== undefined) {
10102
+ const timeoutResult = this.validateImageTimeout(options.imageTimeout);
10103
+ if (!timeoutResult.valid) {
10104
+ errors.push(`Image timeout: ${timeoutResult.error}`);
8649
10105
  }
8650
10106
  }
8651
- if (style === 3 /* BORDER_STYLE.DOTTED */) {
8652
- this.ctx.lineCap = 'round';
8653
- this.ctx.lineWidth = width;
10107
+ // Validate dimensions
10108
+ if (options.width !== undefined || options.height !== undefined) {
10109
+ const width = options.width ?? 800;
10110
+ const height = options.height ?? 600;
10111
+ const dimensionsResult = this.validateDimensions(width, height);
10112
+ if (!dimensionsResult.valid) {
10113
+ errors.push(`Dimensions: ${dimensionsResult.error}`);
10114
+ }
8654
10115
  }
8655
- else {
8656
- this.ctx.lineWidth = width * 2 + 1.1;
10116
+ // Validate scale
10117
+ if (options.scale !== undefined) {
10118
+ const scaleResult = this.validateScale(options.scale);
10119
+ if (!scaleResult.valid) {
10120
+ errors.push(`Scale: ${scaleResult.error}`);
10121
+ }
8657
10122
  }
8658
- this.ctx.strokeStyle = asString(color);
8659
- this.ctx.stroke();
8660
- this.ctx.setLineDash([]);
8661
- // dashed round edge gap
8662
- if (style === 2 /* BORDER_STYLE.DASHED */) {
8663
- if (isBezierCurve(boxPaths[0])) {
8664
- const path1 = boxPaths[3];
8665
- const path2 = boxPaths[0];
8666
- this.ctx.beginPath();
8667
- this.formatPath([new Vector(path1.end.x, path1.end.y), new Vector(path2.start.x, path2.start.y)]);
8668
- this.ctx.stroke();
10123
+ // Validate CSP nonce
10124
+ if (options.cspNonce !== undefined) {
10125
+ const nonceResult = this.validateCspNonce(options.cspNonce);
10126
+ if (!nonceResult.valid) {
10127
+ errors.push(`CSP nonce: ${nonceResult.error}`);
8669
10128
  }
8670
- if (isBezierCurve(boxPaths[1])) {
8671
- const path1 = boxPaths[1];
8672
- const path2 = boxPaths[2];
8673
- this.ctx.beginPath();
8674
- this.formatPath([new Vector(path1.end.x, path1.end.y), new Vector(path2.start.x, path2.start.y)]);
8675
- this.ctx.stroke();
10129
+ }
10130
+ // Custom validation
10131
+ if (this.config.customValidator) {
10132
+ const customResult = this.config.customValidator(options, 'options');
10133
+ if (!customResult.valid) {
10134
+ errors.push(`Custom validation: ${customResult.error}`);
8676
10135
  }
8677
10136
  }
8678
- this.ctx.restore();
8679
- }
8680
- async render(element) {
8681
- if (this.options.backgroundColor) {
8682
- this.ctx.fillStyle = asString(this.options.backgroundColor);
8683
- this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
10137
+ if (errors.length > 0) {
10138
+ return {
10139
+ valid: false,
10140
+ error: errors.join('; ')
10141
+ };
8684
10142
  }
8685
- const stack = parseStackingContexts(element);
8686
- await this.renderStack(stack);
8687
- this.applyEffects([]);
8688
- return this.canvas;
10143
+ return { valid: true };
8689
10144
  }
8690
10145
  }
8691
- const isTextInputElement = (container) => {
8692
- if (container instanceof TextareaElementContainer) {
8693
- return true;
8694
- }
8695
- else if (container instanceof SelectElementContainer) {
8696
- return true;
8697
- }
8698
- else if (container instanceof InputElementContainer && container.type !== RADIO && container.type !== CHECKBOX) {
8699
- return true;
8700
- }
8701
- return false;
8702
- };
8703
- const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
8704
- switch (clip) {
8705
- case 0 /* BACKGROUND_CLIP.BORDER_BOX */:
8706
- return calculateBorderBoxPath(curves);
8707
- case 2 /* BACKGROUND_CLIP.CONTENT_BOX */:
8708
- return calculateContentBoxPath(curves);
8709
- case 1 /* BACKGROUND_CLIP.PADDING_BOX */:
8710
- default:
8711
- return calculatePaddingBoxPath(curves);
8712
- }
8713
- };
8714
- const canvasTextAlign = (textAlign) => {
8715
- switch (textAlign) {
8716
- case 1 /* TEXT_ALIGN.CENTER */:
8717
- return 'center';
8718
- case 2 /* TEXT_ALIGN.RIGHT */:
8719
- return 'right';
8720
- case 0 /* TEXT_ALIGN.LEFT */:
8721
- default:
8722
- return 'left';
8723
- }
8724
- };
8725
- // see https://github.com/niklasvh/html2canvas/pull/2645
8726
- const iOSBrokenFonts = ['-apple-system', 'system-ui'];
8727
- const fixIOSSystemFonts = (fontFamilies) => {
8728
- return /iPhone OS 15_(0|1)/.test(window.navigator.userAgent)
8729
- ? fontFamilies.filter((fontFamily) => iOSBrokenFonts.indexOf(fontFamily) === -1)
8730
- : fontFamilies;
8731
- };
8732
-
8733
- class ForeignObjectRenderer extends Renderer {
8734
- constructor(context, options) {
8735
- super(context, options);
8736
- this.canvas = options.canvas ? options.canvas : document.createElement('canvas');
8737
- this.ctx = this.canvas.getContext('2d');
8738
- this.options = options;
8739
- this.canvas.width = Math.floor(options.width * options.scale);
8740
- this.canvas.height = Math.floor(options.height * options.scale);
8741
- this.canvas.style.width = `${options.width}px`;
8742
- this.canvas.style.height = `${options.height}px`;
8743
- this.ctx.scale(this.options.scale, this.options.scale);
8744
- this.ctx.translate(-options.x, -options.y);
8745
- this.context.logger.debug(`EXPERIMENTAL ForeignObject renderer initialized (${options.width}x${options.height} at ${options.x},${options.y}) with scale ${options.scale}`);
8746
- }
8747
- async render(element) {
8748
- const svg = createForeignObjectSVG(this.options.width * this.options.scale, this.options.height * this.options.scale, this.options.scale, this.options.scale, element);
8749
- const img = await loadSerializedSVG(svg);
8750
- if (this.options.backgroundColor) {
8751
- this.ctx.fillStyle = asString(this.options.backgroundColor);
8752
- this.ctx.fillRect(0, 0, this.options.width * this.options.scale, this.options.height * this.options.scale);
8753
- }
8754
- this.ctx.drawImage(img, -this.options.x * this.options.scale, -this.options.y * this.options.scale);
8755
- return this.canvas;
8756
- }
10146
+ /**
10147
+ * Create a default validator instance
10148
+ */
10149
+ function createDefaultValidator(config = {}) {
10150
+ return new Validator({
10151
+ allowDataUrls: true,
10152
+ maxImageTimeout: 300000, // 5 minutes
10153
+ ...config
10154
+ });
8757
10155
  }
8758
- const loadSerializedSVG = (svg) => new Promise((resolve, reject) => {
8759
- const img = new Image();
8760
- img.onload = () => {
8761
- resolve(img);
8762
- };
8763
- img.onerror = reject;
8764
- img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
8765
- });
8766
10156
 
8767
- class Logger {
8768
- constructor({ id, enabled }) {
8769
- this.id = id;
10157
+ /**
10158
+ * Performance Monitor
10159
+ *
10160
+ * Tracks performance metrics throughout the rendering pipeline.
10161
+ * Provides insights into where time is spent during rendering.
10162
+ *
10163
+ * Usage:
10164
+ * ```typescript
10165
+ * const monitor = new PerformanceMonitor(context);
10166
+ *
10167
+ * monitor.start('clone');
10168
+ * await cloneDocument();
10169
+ * monitor.end('clone');
10170
+ *
10171
+ * const summary = monitor.getSummary();
10172
+ * ```
10173
+ */
10174
+ class PerformanceMonitor {
10175
+ constructor(context, enabled = true) {
10176
+ this.context = context;
10177
+ this.activeMetrics = new Map();
10178
+ this.completedMetrics = [];
8770
10179
  this.enabled = enabled;
8771
- this.start = Date.now();
10180
+ // Fallback for environments without performance.now()
10181
+ this.getTime =
10182
+ typeof performance !== 'undefined' && typeof performance.now === 'function'
10183
+ ? () => performance.now()
10184
+ : () => Date.now();
8772
10185
  }
8773
- debug(...args) {
8774
- if (this.enabled) {
8775
- // eslint-disable-next-line no-console
8776
- if (typeof window !== 'undefined' && window.console && typeof console.debug === 'function') {
8777
- // eslint-disable-next-line no-console
8778
- console.debug(this.id, `${this.getTime()}ms`, ...args);
8779
- }
8780
- else {
8781
- this.info(...args);
8782
- }
10186
+ /**
10187
+ * Start measuring a performance metric
10188
+ *
10189
+ * @param name - Unique name for this metric
10190
+ * @param metadata - Optional metadata to attach
10191
+ */
10192
+ start(name, metadata) {
10193
+ if (!this.enabled) {
10194
+ return;
8783
10195
  }
10196
+ if (this.activeMetrics.has(name)) {
10197
+ this.context?.logger.warn(`Performance metric '${name}' already started. Overwriting.`);
10198
+ }
10199
+ this.activeMetrics.set(name, {
10200
+ name,
10201
+ startTime: this.getTime(),
10202
+ metadata
10203
+ });
8784
10204
  }
8785
- getTime() {
8786
- return Date.now() - this.start;
10205
+ /**
10206
+ * End measuring a performance metric
10207
+ *
10208
+ * @param name - Name of the metric to end
10209
+ * @returns The completed metric, or undefined if not found
10210
+ */
10211
+ end(name) {
10212
+ if (!this.enabled) {
10213
+ return undefined;
10214
+ }
10215
+ const metric = this.activeMetrics.get(name);
10216
+ if (!metric) {
10217
+ this.context?.logger.warn(`Performance metric '${name}' not found. Was start() called?`);
10218
+ return undefined;
10219
+ }
10220
+ metric.endTime = this.getTime();
10221
+ metric.duration = metric.endTime - metric.startTime;
10222
+ this.completedMetrics.push(metric);
10223
+ this.activeMetrics.delete(name);
10224
+ this.context?.logger.debug(`⏱️ ${name}: ${metric.duration.toFixed(2)}ms`, metric.metadata);
10225
+ return metric;
8787
10226
  }
8788
- info(...args) {
8789
- if (this.enabled) {
8790
- // eslint-disable-next-line no-console
8791
- if (typeof window !== 'undefined' && window.console && typeof console.info === 'function') {
8792
- // eslint-disable-next-line no-console
8793
- console.info(this.id, `${this.getTime()}ms`, ...args);
8794
- }
10227
+ /**
10228
+ * Measure a synchronous function
10229
+ *
10230
+ * @param name - Name for this measurement
10231
+ * @param fn - Function to measure
10232
+ * @param metadata - Optional metadata
10233
+ * @returns The function's return value
10234
+ */
10235
+ measure(name, fn, metadata) {
10236
+ this.start(name, metadata);
10237
+ try {
10238
+ const result = fn();
10239
+ this.end(name);
10240
+ return result;
10241
+ }
10242
+ catch (error) {
10243
+ this.end(name);
10244
+ throw error;
8795
10245
  }
8796
10246
  }
8797
- warn(...args) {
8798
- if (this.enabled) {
8799
- if (typeof window !== 'undefined' && window.console && typeof console.warn === 'function') {
8800
- console.warn(this.id, `${this.getTime()}ms`, ...args);
8801
- }
8802
- else {
8803
- this.info(...args);
8804
- }
10247
+ /**
10248
+ * Measure an asynchronous function
10249
+ *
10250
+ * @param name - Name for this measurement
10251
+ * @param fn - Async function to measure
10252
+ * @param metadata - Optional metadata
10253
+ * @returns Promise resolving to the function's return value
10254
+ */
10255
+ async measureAsync(name, fn, metadata) {
10256
+ this.start(name, metadata);
10257
+ try {
10258
+ const result = await fn();
10259
+ this.end(name);
10260
+ return result;
10261
+ }
10262
+ catch (error) {
10263
+ this.end(name);
10264
+ throw error;
8805
10265
  }
8806
10266
  }
8807
- error(...args) {
8808
- if (this.enabled) {
8809
- if (typeof window !== 'undefined' && window.console && typeof console.error === 'function') {
8810
- console.error(this.id, `${this.getTime()}ms`, ...args);
8811
- }
8812
- else {
8813
- this.info(...args);
8814
- }
10267
+ /**
10268
+ * Get all completed metrics
10269
+ *
10270
+ * @returns Array of completed performance metrics
10271
+ */
10272
+ getMetrics() {
10273
+ return [...this.completedMetrics];
10274
+ }
10275
+ /**
10276
+ * Get a specific metric by name
10277
+ *
10278
+ * @param name - Metric name
10279
+ * @returns The metric, or undefined if not found
10280
+ */
10281
+ getMetric(name) {
10282
+ return this.completedMetrics.find((m) => m.name === name);
10283
+ }
10284
+ /**
10285
+ * Get performance summary
10286
+ *
10287
+ * @returns Aggregated performance data
10288
+ */
10289
+ getSummary() {
10290
+ const totalDuration = this.completedMetrics.reduce((sum, metric) => sum + (metric.duration || 0), 0);
10291
+ const breakdown = this.completedMetrics.map((metric) => ({
10292
+ name: metric.name,
10293
+ duration: metric.duration || 0,
10294
+ percentage: totalDuration > 0 ? (((metric.duration || 0) / totalDuration) * 100).toFixed(1) + '%' : '0%'
10295
+ }));
10296
+ return {
10297
+ totalDuration,
10298
+ metrics: this.getMetrics(),
10299
+ breakdown
10300
+ };
10301
+ }
10302
+ /**
10303
+ * Log performance summary to console
10304
+ */
10305
+ logSummary() {
10306
+ if (!this.enabled || this.completedMetrics.length === 0 || !this.context) {
10307
+ return;
8815
10308
  }
10309
+ const summary = this.getSummary();
10310
+ this.context.logger.info(`\n📊 Performance Summary (Total: ${summary.totalDuration.toFixed(2)}ms):`);
10311
+ summary.breakdown
10312
+ .sort((a, b) => b.duration - a.duration)
10313
+ .forEach((item) => {
10314
+ this.context.logger.info(` ${item.name.padEnd(20)} ${item.duration.toFixed(2).padStart(8)}ms ${item.percentage.padStart(6)}`);
10315
+ });
8816
10316
  }
8817
- }
8818
- Logger.instances = {};
8819
-
8820
- class Context {
8821
- constructor(options, windowBounds) {
8822
- this.windowBounds = windowBounds;
8823
- this.instanceName = `#${Context.instanceCount++}`;
8824
- this.logger = new Logger({ id: this.instanceName, enabled: options.logging });
8825
- this.cache = options.cache ?? new Cache(this, options);
10317
+ /**
10318
+ * Clear all metrics
10319
+ */
10320
+ clear() {
10321
+ this.activeMetrics.clear();
10322
+ this.completedMetrics.splice(0);
10323
+ }
10324
+ /**
10325
+ * Check if monitoring is enabled
10326
+ */
10327
+ isEnabled() {
10328
+ return this.enabled;
10329
+ }
10330
+ /**
10331
+ * Get active (uncompleted) metrics
10332
+ * Useful for debugging leaked measurements
10333
+ */
10334
+ getActiveMetrics() {
10335
+ return Array.from(this.activeMetrics.keys());
8826
10336
  }
8827
10337
  }
8828
- Context.instanceCount = 1;
8829
10338
 
8830
- let cspNonce;
8831
- const setCspNonce = (nonce) => {
8832
- cspNonce = nonce;
10339
+ /**
10340
+ * Main html2canvas function with improved configuration management
10341
+ *
10342
+ * @param element - Element to render
10343
+ * @param options - Rendering options
10344
+ * @param config - Optional configuration (for advanced use cases)
10345
+ * @returns Promise resolving to rendered canvas
10346
+ */
10347
+ const html2canvas = (element, options = {}, config) => {
10348
+ // Create configuration from element if not provided
10349
+ const finalConfig = config ||
10350
+ Html2CanvasConfig.fromElement(element, {
10351
+ cspNonce: options.cspNonce,
10352
+ cache: options.cache
10353
+ });
10354
+ return renderElement(element, options, finalConfig);
8833
10355
  };
8834
- const html2canvas = (element, options = {}) => {
8835
- return renderElement(element, options);
10356
+ /**
10357
+ * Set CSP nonce for inline styles
10358
+ * @deprecated Use options.cspNonce instead
10359
+ */
10360
+ const setCspNonce = (nonce) => {
10361
+ console.warn('[html2canvas-pro] setCspNonce is deprecated. ' +
10362
+ 'Pass cspNonce in options instead: html2canvas(element, { cspNonce: "..." })');
10363
+ // For backward compatibility, set default config
10364
+ if (typeof window !== 'undefined') {
10365
+ setDefaultConfig(new Html2CanvasConfig({ window, cspNonce: nonce }));
10366
+ }
8836
10367
  };
8837
10368
  html2canvas.setCspNonce = setCspNonce;
8838
- if (typeof window !== 'undefined') {
8839
- CacheStorage.setContext(window);
8840
- }
8841
- const renderElement = async (element, opts) => {
10369
+ /**
10370
+ * Coerce number-like option values for backward compatibility (e.g. string "2" from form/query).
10371
+ * Mutates opts in place; callers should avoid reusing the same options object if they rely on original types.
10372
+ */
10373
+ const coerceNumberOptions = (opts) => {
10374
+ const numKeys = [
10375
+ 'scale',
10376
+ 'width',
10377
+ 'height',
10378
+ 'imageTimeout',
10379
+ 'x',
10380
+ 'y',
10381
+ 'windowWidth',
10382
+ 'windowHeight',
10383
+ 'scrollX',
10384
+ 'scrollY'
10385
+ ];
10386
+ numKeys.forEach((key) => {
10387
+ const v = opts[key];
10388
+ if (v !== undefined && v !== null && typeof v !== 'number') {
10389
+ const n = Number(v);
10390
+ if (!Number.isNaN(n)) {
10391
+ opts[key] = n;
10392
+ }
10393
+ }
10394
+ });
10395
+ };
10396
+ const renderElement = async (element, opts, config) => {
10397
+ coerceNumberOptions(opts);
10398
+ // Input validation (unless explicitly skipped)
10399
+ if (!opts.skipValidation) {
10400
+ const validator = opts.validator || createDefaultValidator();
10401
+ // Validate element
10402
+ const elementValidation = validator.validateElement(element);
10403
+ if (!elementValidation.valid) {
10404
+ throw new Error(elementValidation.error);
10405
+ }
10406
+ // Validate options
10407
+ const optionsValidation = validator.validateOptions(opts);
10408
+ if (!optionsValidation.valid) {
10409
+ throw new Error(`Invalid options: ${optionsValidation.error}`);
10410
+ }
10411
+ }
8842
10412
  if (!element || typeof element !== 'object') {
8843
- return Promise.reject('Invalid element provided as first argument');
10413
+ throw new Error('Invalid element provided as first argument');
8844
10414
  }
8845
10415
  const ownerDocument = element.ownerDocument;
8846
10416
  if (!ownerDocument) {
@@ -8859,17 +10429,29 @@
8859
10429
  };
8860
10430
  const contextOptions = {
8861
10431
  logging: opts.logging ?? true,
8862
- cache: opts.cache,
10432
+ cache: opts.cache ?? config.cache,
8863
10433
  ...resourceOptions
8864
10434
  };
10435
+ // Fallbacks for minimal window (e.g. element-like mocks) so we don't get NaN
10436
+ const DEFAULT_WINDOW_WIDTH = 800;
10437
+ const DEFAULT_WINDOW_HEIGHT = 600;
10438
+ const DEFAULT_SCROLL = 0;
10439
+ const win = defaultView;
8865
10440
  const windowOptions = {
8866
- windowWidth: opts.windowWidth ?? defaultView.innerWidth,
8867
- windowHeight: opts.windowHeight ?? defaultView.innerHeight,
8868
- scrollX: opts.scrollX ?? defaultView.pageXOffset,
8869
- scrollY: opts.scrollY ?? defaultView.pageYOffset
10441
+ windowWidth: opts.windowWidth ?? win.innerWidth ?? DEFAULT_WINDOW_WIDTH,
10442
+ windowHeight: opts.windowHeight ?? win.innerHeight ?? DEFAULT_WINDOW_HEIGHT,
10443
+ scrollX: opts.scrollX ?? win.pageXOffset ?? DEFAULT_SCROLL,
10444
+ scrollY: opts.scrollY ?? win.pageYOffset ?? DEFAULT_SCROLL
8870
10445
  };
8871
10446
  const windowBounds = new Bounds(windowOptions.scrollX, windowOptions.scrollY, windowOptions.windowWidth, windowOptions.windowHeight);
8872
- const context = new Context(contextOptions, windowBounds);
10447
+ const context = new Context(contextOptions, windowBounds, config);
10448
+ // Initialize performance monitoring if enabled
10449
+ const performanceMonitoring = opts.enablePerformanceMonitoring ?? opts.logging ?? false;
10450
+ const perfMonitor = new PerformanceMonitor(context, performanceMonitoring);
10451
+ perfMonitor.start('total', {
10452
+ width: windowOptions.windowWidth,
10453
+ height: windowOptions.windowHeight
10454
+ });
8873
10455
  const foreignObjectRendering = opts.foreignObjectRendering ?? false;
8874
10456
  const cloneOptions = {
8875
10457
  allowTaint: opts.allowTaint ?? false,
@@ -8878,15 +10460,17 @@
8878
10460
  iframeContainer: opts.iframeContainer,
8879
10461
  inlineImages: foreignObjectRendering,
8880
10462
  copyStyles: foreignObjectRendering,
8881
- cspNonce
10463
+ cspNonce: opts.cspNonce ?? config.cspNonce
8882
10464
  };
8883
10465
  context.logger.debug(`Starting document clone with size ${windowBounds.width}x${windowBounds.height} scrolled to ${-windowBounds.left},${-windowBounds.top}`);
10466
+ perfMonitor.start('clone');
8884
10467
  const documentCloner = new DocumentCloner(context, element, cloneOptions);
8885
10468
  const clonedElement = documentCloner.clonedReferenceElement;
8886
10469
  if (!clonedElement) {
8887
- return Promise.reject(`Unable to find element in cloned iframe`);
10470
+ throw new Error('Unable to find element in cloned iframe');
8888
10471
  }
8889
10472
  const container = await documentCloner.toIFrame(ownerDocument, windowBounds);
10473
+ perfMonitor.end('clone');
8890
10474
  const { width, height, left, top } = isBodyElement(clonedElement) || isHTMLElement(clonedElement)
8891
10475
  ? parseDocumentSize(clonedElement.ownerDocument)
8892
10476
  : parseBounds(context, clonedElement);
@@ -8898,32 +10482,56 @@
8898
10482
  x: (opts.x ?? 0) + left,
8899
10483
  y: (opts.y ?? 0) + top,
8900
10484
  width: opts.width ?? Math.ceil(width),
8901
- height: opts.height ?? Math.ceil(height)
10485
+ height: opts.height ?? Math.ceil(height),
10486
+ imageSmoothing: opts.imageSmoothing,
10487
+ imageSmoothingQuality: opts.imageSmoothingQuality
8902
10488
  };
8903
10489
  let canvas;
8904
- if (foreignObjectRendering) {
8905
- context.logger.debug(`Document cloned, using foreign object rendering`);
8906
- const renderer = new ForeignObjectRenderer(context, renderOptions);
8907
- canvas = await renderer.render(clonedElement);
8908
- }
8909
- else {
8910
- context.logger.debug(`Document cloned, element located at ${left},${top} with size ${width}x${height} using computed rendering`);
8911
- context.logger.debug(`Starting DOM parsing`);
8912
- const root = parseTree(context, clonedElement);
8913
- if (backgroundColor === root.styles.backgroundColor) {
8914
- root.styles.backgroundColor = COLORS.TRANSPARENT;
10490
+ let root;
10491
+ try {
10492
+ if (foreignObjectRendering) {
10493
+ context.logger.debug(`Document cloned, using foreign object rendering`);
10494
+ perfMonitor.start('render-foreignobject');
10495
+ const renderer = new ForeignObjectRenderer(context, renderOptions);
10496
+ canvas = await renderer.render(clonedElement);
10497
+ perfMonitor.end('render-foreignobject');
10498
+ }
10499
+ else {
10500
+ context.logger.debug(`Document cloned, element located at ${left},${top} with size ${width}x${height} using computed rendering`);
10501
+ context.logger.debug(`Starting DOM parsing`);
10502
+ perfMonitor.start('parse');
10503
+ root = parseTree(context, clonedElement);
10504
+ perfMonitor.end('parse');
10505
+ if (backgroundColor === root.styles.backgroundColor) {
10506
+ root.styles.backgroundColor = COLORS.TRANSPARENT;
10507
+ }
10508
+ context.logger.debug(`Starting renderer for element at ${renderOptions.x},${renderOptions.y} with size ${renderOptions.width}x${renderOptions.height}`);
10509
+ perfMonitor.start('render');
10510
+ const renderer = new CanvasRenderer(context, renderOptions);
10511
+ canvas = await renderer.render(root);
10512
+ perfMonitor.end('render');
10513
+ }
10514
+ perfMonitor.start('cleanup');
10515
+ if (opts.removeContainer ?? true) {
10516
+ if (!DocumentCloner.destroy(container)) {
10517
+ context.logger.error(`Cannot detach cloned iframe as it is not in the DOM anymore`);
10518
+ }
10519
+ }
10520
+ perfMonitor.end('cleanup');
10521
+ perfMonitor.end('total');
10522
+ context.logger.debug(`Finished rendering`);
10523
+ // Log performance summary if monitoring is enabled
10524
+ if (performanceMonitoring) {
10525
+ perfMonitor.logSummary();
8915
10526
  }
8916
- context.logger.debug(`Starting renderer for element at ${renderOptions.x},${renderOptions.y} with size ${renderOptions.width}x${renderOptions.height}`);
8917
- const renderer = new CanvasRenderer(context, renderOptions);
8918
- canvas = await renderer.render(root);
10527
+ return canvas;
8919
10528
  }
8920
- if (opts.removeContainer ?? true) {
8921
- if (!DocumentCloner.destroy(container)) {
8922
- context.logger.error(`Cannot detach cloned iframe as it is not in the DOM anymore`);
10529
+ finally {
10530
+ // Restore DOM modifications (animations, transforms) in cloned document
10531
+ if (root) {
10532
+ root.restoreTree();
8923
10533
  }
8924
10534
  }
8925
- context.logger.debug(`Finished rendering`);
8926
- return canvas;
8927
10535
  };
8928
10536
  const parseBackgroundColor = (context, element, backgroundColorOverride) => {
8929
10537
  const ownerDocument = element.ownerDocument;
@@ -8948,6 +10556,10 @@
8948
10556
  : defaultBackgroundColor;
8949
10557
  };
8950
10558
 
10559
+ exports.Html2CanvasConfig = Html2CanvasConfig;
10560
+ exports.PerformanceMonitor = PerformanceMonitor;
10561
+ exports.Validator = Validator;
10562
+ exports.createDefaultValidator = createDefaultValidator;
8951
10563
  exports.default = html2canvas;
8952
10564
  exports.html2canvas = html2canvas;
8953
10565
  exports.setCspNonce = setCspNonce;