html2canvas-pro 2.0.0 → 2.0.1

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 (31) hide show
  1. package/dist/html2canvas-pro.esm.js +524 -91
  2. package/dist/html2canvas-pro.esm.js.map +1 -1
  3. package/dist/html2canvas-pro.js +524 -91
  4. package/dist/html2canvas-pro.js.map +1 -1
  5. package/dist/html2canvas-pro.min.js +5 -5
  6. package/dist/lib/css/index.js +2 -0
  7. package/dist/lib/css/index.js.map +1 -1
  8. package/dist/lib/css/property-descriptors/__tests__/clip-path.test.js +273 -0
  9. package/dist/lib/css/property-descriptors/__tests__/clip-path.test.js.map +1 -0
  10. package/dist/lib/css/property-descriptors/clip-path.js +190 -0
  11. package/dist/lib/css/property-descriptors/clip-path.js.map +1 -0
  12. package/dist/lib/dom/dom-normalizer.js +57 -21
  13. package/dist/lib/dom/dom-normalizer.js.map +1 -1
  14. package/dist/lib/render/canvas/__tests__/text-renderer.test.js +283 -36
  15. package/dist/lib/render/canvas/__tests__/text-renderer.test.js.map +1 -1
  16. package/dist/lib/render/canvas/effects-renderer.js +11 -6
  17. package/dist/lib/render/canvas/effects-renderer.js.map +1 -1
  18. package/dist/lib/render/canvas/text-renderer.js +131 -64
  19. package/dist/lib/render/canvas/text-renderer.js.map +1 -1
  20. package/dist/lib/render/effects.js +17 -1
  21. package/dist/lib/render/effects.js.map +1 -1
  22. package/dist/lib/render/stacking-context.js +131 -0
  23. package/dist/lib/render/stacking-context.js.map +1 -1
  24. package/dist/types/css/index.d.ts +2 -0
  25. package/dist/types/css/property-descriptors/__tests__/clip-path.test.d.ts +1 -0
  26. package/dist/types/css/property-descriptors/clip-path.d.ts +62 -0
  27. package/dist/types/dom/dom-normalizer.d.ts +30 -11
  28. package/dist/types/render/canvas/effects-renderer.d.ts +2 -1
  29. package/dist/types/render/canvas/text-renderer.d.ts +20 -2
  30. package/dist/types/render/effects.d.ts +15 -1
  31. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * html2canvas-pro 2.0.0 <https://yorickshan.github.io/html2canvas-pro/>
2
+ * html2canvas-pro 2.0.1 <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
  */
@@ -3430,6 +3430,191 @@
3430
3430
  const borderBottomWidth = borderWidthForSide('bottom');
3431
3431
  const borderLeftWidth = borderWidthForSide('left');
3432
3432
 
