html2canvas-pro 2.0.0 → 2.0.2

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 (37) hide show
  1. package/dist/html2canvas-pro.esm.js +564 -114
  2. package/dist/html2canvas-pro.esm.js.map +1 -1
  3. package/dist/html2canvas-pro.js +564 -114
  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/__tests__/image-rendering-integration.test.js +7 -6
  11. package/dist/lib/css/property-descriptors/__tests__/image-rendering-integration.test.js.map +1 -1
  12. package/dist/lib/css/property-descriptors/clip-path.js +190 -0
  13. package/dist/lib/css/property-descriptors/clip-path.js.map +1 -0
  14. package/dist/lib/dom/__tests__/dom-normalizer.test.js +6 -6
  15. package/dist/lib/dom/__tests__/dom-normalizer.test.js.map +1 -1
  16. package/dist/lib/dom/document-cloner.js +40 -23
  17. package/dist/lib/dom/document-cloner.js.map +1 -1
  18. package/dist/lib/dom/dom-normalizer.js +57 -21
  19. package/dist/lib/dom/dom-normalizer.js.map +1 -1
  20. package/dist/lib/render/canvas/__tests__/text-renderer.test.js +283 -36
  21. package/dist/lib/render/canvas/__tests__/text-renderer.test.js.map +1 -1
  22. package/dist/lib/render/canvas/effects-renderer.js +11 -6
  23. package/dist/lib/render/canvas/effects-renderer.js.map +1 -1
  24. package/dist/lib/render/canvas/text-renderer.js +131 -64
  25. package/dist/lib/render/canvas/text-renderer.js.map +1 -1
  26. package/dist/lib/render/effects.js +17 -1
  27. package/dist/lib/render/effects.js.map +1 -1
  28. package/dist/lib/render/stacking-context.js +131 -0
  29. package/dist/lib/render/stacking-context.js.map +1 -1
  30. package/dist/types/css/index.d.ts +2 -0
  31. package/dist/types/css/property-descriptors/__tests__/clip-path.test.d.ts +1 -0
  32. package/dist/types/css/property-descriptors/clip-path.d.ts +62 -0
  33. package/dist/types/dom/dom-normalizer.d.ts +30 -11
  34. package/dist/types/render/canvas/effects-renderer.d.ts +2 -1
  35. package/dist/types/render/canvas/text-renderer.d.ts +20 -2
  36. package/dist/types/render/effects.d.ts +15 -1
  37. 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.2 <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
  */
@@ -3424,6 +3424,191 @@ const borderRightWidth = borderWidthForSide('right');
3424
3424
  const borderBottomWidth = borderWidthForSide('bottom');
3425
3425
  const borderLeftWidth = borderWidthForSide('left');
3426
3426
 
