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.
- package/dist/html2canvas-pro.esm.js +564 -114
- package/dist/html2canvas-pro.esm.js.map +1 -1
- package/dist/html2canvas-pro.js +564 -114
- package/dist/html2canvas-pro.js.map +1 -1
- package/dist/html2canvas-pro.min.js +5 -5
- package/dist/lib/css/index.js +2 -0
- package/dist/lib/css/index.js.map +1 -1
- package/dist/lib/css/property-descriptors/__tests__/clip-path.test.js +273 -0
- package/dist/lib/css/property-descriptors/__tests__/clip-path.test.js.map +1 -0
- package/dist/lib/css/property-descriptors/__tests__/image-rendering-integration.test.js +7 -6
- package/dist/lib/css/property-descriptors/__tests__/image-rendering-integration.test.js.map +1 -1
- package/dist/lib/css/property-descriptors/clip-path.js +190 -0
- package/dist/lib/css/property-descriptors/clip-path.js.map +1 -0
- package/dist/lib/dom/__tests__/dom-normalizer.test.js +6 -6
- package/dist/lib/dom/__tests__/dom-normalizer.test.js.map +1 -1
- package/dist/lib/dom/document-cloner.js +40 -23
- package/dist/lib/dom/document-cloner.js.map +1 -1
- package/dist/lib/dom/dom-normalizer.js +57 -21
- package/dist/lib/dom/dom-normalizer.js.map +1 -1
- package/dist/lib/render/canvas/__tests__/text-renderer.test.js +283 -36
- package/dist/lib/render/canvas/__tests__/text-renderer.test.js.map +1 -1
- package/dist/lib/render/canvas/effects-renderer.js +11 -6
- package/dist/lib/render/canvas/effects-renderer.js.map +1 -1
- package/dist/lib/render/canvas/text-renderer.js +131 -64
- package/dist/lib/render/canvas/text-renderer.js.map +1 -1
- package/dist/lib/render/effects.js +17 -1
- package/dist/lib/render/effects.js.map +1 -1
- package/dist/lib/render/stacking-context.js +131 -0
- package/dist/lib/render/stacking-context.js.map +1 -1
- package/dist/types/css/index.d.ts +2 -0
- package/dist/types/css/property-descriptors/__tests__/clip-path.test.d.ts +1 -0
- package/dist/types/css/property-descriptors/clip-path.d.ts +62 -0
- package/dist/types/dom/dom-normalizer.d.ts +30 -11
- package/dist/types/render/canvas/effects-renderer.d.ts +2 -1
- package/dist/types/render/canvas/text-renderer.d.ts +20 -2
- package/dist/types/render/effects.d.ts +15 -1
- package/package.json +1 -1
package/dist/html2canvas-pro.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* html2canvas-pro 2.0.
|
|
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
|
*/
|
|
@@ -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
|
|
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
|
-
//
|
|
4843
|
-
// getBoundingClientRect
|
|
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 = '
|
|
5062
|
+
element.style.transform = 'translate(0, 0)';
|
|
4847
5063
|
}
|
|
4848
|
-
//
|
|
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 = '
|
|
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
|
-
*
|
|
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
|
}
|
|
@@ -6375,36 +6597,50 @@
|
|
|
6375
6597
|
* */
|
|
6376
6598
|
const baseUri = documentClone.baseURI;
|
|
6377
6599
|
documentClone.open();
|
|
6600
|
+
const rawHTML = serializeDoctype(document.doctype) + '<html></html>';
|
|
6378
6601
|
try {
|
|
6379
|
-
//
|
|
6380
|
-
//
|
|
6381
|
-
const
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6602
|
+
// Fixing "This document requires 'TrustedHTML' assignment. The action has been blocked." error.
|
|
6603
|
+
// Reuse existing policy when present (e.g. second html2canvas call) to avoid createPolicy duplicate-name throw.
|
|
6604
|
+
const ownerWindow = this.referenceElement.ownerDocument?.defaultView;
|
|
6605
|
+
const trustedTypesFactory = ownerWindow && ownerWindow.trustedTypes;
|
|
6606
|
+
let policy = trustedTypesFactory?.getPolicy?.('html2canvas-pro');
|
|
6607
|
+
if (!policy && trustedTypesFactory) {
|
|
6608
|
+
policy = trustedTypesFactory.createPolicy('html2canvas-pro', {
|
|
6609
|
+
createHTML: (string) => string
|
|
6610
|
+
});
|
|
6611
|
+
}
|
|
6612
|
+
if (policy) {
|
|
6613
|
+
documentClone.write(policy.createHTML(rawHTML));
|
|
6614
|
+
}
|
|
6615
|
+
else {
|
|
6616
|
+
documentClone.write(rawHTML);
|
|
6617
|
+
}
|
|
6387
6618
|
}
|
|
6388
|
-
catch (
|
|
6389
|
-
|
|
6390
|
-
documentClone.write(serializeDoctype(document.doctype) + '<html></html>');
|
|
6619
|
+
catch (_e) {
|
|
6620
|
+
documentClone.write(rawHTML);
|
|
6391
6621
|
}
|
|
6392
6622
|
// Chrome scrolls the parent document for some reason after the write to the cloned window???
|
|
6393
6623
|
restoreOwnerScroll(this.referenceElement.ownerDocument, scrollX, scrollY);
|
|
6394
6624
|
/**
|
|
6395
|
-
*
|
|
6625
|
+
* IMPORTANT: documentClone.close() MUST be called BEFORE adoptNode().
|
|
6396
6626
|
*
|
|
6397
|
-
* In Chrome, calling adoptNode()
|
|
6398
|
-
*
|
|
6627
|
+
* In Chrome, calling adoptNode() while the document is still "open"
|
|
6628
|
+
* (between document.open() and document.close()) causes CSS rules with
|
|
6629
|
+
* uppercase characters in class names (e.g. ".MyClass") to not match
|
|
6630
|
+
* correctly. Chrome's CSS engine only enters a fully-resolved matching
|
|
6631
|
+
* mode once the document is closed.
|
|
6399
6632
|
*
|
|
6400
|
-
*
|
|
6401
|
-
*
|
|
6402
|
-
*
|
|
6403
|
-
*
|
|
6633
|
+
* Correct order: open() → write() → close() → adoptNode() → replaceChild()
|
|
6634
|
+
*
|
|
6635
|
+
* Timing: close() queues the iframe 'load' event; because JS is single-threaded,
|
|
6636
|
+
* the synchronous adoptNode() and replaceChild() below complete before that
|
|
6637
|
+
* event is dispatched. iframeLoader's setInterval will therefore see the body
|
|
6638
|
+
* already populated on its first tick.
|
|
6639
|
+
*/
|
|
6640
|
+
documentClone.close();
|
|
6404
6641
|
const adoptedNode = documentClone.adoptNode(this.documentElement);
|
|
6405
6642
|
addBase(adoptedNode, baseUri);
|
|
6406
6643
|
documentClone.replaceChild(adoptedNode, documentClone.documentElement);
|
|
6407
|
-
documentClone.close();
|
|
6408
6644
|
return iframeLoad;
|
|
6409
6645
|
}
|
|
6410
6646
|
createElementClone(node) {
|
|
@@ -6933,13 +7169,16 @@
|
|
|
6933
7169
|
str += doctype.name;
|
|
6934
7170
|
}
|
|
6935
7171
|
if (doctype.internalSubset) {
|
|
6936
|
-
str += doctype.internalSubset;
|
|
7172
|
+
str += ' ' + doctype.internalSubset.replace(/"/g, '"').replace(/>/g, '>');
|
|
6937
7173
|
}
|
|
6938
7174
|
if (doctype.publicId) {
|
|
6939
|
-
str +=
|
|
7175
|
+
str += ' PUBLIC "' + doctype.publicId.replace(/"/g, '"') + '"';
|
|
7176
|
+
if (doctype.systemId) {
|
|
7177
|
+
str += ' "' + doctype.systemId.replace(/"/g, '"') + '"';
|
|
7178
|
+
}
|
|
6940
7179
|
}
|
|
6941
|
-
if (doctype.systemId) {
|
|
6942
|
-
str +=
|
|
7180
|
+
else if (doctype.systemId) {
|
|
7181
|
+
str += ' SYSTEM "' + doctype.systemId.replace(/"/g, '"') + '"';
|
|
6943
7182
|
}
|
|
6944
7183
|
str += '>';
|
|
6945
7184
|
}
|
|
@@ -7228,9 +7467,23 @@
|
|
|
7228
7467
|
this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
|
|
7229
7468
|
}
|
|
7230
7469
|
}
|
|
7470
|
+
/**
|
|
7471
|
+
* Clips the element and all its descendants to an arbitrary canvas-drawn shape.
|
|
7472
|
+
* The `applyClip` callback is responsible for calling beginPath, the shape
|
|
7473
|
+
* operations, and ctx.clip() — giving each shape type full control over how
|
|
7474
|
+
* the path is constructed (arc, ellipse, lineTo, Path2D, etc.).
|
|
7475
|
+
*/
|
|
7476
|
+
class ClipPathEffect {
|
|
7477
|
+
constructor(applyClip) {
|
|
7478
|
+
this.applyClip = applyClip;
|
|
7479
|
+
this.type = 3 /* EffectType.CLIP_PATH */;
|
|
7480
|
+
this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
|
|
7481
|
+
}
|
|
7482
|
+
}
|
|
7231
7483
|
const isTransformEffect = (effect) => effect.type === 0 /* EffectType.TRANSFORM */;
|
|
7232
7484
|
const isClipEffect = (effect) => effect.type === 1 /* EffectType.CLIP */;
|
|
7233
7485
|
const isOpacityEffect = (effect) => effect.type === 2 /* EffectType.OPACITY */;
|
|
7486
|
+
const isClipPathEffect = (effect) => effect.type === 3 /* EffectType.CLIP_PATH */;
|
|
7234
7487
|
|
|
7235
7488
|
const equalPath = (a, b) => {
|
|
7236
7489
|
if (a.length === b.length) {
|
|
@@ -7305,6 +7558,12 @@
|
|
|
7305
7558
|
this.effects.push(new ClipEffect(paddingBox, 4 /* EffectTarget.CONTENT */));
|
|
7306
7559
|
}
|
|
7307
7560
|
}
|
|
7561
|
+
if (this.container.styles.clipPath.type !== 0 /* CLIP_PATH_TYPE.NONE */) {
|
|
7562
|
+
const clipPathEffect = buildClipPathEffect(this.container.styles.clipPath, this.container.bounds);
|
|
7563
|
+
if (clipPathEffect) {
|
|
7564
|
+
this.effects.push(clipPathEffect);
|
|
7565
|
+
}
|
|
7566
|
+
}
|
|
7308
7567
|
}
|
|
7309
7568
|
getEffects(target) {
|
|
7310
7569
|
let inFlow = [2 /* POSITION.ABSOLUTE */, 3 /* POSITION.FIXED */].indexOf(this.container.styles.position) === -1;
|
|
@@ -7331,6 +7590,126 @@
|
|
|
7331
7590
|
return effects.filter((effect) => contains(effect.target, target));
|
|
7332
7591
|
}
|
|
7333
7592
|
}
|
|
7593
|
+
/**
|
|
7594
|
+
* Resolve a `closest-side` or `farthest-side` shape-radius keyword to pixels
|
|
7595
|
+
* for a single axis. Used by both `circle()` (per-axis) and `ellipse()`.
|
|
7596
|
+
*
|
|
7597
|
+
* @param r - The ShapeRadius (keyword or length-percentage).
|
|
7598
|
+
* @param center - Absolute center coordinate on this axis (cx or cy).
|
|
7599
|
+
* @param start - Absolute start of the reference box on this axis.
|
|
7600
|
+
* @param end - Absolute end of the reference box on this axis.
|
|
7601
|
+
* @param dimRef - Reference dimension for resolving a length-percentage value.
|
|
7602
|
+
*/
|
|
7603
|
+
const resolveAxisRadius = (r, center, start, end, dimRef) => {
|
|
7604
|
+
if (r === 'closest-side')
|
|
7605
|
+
return Math.min(center - start, end - center);
|
|
7606
|
+
if (r === 'farthest-side')
|
|
7607
|
+
return Math.max(center - start, end - center);
|
|
7608
|
+
return getAbsoluteValue(r, dimRef);
|
|
7609
|
+
};
|
|
7610
|
+
/**
|
|
7611
|
+
* Convert a parsed ClipPathValue + element bounds into a ClipPathEffect whose
|
|
7612
|
+
* `applyClip` callback draws the clip shape directly onto the canvas context.
|
|
7613
|
+
*
|
|
7614
|
+
* All coordinates are computed in page-absolute space at construction time so
|
|
7615
|
+
* the callback itself is allocation-free and executes synchronously.
|
|
7616
|
+
*/
|
|
7617
|
+
const buildClipPathEffect = (clipPath, bounds) => {
|
|
7618
|
+
const { left: bLeft, top: bTop, width: bWidth, height: bHeight } = bounds;
|
|
7619
|
+
switch (clipPath.type) {
|
|
7620
|
+
case 1 /* CLIP_PATH_TYPE.INSET */: {
|
|
7621
|
+
const iLeft = getAbsoluteValue(clipPath.left, bWidth);
|
|
7622
|
+
const iTop = getAbsoluteValue(clipPath.top, bHeight);
|
|
7623
|
+
const x = bLeft + iLeft;
|
|
7624
|
+
const y = bTop + iTop;
|
|
7625
|
+
// Clamp to zero: per CSS spec, overlapping insets produce an empty shape.
|
|
7626
|
+
const w = Math.max(0, bWidth - iLeft - getAbsoluteValue(clipPath.right, bWidth));
|
|
7627
|
+
const h = Math.max(0, bHeight - iTop - getAbsoluteValue(clipPath.bottom, bHeight));
|
|
7628
|
+
return new ClipPathEffect((ctx) => {
|
|
7629
|
+
ctx.beginPath();
|
|
7630
|
+
ctx.rect(x, y, w, h);
|
|
7631
|
+
ctx.clip();
|
|
7632
|
+
});
|
|
7633
|
+
}
|
|
7634
|
+
case 2 /* CLIP_PATH_TYPE.CIRCLE */: {
|
|
7635
|
+
const cx = bLeft + getAbsoluteValue(clipPath.cx, bWidth);
|
|
7636
|
+
const cy = bTop + getAbsoluteValue(clipPath.cy, bHeight);
|
|
7637
|
+
let r;
|
|
7638
|
+
if (clipPath.radius === 'closest-side') {
|
|
7639
|
+
r = Math.min(cx - bLeft, cy - bTop, bLeft + bWidth - cx, bTop + bHeight - cy);
|
|
7640
|
+
}
|
|
7641
|
+
else if (clipPath.radius === 'farthest-side') {
|
|
7642
|
+
r = Math.max(cx - bLeft, cy - bTop, bLeft + bWidth - cx, bTop + bHeight - cy);
|
|
7643
|
+
}
|
|
7644
|
+
else {
|
|
7645
|
+
// Per CSS spec, percentage is relative to sqrt(w² + h²) / sqrt(2).
|
|
7646
|
+
r = getAbsoluteValue(clipPath.radius, Math.sqrt(bWidth * bWidth + bHeight * bHeight) / Math.SQRT2);
|
|
7647
|
+
}
|
|
7648
|
+
return new ClipPathEffect((ctx) => {
|
|
7649
|
+
ctx.beginPath();
|
|
7650
|
+
ctx.arc(cx, cy, Math.max(0, r), 0, Math.PI * 2);
|
|
7651
|
+
ctx.clip();
|
|
7652
|
+
});
|
|
7653
|
+
}
|
|
7654
|
+
case 3 /* CLIP_PATH_TYPE.ELLIPSE */: {
|
|
7655
|
+
const cx = bLeft + getAbsoluteValue(clipPath.cx, bWidth);
|
|
7656
|
+
const cy = bTop + getAbsoluteValue(clipPath.cy, bHeight);
|
|
7657
|
+
const rx = resolveAxisRadius(clipPath.rx, cx, bLeft, bLeft + bWidth, bWidth);
|
|
7658
|
+
const ry = resolveAxisRadius(clipPath.ry, cy, bTop, bTop + bHeight, bHeight);
|
|
7659
|
+
return new ClipPathEffect((ctx) => {
|
|
7660
|
+
ctx.beginPath();
|
|
7661
|
+
ctx.ellipse(cx, cy, Math.max(0, rx), Math.max(0, ry), 0, 0, Math.PI * 2);
|
|
7662
|
+
ctx.clip();
|
|
7663
|
+
});
|
|
7664
|
+
}
|
|
7665
|
+
case 4 /* CLIP_PATH_TYPE.POLYGON */: {
|
|
7666
|
+
// Pre-compute all vertices in page-absolute coordinates.
|
|
7667
|
+
const absPoints = clipPath.points.map(([px, py]) => [bLeft + getAbsoluteValue(px, bWidth), bTop + getAbsoluteValue(py, bHeight)]);
|
|
7668
|
+
return new ClipPathEffect((ctx) => {
|
|
7669
|
+
ctx.beginPath();
|
|
7670
|
+
if (absPoints.length > 0) {
|
|
7671
|
+
ctx.moveTo(absPoints[0][0], absPoints[0][1]);
|
|
7672
|
+
for (let i = 1; i < absPoints.length; i++) {
|
|
7673
|
+
ctx.lineTo(absPoints[i][0], absPoints[i][1]);
|
|
7674
|
+
}
|
|
7675
|
+
ctx.closePath();
|
|
7676
|
+
}
|
|
7677
|
+
// Calling clip() with an empty path (zero points) is intentional:
|
|
7678
|
+
// it clips the entire region to nothing, which is the correct
|
|
7679
|
+
// behaviour for a degenerate polygon() per the CSS spec.
|
|
7680
|
+
ctx.clip();
|
|
7681
|
+
});
|
|
7682
|
+
}
|
|
7683
|
+
case 5 /* CLIP_PATH_TYPE.PATH */: {
|
|
7684
|
+
// path() coordinates are in the element's local space (0,0 = element top-left).
|
|
7685
|
+
// We temporarily translate the canvas origin to the element's position, clip
|
|
7686
|
+
// with the Path2D, then restore only the transform matrix (not the clipping
|
|
7687
|
+
// region) via setTransform so the clip persists for the enclosing
|
|
7688
|
+
// ctx.save() / ctx.restore() pair managed by EffectsRenderer.
|
|
7689
|
+
//
|
|
7690
|
+
// When the element also has a CSS transform, that transform was already applied
|
|
7691
|
+
// by a preceding TransformEffect, so the path coordinates end up correctly in
|
|
7692
|
+
// the element's transformed local space — matching browser behaviour.
|
|
7693
|
+
const { d } = clipPath;
|
|
7694
|
+
return new ClipPathEffect((ctx) => {
|
|
7695
|
+
try {
|
|
7696
|
+
const savedTransform = ctx.getTransform();
|
|
7697
|
+
ctx.translate(bLeft, bTop);
|
|
7698
|
+
ctx.clip(new Path2D(d));
|
|
7699
|
+
ctx.setTransform(savedTransform);
|
|
7700
|
+
}
|
|
7701
|
+
catch (_e) {
|
|
7702
|
+
// Path2D or getTransform/setTransform not supported in this environment.
|
|
7703
|
+
}
|
|
7704
|
+
});
|
|
7705
|
+
}
|
|
7706
|
+
case 0 /* CLIP_PATH_TYPE.NONE */:
|
|
7707
|
+
return null;
|
|
7708
|
+
default: {
|
|
7709
|
+
return null;
|
|
7710
|
+
}
|
|
7711
|
+
}
|
|
7712
|
+
};
|
|
7334
7713
|
const parseStackTree = (parent, stackingContext, realStackingContext, listItems) => {
|
|
7335
7714
|
parent.container.elements.forEach((child) => {
|
|
7336
7715
|
const treatAsRealStackingContext = contains(child.flags, 4 /* FLAGS.CREATES_REAL_STACKING_CONTEXT */);
|
|
@@ -8159,7 +8538,8 @@
|
|
|
8159
8538
|
* Handles rendering effects including:
|
|
8160
8539
|
* - Opacity effects
|
|
8161
8540
|
* - Transform effects (matrix transformations)
|
|
8162
|
-
* - Clip effects (clipping
|
|
8541
|
+
* - Clip effects (overflow / border-radius clipping via Path[])
|
|
8542
|
+
* - Clip-path effects (CSS clip-path shapes: inset, circle, ellipse, polygon, path)
|
|
8163
8543
|
*/
|
|
8164
8544
|
/**
|
|
8165
8545
|
* Effects Renderer
|
|
@@ -8194,21 +8574,25 @@
|
|
|
8194
8574
|
*/
|
|
8195
8575
|
applyEffect(effect) {
|
|
8196
8576
|
this.ctx.save();
|
|
8197
|
-
// Apply opacity effect
|
|
8198
8577
|
if (isOpacityEffect(effect)) {
|
|
8578
|
+
// Opacity: multiply into the current global alpha for nested transparency.
|
|
8199
8579
|
this.ctx.globalAlpha = effect.opacity;
|
|
8200
8580
|
}
|
|
8201
|
-
|
|
8202
|
-
|
|
8581
|
+
else if (isTransformEffect(effect)) {
|
|
8582
|
+
// Transform: translate to origin, apply matrix, translate back.
|
|
8203
8583
|
this.ctx.translate(effect.offsetX, effect.offsetY);
|
|
8204
8584
|
this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
|
|
8205
8585
|
this.ctx.translate(-effect.offsetX, -effect.offsetY);
|
|
8206
8586
|
}
|
|
8207
|
-
|
|
8208
|
-
|
|
8587
|
+
else if (isClipEffect(effect)) {
|
|
8588
|
+
// Clip (overflow / border-radius): build path via callback then clip.
|
|
8209
8589
|
this.pathCallback.path(effect.path);
|
|
8210
8590
|
this.ctx.clip();
|
|
8211
8591
|
}
|
|
8592
|
+
else if (isClipPathEffect(effect)) {
|
|
8593
|
+
// Clip-path: delegate shape drawing (beginPath … clip()) to the effect.
|
|
8594
|
+
effect.applyClip(this.ctx);
|
|
8595
|
+
}
|
|
8212
8596
|
this.activeEffects.push(effect);
|
|
8213
8597
|
}
|
|
8214
8598
|
/**
|
|
@@ -8251,6 +8635,22 @@
|
|
|
8251
8635
|
*/
|
|
8252
8636
|
// iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
|
|
8253
8637
|
const iOSBrokenFonts = ['-apple-system', 'system-ui'];
|
|
8638
|
+
/**
|
|
8639
|
+
* Detect CJK (Chinese, Japanese, Korean) characters in a string.
|
|
8640
|
+
* CJK characters use the ideographic baseline in browsers, which differs
|
|
8641
|
+
* from the alphabetic baseline used for Latin script.
|
|
8642
|
+
*
|
|
8643
|
+
* Covers:
|
|
8644
|
+
* U+2E80–U+2FFF CJK Radicals Supplement, Kangxi Radicals
|
|
8645
|
+
* U+3000–U+30FF CJK Symbols & Punctuation (。、「」…), Hiragana, Katakana
|
|
8646
|
+
* U+3400–U+4DBF CJK Extension A
|
|
8647
|
+
* U+4E00–U+9FFF CJK Unified Ideographs (most common Chinese/Japanese/Korean)
|
|
8648
|
+
* U+AC00–U+D7AF Hangul Syllables
|
|
8649
|
+
* U+F900–U+FAFF CJK Compatibility Ideographs
|
|
8650
|
+
* U+FF01–U+FFEF Halfwidth and Fullwidth Forms (A B 1 2 ! ? etc.)
|
|
8651
|
+
*/
|
|
8652
|
+
const CJK_CHAR_REGEX = /[\u2E80-\u2FFF\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFFEF]/;
|
|
8653
|
+
const hasCJKCharacters = (text) => CJK_CHAR_REGEX.test(text);
|
|
8254
8654
|
/**
|
|
8255
8655
|
* Detect iOS version from user agent
|
|
8256
8656
|
* Returns null if not iOS or version cannot be determined
|
|
@@ -8308,21 +8708,50 @@
|
|
|
8308
8708
|
this.options = deps.options;
|
|
8309
8709
|
}
|
|
8310
8710
|
/**
|
|
8311
|
-
*
|
|
8312
|
-
*
|
|
8711
|
+
* Iterate grapheme clusters one-by-one, applying correct letter-spacing and
|
|
8712
|
+
* per-script baseline for each character.
|
|
8713
|
+
*
|
|
8714
|
+
* Issue #73: When letter-spacing is non-zero, text must be rendered character by
|
|
8715
|
+
* character. This helper centralises two fixes applied during that iteration:
|
|
8716
|
+
* 1. Add `letterSpacing` to each character's advance width (was previously
|
|
8717
|
+
* omitted, causing characters to render without any spacing).
|
|
8718
|
+
* 2. Switch to the ideographic baseline for CJK glyphs so their vertical
|
|
8719
|
+
* position matches how browsers lay them out in the DOM.
|
|
8720
|
+
*
|
|
8721
|
+
* The `renderFn` callback receives (letter, x, y) and performs the actual draw
|
|
8722
|
+
* call (fillText or strokeText), allowing fill and stroke paths to share one
|
|
8723
|
+
* implementation.
|
|
8724
|
+
*/
|
|
8725
|
+
iterateLettersWithLetterSpacing(text, letterSpacing, baseline, renderFn) {
|
|
8726
|
+
const letters = segmentGraphemes(text.text);
|
|
8727
|
+
const y = text.bounds.top + baseline;
|
|
8728
|
+
let left = text.bounds.left;
|
|
8729
|
+
for (const letter of letters) {
|
|
8730
|
+
if (hasCJKCharacters(letter)) {
|
|
8731
|
+
const savedBaseline = this.ctx.textBaseline;
|
|
8732
|
+
this.ctx.textBaseline = 'ideographic';
|
|
8733
|
+
renderFn(letter, left, y);
|
|
8734
|
+
this.ctx.textBaseline = savedBaseline;
|
|
8735
|
+
}
|
|
8736
|
+
else {
|
|
8737
|
+
renderFn(letter, left, y);
|
|
8738
|
+
}
|
|
8739
|
+
left += this.ctx.measureText(letter).width + letterSpacing;
|
|
8740
|
+
}
|
|
8741
|
+
}
|
|
8742
|
+
/**
|
|
8743
|
+
* Render text with letter-spacing applied (fill pass).
|
|
8744
|
+
* When letterSpacing is 0 the whole string is drawn in one call; otherwise each
|
|
8745
|
+
* grapheme is drawn individually so spacing and CJK baseline are applied correctly.
|
|
8313
8746
|
*/
|
|
8314
8747
|
renderTextWithLetterSpacing(text, letterSpacing, baseline) {
|
|
8315
8748
|
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
8749
|
this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
|
|
8319
8750
|
}
|
|
8320
8751
|
else {
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
return left + this.ctx.measureText(letter).width;
|
|
8325
|
-
}, text.bounds.left);
|
|
8752
|
+
this.iterateLettersWithLetterSpacing(text, letterSpacing, baseline, (letter, x, y) => {
|
|
8753
|
+
this.ctx.fillText(letter, x, y);
|
|
8754
|
+
});
|
|
8326
8755
|
}
|
|
8327
8756
|
}
|
|
8328
8757
|
/**
|
|
@@ -8340,8 +8769,17 @@
|
|
|
8340
8769
|
if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
|
|
8341
8770
|
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
|
|
8342
8771
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
8343
|
-
this.ctx.lineJoin =
|
|
8344
|
-
|
|
8772
|
+
this.ctx.lineJoin =
|
|
8773
|
+
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
|
|
8774
|
+
if (styles.letterSpacing === 0) {
|
|
8775
|
+
this.ctx.strokeText(textBound.text, textBound.bounds.left, textBound.bounds.top + styles.fontSize.number);
|
|
8776
|
+
}
|
|
8777
|
+
else {
|
|
8778
|
+
this.iterateLettersWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
|
|
8779
|
+
}
|
|
8780
|
+
this.ctx.strokeStyle = '';
|
|
8781
|
+
this.ctx.lineWidth = 0;
|
|
8782
|
+
this.ctx.lineJoin = 'miter';
|
|
8345
8783
|
}
|
|
8346
8784
|
break;
|
|
8347
8785
|
}
|
|
@@ -8451,26 +8889,47 @@
|
|
|
8451
8889
|
}
|
|
8452
8890
|
// Helper method to truncate text and add ellipsis if needed
|
|
8453
8891
|
truncateTextWithEllipsis(text, maxWidth, letterSpacing) {
|
|
8454
|
-
|
|
8892
|
+
// Use the Unicode ellipsis character (U+2026) whose width the browser measures
|
|
8893
|
+
// as a single glyph, matching native text-overflow behaviour more closely.
|
|
8894
|
+
const ellipsis = '\u2026';
|
|
8455
8895
|
const ellipsisWidth = this.ctx.measureText(ellipsis).width;
|
|
8896
|
+
// Segment into grapheme clusters so multi-byte characters (emoji, composed
|
|
8897
|
+
// sequences) are never split mid-character.
|
|
8898
|
+
const graphemes = segmentGraphemes(text);
|
|
8456
8899
|
if (letterSpacing === 0) {
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8900
|
+
// Measure the whole candidate string for accuracy: the browser applies
|
|
8901
|
+
// kerning and ligatures when rendering multiple glyphs together, so
|
|
8902
|
+
// measuring them as one string is more precise than summing individual widths.
|
|
8903
|
+
// Binary search reduces measurements from O(n) to O(log n).
|
|
8904
|
+
const fits = (n) => this.ctx.measureText(graphemes.slice(0, n).join('')).width + ellipsisWidth <= maxWidth;
|
|
8905
|
+
let lo = 0;
|
|
8906
|
+
let hi = graphemes.length;
|
|
8907
|
+
while (lo < hi) {
|
|
8908
|
+
const mid = (lo + hi + 1) >> 1;
|
|
8909
|
+
if (fits(mid)) {
|
|
8910
|
+
lo = mid;
|
|
8911
|
+
}
|
|
8912
|
+
else {
|
|
8913
|
+
hi = mid - 1;
|
|
8914
|
+
}
|
|
8460
8915
|
}
|
|
8461
|
-
return
|
|
8916
|
+
return graphemes.slice(0, lo).join('') + ellipsis;
|
|
8462
8917
|
}
|
|
8463
8918
|
else {
|
|
8464
|
-
const letters = segmentGraphemes(text);
|
|
8465
8919
|
let width = ellipsisWidth;
|
|
8466
|
-
|
|
8467
|
-
for (const letter of
|
|
8468
|
-
const
|
|
8469
|
-
|
|
8920
|
+
const result = [];
|
|
8921
|
+
for (const letter of graphemes) {
|
|
8922
|
+
const glyphWidth = this.ctx.measureText(letter).width;
|
|
8923
|
+
// Check against glyph width only (no trailing spacing): letter-spacing
|
|
8924
|
+
// is applied *between* characters, not after the final glyph. Using
|
|
8925
|
+
// `glyphWidth + letterSpacing` would incorrectly discard letters that
|
|
8926
|
+
// fit as the last character before the ellipsis.
|
|
8927
|
+
if (width + glyphWidth > maxWidth) {
|
|
8470
8928
|
break;
|
|
8471
8929
|
}
|
|
8472
8930
|
result.push(letter);
|
|
8473
|
-
|
|
8931
|
+
// Accumulate glyph + inter-character spacing for the *next* iteration.
|
|
8932
|
+
width += glyphWidth + letterSpacing;
|
|
8474
8933
|
}
|
|
8475
8934
|
return result.join('') + ellipsis;
|
|
8476
8935
|
}
|
|
@@ -8546,6 +9005,8 @@
|
|
|
8546
9005
|
const firstBound = lastLine[0];
|
|
8547
9006
|
const availableWidth = containerBounds.width - (firstBound.bounds.left - containerBounds.left);
|
|
8548
9007
|
const truncatedText = this.truncateTextWithEllipsis(lastLineText, availableWidth, styles.letterSpacing);
|
|
9008
|
+
// Build TextBounds once; reused for fill and stroke without re-allocating.
|
|
9009
|
+
const truncatedBounds = new TextBounds(truncatedText, firstBound.bounds);
|
|
8549
9010
|
paintOrder.forEach((paintOrderLayer) => {
|
|
8550
9011
|
switch (paintOrderLayer) {
|
|
8551
9012
|
case 0 /* PAINT_ORDER_LAYER.FILL */:
|
|
@@ -8554,28 +9015,24 @@
|
|
|
8554
9015
|
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8555
9016
|
}
|
|
8556
9017
|
else {
|
|
8557
|
-
|
|
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);
|
|
9018
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
|
|
8562
9019
|
}
|
|
8563
9020
|
break;
|
|
8564
9021
|
case 1 /* PAINT_ORDER_LAYER.STROKE */:
|
|
8565
9022
|
if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
|
|
8566
9023
|
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
|
|
8567
9024
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
8568
|
-
this.ctx.lineJoin =
|
|
9025
|
+
this.ctx.lineJoin =
|
|
9026
|
+
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
|
|
8569
9027
|
if (styles.letterSpacing === 0) {
|
|
8570
9028
|
this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8571
9029
|
}
|
|
8572
9030
|
else {
|
|
8573
|
-
|
|
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);
|
|
9031
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
|
|
8578
9032
|
}
|
|
9033
|
+
this.ctx.strokeStyle = '';
|
|
9034
|
+
this.ctx.lineWidth = 0;
|
|
9035
|
+
this.ctx.lineJoin = 'miter';
|
|
8579
9036
|
}
|
|
8580
9037
|
break;
|
|
8581
9038
|
}
|
|
@@ -8619,19 +9076,18 @@
|
|
|
8619
9076
|
// If ellipsis is needed, render the truncated text once
|
|
8620
9077
|
if (needsEllipsis) {
|
|
8621
9078
|
const firstBound = text.textBounds[0];
|
|
9079
|
+
// Build TextBounds once; reused across paint layers and every shadow pass
|
|
9080
|
+
// to avoid repeated allocation inside forEach callbacks.
|
|
9081
|
+
const truncatedBounds = new TextBounds(truncatedText, firstBound.bounds);
|
|
8622
9082
|
paintOrder.forEach((paintOrderLayer) => {
|
|
8623
9083
|
switch (paintOrderLayer) {
|
|
8624
|
-
case 0 /* PAINT_ORDER_LAYER.FILL */:
|
|
9084
|
+
case 0 /* PAINT_ORDER_LAYER.FILL */: {
|
|
8625
9085
|
this.ctx.fillStyle = asString(styles.color);
|
|
8626
9086
|
if (styles.letterSpacing === 0) {
|
|
8627
9087
|
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8628
9088
|
}
|
|
8629
9089
|
else {
|
|
8630
|
-
|
|
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);
|
|
9090
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
|
|
8635
9091
|
}
|
|
8636
9092
|
const textShadows = styles.textShadow;
|
|
8637
9093
|
if (textShadows.length && truncatedText.trim().length) {
|
|
@@ -8647,11 +9103,7 @@
|
|
|
8647
9103
|
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8648
9104
|
}
|
|
8649
9105
|
else {
|
|
8650
|
-
|
|
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);
|
|
9106
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
|
|
8655
9107
|
}
|
|
8656
9108
|
});
|
|
8657
9109
|
this.ctx.shadowColor = '';
|
|
@@ -8660,21 +9112,22 @@
|
|
|
8660
9112
|
this.ctx.shadowBlur = 0;
|
|
8661
9113
|
}
|
|
8662
9114
|
break;
|
|
9115
|
+
}
|
|
8663
9116
|
case 1 /* PAINT_ORDER_LAYER.STROKE */:
|
|
8664
9117
|
if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
|
|
8665
9118
|
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
|
|
8666
9119
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
8667
|
-
this.ctx.lineJoin =
|
|
9120
|
+
this.ctx.lineJoin =
|
|
9121
|
+
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
|
|
8668
9122
|
if (styles.letterSpacing === 0) {
|
|
8669
9123
|
this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8670
9124
|
}
|
|
8671
9125
|
else {
|
|
8672
|
-
|
|
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);
|
|
9126
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
|
|
8677
9127
|
}
|
|
9128
|
+
this.ctx.strokeStyle = '';
|
|
9129
|
+
this.ctx.lineWidth = 0;
|
|
9130
|
+
this.ctx.lineJoin = 'miter';
|
|
8678
9131
|
}
|
|
8679
9132
|
break;
|
|
8680
9133
|
}
|
|
@@ -8685,7 +9138,7 @@
|
|
|
8685
9138
|
text.textBounds.forEach((text) => {
|
|
8686
9139
|
paintOrder.forEach((paintOrderLayer) => {
|
|
8687
9140
|
switch (paintOrderLayer) {
|
|
8688
|
-
case 0 /* PAINT_ORDER_LAYER.FILL */:
|
|
9141
|
+
case 0 /* PAINT_ORDER_LAYER.FILL */: {
|
|
8689
9142
|
this.ctx.fillStyle = asString(styles.color);
|
|
8690
9143
|
this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
|
|
8691
9144
|
const textShadows = styles.textShadow;
|
|
@@ -8709,29 +9162,26 @@
|
|
|
8709
9162
|
this.renderTextDecoration(text.bounds, styles);
|
|
8710
9163
|
}
|
|
8711
9164
|
break;
|
|
8712
|
-
|
|
9165
|
+
}
|
|
9166
|
+
case 1 /* PAINT_ORDER_LAYER.STROKE */: {
|
|
8713
9167
|
if (styles.webkitTextStrokeWidth && text.text.trim().length) {
|
|
8714
9168
|
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
|
|
8715
9169
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
8716
|
-
this.ctx.lineJoin =
|
|
8717
|
-
|
|
8718
|
-
// Previously used text.bounds.height which caused stroke to render too low
|
|
9170
|
+
this.ctx.lineJoin =
|
|
9171
|
+
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
|
|
8719
9172
|
const baseline = styles.fontSize.number;
|
|
8720
9173
|
if (styles.letterSpacing === 0) {
|
|
8721
9174
|
this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
|
|
8722
9175
|
}
|
|
8723
9176
|
else {
|
|
8724
|
-
|
|
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);
|
|
9177
|
+
this.iterateLettersWithLetterSpacing(text, styles.letterSpacing, baseline, (letter, x, y) => this.ctx.strokeText(letter, x, y));
|
|
8729
9178
|
}
|
|
9179
|
+
this.ctx.strokeStyle = '';
|
|
9180
|
+
this.ctx.lineWidth = 0;
|
|
9181
|
+
this.ctx.lineJoin = 'miter';
|
|
8730
9182
|
}
|
|
8731
|
-
this.ctx.strokeStyle = '';
|
|
8732
|
-
this.ctx.lineWidth = 0;
|
|
8733
|
-
this.ctx.lineJoin = 'miter';
|
|
8734
9183
|
break;
|
|
9184
|
+
}
|
|
8735
9185
|
}
|
|
8736
9186
|
});
|
|
8737
9187
|
});
|