3433
+ const NONE = { type: 0 /* CLIP_PATH_TYPE.NONE */ };
3434
+ /**
3435
+ * Parse a shape-radius token: <length-percentage> | closest-side | farthest-side.
3436
+ * Defaults to 'closest-side' when no tokens are provided.
3437
+ */
3438
+ const parseShapeRadius = (tokens) => {
3439
+ const [first] = tokens;
3440
+ if (!first)
3441
+ return 'closest-side';
3442
+ if (isIdentToken(first)) {
3443
+ // Any unrecognised keyword (e.g. 'closest-corner') intentionally falls back to
3444
+ // 'closest-side' as the CSS spec requires unknown values to be treated as invalid
3445
+ // and the initial value of <shape-radius> is 'closest-side'.
3446
+ return first.value === 'farthest-side' ? 'farthest-side' : 'closest-side';
3447
+ }
3448
+ return isLengthPercentage(first) ? first : 'closest-side';
3449
+ };
3450
+ /**
3451
+ * Parse a CSS <position> as (cx, cy), each as a LengthPercentage.
3452
+ *
3453
+ * Supports the **1–2 value** subset of the CSS `<position>` syntax.
3454
+ * The 4-value form (`at left 10px top 20px`) is not supported and will be
3455
+ * parsed on a best-effort basis.
3456
+ *
3457
+ * Axis assignment rules:
3458
+ * - `left` / `right` → x-axis (cx)
3459
+ * - `top` / `bottom` → y-axis (cy)
3460
+ * - `center` or a <length-percentage> → fills the first unset axis, in order
3461
+ *
3462
+ * Examples:
3463
+ * "at left" → cx=0, cy=50% (left is x; y defaults to center)
3464
+ * "at top" → cx=50%, cy=0 (top is y; x defaults to center)
3465
+ * "at center 30%" → cx=50%, cy=30%
3466
+ * "at 30% center" → cx=30%, cy=50%
3467
+ * "at left top" → cx=0, cy=0
3468
+ * "at top left" → cx=0, cy=0 (keyword order is irrelevant)
3469
+ *
3470
+ * Unset axes fall back to 50%.
3471
+ */
3472
+ const parsePosition = (tokens) => {
3473
+ let cx = null;
3474
+ let cy = null;
3475
+ for (const token of tokens) {
3476
+ if (isIdentToken(token)) {
3477
+ switch (token.value) {
3478
+ case 'left':
3479
+ cx = ZERO_LENGTH;
3480
+ break;
3481
+ case 'right':
3482
+ cx = HUNDRED_PERCENT;
3483
+ break;
3484
+ case 'top':
3485
+ cy = ZERO_LENGTH;
3486
+ break;
3487
+ case 'bottom':
3488
+ cy = HUNDRED_PERCENT;
3489
+ break;
3490
+ case 'center':
3491
+ // `center` fills whichever axis has not yet been claimed.
3492
+ if (cx === null)
3493
+ cx = FIFTY_PERCENT;
3494
+ else if (cy === null)
3495
+ cy = FIFTY_PERCENT;
3496
+ break;
3497
+ }
3498
+ }
3499
+ else if (isLengthPercentage(token)) {
3500
+ // Length-percentages are assigned in source order.
3501
+ if (cx === null)
3502
+ cx = token;
3503
+ else if (cy === null)
3504
+ cy = token;
3505
+ }
3506
+ }
3507
+ return { cx: cx ?? FIFTY_PERCENT, cy: cy ?? FIFTY_PERCENT };
3508
+ };
3509
+ /**
3510
+ * inset( <length-percentage>{1,4} [ round <'border-radius'> ]? )
3511
+ * The 1-4 shorthand follows the same expansion as margin/padding:
3512
+ * 1 value → all four sides
3513
+ * 2 values → top/bottom | left/right
3514
+ * 3 values → top | left/right | bottom
3515
+ * 4 values → top | right | bottom | left
3516
+ * The optional `round` clause (border-radius) is parsed but ignored.
3517
+ */
3518
+ const parseInset = (values) => {
3519
+ const lengths = [];
3520
+ for (const token of values) {
3521
+ if (token.type === 31 /* TokenType.WHITESPACE_TOKEN */)
3522
+ continue;
3523
+ if (isIdentToken(token) && token.value === 'round')
3524
+ break;
3525
+ if (isLengthPercentage(token))
3526
+ lengths.push(token);
3527
+ }
3528
+ const v0 = lengths[0] ?? ZERO_LENGTH;
3529
+ const v1 = lengths[1] ?? v0;
3530
+ const v2 = lengths[2] ?? v0;
3531
+ const v3 = lengths[3] ?? v1;
3532
+ return { type: 1 /* CLIP_PATH_TYPE.INSET */, top: v0, right: v1, bottom: v2, left: v3 };
3533
+ };
3534
+ /**
3535
+ * circle( [ <shape-radius> ]? [ at <position> ]? )
3536
+ */
3537
+ const parseCircle = (values) => {
3538
+ const nonWs = values.filter(nonWhiteSpace);
3539
+ const atIdx = nonWs.findIndex((t) => isIdentWithValue(t, 'at'));
3540
+ const radiusTokens = atIdx === -1 ? nonWs : nonWs.slice(0, atIdx);
3541
+ const posTokens = atIdx === -1 ? [] : nonWs.slice(atIdx + 1);
3542
+ return {
3543
+ type: 2 /* CLIP_PATH_TYPE.CIRCLE */,
3544
+ radius: parseShapeRadius(radiusTokens),
3545
+ ...parsePosition(posTokens)
3546
+ };
3547
+ };
3548
+ /**
3549
+ * ellipse( [ <shape-radius>{2} ]? [ at <position> ]? )
3550
+ */
3551
+ const parseEllipse = (values) => {
3552
+ const nonWs = values.filter(nonWhiteSpace);
3553
+ const atIdx = nonWs.findIndex((t) => isIdentWithValue(t, 'at'));
3554
+ const radiusTokens = atIdx === -1 ? nonWs : nonWs.slice(0, atIdx);
3555
+ const posTokens = atIdx === -1 ? [] : nonWs.slice(atIdx + 1);
3556
+ return {
3557
+ type: 3 /* CLIP_PATH_TYPE.ELLIPSE */,
3558
+ rx: parseShapeRadius(radiusTokens.slice(0, 1)),
3559
+ ry: parseShapeRadius(radiusTokens.slice(1, 2)),
3560
+ ...parsePosition(posTokens)
3561
+ };
3562
+ };
3563
+ /**
3564
+ * polygon( [ <fill-rule>, ]? [ <length-percentage> <length-percentage> ]# )
3565
+ * Each comma-separated group defines one vertex (x y).
3566
+ * A leading fill-rule keyword (nonzero/evenodd) is skipped.
3567
+ */
3568
+ const parsePolygon = (values) => {
3569
+ const args = parseFunctionArgs(values);
3570
+ const points = [];
3571
+ for (const arg of args) {
3572
+ if (arg.length === 1 && isIdentToken(arg[0]))
3573
+ continue; // skip fill-rule
3574
+ const lengths = arg.filter(isLengthPercentage);
3575
+ if (lengths.length >= 2) {
3576
+ points.push([lengths[0], lengths[1]]);
3577
+ }
3578
+ }
3579
+ return { type: 4 /* CLIP_PATH_TYPE.POLYGON */, points };
3580
+ };
3581
+ /**
3582
+ * path( [ <fill-rule>, ]? <string> )
3583
+ * The string value is the SVG path data (coordinates in the element's local space).
3584
+ */
3585
+ const parsePath = (values) => {
3586
+ const stringToken = values.find((t) => t.type === 0 /* TokenType.STRING_TOKEN */);
3587
+ if (!stringToken)
3588
+ return NONE;
3589
+ return { type: 5 /* CLIP_PATH_TYPE.PATH */, d: stringToken.value };
3590
+ };
3591
+ const clipPath = {
3592
+ name: 'clip-path',
3593
+ initialValue: 'none',
3594
+ prefix: false,
3595
+ type: 0 /* PropertyDescriptorParsingType.VALUE */,
3596
+ parse: (_context, token) => {
3597
+ if (isIdentToken(token) && token.value === 'none') {
3598
+ return NONE;
3599
+ }
3600
+ if (token.type === 18 /* TokenType.FUNCTION */) {
3601
+ switch (token.name) {
3602
+ case 'inset':
3603
+ return parseInset(token.values);
3604
+ case 'circle':
3605
+ return parseCircle(token.values);
3606
+ case 'ellipse':
3607
+ return parseEllipse(token.values);
3608
+ case 'polygon':
3609
+ return parsePolygon(token.values);
3610
+ case 'path':
3611
+ return parsePath(token.values);
3612
+ }
3613
+ }
3614
+ return NONE;
3615
+ }
3616
+ };
3617
+
3433
3618
  const color = {
3434
3619
  name: `color`,
3435
3620
  initialValue: 'transparent',
@@ -4628,6 +4813,7 @@
4628
4813
  this.borderBottomWidth = parse(context, borderBottomWidth, declaration.borderBottomWidth);
4629
4814
  this.borderLeftWidth = parse(context, borderLeftWidth, declaration.borderLeftWidth);
4630
4815
  this.boxShadow = parse(context, boxShadow, declaration.boxShadow);
4816
+ this.clipPath = parse(context, clipPath, declaration.clipPath);
4631
4817
  this.color = parse(context, color, declaration.color);
4632
4818
  this.direction = parse(context, direction, declaration.direction);
4633
4819
  this.display = parse(context, display, declaration.display);
@@ -4819,11 +5005,38 @@
4819
5005
  */
4820
5006
  /**
4821
5007
  * Normalize element styles for accurate rendering
4822
- * This includes disabling animations and resetting transforms
5008
+ * This includes disabling animations and neutralizing transforms.
4823
5009
  */
4824
5010
  class DOMNormalizer {
4825
5011
  /**
4826
- * Normalize a single element and return original styles
5012
+ * Normalize a single element and return original styles.
5013
+ *
5014
+ * ## Why we replace transforms with an identity value instead of "none"
5015
+ *
5016
+ * `getBoundingClientRect()` returns visual (post-transform) coordinates, so we
5017
+ * must neutralize any active transform before measuring element bounds.
5018
+ *
5019
+ * The naive approach of setting `transform: none` (or `rotate: none`) has a
5020
+ * critical side-effect: per **CSS Transforms Level 2**, an element whose
5021
+ * `transform` is non-none automatically becomes the **containing block** for
5022
+ * all of its `position: absolute` *and* `position: fixed` descendants.
5023
+ * Setting it to `none` destroys that role, causing children to resolve their
5024
+ * percentage dimensions and offsets against an unintended ancestor — which
5025
+ * produces completely wrong bounds.
5026
+ *
5027
+ * Solution: instead of removing the transform, we replace it with a visually
5028
+ * inert identity value:
5029
+ *
5030
+ * - `transform: scale(0.5)` → `transform: translate(0, 0)`
5031
+ * - `translate(0, 0)` is an identity transform (no visual change, no layout shift).
5032
+ * - `getBoundingClientRect()` returns the same layout-space coordinates as
5033
+ * if there were no transform at all.
5034
+ * - Because the value is still non-none, the element **remains a containing
5035
+ * block** for both `position: absolute` and `position: fixed` descendants.
5036
+ *
5037
+ * - `rotate: 45deg` → `rotate: 0deg`
5038
+ * - `0deg` is the identity rotation; `0deg ≠ none`, so the same containing-
5039
+ * block guarantee holds.
4827
5040
  *
4828
5041
  * @param element - Element to normalize
4829
5042
  * @param styles - Parsed CSS styles
@@ -4839,33 +5052,43 @@
4839
5052
  originalStyles.animationDuration = element.style.animationDuration;
4840
5053
  element.style.animationDuration = '0s';
4841
5054
  }
4842
- // Reset transform for accurate bounds calculation
4843
- // getBoundingClientRect takes transforms into account
5055
+ // Replace the actual transform with an identity translate so that:
5056
+ // 1. getBoundingClientRect() returns layout-space (unscaled/unrotated) coords.
5057
+ // 2. The element still satisfies "transform != none" and therefore keeps
5058
+ // its role as a containing block for position:absolute / position:fixed
5059
+ // descendants (CSS Transforms Level 2 §2.3).
4844
5060
  if (styles.transform !== null) {
4845
5061
  originalStyles.transform = element.style.transform;
4846
- element.style.transform = 'none';
5062
+ element.style.transform = 'translate(0, 0)';
4847
5063
  }
4848
- // Reset rotate property similarly to transform
5064
+ // Same rationale for the standalone `rotate` property.
5065
+ // `rotate: 0deg` is an identity rotation with no visual effect.
5066
+ //
5067
+ // However, individual transform properties (`rotate`, `translate`, `scale`)
5068
+ // are part of CSS Transforms Level 2 and their containing-block guarantee
5069
+ // is not uniformly implemented across all browsers. To be safe, if `rotate`
5070
+ // is the only transform-like property active on this element, we also set
5071
+ // `transform: translate(0, 0)` so that the containing-block role is reliably
5072
+ // preserved via the well-supported `transform` property.
4849
5073
  if (styles.rotate !== null) {
4850
5074
  originalStyles.rotate = element.style.rotate;
4851
- element.style.rotate = 'none';
5075
+ element.style.rotate = '0deg';
5076
+ // Individual transform properties (`rotate`, `translate`, `scale`) are
5077
+ // CSS Transforms Level 2 and their containing-block guarantee is not
5078
+ // uniformly implemented in all browsers. If `transform` was not already
5079
+ // set to translate(0,0) in the block above (i.e. this element has
5080
+ // `rotate` but no `transform`), we set it now so the containing-block
5081
+ // role is reliably established via the widely-supported `transform`
5082
+ // property – independently of browser support for individual props.
5083
+ if (originalStyles.transform === undefined) {
5084
+ originalStyles.transform = element.style.transform;
5085
+ element.style.transform = 'translate(0, 0)';
5086
+ }
4852
5087
  }
4853
5088
  return originalStyles;
4854
5089
  }
4855
5090
  /**
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
5091
+ * Restore element styles after rendering.
4869
5092
  *
4870
5093
  * @param element - Element to restore
4871
5094
  * @param originalStyles - Original styles to restore
@@ -4874,7 +5097,6 @@
4874
5097
  if (!isHTMLElementNode(element)) {
4875
5098
  return;
4876
5099
  }
4877
- // Restore each property that was saved
4878
5100
  if (originalStyles.animationDuration !== undefined) {
4879
5101
  element.style.animationDuration = originalStyles.animationDuration;
4880
5102
  }
@@ -7228,9 +7450,23 @@
7228
7450
  this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
7229
7451
  }
7230
7452
  }
7453
+ /**
7454
+ * Clips the element and all its descendants to an arbitrary canvas-drawn shape.
7455
+ * The `applyClip` callback is responsible for calling beginPath, the shape
7456
+ * operations, and ctx.clip() — giving each shape type full control over how
7457
+ * the path is constructed (arc, ellipse, lineTo, Path2D, etc.).
7458
+ */
7459
+ class ClipPathEffect {
7460
+ constructor(applyClip) {
7461
+ this.applyClip = applyClip;
7462
+ this.type = 3 /* EffectType.CLIP_PATH */;
7463
+ this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
7464
+ }
7465
+ }
7231
7466
  const isTransformEffect = (effect) => effect.type === 0 /* EffectType.TRANSFORM */;
7232
7467
  const isClipEffect = (effect) => effect.type === 1 /* EffectType.CLIP */;
7233
7468
  const isOpacityEffect = (effect) => effect.type === 2 /* EffectType.OPACITY */;
7469
+ const isClipPathEffect = (effect) => effect.type === 3 /* EffectType.CLIP_PATH */;
7234
7470
 
7235
7471
  const equalPath = (a, b) => {
7236
7472
  if (a.length === b.length) {
@@ -7305,6 +7541,12 @@
7305
7541
  this.effects.push(new ClipEffect(paddingBox, 4 /* EffectTarget.CONTENT */));
7306
7542
  }
7307
7543
  }
7544
+ if (this.container.styles.clipPath.type !== 0 /* CLIP_PATH_TYPE.NONE */) {
7545
+ const clipPathEffect = buildClipPathEffect(this.container.styles.clipPath, this.container.bounds);
7546
+ if (clipPathEffect) {
7547
+ this.effects.push(clipPathEffect);
7548
+ }
7549
+ }
7308
7550
  }
7309
7551
  getEffects(target) {
7310
7552
  let inFlow = [2 /* POSITION.ABSOLUTE */, 3 /* POSITION.FIXED */].indexOf(this.container.styles.position) === -1;
@@ -7331,6 +7573,126 @@
7331
7573
  return effects.filter((effect) => contains(effect.target, target));
7332
7574
  }
7333
7575
  }
