html2canvas-pro 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/html2canvas-pro.esm.js +524 -91
- package/dist/html2canvas-pro.esm.js.map +1 -1
- package/dist/html2canvas-pro.js +524 -91
- 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/clip-path.js +190 -0
- package/dist/lib/css/property-descriptors/clip-path.js.map +1 -0
- 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.1 <https://yorickshan.github.io/html2canvas-pro/>
|
|
3
3
|
* Copyright (c) 2024-present yorickshan and html2canvas-pro contributors
|
|
4
4
|
* Released under MIT License
|
|
5
5
|
*/
|
|
@@ -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
|
}
|
|
@@ -7222,9 +7444,23 @@ class OpacityEffect {
|
|
|
7222
7444
|
this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
|
|
7223
7445
|
}
|
|
7224
7446
|
}
|
|
7447
|
+
/**
|
|
7448
|
+
* Clips the element and all its descendants to an arbitrary canvas-drawn shape.
|
|
7449
|
+
* The `applyClip` callback is responsible for calling beginPath, the shape
|
|
7450
|
+
* operations, and ctx.clip() — giving each shape type full control over how
|
|
7451
|
+
* the path is constructed (arc, ellipse, lineTo, Path2D, etc.).
|
|
7452
|
+
*/
|
|
7453
|
+
class ClipPathEffect {
|
|
7454
|
+
constructor(applyClip) {
|
|
7455
|
+
this.applyClip = applyClip;
|
|
7456
|
+
this.type = 3 /* EffectType.CLIP_PATH */;
|
|
7457
|
+
this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
|
|
7458
|
+
}
|
|
7459
|
+
}
|
|
7225
7460
|
const isTransformEffect = (effect) => effect.type === 0 /* EffectType.TRANSFORM */;
|
|
7226
7461
|
const isClipEffect = (effect) => effect.type === 1 /* EffectType.CLIP */;
|
|
7227
7462
|
const isOpacityEffect = (effect) => effect.type === 2 /* EffectType.OPACITY */;
|
|
7463
|
+
const isClipPathEffect = (effect) => effect.type === 3 /* EffectType.CLIP_PATH */;
|
|
7228
7464
|
|
|
7229
7465
|
const equalPath = (a, b) => {
|
|
7230
7466
|
if (a.length === b.length) {
|
|
@@ -7299,6 +7535,12 @@ class ElementPaint {
|
|
|
7299
7535
|
this.effects.push(new ClipEffect(paddingBox, 4 /* EffectTarget.CONTENT */));
|
|
7300
7536
|
}
|
|
7301
7537
|
}
|
|
7538
|
+
if (this.container.styles.clipPath.type !== 0 /* CLIP_PATH_TYPE.NONE */) {
|
|
7539
|
+
const clipPathEffect = buildClipPathEffect(this.container.styles.clipPath, this.container.bounds);
|
|
7540
|
+
if (clipPathEffect) {
|
|
7541
|
+
this.effects.push(clipPathEffect);
|
|
7542
|
+
}
|
|
7543
|
+
}
|
|
7302
7544
|
}
|
|
7303
7545
|
getEffects(target) {
|
|
7304
7546
|
let inFlow = [2 /* POSITION.ABSOLUTE */, 3 /* POSITION.FIXED */].indexOf(this.container.styles.position) === -1;
|
|
@@ -7325,6 +7567,126 @@ class ElementPaint {
|
|
|
7325
7567
|
return effects.filter((effect) => contains(effect.target, target));
|
|
7326
7568
|
}
|
|
7327
7569
|
}
|
|
7570
|
+
/**
|
|
7571
|
+
* Resolve a `closest-side` or `farthest-side` shape-radius keyword to pixels
|
|
7572
|
+
* for a single axis. Used by both `circle()` (per-axis) and `ellipse()`.
|
|
7573
|
+
*
|
|
7574
|
+
* @param r - The ShapeRadius (keyword or length-percentage).
|
|
7575
|
+
* @param center - Absolute center coordinate on this axis (cx or cy).
|
|
7576
|
+
* @param start - Absolute start of the reference box on this axis.
|
|
7577
|
+
* @param end - Absolute end of the reference box on this axis.
|
|
7578
|
+
* @param dimRef - Reference dimension for resolving a length-percentage value.
|
|
7579
|
+
*/
|
|
7580
|
+
const resolveAxisRadius = (r, center, start, end, dimRef) => {
|
|
7581
|
+
if (r === 'closest-side')
|
|
7582
|
+
return Math.min(center - start, end - center);
|
|
7583
|
+
if (r === 'farthest-side')
|
|
7584
|
+
return Math.max(center - start, end - center);
|
|
7585
|
+
return getAbsoluteValue(r, dimRef);
|
|
7586
|
+
};
|
|
7587
|
+
/**
|
|
7588
|
+
* Convert a parsed ClipPathValue + element bounds into a ClipPathEffect whose
|
|
7589
|
+
* `applyClip` callback draws the clip shape directly onto the canvas context.
|
|
7590
|
+
*
|
|
7591
|
+
* All coordinates are computed in page-absolute space at construction time so
|
|
7592
|
+
* the callback itself is allocation-free and executes synchronously.
|
|
7593
|
+
*/
|
|
7594
|
+
const buildClipPathEffect = (clipPath, bounds) => {
|
|
7595
|
+
const { left: bLeft, top: bTop, width: bWidth, height: bHeight } = bounds;
|
|
7596
|
+
switch (clipPath.type) {
|
|
7597
|
+
case 1 /* CLIP_PATH_TYPE.INSET */: {
|
|
7598
|
+
const iLeft = getAbsoluteValue(clipPath.left, bWidth);
|
|
7599
|
+
const iTop = getAbsoluteValue(clipPath.top, bHeight);
|
|
7600
|
+
const x = bLeft + iLeft;
|
|
7601
|
+
const y = bTop + iTop;
|
|
7602
|
+
// Clamp to zero: per CSS spec, overlapping insets produce an empty shape.
|
|
7603
|
+
const w = Math.max(0, bWidth - iLeft - getAbsoluteValue(clipPath.right, bWidth));
|
|
7604
|
+
const h = Math.max(0, bHeight - iTop - getAbsoluteValue(clipPath.bottom, bHeight));
|
|
7605
|
+
return new ClipPathEffect((ctx) => {
|
|
7606
|
+
ctx.beginPath();
|
|
7607
|
+
ctx.rect(x, y, w, h);
|
|
7608
|
+
ctx.clip();
|
|
7609
|
+
});
|
|
7610
|
+
}
|
|
7611
|
+
case 2 /* CLIP_PATH_TYPE.CIRCLE */: {
|
|
7612
|
+
const cx = bLeft + getAbsoluteValue(clipPath.cx, bWidth);
|
|
7613
|
+
const cy = bTop + getAbsoluteValue(clipPath.cy, bHeight);
|
|
7614
|
+
let r;
|
|
7615
|
+
if (clipPath.radius === 'closest-side') {
|
|
7616
|
+
r = Math.min(cx - bLeft, cy - bTop, bLeft + bWidth - cx, bTop + bHeight - cy);
|
|
7617
|
+
}
|
|
7618
|
+
else if (clipPath.radius === 'farthest-side') {
|
|
7619
|
+
r = Math.max(cx - bLeft, cy - bTop, bLeft + bWidth - cx, bTop + bHeight - cy);
|
|
7620
|
+
}
|
|
7621
|
+
else {
|
|
7622
|
+
// Per CSS spec, percentage is relative to sqrt(w² + h²) / sqrt(2).
|
|
7623
|
+
r = getAbsoluteValue(clipPath.radius, Math.sqrt(bWidth * bWidth + bHeight * bHeight) / Math.SQRT2);
|
|
7624
|
+
}
|
|
7625
|
+
return new ClipPathEffect((ctx) => {
|
|
7626
|
+
ctx.beginPath();
|
|
7627
|
+
ctx.arc(cx, cy, Math.max(0, r), 0, Math.PI * 2);
|
|
7628
|
+
ctx.clip();
|
|
7629
|
+
});
|
|
7630
|
+
}
|
|
7631
|
+
case 3 /* CLIP_PATH_TYPE.ELLIPSE */: {
|
|
7632
|
+
const cx = bLeft + getAbsoluteValue(clipPath.cx, bWidth);
|
|
7633
|
+
const cy = bTop + getAbsoluteValue(clipPath.cy, bHeight);
|
|
7634
|
+
const rx = resolveAxisRadius(clipPath.rx, cx, bLeft, bLeft + bWidth, bWidth);
|
|
7635
|
+
const ry = resolveAxisRadius(clipPath.ry, cy, bTop, bTop + bHeight, bHeight);
|
|
7636
|
+
return new ClipPathEffect((ctx) => {
|
|
7637
|
+
ctx.beginPath();
|
|
7638
|
+
ctx.ellipse(cx, cy, Math.max(0, rx), Math.max(0, ry), 0, 0, Math.PI * 2);
|
|
7639
|
+
ctx.clip();
|
|
7640
|
+
});
|
|
7641
|
+
}
|
|
7642
|
+
case 4 /* CLIP_PATH_TYPE.POLYGON */: {
|
|
7643
|
+
// Pre-compute all vertices in page-absolute coordinates.
|
|
7644
|
+
const absPoints = clipPath.points.map(([px, py]) => [bLeft + getAbsoluteValue(px, bWidth), bTop + getAbsoluteValue(py, bHeight)]);
|
|
7645
|
+
return new ClipPathEffect((ctx) => {
|
|
7646
|
+
ctx.beginPath();
|
|
7647
|
+
if (absPoints.length > 0) {
|
|
7648
|
+
ctx.moveTo(absPoints[0][0], absPoints[0][1]);
|
|
7649
|
+
for (let i = 1; i < absPoints.length; i++) {
|
|
7650
|
+
ctx.lineTo(absPoints[i][0], absPoints[i][1]);
|
|
7651
|
+
}
|
|
7652
|
+
ctx.closePath();
|
|
7653
|
+
}
|
|
7654
|
+
// Calling clip() with an empty path (zero points) is intentional:
|
|
7655
|
+
// it clips the entire region to nothing, which is the correct
|
|
7656
|
+
// behaviour for a degenerate polygon() per the CSS spec.
|
|
7657
|
+
ctx.clip();
|
|
7658
|
+
});
|
|
7659
|
+
}
|
|
7660
|
+
case 5 /* CLIP_PATH_TYPE.PATH */: {
|
|
7661
|
+
// path() coordinates are in the element's local space (0,0 = element top-left).
|
|
7662
|
+
// We temporarily translate the canvas origin to the element's position, clip
|
|
7663
|
+
// with the Path2D, then restore only the transform matrix (not the clipping
|
|
7664
|
+
// region) via setTransform so the clip persists for the enclosing
|
|
7665
|
+
// ctx.save() / ctx.restore() pair managed by EffectsRenderer.
|
|
7666
|
+
//
|
|
7667
|
+
// When the element also has a CSS transform, that transform was already applied
|
|
7668
|
+
// by a preceding TransformEffect, so the path coordinates end up correctly in
|
|
7669
|
+
// the element's transformed local space — matching browser behaviour.
|
|
7670
|
+
const { d } = clipPath;
|
|
7671
|
+
return new ClipPathEffect((ctx) => {
|
|
7672
|
+
try {
|
|
7673
|
+
const savedTransform = ctx.getTransform();
|
|
7674
|
+
ctx.translate(bLeft, bTop);
|
|
7675
|
+
ctx.clip(new Path2D(d));
|
|
7676
|
+
ctx.setTransform(savedTransform);
|
|
7677
|
+
}
|
|
7678
|
+
catch (_e) {
|
|
7679
|
+
// Path2D or getTransform/setTransform not supported in this environment.
|
|
7680
|
+
}
|
|
7681
|
+
});
|
|
7682
|
+
}
|
|
7683
|
+
case 0 /* CLIP_PATH_TYPE.NONE */:
|
|
7684
|
+
return null;
|
|
7685
|
+
default: {
|
|
7686
|
+
return null;
|
|
7687
|
+
}
|
|
7688
|
+
}
|
|
7689
|
+
};
|
|
7328
7690
|
const parseStackTree = (parent, stackingContext, realStackingContext, listItems) => {
|
|
7329
7691
|
parent.container.elements.forEach((child) => {
|
|
7330
7692
|
const treatAsRealStackingContext = contains(child.flags, 4 /* FLAGS.CREATES_REAL_STACKING_CONTEXT */);
|
|
@@ -8153,7 +8515,8 @@ class BorderRenderer {
|
|
|
8153
8515
|
* Handles rendering effects including:
|
|
8154
8516
|
* - Opacity effects
|
|
8155
8517
|
* - Transform effects (matrix transformations)
|
|
8156
|
-
* - Clip effects (clipping
|
|
8518
|
+
* - Clip effects (overflow / border-radius clipping via Path[])
|
|
8519
|
+
* - Clip-path effects (CSS clip-path shapes: inset, circle, ellipse, polygon, path)
|
|
8157
8520
|
*/
|
|
8158
8521
|
/**
|
|
8159
8522
|
* Effects Renderer
|
|
@@ -8188,21 +8551,25 @@ class EffectsRenderer {
|
|
|
8188
8551
|
*/
|
|
8189
8552
|
applyEffect(effect) {
|
|
8190
8553
|
this.ctx.save();
|
|
8191
|
-
// Apply opacity effect
|
|
8192
8554
|
if (isOpacityEffect(effect)) {
|
|
8555
|
+
// Opacity: multiply into the current global alpha for nested transparency.
|
|
8193
8556
|
this.ctx.globalAlpha = effect.opacity;
|
|
8194
8557
|
}
|
|
8195
|
-
|
|
8196
|
-
|
|
8558
|
+
else if (isTransformEffect(effect)) {
|
|
8559
|
+
// Transform: translate to origin, apply matrix, translate back.
|
|
8197
8560
|
this.ctx.translate(effect.offsetX, effect.offsetY);
|
|
8198
8561
|
this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
|
|
8199
8562
|
this.ctx.translate(-effect.offsetX, -effect.offsetY);
|
|
8200
8563
|
}
|
|
8201
|
-
|
|
8202
|
-
|
|
8564
|
+
else if (isClipEffect(effect)) {
|
|
8565
|
+
// Clip (overflow / border-radius): build path via callback then clip.
|
|
8203
8566
|
this.pathCallback.path(effect.path);
|
|
8204
8567
|
this.ctx.clip();
|
|
8205
8568
|
}
|
|
8569
|
+
else if (isClipPathEffect(effect)) {
|
|
8570
|
+
// Clip-path: delegate shape drawing (beginPath … clip()) to the effect.
|
|
8571
|
+
effect.applyClip(this.ctx);
|
|
8572
|
+
}
|
|
8206
8573
|
this.activeEffects.push(effect);
|
|
8207
8574
|
}
|
|
8208
8575
|
/**
|
|
@@ -8245,6 +8612,22 @@ class EffectsRenderer {
|
|
|
8245
8612
|
*/
|
|
8246
8613
|
// iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
|
|
8247
8614
|
const iOSBrokenFonts = ['-apple-system', 'system-ui'];
|
|
8615
|
+
/**
|
|
8616
|
+
* Detect CJK (Chinese, Japanese, Korean) characters in a string.
|
|
8617
|
+
* CJK characters use the ideographic baseline in browsers, which differs
|
|
8618
|
+
* from the alphabetic baseline used for Latin script.
|
|
8619
|
+
*
|
|
8620
|
+
* Covers:
|
|
8621
|
+
* U+2E80–U+2FFF CJK Radicals Supplement, Kangxi Radicals
|
|
8622
|
+
* U+3000–U+30FF CJK Symbols & Punctuation (。、「」…), Hiragana, Katakana
|
|
8623
|
+
* U+3400–U+4DBF CJK Extension A
|
|
8624
|
+
* U+4E00–U+9FFF CJK Unified Ideographs (most common Chinese/Japanese/Korean)
|
|
8625
|
+
* U+AC00–U+D7AF Hangul Syllables
|
|
8626
|
+
* U+F900–U+FAFF CJK Compatibility Ideographs
|
|
8627
|
+
* U+FF01–U+FFEF Halfwidth and Fullwidth Forms (A B 1 2 ! ? etc.)
|
|
8628
|
+
*/
|
|
8629
|
+
const CJK_CHAR_REGEX = /[\u2E80-\u2FFF\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFFEF]/;
|
|
8630
|
+
const hasCJKCharacters = (text) => CJK_CHAR_REGEX.test(text);
|
|
8248
8631
|
/**
|
|
8249
8632
|
* Detect iOS version from user agent
|
|
8250
8633
|
* Returns null if not iOS or version cannot be determined
|
|
@@ -8302,21 +8685,50 @@ class TextRenderer {
|
|
|
8302
8685
|
this.options = deps.options;
|
|
8303
8686
|
}
|
|
8304
8687
|
/**
|
|
8305
|
-
*
|
|
8306
|
-
*
|
|
8688
|
+
* Iterate grapheme clusters one-by-one, applying correct letter-spacing and
|
|
8689
|
+
* per-script baseline for each character.
|
|
8690
|
+
*
|
|
8691
|
+
* Issue #73: When letter-spacing is non-zero, text must be rendered character by
|
|
8692
|
+
* character. This helper centralises two fixes applied during that iteration:
|
|
8693
|
+
* 1. Add `letterSpacing` to each character's advance width (was previously
|
|
8694
|
+
* omitted, causing characters to render without any spacing).
|
|
8695
|
+
* 2. Switch to the ideographic baseline for CJK glyphs so their vertical
|
|
8696
|
+
* position matches how browsers lay them out in the DOM.
|
|
8697
|
+
*
|
|
8698
|
+
* The `renderFn` callback receives (letter, x, y) and performs the actual draw
|
|
8699
|
+
* call (fillText or strokeText), allowing fill and stroke paths to share one
|
|
8700
|
+
* implementation.
|
|
8701
|
+
*/
|
|
8702
|
+
iterateLettersWithLetterSpacing(text, letterSpacing, baseline, renderFn) {
|
|
8703
|
+
const letters = segmentGraphemes(text.text);
|
|
8704
|
+
const y = text.bounds.top + baseline;
|
|
8705
|
+
let left = text.bounds.left;
|
|
8706
|
+
for (const letter of letters) {
|
|
8707
|
+
if (hasCJKCharacters(letter)) {
|
|
8708
|
+
const savedBaseline = this.ctx.textBaseline;
|
|
8709
|
+
this.ctx.textBaseline = 'ideographic';
|
|
8710
|
+
renderFn(letter, left, y);
|
|
8711
|
+
this.ctx.textBaseline = savedBaseline;
|
|
8712
|
+
}
|
|
8713
|
+
else {
|
|
8714
|
+
renderFn(letter, left, y);
|
|
8715
|
+
}
|
|
8716
|
+
left += this.ctx.measureText(letter).width + letterSpacing;
|
|
8717
|
+
}
|
|
8718
|
+
}
|
|
8719
|
+
/**
|
|
8720
|
+
* Render text with letter-spacing applied (fill pass).
|
|
8721
|
+
* When letterSpacing is 0 the whole string is drawn in one call; otherwise each
|
|
8722
|
+
* grapheme is drawn individually so spacing and CJK baseline are applied correctly.
|
|
8307
8723
|
*/
|
|
8308
8724
|
renderTextWithLetterSpacing(text, letterSpacing, baseline) {
|
|
8309
8725
|
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
8726
|
this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
|
|
8313
8727
|
}
|
|
8314
8728
|
else {
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8318
|
-
return left + this.ctx.measureText(letter).width;
|
|
8319
|
-
}, text.bounds.left);
|
|
8729
|
+
this.iterateLettersWithLetterSpacing(text, letterSpacing, baseline, (letter, x, y) => {
|
|
8730
|
+
this.ctx.fillText(letter, x, y);
|
|
8731
|
+
});
|
|
8320
8732
|
}
|
|
8321
8733
|
}
|
|
8322
8734
|
/**
|
|
@@ -8334,8 +8746,17 @@ class TextRenderer {
|
|
|
8334
8746
|
if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
|
|
8335
8747
|
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
|
|
8336
8748
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
8337
|
-
this.ctx.lineJoin =
|
|
8338
|
-
|
|
8749
|
+
this.ctx.lineJoin =
|
|
8750
|
+
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
|
|
8751
|
+
if (styles.letterSpacing === 0) {
|
|
8752
|
+
this.ctx.strokeText(textBound.text, textBound.bounds.left, textBound.bounds.top + styles.fontSize.number);
|
|
8753
|
+
}
|
|
8754
|
+
else {
|
|
8755
|
+
this.iterateLettersWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
|
|
8756
|
+
}
|
|
8757
|
+
this.ctx.strokeStyle = '';
|
|
8758
|
+
this.ctx.lineWidth = 0;
|
|
8759
|
+
this.ctx.lineJoin = 'miter';
|
|
8339
8760
|
}
|
|
8340
8761
|
break;
|
|
8341
8762
|
}
|
|
@@ -8445,26 +8866,47 @@ class TextRenderer {
|
|
|
8445
8866
|
}
|
|
8446
8867
|
// Helper method to truncate text and add ellipsis if needed
|
|
8447
8868
|
truncateTextWithEllipsis(text, maxWidth, letterSpacing) {
|
|
8448
|
-
|
|
8869
|
+
// Use the Unicode ellipsis character (U+2026) whose width the browser measures
|
|
8870
|
+
// as a single glyph, matching native text-overflow behaviour more closely.
|
|
8871
|
+
const ellipsis = '\u2026';
|
|
8449
8872
|
const ellipsisWidth = this.ctx.measureText(ellipsis).width;
|
|
8873
|
+
// Segment into grapheme clusters so multi-byte characters (emoji, composed
|
|
8874
|
+
// sequences) are never split mid-character.
|
|
8875
|
+
const graphemes = segmentGraphemes(text);
|
|
8450
8876
|
if (letterSpacing === 0) {
|
|
8451
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
8877
|
+
// Measure the whole candidate string for accuracy: the browser applies
|
|
8878
|
+
// kerning and ligatures when rendering multiple glyphs together, so
|
|
8879
|
+
// measuring them as one string is more precise than summing individual widths.
|
|
8880
|
+
// Binary search reduces measurements from O(n) to O(log n).
|
|
8881
|
+
const fits = (n) => this.ctx.measureText(graphemes.slice(0, n).join('')).width + ellipsisWidth <= maxWidth;
|
|
8882
|
+
let lo = 0;
|
|
8883
|
+
let hi = graphemes.length;
|
|
8884
|
+
while (lo < hi) {
|
|
8885
|
+
const mid = (lo + hi + 1) >> 1;
|
|
8886
|
+
if (fits(mid)) {
|
|
8887
|
+
lo = mid;
|
|
8888
|
+
}
|
|
8889
|
+
else {
|
|
8890
|
+
hi = mid - 1;
|
|
8891
|
+
}
|
|
8454
8892
|
}
|
|
8455
|
-
return
|
|
8893
|
+
return graphemes.slice(0, lo).join('') + ellipsis;
|
|
8456
8894
|
}
|
|
8457
8895
|
else {
|
|
8458
|
-
const letters = segmentGraphemes(text);
|
|
8459
8896
|
let width = ellipsisWidth;
|
|
8460
|
-
|
|
8461
|
-
for (const letter of
|
|
8462
|
-
const
|
|
8463
|
-
|
|
8897
|
+
const result = [];
|
|
8898
|
+
for (const letter of graphemes) {
|
|
8899
|
+
const glyphWidth = this.ctx.measureText(letter).width;
|
|
8900
|
+
// Check against glyph width only (no trailing spacing): letter-spacing
|
|
8901
|
+
// is applied *between* characters, not after the final glyph. Using
|
|
8902
|
+
// `glyphWidth + letterSpacing` would incorrectly discard letters that
|
|
8903
|
+
// fit as the last character before the ellipsis.
|
|
8904
|
+
if (width + glyphWidth > maxWidth) {
|
|
8464
8905
|
break;
|
|
8465
8906
|
}
|
|
8466
8907
|
result.push(letter);
|
|
8467
|
-
|
|
8908
|
+
// Accumulate glyph + inter-character spacing for the *next* iteration.
|
|
8909
|
+
width += glyphWidth + letterSpacing;
|
|
8468
8910
|
}
|
|
8469
8911
|
return result.join('') + ellipsis;
|
|
8470
8912
|
}
|
|
@@ -8540,6 +8982,8 @@ class TextRenderer {
|
|
|
8540
8982
|
const firstBound = lastLine[0];
|
|
8541
8983
|
const availableWidth = containerBounds.width - (firstBound.bounds.left - containerBounds.left);
|
|
8542
8984
|
const truncatedText = this.truncateTextWithEllipsis(lastLineText, availableWidth, styles.letterSpacing);
|
|
8985
|
+
// Build TextBounds once; reused for fill and stroke without re-allocating.
|
|
8986
|
+
const truncatedBounds = new TextBounds(truncatedText, firstBound.bounds);
|
|
8543
8987
|
paintOrder.forEach((paintOrderLayer) => {
|
|
8544
8988
|
switch (paintOrderLayer) {
|
|
8545
8989
|
case 0 /* PAINT_ORDER_LAYER.FILL */:
|
|
@@ -8548,28 +8992,24 @@ class TextRenderer {
|
|
|
8548
8992
|
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8549
8993
|
}
|
|
8550
8994
|
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);
|
|
8995
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
|
|
8556
8996
|
}
|
|
8557
8997
|
break;
|
|
8558
8998
|
case 1 /* PAINT_ORDER_LAYER.STROKE */:
|
|
8559
8999
|
if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
|
|
8560
9000
|
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
|
|
8561
9001
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
8562
|
-
this.ctx.lineJoin =
|
|
9002
|
+
this.ctx.lineJoin =
|
|
9003
|
+
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
|
|
8563
9004
|
if (styles.letterSpacing === 0) {
|
|
8564
9005
|
this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8565
9006
|
}
|
|
8566
9007
|
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);
|
|
9008
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
|
|
8572
9009
|
}
|
|
9010
|
+
this.ctx.strokeStyle = '';
|
|
9011
|
+
this.ctx.lineWidth = 0;
|
|
9012
|
+
this.ctx.lineJoin = 'miter';
|
|
8573
9013
|
}
|
|
8574
9014
|
break;
|
|
8575
9015
|
}
|
|
@@ -8613,19 +9053,18 @@ class TextRenderer {
|
|
|
8613
9053
|
// If ellipsis is needed, render the truncated text once
|
|
8614
9054
|
if (needsEllipsis) {
|
|
8615
9055
|
const firstBound = text.textBounds[0];
|
|
9056
|
+
// Build TextBounds once; reused across paint layers and every shadow pass
|
|
9057
|
+
// to avoid repeated allocation inside forEach callbacks.
|
|
9058
|
+
const truncatedBounds = new TextBounds(truncatedText, firstBound.bounds);
|
|
8616
9059
|
paintOrder.forEach((paintOrderLayer) => {
|
|
8617
9060
|
switch (paintOrderLayer) {
|
|
8618
|
-
case 0 /* PAINT_ORDER_LAYER.FILL */:
|
|
9061
|
+
case 0 /* PAINT_ORDER_LAYER.FILL */: {
|
|
8619
9062
|
this.ctx.fillStyle = asString(styles.color);
|
|
8620
9063
|
if (styles.letterSpacing === 0) {
|
|
8621
9064
|
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8622
9065
|
}
|
|
8623
9066
|
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);
|
|
9067
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
|
|
8629
9068
|
}
|
|
8630
9069
|
const textShadows = styles.textShadow;
|
|
8631
9070
|
if (textShadows.length && truncatedText.trim().length) {
|
|
@@ -8641,11 +9080,7 @@ class TextRenderer {
|
|
|
8641
9080
|
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8642
9081
|
}
|
|
8643
9082
|
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);
|
|
9083
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
|
|
8649
9084
|
}
|
|
8650
9085
|
});
|
|
8651
9086
|
this.ctx.shadowColor = '';
|
|
@@ -8654,21 +9089,22 @@ class TextRenderer {
|
|
|
8654
9089
|
this.ctx.shadowBlur = 0;
|
|
8655
9090
|
}
|
|
8656
9091
|
break;
|
|
9092
|
+
}
|
|
8657
9093
|
case 1 /* PAINT_ORDER_LAYER.STROKE */:
|
|
8658
9094
|
if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
|
|
8659
9095
|
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
|
|
8660
9096
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
8661
|
-
this.ctx.lineJoin =
|
|
9097
|
+
this.ctx.lineJoin =
|
|
9098
|
+
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
|
|
8662
9099
|
if (styles.letterSpacing === 0) {
|
|
8663
9100
|
this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
|
|
8664
9101
|
}
|
|
8665
9102
|
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);
|
|
9103
|
+
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
|
|
8671
9104
|
}
|
|
9105
|
+
this.ctx.strokeStyle = '';
|
|
9106
|
+
this.ctx.lineWidth = 0;
|
|
9107
|
+
this.ctx.lineJoin = 'miter';
|
|
8672
9108
|
}
|
|
8673
9109
|
break;
|
|
8674
9110
|
}
|
|
@@ -8679,7 +9115,7 @@ class TextRenderer {
|
|
|
8679
9115
|
text.textBounds.forEach((text) => {
|
|
8680
9116
|
paintOrder.forEach((paintOrderLayer) => {
|
|
8681
9117
|
switch (paintOrderLayer) {
|
|
8682
|
-
case 0 /* PAINT_ORDER_LAYER.FILL */:
|
|
9118
|
+
case 0 /* PAINT_ORDER_LAYER.FILL */: {
|
|
8683
9119
|
this.ctx.fillStyle = asString(styles.color);
|
|
8684
9120
|
this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
|
|
8685
9121
|
const textShadows = styles.textShadow;
|
|
@@ -8703,29 +9139,26 @@ class TextRenderer {
|
|
|
8703
9139
|
this.renderTextDecoration(text.bounds, styles);
|
|
8704
9140
|
}
|
|
8705
9141
|
break;
|
|
8706
|
-
|
|
9142
|
+
}
|
|
9143
|
+
case 1 /* PAINT_ORDER_LAYER.STROKE */: {
|
|
8707
9144
|
if (styles.webkitTextStrokeWidth && text.text.trim().length) {
|
|
8708
9145
|
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
|
|
8709
9146
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
8710
|
-
this.ctx.lineJoin =
|
|
8711
|
-
|
|
8712
|
-
// Previously used text.bounds.height which caused stroke to render too low
|
|
9147
|
+
this.ctx.lineJoin =
|
|
9148
|
+
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
|
|
8713
9149
|
const baseline = styles.fontSize.number;
|
|
8714
9150
|
if (styles.letterSpacing === 0) {
|
|
8715
9151
|
this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
|
|
8716
9152
|
}
|
|
8717
9153
|
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);
|
|
9154
|
+
this.iterateLettersWithLetterSpacing(text, styles.letterSpacing, baseline, (letter, x, y) => this.ctx.strokeText(letter, x, y));
|
|
8723
9155
|
}
|
|
9156
|
+
this.ctx.strokeStyle = '';
|
|
9157
|
+
this.ctx.lineWidth = 0;
|
|
9158
|
+
this.ctx.lineJoin = 'miter';
|
|
8724
9159
|
}
|
|
8725
|
-
this.ctx.strokeStyle = '';
|
|
8726
|
-
this.ctx.lineWidth = 0;
|
|
8727
|
-
this.ctx.lineJoin = 'miter';
|
|
8728
9160
|
break;
|
|
9161
|
+
}
|
|
8729
9162
|
}
|
|
8730
9163
|
});
|
|
8731
9164
|
});
|