3427
+ const NONE = { type: 0 /* CLIP_PATH_TYPE.NONE */ };
3428
+ /**
3429
+ * Parse a shape-radius token: <length-percentage> | closest-side | farthest-side.
3430
+ * Defaults to 'closest-side' when no tokens are provided.
3431
+ */
3432
+ const parseShapeRadius = (tokens) => {
3433
+ const [first] = tokens;
3434
+ if (!first)
3435
+ return 'closest-side';
3436
+ if (isIdentToken(first)) {
3437
+ // Any unrecognised keyword (e.g. 'closest-corner') intentionally falls back to
3438
+ // 'closest-side' as the CSS spec requires unknown values to be treated as invalid
3439
+ // and the initial value of <shape-radius> is 'closest-side'.
3440
+ return first.value === 'farthest-side' ? 'farthest-side' : 'closest-side';
3441
+ }
3442
+ return isLengthPercentage(first) ? first : 'closest-side';
3443
+ };
3444
+ /**
3445
+ * Parse a CSS <position> as (cx, cy), each as a LengthPercentage.
3446
+ *
3447
+ * Supports the **1–2 value** subset of the CSS `<position>` syntax.
3448
+ * The 4-value form (`at left 10px top 20px`) is not supported and will be
3449
+ * parsed on a best-effort basis.
3450
+ *
3451
+ * Axis assignment rules:
3452
+ * - `left` / `right` → x-axis (cx)
3453
+ * - `top` / `bottom` → y-axis (cy)
3454
+ * - `center` or a <length-percentage> → fills the first unset axis, in order
3455
+ *
3456
+ * Examples:
3457
+ * "at left" → cx=0, cy=50% (left is x; y defaults to center)
3458
+ * "at top" → cx=50%, cy=0 (top is y; x defaults to center)
3459
+ * "at center 30%" → cx=50%, cy=30%
3460
+ * "at 30% center" → cx=30%, cy=50%
3461
+ * "at left top" → cx=0, cy=0
3462
+ * "at top left" → cx=0, cy=0 (keyword order is irrelevant)
3463
+ *
3464
+ * Unset axes fall back to 50%.
3465
+ */
3466
+ const parsePosition = (tokens) => {
3467
+ let cx = null;
3468
+ let cy = null;
3469
+ for (const token of tokens) {
3470
+ if (isIdentToken(token)) {
3471
+ switch (token.value) {
3472
+ case 'left':
3473
+ cx = ZERO_LENGTH;
3474
+ break;
3475
+ case 'right':
3476
+ cx = HUNDRED_PERCENT;
3477
+ break;
3478
+ case 'top':
3479
+ cy = ZERO_LENGTH;
3480
+ break;
3481
+ case 'bottom':
3482
+ cy = HUNDRED_PERCENT;
3483
+ break;
3484
+ case 'center':
3485
+ // `center` fills whichever axis has not yet been claimed.
3486
+ if (cx === null)
3487
+ cx = FIFTY_PERCENT;
3488
+ else if (cy === null)
3489
+ cy = FIFTY_PERCENT;
3490
+ break;
3491
+ }
3492
+ }
3493
+ else if (isLengthPercentage(token)) {
3494
+ // Length-percentages are assigned in source order.
3495
+ if (cx === null)
3496
+ cx = token;
3497
+ else if (cy === null)
3498
+ cy = token;
3499
+ }
3500
+ }
3501
+ return { cx: cx ?? FIFTY_PERCENT, cy: cy ?? FIFTY_PERCENT };
3502
+ };
3503
+ /**
3504
+ * inset( <length-percentage>{1,4} [ round <'border-radius'> ]? )
3505
+ * The 1-4 shorthand follows the same expansion as margin/padding:
3506
+ * 1 value → all four sides
3507
+ * 2 values → top/bottom | left/right
3508
+ * 3 values → top | left/right | bottom
3509
+ * 4 values → top | right | bottom | left
3510
+ * The optional `round` clause (border-radius) is parsed but ignored.
3511
+ */
3512
+ const parseInset = (values) => {
3513
+ const lengths = [];
3514
+ for (const token of values) {
3515
+ if (token.type === 31 /* TokenType.WHITESPACE_TOKEN */)
3516
+ continue;
3517
+ if (isIdentToken(token) && token.value === 'round')
3518
+ break;
3519
+ if (isLengthPercentage(token))
3520
+ lengths.push(token);
3521
+ }
3522
+ const v0 = lengths[0] ?? ZERO_LENGTH;
3523
+ const v1 = lengths[1] ?? v0;
3524
+ const v2 = lengths[2] ?? v0;
3525
+ const v3 = lengths[3] ?? v1;
3526
+ return { type: 1 /* CLIP_PATH_TYPE.INSET */, top: v0, right: v1, bottom: v2, left: v3 };
3527
+ };
3528
+ /**
3529
+ * circle( [ <shape-radius> ]? [ at <position> ]? )
3530
+ */
3531
+ const parseCircle = (values) => {
3532
+ const nonWs = values.filter(nonWhiteSpace);
3533
+ const atIdx = nonWs.findIndex((t) => isIdentWithValue(t, 'at'));
3534
+ const radiusTokens = atIdx === -1 ? nonWs : nonWs.slice(0, atIdx);
3535
+ const posTokens = atIdx === -1 ? [] : nonWs.slice(atIdx + 1);
3536
+ return {
3537
+ type: 2 /* CLIP_PATH_TYPE.CIRCLE */,
3538
+ radius: parseShapeRadius(radiusTokens),
3539
+ ...parsePosition(posTokens)
3540
+ };
3541
+ };
3542
+ /**
3543
+ * ellipse( [ <shape-radius>{2} ]? [ at <position> ]? )
3544
+ */
3545
+ const parseEllipse = (values) => {
3546
+ const nonWs = values.filter(nonWhiteSpace);
3547
+ const atIdx = nonWs.findIndex((t) => isIdentWithValue(t, 'at'));
3548
+ const radiusTokens = atIdx === -1 ? nonWs : nonWs.slice(0, atIdx);
3549
+ const posTokens = atIdx === -1 ? [] : nonWs.slice(atIdx + 1);
3550
+ return {
3551
+ type: 3 /* CLIP_PATH_TYPE.ELLIPSE */,
3552
+ rx: parseShapeRadius(radiusTokens.slice(0, 1)),
3553
+ ry: parseShapeRadius(radiusTokens.slice(1, 2)),
3554
+ ...parsePosition(posTokens)
3555
+ };
3556
+ };
3557
+ /**
3558
+ * polygon( [ <fill-rule>, ]? [ <length-percentage> <length-percentage> ]# )
3559
+ * Each comma-separated group defines one vertex (x y).
3560
+ * A leading fill-rule keyword (nonzero/evenodd) is skipped.
3561
+ */
3562
+ const parsePolygon = (values) => {
3563
+ const args = parseFunctionArgs(values);
3564
+ const points = [];
3565
+ for (const arg of args) {
3566
+ if (arg.length === 1 && isIdentToken(arg[0]))
3567
+ continue; // skip fill-rule
3568
+ const lengths = arg.filter(isLengthPercentage);
3569
+ if (lengths.length >= 2) {
3570
+ points.push([lengths[0], lengths[1]]);
3571
+ }
3572
+ }
3573
+ return { type: 4 /* CLIP_PATH_TYPE.POLYGON */, points };
3574
+ };
3575
+ /**
3576
+ * path( [ <fill-rule>, ]? <string> )
3577
+ * The string value is the SVG path data (coordinates in the element's local space).
3578
+ */
3579
+ const parsePath = (values) => {
3580
+ const stringToken = values.find((t) => t.type === 0 /* TokenType.STRING_TOKEN */);
3581
+ if (!stringToken)
3582
+ return NONE;
3583
+ return { type: 5 /* CLIP_PATH_TYPE.PATH */, d: stringToken.value };
3584
+ };
3585
+ const clipPath = {
3586
+ name: 'clip-path',
3587
+ initialValue: 'none',
3588
+ prefix: false,
3589
+ type: 0 /* PropertyDescriptorParsingType.VALUE */,
3590
+ parse: (_context, token) => {
3591
+ if (isIdentToken(token) && token.value === 'none') {
3592
+ return NONE;
3593
+ }
3594
+ if (token.type === 18 /* TokenType.FUNCTION */) {
3595
+ switch (token.name) {
3596
+ case 'inset':
3597
+ return parseInset(token.values);
3598
+ case 'circle':
3599
+ return parseCircle(token.values);
3600
+ case 'ellipse':
3601
+ return parseEllipse(token.values);
3602
+ case 'polygon':
3603
+ return parsePolygon(token.values);
3604
+ case 'path':
3605
+ return parsePath(token.values);
3606
+ }
3607
+ }
3608
+ return NONE;
3609
+ }
3610
+ };
3611
+
3427
3612
  const color = {
3428
3613
  name: `color`,
3429
3614
  initialValue: 'transparent',
@@ -4622,6 +4807,7 @@ class CSSParsedDeclaration {
4622
4807
  this.borderBottomWidth = parse(context, borderBottomWidth, declaration.borderBottomWidth);
4623
4808
  this.borderLeftWidth = parse(context, borderLeftWidth, declaration.borderLeftWidth);
4624
4809
  this.boxShadow = parse(context, boxShadow, declaration.boxShadow);
4810
+ this.clipPath = parse(context, clipPath, declaration.clipPath);
4625
4811
  this.color = parse(context, color, declaration.color);
4626
4812
  this.direction = parse(context, direction, declaration.direction);
4627
4813
  this.display = parse(context, display, declaration.display);
@@ -4813,11 +4999,38 @@ const isDebugging = (element, type) => {
4813
4999
  */
4814
5000
  /**
4815
5001
  * Normalize element styles for accurate rendering
4816
- * This includes disabling animations and resetting transforms
5002
+ * This includes disabling animations and neutralizing transforms.
4817
5003
  */
4818
5004
  class DOMNormalizer {
4819
5005
  /**
4820
- * Normalize a single element and return original styles
5006
+ * Normalize a single element and return original styles.
5007
+ *
5008
+ * ## Why we replace transforms with an identity value instead of "none"
5009
+ *
5010
+ * `getBoundingClientRect()` returns visual (post-transform) coordinates, so we
5011
+ * must neutralize any active transform before measuring element bounds.
5012
+ *
5013
+ * The naive approach of setting `transform: none` (or `rotate: none`) has a
5014
+ * critical side-effect: per **CSS Transforms Level 2**, an element whose
5015
+ * `transform` is non-none automatically becomes the **containing block** for
5016
+ * all of its `position: absolute` *and* `position: fixed` descendants.
5017
+ * Setting it to `none` destroys that role, causing children to resolve their
5018
+ * percentage dimensions and offsets against an unintended ancestor — which
5019
+ * produces completely wrong bounds.
5020
+ *
5021
+ * Solution: instead of removing the transform, we replace it with a visually
5022
+ * inert identity value:
5023
+ *
5024
+ * - `transform: scale(0.5)` → `transform: translate(0, 0)`
5025
+ * - `translate(0, 0)` is an identity transform (no visual change, no layout shift).
5026
+ * - `getBoundingClientRect()` returns the same layout-space coordinates as
5027
+ * if there were no transform at all.
5028
+ * - Because the value is still non-none, the element **remains a containing
5029
+ * block** for both `position: absolute` and `position: fixed` descendants.
5030
+ *
5031
+ * - `rotate: 45deg` → `rotate: 0deg`
5032
+ * - `0deg` is the identity rotation; `0deg ≠ none`, so the same containing-
5033
+ * block guarantee holds.
4821
5034
  *
4822
5035
  * @param element - Element to normalize
4823
5036
  * @param styles - Parsed CSS styles
@@ -4833,33 +5046,43 @@ class DOMNormalizer {
4833
5046
  originalStyles.animationDuration = element.style.animationDuration;
4834
5047
  element.style.animationDuration = '0s';
4835
5048
  }
4836
- // Reset transform for accurate bounds calculation
4837
- // getBoundingClientRect takes transforms into account
5049
+ // Replace the actual transform with an identity translate so that:
5050
+ // 1. getBoundingClientRect() returns layout-space (unscaled/unrotated) coords.
5051
+ // 2. The element still satisfies "transform != none" and therefore keeps
5052
+ // its role as a containing block for position:absolute / position:fixed
5053
+ // descendants (CSS Transforms Level 2 §2.3).
4838
5054
  if (styles.transform !== null) {
4839
5055
  originalStyles.transform = element.style.transform;
4840
- element.style.transform = 'none';
5056
+ element.style.transform = 'translate(0, 0)';
4841
5057
  }
4842
- // Reset rotate property similarly to transform
5058
+ // Same rationale for the standalone `rotate` property.
5059
+ // `rotate: 0deg` is an identity rotation with no visual effect.
5060
+ //
5061
+ // However, individual transform properties (`rotate`, `translate`, `scale`)
5062
+ // are part of CSS Transforms Level 2 and their containing-block guarantee
5063
+ // is not uniformly implemented across all browsers. To be safe, if `rotate`
5064
+ // is the only transform-like property active on this element, we also set
5065
+ // `transform: translate(0, 0)` so that the containing-block role is reliably
5066
+ // preserved via the well-supported `transform` property.
4843
5067
  if (styles.rotate !== null) {
4844
5068
  originalStyles.rotate = element.style.rotate;
4845
- element.style.rotate = 'none';
5069
+ element.style.rotate = '0deg';
5070
+ // Individual transform properties (`rotate`, `translate`, `scale`) are
5071
+ // CSS Transforms Level 2 and their containing-block guarantee is not
5072
+ // uniformly implemented in all browsers. If `transform` was not already
5073
+ // set to translate(0,0) in the block above (i.e. this element has
5074
+ // `rotate` but no `transform`), we set it now so the containing-block
5075
+ // role is reliably established via the widely-supported `transform`
5076
+ // property – independently of browser support for individual props.
5077
+ if (originalStyles.transform === undefined) {
5078
+ originalStyles.transform = element.style.transform;
5079
+ element.style.transform = 'translate(0, 0)';
5080
+ }
4846
5081
  }
4847
5082
  return originalStyles;
4848
5083
  }
4849
5084
  /**
4850
- * Normalize element and its descendants recursively
4851
- *
4852
- * @param element - Element to normalize
4853
- * @param styles - Parsed CSS styles
4854
- * @returns Original styles map for restoration
4855
- */
4856
- static normalizeTree(element, styles) {
4857
- return this.normalizeElement(element, styles);
4858
- // Could add recursive normalization here if needed
4859
- // For now, only normalize the element itself
4860
- }
4861
- /**
4862
- * Restore element styles after rendering
5085
+ * Restore element styles after rendering.
4863
5086
  *
4864
5087
  * @param element - Element to restore
4865
5088
  * @param originalStyles - Original styles to restore
@@ -4868,7 +5091,6 @@ class DOMNormalizer {
4868
5091
  if (!isHTMLElementNode(element)) {
4869
5092
  return;
4870
5093
  }
4871
- // Restore each property that was saved
4872
5094
  if (originalStyles.animationDuration !== undefined) {
4873
5095
  element.style.animationDuration = originalStyles.animationDuration;
4874
5096
  }
@@ -6369,36 +6591,50 @@ class DocumentCloner {
6369
6591
  * */
6370
6592
  const baseUri = documentClone.baseURI;
6371
6593
  documentClone.open();
6594
+ const rawHTML = serializeDoctype(document.doctype) + '<html></html>';
6372
6595
  try {
6373
- // fixing "This document requires 'TrustedHTML' assignment. The action has been blocked." error
6374
- // @ts-ignore
6375
- const policy = trustedTypes.createPolicy('my-policy', {
6376
- createHTML: (string) => string
6377
- });
6378
- const rawHTML = serializeDoctype(document.doctype) + '<html></html>';
6379
- const trustedHTML = policy.createHTML(rawHTML);
6380
- documentClone.write(trustedHTML);
6596
+ // Fixing "This document requires 'TrustedHTML' assignment. The action has been blocked." error.
6597
+ // Reuse existing policy when present (e.g. second html2canvas call) to avoid createPolicy duplicate-name throw.
6598
+ const ownerWindow = this.referenceElement.ownerDocument?.defaultView;
6599
+ const trustedTypesFactory = ownerWindow && ownerWindow.trustedTypes;
6600
+ let policy = trustedTypesFactory?.getPolicy?.('html2canvas-pro');
6601
+ if (!policy && trustedTypesFactory) {
6602
+ policy = trustedTypesFactory.createPolicy('html2canvas-pro', {
6603
+ createHTML: (string) => string
6604
+ });
6605
+ }
6606
+ if (policy) {
6607
+ documentClone.write(policy.createHTML(rawHTML));
6608
+ }
6609
+ else {
6610
+ documentClone.write(rawHTML);
6611
+ }
6381
6612
  }
6382
- catch (e) {
6383
- // if browser does not support trustedTypes
6384
- documentClone.write(serializeDoctype(document.doctype) + '<html></html>');
6613
+ catch (_e) {
6614
+ documentClone.write(rawHTML);
6385
6615
  }
6386
6616
  // Chrome scrolls the parent document for some reason after the write to the cloned window???
6387
6617
  restoreOwnerScroll(this.referenceElement.ownerDocument, scrollX, scrollY);
6388
6618
  /**
6389
- * Note: adoptNode() should be called AFTER documentClone.open() and close()
6619
+ * IMPORTANT: documentClone.close() MUST be called BEFORE adoptNode().
6390
6620
  *
6391
- * In Chrome, calling adoptNode() before or during open/write may cause
6392
- * styles with uppercase characters in class names (e.g. ".MyClass") to not apply correctly.
6621
+ * In Chrome, calling adoptNode() while the document is still "open"
6622
+ * (between document.open() and document.close()) causes CSS rules with
6623
+ * uppercase characters in class names (e.g. ".MyClass") to not match
6624
+ * correctly. Chrome's CSS engine only enters a fully-resolved matching
6625
+ * mode once the document is closed.
6393
6626
  *
6394
- * Fix:
6395
- * - Make sure adoptNode() is called after documentClone.open() and close()
6396
- * - This allows Chrome to properly match and apply all CSS rules including mixed-case class selectors.
6397
- * */
6627
+ * Correct order: open() → write() → close() → adoptNode() → replaceChild()
6628
+ *
6629
+ * Timing: close() queues the iframe 'load' event; because JS is single-threaded,
6630
+ * the synchronous adoptNode() and replaceChild() below complete before that
6631
+ * event is dispatched. iframeLoader's setInterval will therefore see the body
6632
+ * already populated on its first tick.
6633
+ */
6634
+ documentClone.close();
6398
6635
  const adoptedNode = documentClone.adoptNode(this.documentElement);
6399
6636
  addBase(adoptedNode, baseUri);
6400
6637
  documentClone.replaceChild(adoptedNode, documentClone.documentElement);
6401
- documentClone.close();
6402
6638
  return iframeLoad;
6403
6639
  }
6404
6640
  createElementClone(node) {
@@ -6927,13 +7163,16 @@ const serializeDoctype = (doctype) => {
6927
7163
  str += doctype.name;
6928
7164
  }
6929
7165
  if (doctype.internalSubset) {
6930
- str += doctype.internalSubset;
7166
+ str += ' ' + doctype.internalSubset.replace(/"/g, '&quot;').replace(/>/g, '&gt;');
6931
7167
  }
6932
7168
  if (doctype.publicId) {
6933
- str += `"${doctype.publicId}"`;
7169
+ str += ' PUBLIC "' + doctype.publicId.replace(/"/g, '&quot;') + '"';
7170
+ if (doctype.systemId) {
7171
+ str += ' "' + doctype.systemId.replace(/"/g, '&quot;') + '"';
7172
+ }
6934
7173
  }
6935
- if (doctype.systemId) {
6936
- str += `"${doctype.systemId}"`;
7174
+ else if (doctype.systemId) {
7175
+ str += ' SYSTEM "' + doctype.systemId.replace(/"/g, '&quot;') + '"';
6937
7176
  }
6938
7177
  str += '>';
6939
7178
  }
@@ -7222,9 +7461,23 @@ class OpacityEffect {
7222
7461
  this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
7223
7462
  }
7224
7463
  }
7464
+ /**
7465
+ * Clips the element and all its descendants to an arbitrary canvas-drawn shape.
7466
+ * The `applyClip` callback is responsible for calling beginPath, the shape
7467
+ * operations, and ctx.clip() — giving each shape type full control over how
7468
+ * the path is constructed (arc, ellipse, lineTo, Path2D, etc.).
7469
+ */
7470
+ class ClipPathEffect {
7471
+ constructor(applyClip) {
7472
+ this.applyClip = applyClip;
7473
+ this.type = 3 /* EffectType.CLIP_PATH */;
7474
+ this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
7475
+ }
7476
+ }
7225
7477
  const isTransformEffect = (effect) => effect.type === 0 /* EffectType.TRANSFORM */;
7226
7478
  const isClipEffect = (effect) => effect.type === 1 /* EffectType.CLIP */;
7227
7479
  const isOpacityEffect = (effect) => effect.type === 2 /* EffectType.OPACITY */;
7480
+ const isClipPathEffect = (effect) => effect.type === 3 /* EffectType.CLIP_PATH */;
7228
7481
 
7229
7482
  const equalPath = (a, b) => {
7230
7483
  if (a.length === b.length) {
@@ -7299,6 +7552,12 @@ class ElementPaint {
7299
7552
  this.effects.push(new ClipEffect(paddingBox, 4 /* EffectTarget.CONTENT */));
7300
7553
  }
7301
7554
  }
7555
+ if (this.container.styles.clipPath.type !== 0 /* CLIP_PATH_TYPE.NONE */) {
7556
+ const clipPathEffect = buildClipPathEffect(this.container.styles.clipPath, this.container.bounds);
7557
+ if (clipPathEffect) {
7558
+ this.effects.push(clipPathEffect);
7559
+ }
7560
+ }
7302
7561
  }
7303
7562
  getEffects(target) {
7304
7563
  let inFlow = [2 /* POSITION.ABSOLUTE */, 3 /* POSITION.FIXED */].indexOf(this.container.styles.position) === -1;
@@ -7325,6 +7584,126 @@ class ElementPaint {
7325
7584
  return effects.filter((effect) => contains(effect.target, target));
7326
7585
  }
7327
7586
  }
7587
+ /**
7588
+ * Resolve a `closest-side` or `farthest-side` shape-radius keyword to pixels
7589
+ * for a single axis. Used by both `circle()` (per-axis) and `ellipse()`.
7590
+ *
7591
+ * @param r - The ShapeRadius (keyword or length-percentage).
7592
+ * @param center - Absolute center coordinate on this axis (cx or cy).
7593
+ * @param start - Absolute start of the reference box on this axis.
7594
+ * @param end - Absolute end of the reference box on this axis.
7595
+ * @param dimRef - Reference dimension for resolving a length-percentage value.
7596
+ */
7597
+ const resolveAxisRadius = (r, center, start, end, dimRef) => {
7598
+ if (r === 'closest-side')
7599
+ return Math.min(center - start, end - center);
7600
+ if (r === 'farthest-side')
7601
+ return Math.max(center - start, end - center);
7602
+ return getAbsoluteValue(r, dimRef);
7603
+ };
7604
+ /**
7605
+ * Convert a parsed ClipPathValue + element bounds into a ClipPathEffect whose
7606
+ * `applyClip` callback draws the clip shape directly onto the canvas context.
7607
+ *
7608
+ * All coordinates are computed in page-absolute space at construction time so
7609
+ * the callback itself is allocation-free and executes synchronously.
7610
+ */
7611
+ const buildClipPathEffect = (clipPath, bounds) => {
7612
+ const { left: bLeft, top: bTop, width: bWidth, height: bHeight } = bounds;
7613
+ switch (clipPath.type) {
7614
+ case 1 /* CLIP_PATH_TYPE.INSET */: {
7615
+ const iLeft = getAbsoluteValue(clipPath.left, bWidth);
7616
+ const iTop = getAbsoluteValue(clipPath.top, bHeight);
7617
+ const x = bLeft + iLeft;
7618
+ const y = bTop + iTop;
7619
+ // Clamp to zero: per CSS spec, overlapping insets produce an empty shape.
7620
+ const w = Math.max(0, bWidth - iLeft - getAbsoluteValue(clipPath.right, bWidth));
7621
+ const h = Math.max(0, bHeight - iTop - getAbsoluteValue(clipPath.bottom, bHeight));
7622
+ return new ClipPathEffect((ctx) => {
7623
+ ctx.beginPath();
7624
+ ctx.rect(x, y, w, h);
7625
+ ctx.clip();
7626
+ });
7627
+ }
7628
+ case 2 /* CLIP_PATH_TYPE.CIRCLE */: {
7629
+ const cx = bLeft + getAbsoluteValue(clipPath.cx, bWidth);
7630
+ const cy = bTop + getAbsoluteValue(clipPath.cy, bHeight);
7631
+ let r;
7632
+ if (clipPath.radius === 'closest-side') {
7633
+ r = Math.min(cx - bLeft, cy - bTop, bLeft + bWidth - cx, bTop + bHeight - cy);
7634
+ }
7635
+ else if (clipPath.radius === 'farthest-side') {
7636
+ r = Math.max(cx - bLeft, cy - bTop, bLeft + bWidth - cx, bTop + bHeight - cy);
7637
+ }
7638
+ else {
7639
+ // Per CSS spec, percentage is relative to sqrt(w² + h²) / sqrt(2).
7640
+ r = getAbsoluteValue(clipPath.radius, Math.sqrt(bWidth * bWidth + bHeight * bHeight) / Math.SQRT2);
7641
+ }
7642
+ return new ClipPathEffect((ctx) => {
7643
+ ctx.beginPath();
7644
+ ctx.arc(cx, cy, Math.max(0, r), 0, Math.PI * 2);
7645
+ ctx.clip();
7646
+ });
7647
+ }
7648
+ case 3 /* CLIP_PATH_TYPE.ELLIPSE */: {
7649
+ const cx = bLeft + getAbsoluteValue(clipPath.cx, bWidth);
7650
+ const cy = bTop + getAbsoluteValue(clipPath.cy, bHeight);
7651
+ const rx = resolveAxisRadius(clipPath.rx, cx, bLeft, bLeft + bWidth, bWidth);
7652
+ const ry = resolveAxisRadius(clipPath.ry, cy, bTop, bTop + bHeight, bHeight);
7653
+ return new ClipPathEffect((ctx) => {
7654
+ ctx.beginPath();
7655
+ ctx.ellipse(cx, cy, Math.max(0, rx), Math.max(0, ry), 0, 0, Math.PI * 2);
7656
+ ctx.clip();
7657
+ });
7658
+ }
7659
+ case 4 /* CLIP_PATH_TYPE.POLYGON */: {
7660
+ // Pre-compute all vertices in page-absolute coordinates.
7661
+ const absPoints = clipPath.points.map(([px, py]) => [bLeft + getAbsoluteValue(px, bWidth), bTop + getAbsoluteValue(py, bHeight)]);
7662
+ return new ClipPathEffect((ctx) => {
7663
+ ctx.beginPath();
7664
+ if (absPoints.length > 0) {
7665
+ ctx.moveTo(absPoints[0][0], absPoints[0][1]);
7666
+ for (let i = 1; i < absPoints.length; i++) {
7667
+ ctx.lineTo(absPoints[i][0], absPoints[i][1]);
7668
+ }
7669
+ ctx.closePath();
7670
+ }
7671
+ // Calling clip() with an empty path (zero points) is intentional:
7672
+ // it clips the entire region to nothing, which is the correct
7673
+ // behaviour for a degenerate polygon() per the CSS spec.
7674
+ ctx.clip();
7675
+ });
7676
+ }
7677
+ case 5 /* CLIP_PATH_TYPE.PATH */: {
7678
+ // path() coordinates are in the element's local space (0,0 = element top-left).
7679
+ // We temporarily translate the canvas origin to the element's position, clip
7680
+ // with the Path2D, then restore only the transform matrix (not the clipping
7681
+ // region) via setTransform so the clip persists for the enclosing
7682
+ // ctx.save() / ctx.restore() pair managed by EffectsRenderer.
7683
+ //
7684
+ // When the element also has a CSS transform, that transform was already applied
7685
+ // by a preceding TransformEffect, so the path coordinates end up correctly in
7686
+ // the element's transformed local space — matching browser behaviour.
7687
+ const { d } = clipPath;
7688
+ return new ClipPathEffect((ctx) => {
7689
+ try {
7690
+ const savedTransform = ctx.getTransform();
7691
+ ctx.translate(bLeft, bTop);
7692
+ ctx.clip(new Path2D(d));
7693
+ ctx.setTransform(savedTransform);
7694
+ }
7695
+ catch (_e) {
7696
+ // Path2D or getTransform/setTransform not supported in this environment.
7697
+ }
7698
+ });
7699
+ }
7700
+ case 0 /* CLIP_PATH_TYPE.NONE */:
7701
+ return null;
7702
+ default: {
7703
+ return null;
7704
+ }
7705
+ }
7706
+ };
7328
7707
  const parseStackTree = (parent, stackingContext, realStackingContext, listItems) => {
7329
7708
  parent.container.elements.forEach((child) => {
7330
7709
  const treatAsRealStackingContext = contains(child.flags, 4 /* FLAGS.CREATES_REAL_STACKING_CONTEXT */);
@@ -8153,7 +8532,8 @@ class BorderRenderer {
8153
8532
  * Handles rendering effects including:
8154
8533
  * - Opacity effects
8155
8534
  * - Transform effects (matrix transformations)
8156
- * - Clip effects (clipping paths)
8535
+ * - Clip effects (overflow / border-radius clipping via Path[])
8536
+ * - Clip-path effects (CSS clip-path shapes: inset, circle, ellipse, polygon, path)
8157
8537
  */
8158
8538
  /**
8159
8539
  * Effects Renderer
@@ -8188,21 +8568,25 @@ class EffectsRenderer {
8188
8568
  */
8189
8569
  applyEffect(effect) {
8190
8570
  this.ctx.save();
8191
- // Apply opacity effect
8192
8571
  if (isOpacityEffect(effect)) {
8572
+ // Opacity: multiply into the current global alpha for nested transparency.
8193
8573
  this.ctx.globalAlpha = effect.opacity;
8194
8574
  }
8195
- // Apply transform effect
8196
- if (isTransformEffect(effect)) {
8575
+ else if (isTransformEffect(effect)) {
8576
+ // Transform: translate to origin, apply matrix, translate back.
8197
8577
  this.ctx.translate(effect.offsetX, effect.offsetY);
8198
8578
  this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
8199
8579
  this.ctx.translate(-effect.offsetX, -effect.offsetY);
8200
8580
  }
8201
- // Apply clip effect
8202
- if (isClipEffect(effect)) {
8581
+ else if (isClipEffect(effect)) {
8582
+ // Clip (overflow / border-radius): build path via callback then clip.
8203
8583
  this.pathCallback.path(effect.path);
8204
8584
  this.ctx.clip();
8205
8585
  }
8586
+ else if (isClipPathEffect(effect)) {
8587
+ // Clip-path: delegate shape drawing (beginPath … clip()) to the effect.
8588
+ effect.applyClip(this.ctx);
8589
+ }
8206
8590
  this.activeEffects.push(effect);
8207
8591
  }
8208
8592
  /**
@@ -8245,6 +8629,22 @@ class EffectsRenderer {
8245
8629
  */
8246
8630
  // iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
8247
8631
  const iOSBrokenFonts = ['-apple-system', 'system-ui'];
8632
+ /**
8633
+ * Detect CJK (Chinese, Japanese, Korean) characters in a string.
8634
+ * CJK characters use the ideographic baseline in browsers, which differs
8635
+ * from the alphabetic baseline used for Latin script.
8636
+ *
8637
+ * Covers:
8638
+ * U+2E80–U+2FFF CJK Radicals Supplement, Kangxi Radicals
8639
+ * U+3000–U+30FF CJK Symbols & Punctuation (。、「」…), Hiragana, Katakana
8640
+ * U+3400–U+4DBF CJK Extension A
8641
+ * U+4E00–U+9FFF CJK Unified Ideographs (most common Chinese/Japanese/Korean)
8642
+ * U+AC00–U+D7AF Hangul Syllables
8643
+ * U+F900–U+FAFF CJK Compatibility Ideographs
8644
+ * U+FF01–U+FFEF Halfwidth and Fullwidth Forms (A B 1 2 ! ? etc.)
8645
+ */
8646
+ const CJK_CHAR_REGEX = /[\u2E80-\u2FFF\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFFEF]/;
8647
+ const hasCJKCharacters = (text) => CJK_CHAR_REGEX.test(text);
8248
8648
  /**
8249
8649
  * Detect iOS version from user agent
8250
8650
  * Returns null if not iOS or version cannot be determined
@@ -8302,21 +8702,50 @@ class TextRenderer {
8302
8702
  this.options = deps.options;
8303
8703
  }
8304
8704
  /**
8305
- * Render text with letter spacing
8306
- * Public method used by list rendering
8705
+ * Iterate grapheme clusters one-by-one, applying correct letter-spacing and
8706
+ * per-script baseline for each character.
8707
+ *
8708
+ * Issue #73: When letter-spacing is non-zero, text must be rendered character by
8709
+ * character. This helper centralises two fixes applied during that iteration:
8710
+ * 1. Add `letterSpacing` to each character's advance width (was previously
8711
+ * omitted, causing characters to render without any spacing).
8712
+ * 2. Switch to the ideographic baseline for CJK glyphs so their vertical
8713
+ * position matches how browsers lay them out in the DOM.
8714
+ *
8715
+ * The `renderFn` callback receives (letter, x, y) and performs the actual draw
8716
+ * call (fillText or strokeText), allowing fill and stroke paths to share one
8717
+ * implementation.
8718
+ */
8719
+ iterateLettersWithLetterSpacing(text, letterSpacing, baseline, renderFn) {
8720
+ const letters = segmentGraphemes(text.text);
8721
+ const y = text.bounds.top + baseline;
8722
+ let left = text.bounds.left;
8723
+ for (const letter of letters) {
8724
+ if (hasCJKCharacters(letter)) {
8725
+ const savedBaseline = this.ctx.textBaseline;
8726
+ this.ctx.textBaseline = 'ideographic';
8727
+ renderFn(letter, left, y);
8728
+ this.ctx.textBaseline = savedBaseline;
8729
+ }
8730
+ else {
8731
+ renderFn(letter, left, y);
8732
+ }
8733
+ left += this.ctx.measureText(letter).width + letterSpacing;
8734
+ }
8735
+ }
8736
+ /**
8737
+ * Render text with letter-spacing applied (fill pass).
8738
+ * When letterSpacing is 0 the whole string is drawn in one call; otherwise each
8739
+ * grapheme is drawn individually so spacing and CJK baseline are applied correctly.
8307
8740
  */
8308
8741
  renderTextWithLetterSpacing(text, letterSpacing, baseline) {
8309
8742
  if (letterSpacing === 0) {
8310
- // Use alphabetic baseline for consistent text positioning across browsers
8311
- // Issue #129: text.bounds.top + text.bounds.height causes text to render too low
8312
8743
  this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
8313
8744
  }
8314
8745
  else {
8315
- const letters = segmentGraphemes(text.text);
8316
- letters.reduce((left, letter) => {
8317
- this.ctx.fillText(letter, left, text.bounds.top + baseline);
8318
- return left + this.ctx.measureText(letter).width;
8319
- }, text.bounds.left);
8746
+ this.iterateLettersWithLetterSpacing(text, letterSpacing, baseline, (letter, x, y) => {
8747
+ this.ctx.fillText(letter, x, y);
8748
+ });
8320
8749
  }
8321
8750
  }
8322
8751
  /**
@@ -8334,8 +8763,17 @@ class TextRenderer {
8334
8763
  if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
8335
8764
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8336
8765
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8337
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8338
- this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
8766
+ this.ctx.lineJoin =
8767
+ typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
8768
+ if (styles.letterSpacing === 0) {
8769
+ this.ctx.strokeText(textBound.text, textBound.bounds.left, textBound.bounds.top + styles.fontSize.number);
8770
+ }
8771
+ else {
8772
+ this.iterateLettersWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
8773
+ }
8774
+ this.ctx.strokeStyle = '';
8775
+ this.ctx.lineWidth = 0;
8776
+ this.ctx.lineJoin = 'miter';
8339
8777
  }
8340
8778
  break;
8341
8779
  }
@@ -8445,26 +8883,47 @@ class TextRenderer {
8445
8883
  }
8446
8884
  // Helper method to truncate text and add ellipsis if needed
8447
8885
  truncateTextWithEllipsis(text, maxWidth, letterSpacing) {
8448
- const ellipsis = '...';
8886
+ // Use the Unicode ellipsis character (U+2026) whose width the browser measures
8887
+ // as a single glyph, matching native text-overflow behaviour more closely.
8888
+ const ellipsis = '\u2026';
8449
8889
  const ellipsisWidth = this.ctx.measureText(ellipsis).width;
8890
+ // Segment into grapheme clusters so multi-byte characters (emoji, composed
8891
+ // sequences) are never split mid-character.
8892
+ const graphemes = segmentGraphemes(text);
8450
8893
  if (letterSpacing === 0) {
8451
- let truncated = text;
8452
- while (this.ctx.measureText(truncated).width + ellipsisWidth > maxWidth && truncated.length > 0) {
8453
- truncated = truncated.slice(0, -1);
8894
+ // Measure the whole candidate string for accuracy: the browser applies
8895
+ // kerning and ligatures when rendering multiple glyphs together, so
8896
+ // measuring them as one string is more precise than summing individual widths.
8897
+ // Binary search reduces measurements from O(n) to O(log n).
8898
+ const fits = (n) => this.ctx.measureText(graphemes.slice(0, n).join('')).width + ellipsisWidth <= maxWidth;
8899
+ let lo = 0;
8900
+ let hi = graphemes.length;
8901
+ while (lo < hi) {
8902
+ const mid = (lo + hi + 1) >> 1;
8903
+ if (fits(mid)) {
8904
+ lo = mid;
8905
+ }
8906
+ else {
8907
+ hi = mid - 1;
8908
+ }
8454
8909
  }
8455
- return truncated + ellipsis;
8910
+ return graphemes.slice(0, lo).join('') + ellipsis;
8456
8911
  }
8457
8912
  else {
8458
- const letters = segmentGraphemes(text);
8459
8913
  let width = ellipsisWidth;
8460
- let result = [];
8461
- for (const letter of letters) {
8462
- const letterWidth = this.ctx.measureText(letter).width + letterSpacing;
8463
- if (width + letterWidth > maxWidth) {
8914
+ const result = [];
8915
+ for (const letter of graphemes) {
8916
+ const glyphWidth = this.ctx.measureText(letter).width;
8917
+ // Check against glyph width only (no trailing spacing): letter-spacing
8918
+ // is applied *between* characters, not after the final glyph. Using
8919
+ // `glyphWidth + letterSpacing` would incorrectly discard letters that
8920
+ // fit as the last character before the ellipsis.
8921
+ if (width + glyphWidth > maxWidth) {
8464
8922
  break;
8465
8923
  }
8466
8924
  result.push(letter);
8467
- width += letterWidth;
8925
+ // Accumulate glyph + inter-character spacing for the *next* iteration.
8926
+ width += glyphWidth + letterSpacing;
8468
8927
  }
8469
8928
  return result.join('') + ellipsis;
8470
8929
  }
@@ -8540,6 +8999,8 @@ class TextRenderer {
8540
8999
  const firstBound = lastLine[0];
8541
9000
  const availableWidth = containerBounds.width - (firstBound.bounds.left - containerBounds.left);
8542
9001
  const truncatedText = this.truncateTextWithEllipsis(lastLineText, availableWidth, styles.letterSpacing);
9002
+ // Build TextBounds once; reused for fill and stroke without re-allocating.
9003
+ const truncatedBounds = new TextBounds(truncatedText, firstBound.bounds);
8543
9004
  paintOrder.forEach((paintOrderLayer) => {
8544
9005
  switch (paintOrderLayer) {
8545
9006
  case 0 /* PAINT_ORDER_LAYER.FILL */:
@@ -8548,28 +9009,24 @@ class TextRenderer {
8548
9009
  this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8549
9010
  }
8550
9011
  else {
8551
- const letters = segmentGraphemes(truncatedText);
8552
- letters.reduce((left, letter) => {
8553
- this.ctx.fillText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8554
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8555
- }, firstBound.bounds.left);
9012
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
8556
9013
  }
8557
9014
  break;
8558
9015
  case 1 /* PAINT_ORDER_LAYER.STROKE */:
8559
9016
  if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8560
9017
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8561
9018
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8562
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
9019
+ this.ctx.lineJoin =
9020
+ typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
8563
9021
  if (styles.letterSpacing === 0) {
8564
9022
  this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8565
9023
  }
8566
9024
  else {
8567
- const letters = segmentGraphemes(truncatedText);
8568
- letters.reduce((left, letter) => {
8569
- this.ctx.strokeText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8570
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8571
- }, firstBound.bounds.left);
9025
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
8572
9026
  }
9027
+ this.ctx.strokeStyle = '';
9028
+ this.ctx.lineWidth = 0;
9029
+ this.ctx.lineJoin = 'miter';
8573
9030
  }
8574
9031
  break;
8575
9032
  }
@@ -8613,19 +9070,18 @@ class TextRenderer {
8613
9070
  // If ellipsis is needed, render the truncated text once
8614
9071
  if (needsEllipsis) {
8615
9072
  const firstBound = text.textBounds[0];
9073
+ // Build TextBounds once; reused across paint layers and every shadow pass
9074
+ // to avoid repeated allocation inside forEach callbacks.
9075
+ const truncatedBounds = new TextBounds(truncatedText, firstBound.bounds);
8616
9076
  paintOrder.forEach((paintOrderLayer) => {
8617
9077
  switch (paintOrderLayer) {
8618
- case 0 /* PAINT_ORDER_LAYER.FILL */:
9078
+ case 0 /* PAINT_ORDER_LAYER.FILL */: {
8619
9079
  this.ctx.fillStyle = asString(styles.color);
8620
9080
  if (styles.letterSpacing === 0) {
8621
9081
  this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8622
9082
  }
8623
9083
  else {
8624
- const letters = segmentGraphemes(truncatedText);
8625
- letters.reduce((left, letter) => {
8626
- this.ctx.fillText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8627
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8628
- }, firstBound.bounds.left);
9084
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
8629
9085
  }
8630
9086
  const textShadows = styles.textShadow;
8631
9087
  if (textShadows.length && truncatedText.trim().length) {
@@ -8641,11 +9097,7 @@ class TextRenderer {
8641
9097
  this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8642
9098
  }
8643
9099
  else {
8644
- const letters = segmentGraphemes(truncatedText);
8645
- letters.reduce((left, letter) => {
8646
- this.ctx.fillText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8647
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8648
- }, firstBound.bounds.left);
9100
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
8649
9101
  }
8650
9102
  });
8651
9103
  this.ctx.shadowColor = '';
@@ -8654,21 +9106,22 @@ class TextRenderer {
8654
9106
  this.ctx.shadowBlur = 0;
8655
9107
  }
8656
9108
  break;
9109
+ }
8657
9110
  case 1 /* PAINT_ORDER_LAYER.STROKE */:
8658
9111
  if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8659
9112
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8660
9113
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8661
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
9114
+ this.ctx.lineJoin =
9115
+ typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
8662
9116
  if (styles.letterSpacing === 0) {
8663
9117
  this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8664
9118
  }
8665
9119
  else {
8666
- const letters = segmentGraphemes(truncatedText);
8667
- letters.reduce((left, letter) => {
8668
- this.ctx.strokeText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8669
- return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8670
- }, firstBound.bounds.left);
9120
+ this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
8671
9121
  }
9122
+ this.ctx.strokeStyle = '';
9123
+ this.ctx.lineWidth = 0;
9124
+ this.ctx.lineJoin = 'miter';
8672
9125
  }
8673
9126
  break;
8674
9127
  }
@@ -8679,7 +9132,7 @@ class TextRenderer {
8679
9132
  text.textBounds.forEach((text) => {
8680
9133
  paintOrder.forEach((paintOrderLayer) => {
8681
9134
  switch (paintOrderLayer) {
8682
- case 0 /* PAINT_ORDER_LAYER.FILL */:
9135
+ case 0 /* PAINT_ORDER_LAYER.FILL */: {
8683
9136
  this.ctx.fillStyle = asString(styles.color);
8684
9137
  this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
8685
9138
  const textShadows = styles.textShadow;
@@ -8703,29 +9156,26 @@ class TextRenderer {
8703
9156
  this.renderTextDecoration(text.bounds, styles);
8704
9157
  }
8705
9158
  break;
8706
- case 1 /* PAINT_ORDER_LAYER.STROKE */:
9159
+ }
9160
+ case 1 /* PAINT_ORDER_LAYER.STROKE */: {
8707
9161
  if (styles.webkitTextStrokeWidth && text.text.trim().length) {
8708
9162
  this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8709
9163
  this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8710
- this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8711
- // Issue #110: Use baseline (fontSize) for consistent positioning with fill
8712
- // Previously used text.bounds.height which caused stroke to render too low
9164
+ this.ctx.lineJoin =
9165
+ typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
8713
9166
  const baseline = styles.fontSize.number;
8714
9167
  if (styles.letterSpacing === 0) {
8715
9168
  this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
8716
9169
  }
8717
9170
  else {
8718
- const letters = segmentGraphemes(text.text);
8719
- letters.reduce((left, letter) => {
8720
- this.ctx.strokeText(letter, left, text.bounds.top + baseline);
8721
- return left + this.ctx.measureText(letter).width;
8722
- }, text.bounds.left);
9171
+ this.iterateLettersWithLetterSpacing(text, styles.letterSpacing, baseline, (letter, x, y) => this.ctx.strokeText(letter, x, y));
8723
9172
  }
9173
+ this.ctx.strokeStyle = '';
9174
+ this.ctx.lineWidth = 0;
9175
+ this.ctx.lineJoin = 'miter';
8724
9176
  }
8725
- this.ctx.strokeStyle = '';
8726
- this.ctx.lineWidth = 0;
8727
- this.ctx.lineJoin = 'miter';
8728
9177
  break;
9178
+ }
8729
9179
  }
8730
9180
  });
8731
9181
  });