7576
+ /**
7577
+ * Resolve a `closest-side` or `farthest-side` shape-radius keyword to pixels
7578
+ * for a single axis. Used by both `circle()` (per-axis) and `ellipse()`.
7579
+ *
7580
+ * @param r - The ShapeRadius (keyword or length-percentage).
7581
+ * @param center - Absolute center coordinate on this axis (cx or cy).
7582
+ * @param start - Absolute start of the reference box on this axis.
7583
+ * @param end - Absolute end of the reference box on this axis.
7584
+ * @param dimRef - Reference dimension for resolving a length-percentage value.
7585
+ */
7586
+ const resolveAxisRadius = (r, center, start, end, dimRef) => {
7587
+ if (r === 'closest-side')
7588
+ return Math.min(center - start, end - center);
7589
+ if (r === 'farthest-side')
7590
+ return Math.max(center - start, end - center);
7591
+ return getAbsoluteValue(r, dimRef);
7592
+ };
7593
+ /**
7594
+ * Convert a parsed ClipPathValue + element bounds into a ClipPathEffect whose
7595
+ * `applyClip` callback draws the clip shape directly onto the canvas context.
7596
+ *
7597
+ * All coordinates are computed in page-absolute space at construction time so
7598
+ * the callback itself is allocation-free and executes synchronously.
7599
+ */
7600
+ const buildClipPathEffect = (clipPath, bounds) => {
7601
+ const { left: bLeft, top: bTop, width: bWidth, height: bHeight } = bounds;
7602
+ switch (clipPath.type) {
7603
+ case 1 /* CLIP_PATH_TYPE.INSET */: {
7604
+ const iLeft = getAbsoluteValue(clipPath.left, bWidth);
7605
+ const iTop = getAbsoluteValue(clipPath.top, bHeight);
7606
+ const x = bLeft + iLeft;
7607
+ const y = bTop + iTop;
7608
+ // Clamp to zero: per CSS spec, overlapping insets produce an empty shape.
7609
+ const w = Math.max(0, bWidth - iLeft - getAbsoluteValue(clipPath.right, bWidth));
7610
+ const h = Math.max(0, bHeight - iTop - getAbsoluteValue(clipPath.bottom, bHeight));
7611
+ return new ClipPathEffect((ctx) => {
7612
+ ctx.beginPath();
7613
+ ctx.rect(x, y, w, h);
7614
+ ctx.clip();
7615
+ });
7616
+ }
7617
+ case 2 /* CLIP_PATH_TYPE.CIRCLE */: {
7618
+ const cx = bLeft + getAbsoluteValue(clipPath.cx, bWidth);
7619
+ const cy = bTop + getAbsoluteValue(clipPath.cy, bHeight);
7620
+ let r;
7621
+ if (clipPath.radius === 'closest-side') {
7622
+ r = Math.min(cx - bLeft, cy - bTop, bLeft + bWidth - cx, bTop + bHeight - cy);
7623
+ }
7624
+ else if (clipPath.radius === 'farthest-side') {
7625
+ r = Math.max(cx - bLeft, cy - bTop, bLeft + bWidth - cx, bTop + bHeight - cy);
7626
+ }
7627
+ else {
7628
+ // Per CSS spec, percentage is relative to sqrt(w² + h²) / sqrt(2).
7629
+ r = getAbsoluteValue(clipPath.radius, Math.sqrt(bWidth * bWidth + bHeight * bHeight) / Math.SQRT2);
7630
+ }
7631
+ return new ClipPathEffect((ctx) => {
7632
+ ctx.beginPath();
7633
+ ctx.arc(cx, cy, Math.max(0, r), 0, Math.PI * 2);
7634
+ ctx.clip();
7635
+ });
7636
+ }
7637
+ case 3 /* CLIP_PATH_TYPE.ELLIPSE */: {
7638
+ const cx = bLeft + getAbsoluteValue(clipPath.cx, bWidth);
7639
+ const cy = bTop + getAbsoluteValue(clipPath.cy, bHeight);
7640
+ const rx = resolveAxisRadius(clipPath.rx, cx, bLeft, bLeft + bWidth, bWidth);
7641
+ const ry = resolveAxisRadius(clipPath.ry, cy, bTop, bTop + bHeight, bHeight);
7642
+ return new ClipPathEffect((ctx) => {
7643
+ ctx.beginPath();
7644
+ ctx.ellipse(cx, cy, Math.max(0, rx), Math.max(0, ry), 0, 0, Math.PI * 2);
7645
+ ctx.clip();
7646
+ });
7647
+ }
7648
+ case 4 /* CLIP_PATH_TYPE.POLYGON */: {
7649
+ // Pre-compute all vertices in page-absolute coordinates.
7650
+ const absPoints = clipPath.points.map(([px, py]) => [bLeft + getAbsoluteValue(px, bWidth), bTop + getAbsoluteValue(py, bHeight)]);
7651
+ return new ClipPathEffect((ctx) => {
7652
+ ctx.beginPath();
7653
+ if (absPoints.length > 0) {
7654
+ ctx.moveTo(absPoints[0][0], absPoints[0][1]);
7655
+ for (let i = 1; i < absPoints.length; i++) {
7656
+ ctx.lineTo(absPoints[i][0], absPoints[i][1]);
7657
+ }
7658
+ ctx.closePath();
7659
+ }
7660
+ // Calling clip() with an empty path (zero points) is intentional:
7661
+ // it clips the entire region to nothing, which is the correct
7662
+ // behaviour for a degenerate polygon() per the CSS spec.
7663
+ ctx.clip();
7664
+ });
7665
+ }
7666
+ case 5 /* CLIP_PATH_TYPE.PATH */: {
7667
+ // path() coordinates are in the element's local space (0,0 = element top-left).
7668
+ // We temporarily translate the canvas origin to the element's position, clip
7669
+ // with the Path2D, then restore only the transform matrix (not the clipping
7670
+ // region) via setTransform so the clip persists for the enclosing
7671
+ // ctx.save() / ctx.restore() pair managed by EffectsRenderer.
7672
+ //
7673
+ // When the element also has a CSS transform, that transform was already applied
7674
+ // by a preceding TransformEffect, so the path coordinates end up correctly in
7675
+ // the element's transformed local space — matching browser behaviour.
7676
+ const { d } = clipPath;
7677
+ return new ClipPathEffect((ctx) => {
7678
+ try {
7679
+ const savedTransform = ctx.getTransform();
7680
+ ctx.translate(bLeft, bTop);
7681
+ ctx.clip(new Path2D(d));
7682
+ ctx.setTransform(savedTransform);
7683
+ }
7684
+ catch (_e) {
7685
+ // Path2D or getTransform/setTransform not supported in this environment.
7686
+ }
7687
+ });
7688
+ }
7689
+ case 0 /* CLIP_PATH_TYPE.NONE */:
7690
+ return null;
7691
+ default: {
7692
+ return null;
7693
+ }
7694
+ }
7695
+ };
7334
7696
  const parseStackTree = (parent, stackingContext, realStackingContext, listItems) => {
7335
7697
  parent.container.elements.forEach((child) => {
7336
7698
  const treatAsRealStackingContext = contains(child.flags, 4 /* FLAGS.CREATES_REAL_STACKING_CONTEXT */);
@@ -8159,7 +8521,8 @@
8159
8521
  * Handles rendering effects including:
8160
8522
  * - Opacity effects
8161
8523
  * - Transform effects (matrix transformations)
8162
- * - Clip effects (clipping paths)
8524
+ * - Clip effects (overflow / border-radius clipping via Path[])
8525
+ * - Clip-path effects (CSS clip-path shapes: inset, circle, ellipse, polygon, path)
8163
8526
  */
8164
8527
  /**
8165
8528
  * Effects Renderer
@@ -8194,21 +8557,25 @@
8194
8557
  */
8195
8558
  applyEffect(effect) {
8196
8559
  this.ctx.save();
8197
- // Apply opacity effect
8198
8560
  if (isOpacityEffect(effect)) {
8561
+ // Opacity: multiply into the current global alpha for nested transparency.
8199
8562
  this.ctx.globalAlpha = effect.opacity;
8200
8563
  }
8201
- // Apply transform effect
8202
- if (isTransformEffect(effect)) {
8564
+ else if (isTransformEffect(effect)) {
8565
+ // Transform: translate to origin, apply matrix, translate back.
8203
8566
  this.ctx.translate(effect.offsetX, effect.offsetY);
8204
8567
  this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
8205
8568
  this.ctx.translate(-effect.offsetX, -effect.offsetY);
8206
8569
  }
8207
- // Apply clip effect
8208
- if (isClipEffect(effect)) {
8570
+ else if (isClipEffect(effect)) {
8571
+ // Clip (overflow / border-radius): build path via callback then clip.
8209
8572
  this.pathCallback.path(effect.path);
8210
8573
  this.ctx.clip();
8211
8574
  }
8575
+ else if (isClipPathEffect(effect)) {
8576
+ // Clip-path: delegate shape drawing (beginPath … clip()) to the effect.
8577
+ effect.applyClip(this.ctx);
8578
+ }
8212
8579
  this.activeEffects.push(effect);
8213
8580
  }
8214
8581
  /**
@@ -8251,6 +8618,22 @@
8251
8618
  */
8252
8619
  // iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
8253
8620
  const iOSBrokenFonts = ['-apple-system', 'system-ui'];
8621
+ /**
8622
+ * Detect CJK (Chinese, Japanese, Korean) characters in a string.
8623
+ * CJK characters use the ideographic baseline in browsers, which differs
8624
+ * from the alphabetic baseline used for Latin script.
8625
+ *
8626
+ * Covers:
8627
+ * U+2E80–U+2FFF CJK Radicals Supplement, Kangxi Radicals
8628
+ * U+3000–U+30FF CJK Symbols & Punctuation (。、「」…), Hiragana, Katakana
8629
+ * U+3400–U+4DBF CJK Extension A
8630
+ * U+4E00–U+9FFF CJK Unified Ideographs (most common Chinese/Japanese/Korean)
8631
+ * U+AC00–U+D7AF Hangul Syllables
8632
+ * U+F900–U+FAFF CJK Compatibility Ideographs
8633
+ * U+FF01–U+FFEF Halfwidth and Fullwidth Forms (A B 1 2 ! ? etc.)
8634
+ */
8635
+ const CJK_CHAR_REGEX = /[\u2E80-\u2FFF\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFFEF]/;
8636
+ const hasCJKCharacters = (text) => CJK_CHAR_REGEX.test(text);
8254
8637
  /**
8255
8638
  * Detect iOS version from user agent
8256
8639
  * Returns null if not iOS or version cannot be determined
@@ -8308,21 +8691,50 @@
8308
8691
  this.options = deps.options;
8309
8692
  }
8310
8693
  /**
8311
- * Render text with letter spacing
8312
- * Public method used by list rendering
8694
+ * Iterate grapheme clusters one-by-one, applying correct letter-spacing and
8695
+ * per-script baseline for each character.
8696
+ *
8697
+ * Issue #73: When letter-spacing is non-zero, text must be rendered character by
8698
+ * character. This helper centralises two fixes applied during that iteration:
8699
+ * 1. Add `letterSpacing` to each character's advance width (was previously
8700
+ * omitted, causing characters to render without any spacing).
8701
+ * 2. Switch to the ideographic baseline for CJK glyphs so their vertical
8702
+ * position matches how browsers lay them out in the DOM.
8703
+ *
8704
+ * The `renderFn` callback receives (letter, x, y) and performs the actual draw
8705
+ * call (fillText or strokeText), allowing fill and stroke paths to share one
8706
+ * implementation.
8707
+ */
8708
+ iterateLettersWithLetterSpacing(text, letterSpacing, baseline, renderFn) {
8709
+ const letters = segmentGraphemes(text.text);
8710
+ const y = text.bounds.top + baseline;
8711
+ let left = text.bounds.left;
8712
+ for (const letter of letters) {
8713
+ if (hasCJKCharacters(letter)) {
8714
+ const savedBaseline = this.ctx.textBaseline;
8715
+ this.ctx.textBaseline = 'ideographic';
8716
+ renderFn(letter, left, y);
8717
+ this.ctx.textBaseline = savedBaseline;
8718
+ }
8719
+ else {
8720
+ renderFn(letter, left, y);
8721
+ }
8722
+ left += this.ctx.measureText(letter).width + letterSpacing;
8723
+ }
8724
+ }
8725
+ /**
8726
+ * Render text with letter-spacing applied (fill pass).
8727
+ * When letterSpacing is 0 the whole string is drawn in one call; otherwise each
8728
+ * grapheme is drawn individually so spacing and CJK baseline are applied correctly.
8313
8729
  */
8314
8730
  renderTextWithLetterSpacing(text, letterSpacing, baseline) {
8315
8731
  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
8732
  this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
8319
8733
  }
8320
8734
  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);
8735
+ this.iterateLettersWithLetterSpacing(text, letterSpacing, baseline, (letter, x, y) => {
8736
+ this.ctx.fillText(letter, x, y);
8737
+ });
8326
8738
  }
8327
8739
  }
8328
8740
  /**
@@ -8340,8 +8752,17 @@
8340
8752
  if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
8341
8753
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8342
8754
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8343
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8344
- this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
8755
+ this.ctx.lineJoin =
8756
+ typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
8757
+ if (styles.letterSpacing === 0) {
8758
+ this.ctx.strokeText(textBound.text, textBound.bounds.left, textBound.bounds.top + styles.fontSize.number);
8759
+ }
8760
+ else {
8761
+ this.iterateLettersWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
8762
+ }
8763
+ this.ctx.strokeStyle = '';
8764
+ this.ctx.lineWidth = 0;
8765
+ this.ctx.lineJoin = 'miter';
8345
8766
  }
8346
8767
  break;
8347
8768
  }
@@ -8451,26 +8872,47 @@
8451
8872
  }
8452
8873
  // Helper method to truncate text and add ellipsis if needed
8453
8874
  truncateTextWithEllipsis(text, maxWidth, letterSpacing) {
8454
- const ellipsis = '...';
8875
+ // Use the Unicode ellipsis character (U+2026) whose width the browser measures
8876
+ // as a single glyph, matching native text-overflow behaviour more closely.
8877
+ const ellipsis = '\u2026';
8455
8878
  const ellipsisWidth = this.ctx.measureText(ellipsis).width;
8879
+ // Segment into grapheme clusters so multi-byte characters (emoji, composed
8880
+ // sequences) are never split mid-character.
8881
+ const graphemes = segmentGraphemes(text);
8456
8882
  if (letterSpacing === 0) {
8457
- let truncated = text;
8458
- while (this.ctx.measureText(truncated).width + ellipsisWidth > maxWidth && truncated.length > 0) {
8459
- truncated = truncated.slice(0, -1);
8883
+ // Measure the whole candidate string for accuracy: the browser applies
8884
+ // kerning and ligatures when rendering multiple glyphs together, so
8885
+ // measuring them as one string is more precise than summing individual widths.
8886
+ // Binary search reduces measurements from O(n) to O(log n).
8887
+ const fits = (n) => this.ctx.measureText(graphemes.slice(0, n).join('')).width + ellipsisWidth <= maxWidth;
8888
+ let lo = 0;
8889
+ let hi = graphemes.length;
8890
+ while (lo < hi) {
8891
+ const mid = (lo + hi + 1) >> 1;
8892
+ if (fits(mid)) {
8893
+ lo = mid;
8894
+ }
8895
+ else {
8896
+ hi = mid - 1;
8897
+ }
8460
8898
  }
8461
- return truncated + ellipsis;
8899
+ return graphemes.slice(0, lo).join('') + ellipsis;
8462
8900
  }
8463
8901
  else {
8464
- const letters = segmentGraphemes(text);
8465
8902
  let width = ellipsisWidth;
8466
- let result = [];
8467
- for (const letter of letters) {
8468
- const letterWidth = this.ctx.measureText(letter).width + letterSpacing;
8469
- if (width + letterWidth > maxWidth) {
8903
+ const result = [];
8904
+ for (const letter of graphemes) {
8905
+ const glyphWidth = this.ctx.measureText(letter).width;
8906
+ // Check against glyph width only (no trailing spacing): letter-spacing
8907
+ // is applied *between* characters, not after the final glyph. Using
8908
+ // `glyphWidth + letterSpacing` would incorrectly discard letters that
8909
+ // fit as the last character before the ellipsis.
8910
+ if (width + glyphWidth > maxWidth) {
8470
8911
  break;
8471
8912
  }
8472
8913
  result.push(letter);
8473
- width += letterWidth;
8914
+ // Accumulate glyph + inter-character spacing for the *next* iteration.
8915
+ width += glyphWidth + letterSpacing;
8474
8916
  }
8475
8917
  return result.join('') + ellipsis;
8476
8918
  }
@@ -8546,6 +8988,8 @@
8546
8988
  const firstBound = lastLine[0];
8547
8989
  const availableWidth = containerBounds.width - (firstBound.bounds.left - containerBounds.left);
8548
8990
  const truncatedText = this.truncateTextWithEllipsis(lastLineText, availableWidth, styles.letterSpacing);
8991
+ // Build TextBounds once; reused for fill and stroke without re-allocating.
8992
+ const truncatedBounds = new TextBounds(truncatedText, firstBound.bounds);
8549
8993
  paintOrder.forEach((paintOrderLayer) => {
8550
8994
  switch (paintOrderLayer) {
8551
8995
  case 0 /* PAINT_ORDER_LAYER.FILL */:
@@ -8554,28 +8998,24 @@
8554
8998
  this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8555
8999
  }
8556
9000
  else {
8557
- const letters = segmentGraphemes(truncatedText);
8558
- letters.reduce((left, letter) => {
8559
- this.ctx.fillText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8560
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8561
- }, firstBound.bounds.left);
9001
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
8562
9002
  }
8563
9003
  break;
8564
9004
  case 1 /* PAINT_ORDER_LAYER.STROKE */:
8565
9005
  if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8566
9006
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8567
9007
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8568
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
9008
+ this.ctx.lineJoin =
9009
+ typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
8569
9010
  if (styles.letterSpacing === 0) {
8570
9011
  this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8571
9012
  }
8572
9013
  else {
8573
- const letters = segmentGraphemes(truncatedText);
8574
- letters.reduce((left, letter) => {
8575
- this.ctx.strokeText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8576
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8577
- }, firstBound.bounds.left);
9014
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
8578
9015
  }
9016
+ this.ctx.strokeStyle = '';
9017
+ this.ctx.lineWidth = 0;
9018
+ this.ctx.lineJoin = 'miter';
8579
9019
  }
