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
|
@@ -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
|
*/
|
|
@@ -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
|
|
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
|
-
//
|
|
4837
|
-
// getBoundingClientRect
|
|
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 = '
|
|
5056
|
+
element.style.transform = 'translate(0, 0)';
|
|
4841
5057
|
}
|
|
4842
|
-
//
|
|
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 = '
|
|
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
|
-
*
|
|
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
|
-
//
|
|
6374
|
-
//
|
|
6375
|
-
const
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
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 (
|
|
6383
|
-
|
|
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
|
-
*
|
|
6619
|
+
* IMPORTANT: documentClone.close() MUST be called BEFORE adoptNode().
|
|
6390
6620
|
*
|
|
6391
|
-
* In Chrome, calling adoptNode()
|
|
6392
|
-
*
|
|
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
|
-
*
|
|
6395
|
-
*
|
|
6396
|
-
*
|
|
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, '"').replace(/>/g, '>');
|
|
6931
7167
|
}
|
|
6932
7168
|
if (doctype.publicId) {
|
|
6933
|
-
str +=
|
|
7169
|
+
str += ' PUBLIC "' + doctype.publicId.replace(/"/g, '"') + '"';
|
|
7170
|
+
if (doctype.systemId) {
|
|
7171
|
+
str += ' "' + doctype.systemId.replace(/"/g, '"') + '"';
|
|
7172
|
+
}
|
|
6934
7173
|
}
|
|
6935
|
-
if (doctype.systemId) {
|
|
6936
|
-
str +=
|
|
7174
|
+
else if (doctype.systemId) {
|
|
7175
|
+
str += ' SYSTEM "' + doctype.systemId.replace(/"/g, '"') + '"';
|
|
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
|
|
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
|
-
|
|
8196
|
-
|
|
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
|
-
|
|
8202
|
-
|
|
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
|
-
*
|
|
8306
|
-
*
|
|
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
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
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 =
|
|
8338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
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
|
|
8910
|
+
return graphemes.slice(0, lo).join('') + ellipsis;
|
|
8456
8911
|
}
|
|
8457
8912
|
else {
|
|
8458
|
-
const letters = segmentGraphemes(text);
|
|
8459
8913
|
let width = ellipsisWidth;
|
|
8460
|
-
|
|
8461
|
-
for (const letter of
|
|
8462
|
-
const
|
|
8463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
8711
|
-
|
|
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
|
-
|
|
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
|
});
|