8580
9020
  break;
8581
9021
  }
@@ -8619,19 +9059,18 @@
8619
9059
  // If ellipsis is needed, render the truncated text once
8620
9060
  if (needsEllipsis) {
8621
9061
  const firstBound = text.textBounds[0];
9062
+ // Build TextBounds once; reused across paint layers and every shadow pass
9063
+ // to avoid repeated allocation inside forEach callbacks.
9064
+ const truncatedBounds = new TextBounds(truncatedText, firstBound.bounds);
8622
9065
  paintOrder.forEach((paintOrderLayer) => {
8623
9066
  switch (paintOrderLayer) {
8624
- case 0 /* PAINT_ORDER_LAYER.FILL */:
9067
+ case 0 /* PAINT_ORDER_LAYER.FILL */: {
8625
9068
  this.ctx.fillStyle = asString(styles.color);
8626
9069
  if (styles.letterSpacing === 0) {
8627
9070
  this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8628
9071
  }
8629
9072
  else {
8630
- const letters = segmentGraphemes(truncatedText);
8631
- letters.reduce((left, letter) => {
8632
- this.ctx.fillText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8633
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8634
- }, firstBound.bounds.left);
9073
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
8635
9074
  }
8636
9075
  const textShadows = styles.textShadow;
8637
9076
  if (textShadows.length && truncatedText.trim().length) {
@@ -8647,11 +9086,7 @@
8647
9086
  this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8648
9087
  }
8649
9088
  else {
8650
- const letters = segmentGraphemes(truncatedText);
8651
- letters.reduce((left, letter) => {
8652
- this.ctx.fillText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8653
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8654
- }, firstBound.bounds.left);
9089
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
8655
9090
  }
8656
9091
  });
8657
9092
  this.ctx.shadowColor = '';
@@ -8660,21 +9095,22 @@
8660
9095
  this.ctx.shadowBlur = 0;
8661
9096
  }
8662
9097
  break;
9098
+ }
8663
9099
  case 1 /* PAINT_ORDER_LAYER.STROKE */:
8664
9100
  if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8665
9101
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8666
9102
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8667
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
9103
+ this.ctx.lineJoin =
9104
+ typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
8668
9105
  if (styles.letterSpacing === 0) {
8669
9106
  this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8670
9107
  }
8671
9108
  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);
9109
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
8677
9110
  }
9111
+ this.ctx.strokeStyle = '';
9112
+ this.ctx.lineWidth = 0;
9113
+ this.ctx.lineJoin = 'miter';
8678
9114
  }
8679
9115
  break;
8680
9116
  }
@@ -8685,7 +9121,7 @@
8685
9121
  text.textBounds.forEach((text) => {
8686
9122
  paintOrder.forEach((paintOrderLayer) => {
8687
9123
  switch (paintOrderLayer) {
8688
- case 0 /* PAINT_ORDER_LAYER.FILL */:
9124
+ case 0 /* PAINT_ORDER_LAYER.FILL */: {
8689
9125
  this.ctx.fillStyle = asString(styles.color);
8690
9126
  this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8691
9127
  const textShadows = styles.textShadow;
@@ -8709,29 +9145,26 @@
8709
9145
  this.renderTextDecoration(text.bounds, styles);
8710
9146
  }
8711
9147
  break;
8712
- case 1 /* PAINT_ORDER_LAYER.STROKE */:
9148
+ }
9149
+ case 1 /* PAINT_ORDER_LAYER.STROKE */: {
8713
9150
  if (styles.webkitTextStrokeWidth && text.text.trim().length) {
8714
9151
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8715
9152
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
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
9153
+ this.ctx.lineJoin =
9154
+ typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
8719
9155
  const baseline = styles.fontSize.number;
8720
9156
  if (styles.letterSpacing === 0) {
8721
9157
  this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
8722
9158
  }
8723
9159
  else {
8724
- const letters = segmentGraphemes(text.text);
8725
- letters.reduce((left, letter) => {
8726
- this.ctx.strokeText(letter, left, text.bounds.top + baseline);
8727
- return left + this.ctx.measureText(letter).width;
8728
- }, text.bounds.left);
9160
+ this.iterateLettersWithLetterSpacing(text, styles.letterSpacing, baseline, (letter, x, y) => this.ctx.strokeText(letter, x, y));
8729
9161
  }
9162
+ this.ctx.strokeStyle = '';
9163
+ this.ctx.lineWidth = 0;
9164
+ this.ctx.lineJoin = 'miter';
8730
9165
  }
8731
- this.ctx.strokeStyle = '';
8732
- this.ctx.lineWidth = 0;
8733
- this.ctx.lineJoin = 'miter';
8734
9166
  break;
9167
+ }
8735
9168
  }
8736
9169
  });
8737
9170
  });