html2canvas-pro 2.2.4 → 2.3.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.cjs +11221 -0
- package/dist/html2canvas-pro.cjs.map +1 -0
- package/dist/html2canvas-pro.esm.js +2812 -2587
- package/dist/html2canvas-pro.esm.js.map +1 -1
- package/dist/html2canvas-pro.js +2812 -2587
- package/dist/html2canvas-pro.js.map +1 -1
- package/dist/html2canvas-pro.min.js +4 -5
- package/dist/lib/core/abort-helper.js +20 -0
- package/dist/lib/core/background-parser.js +41 -0
- package/dist/lib/core/cache-storage.js +63 -7
- package/dist/lib/core/config-assembler.js +88 -0
- package/dist/lib/core/lru-map.js +66 -0
- package/dist/lib/core/render-element.js +24 -103
- package/dist/lib/css/property-descriptors/background-position.js +1 -11
- package/dist/lib/css/property-descriptors/background-size.js +3 -1
- package/dist/lib/css/syntax/token-constants.js +214 -0
- package/dist/lib/css/syntax/token-singletons.js +36 -0
- package/dist/lib/css/syntax/token-types.js +10 -0
- package/dist/lib/css/syntax/tokenizer.js +175 -307
- package/dist/lib/css/types/length-percentage.js +63 -3
- package/dist/lib/dom/document-cloner.js +23 -22
- package/dist/lib/dom/node-parser.js +1 -21
- package/dist/lib/dom/slot-cloner.js +8 -8
- package/dist/lib/render/background.js +4 -1
- package/dist/lib/render/canvas/background-renderer.js +27 -39
- package/dist/lib/render/canvas/canvas-renderer.js +3 -3
- package/dist/lib/render/canvas/effects-renderer.js +94 -32
- package/dist/lib/render/canvas/text-renderer.js +94 -32
- package/dist/lib/render/stacking-context.js +12 -6
- package/dist/types/core/abort-helper.d.ts +12 -0
- package/dist/types/core/background-parser.d.ts +16 -0
- package/dist/types/core/cache-storage.d.ts +25 -0
- package/dist/types/core/config-assembler.d.ts +28 -0
- package/dist/types/core/lru-map.d.ts +37 -0
- package/dist/types/css/syntax/token-constants.d.ts +74 -0
- package/dist/types/css/syntax/token-singletons.d.ts +22 -0
- package/dist/types/css/syntax/token-types.d.ts +72 -0
- package/dist/types/css/syntax/tokenizer.d.ts +6 -73
- package/dist/types/css/types/length-percentage.d.ts +42 -1
- package/dist/types/dom/document-cloner.d.ts +5 -0
- package/dist/types/dom/node-parser.d.ts +0 -2
- package/dist/types/render/canvas/background-renderer.d.ts +0 -10
- package/dist/types/render/canvas/effects-renderer.d.ts +42 -17
- package/dist/types/render/canvas/text-renderer.d.ts +15 -2
- package/dist/types/render/stacking-context.d.ts +26 -1
- package/package.json +5 -7
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* - Transform effects (matrix transformations)
|
|
8
8
|
* - Clip effects (overflow / border-radius clipping via Path[])
|
|
9
9
|
* - Clip-path effects (CSS clip-path shapes: inset, circle, ellipse, polygon, path)
|
|
10
|
+
* - Blend effects (mix-blend-mode)
|
|
11
|
+
* - Filter effects (CSS filter functions)
|
|
10
12
|
*/
|
|
11
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
14
|
exports.EffectsRenderer = void 0;
|
|
@@ -16,61 +18,104 @@ const effects_1 = require("../effects");
|
|
|
16
18
|
*
|
|
17
19
|
* Manages rendering effects stack including opacity, transforms, and clipping.
|
|
18
20
|
* Extracted from CanvasRenderer to improve code organization and maintainability.
|
|
21
|
+
*
|
|
22
|
+
* ## Save/restore optimisation
|
|
23
|
+
*
|
|
24
|
+
* Canvas `save()` / `restore()` snapshot and restore the entire canvas state
|
|
25
|
+
* (transform matrix, clip region, compositing mode, filter, shadow, …) which
|
|
26
|
+
* is relatively expensive. The old implementation called `save()` / `restore()`
|
|
27
|
+
* for every single effect unconditionally.
|
|
28
|
+
*
|
|
29
|
+
* This implementation does a **batch pre-scan** before applying effects:
|
|
30
|
+
*
|
|
31
|
+
* | Effect type | Modifies | Reversible? | Needs save? |
|
|
32
|
+
* |--------------------|----------------------|---------------|-------------|
|
|
33
|
+
* | TransformEffect | transform matrix | ❌ irreversible | **YES** |
|
|
34
|
+
* | ClipEffect | clip region | ❌ cumulative | **YES** |
|
|
35
|
+
* | ClipPathEffect | clip region | ❌ cumulative | **YES** |
|
|
36
|
+
* | OpacityEffect | globalAlpha | ✅ scalar | NO |
|
|
37
|
+
* | BlendEffect | globalCompositeOp | ✅ scalar | NO |
|
|
38
|
+
* | FilterEffect | filter + shadow | ✅ string+vec | NO |
|
|
39
|
+
*
|
|
40
|
+
* When the batch contains *only* lightweight effects (opacity / blend / filter)
|
|
41
|
+
* we skip `save()` entirely and manually reset properties on pop.
|
|
42
|
+
*
|
|
43
|
+
* When the batch contains at least one heavyweight effect (transform / clip /
|
|
44
|
+
* clip-path) we call `save()` **once** before the first heavyweight effect and
|
|
45
|
+
* `restore()` **once** when that effect is popped.
|
|
19
46
|
*/
|
|
20
47
|
class EffectsRenderer {
|
|
21
48
|
constructor(deps, pathCallback) {
|
|
22
49
|
this.activeEffects = [];
|
|
50
|
+
/** Whether a canvas state save was performed for the current batch. */
|
|
51
|
+
this.didSave = false;
|
|
52
|
+
/** Index (0-based, from start of activeEffects array) of the last
|
|
53
|
+
* heavyweight effect in the batch — the one whose pop triggers
|
|
54
|
+
* restore(). Set to -1 when no save was performed. */
|
|
55
|
+
this.saveAtDepth = -1;
|
|
23
56
|
this.ctx = deps.ctx;
|
|
24
57
|
this.pathCallback = pathCallback;
|
|
25
58
|
}
|
|
26
59
|
/**
|
|
27
|
-
* Apply multiple effects
|
|
28
|
-
* Clears existing effects and applies new ones
|
|
29
|
-
*
|
|
30
|
-
* @param effects - Array of effects to apply
|
|
60
|
+
* Apply multiple effects.
|
|
61
|
+
* Clears existing effects and applies new ones.
|
|
31
62
|
*/
|
|
32
63
|
applyEffects(effects) {
|
|
33
|
-
//
|
|
64
|
+
// ── 1. Pop all previously active effects ──────────────────────
|
|
34
65
|
while (this.activeEffects.length) {
|
|
35
66
|
this.popEffect();
|
|
36
67
|
}
|
|
37
|
-
//
|
|
38
|
-
|
|
68
|
+
// ── 2. Determine whether to save canvas state ─────────────────
|
|
69
|
+
this.didSave = false;
|
|
70
|
+
this.saveAtDepth = -1;
|
|
71
|
+
const isHeavy = (e) => (0, effects_1.isTransformEffect)(e) || (0, effects_1.isClipEffect)(e) || (0, effects_1.isClipPathEffect)(e);
|
|
72
|
+
// Pre-compute the index of the *last* heavyweight effect so pop
|
|
73
|
+
// can reliably match it regardless of how many heavyweight effects
|
|
74
|
+
// are in the batch (the save was issued before the first, but the
|
|
75
|
+
// restore must happen when the LAST heavyweight is popped).
|
|
76
|
+
const lastHeavyIdx = effects.reduceRight((found, e, idx) => (found !== -1 ? found : isHeavy(e) ? idx : -1), -1);
|
|
77
|
+
// ── 3. Apply each effect ──────────────────────────────────────
|
|
78
|
+
for (let i = 0; i < effects.length; i++) {
|
|
79
|
+
const effect = effects[i];
|
|
80
|
+
if (isHeavy(effect)) {
|
|
81
|
+
if (!this.didSave) {
|
|
82
|
+
this.ctx.save();
|
|
83
|
+
this.didSave = true;
|
|
84
|
+
}
|
|
85
|
+
if (i === lastHeavyIdx) {
|
|
86
|
+
this.saveAtDepth = this.activeEffects.length;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
this.applyEffect(effect);
|
|
90
|
+
}
|
|
39
91
|
}
|
|
40
92
|
/**
|
|
41
|
-
* Apply a single effect
|
|
42
|
-
*
|
|
43
|
-
* @param effect - Effect to apply
|
|
93
|
+
* Apply a single effect (called internally by applyEffects).
|
|
44
94
|
*/
|
|
45
95
|
applyEffect(effect) {
|
|
46
|
-
|
|
47
|
-
if ((0, effects_1.isOpacityEffect)(effect)) {
|
|
48
|
-
// Opacity: multiply into the current global alpha for nested transparency.
|
|
49
|
-
this.ctx.globalAlpha = effect.opacity;
|
|
50
|
-
}
|
|
51
|
-
else if ((0, effects_1.isTransformEffect)(effect)) {
|
|
52
|
-
// Transform: translate to origin, apply matrix, translate back.
|
|
96
|
+
if ((0, effects_1.isTransformEffect)(effect)) {
|
|
53
97
|
this.ctx.translate(effect.offsetX, effect.offsetY);
|
|
54
98
|
this.ctx.transform(effect.matrix[0], effect.matrix[1], effect.matrix[2], effect.matrix[3], effect.matrix[4], effect.matrix[5]);
|
|
55
99
|
this.ctx.translate(-effect.offsetX, -effect.offsetY);
|
|
56
100
|
}
|
|
57
101
|
else if ((0, effects_1.isClipEffect)(effect)) {
|
|
58
|
-
// Clip (overflow / border-radius): build path via callback then clip.
|
|
59
102
|
this.pathCallback.path(effect.path);
|
|
60
103
|
this.ctx.clip();
|
|
61
104
|
}
|
|
62
105
|
else if ((0, effects_1.isClipPathEffect)(effect)) {
|
|
63
|
-
// Clip-path: delegate shape drawing (beginPath … clip()) to the effect.
|
|
64
106
|
effect.applyClip(this.ctx);
|
|
65
107
|
}
|
|
108
|
+
else if ((0, effects_1.isOpacityEffect)(effect)) {
|
|
109
|
+
// Multiply into current globalAlpha so nested opacity works
|
|
110
|
+
// without save/restore overhead.
|
|
111
|
+
this.ctx.globalAlpha *= effect.opacity;
|
|
112
|
+
}
|
|
66
113
|
else if ((0, effects_1.isBlendEffect)(effect)) {
|
|
67
114
|
this.ctx.globalCompositeOperation = effect.compositeOperation;
|
|
68
115
|
}
|
|
69
116
|
else if ((0, effects_1.isFilterEffect)(effect)) {
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// because ctx.filter="drop-shadow(...)" taints the canvas
|
|
73
|
-
// even for same-origin content (Chrome, Firefox).
|
|
117
|
+
// drop-shadow() is rendered via ctx.shadow* to avoid canvas
|
|
118
|
+
// taint; remaining filters go through ctx.filter.
|
|
74
119
|
this.ctx.filter = effect.safeFilterString || 'none';
|
|
75
120
|
if (effect.shadow) {
|
|
76
121
|
this.ctx.shadowOffsetX = effect.shadow.offsetX;
|
|
@@ -82,25 +127,42 @@ class EffectsRenderer {
|
|
|
82
127
|
this.activeEffects.push(effect);
|
|
83
128
|
}
|
|
84
129
|
/**
|
|
85
|
-
* Remove the most recent effect
|
|
86
|
-
* Restores
|
|
130
|
+
* Remove the most recent effect.
|
|
131
|
+
* Restores canvas state if needed.
|
|
87
132
|
*/
|
|
88
133
|
popEffect() {
|
|
134
|
+
if (this.activeEffects.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
// If the effect being popped is the one that triggered the save,
|
|
137
|
+
// restore the canvas state now.
|
|
138
|
+
if (this.didSave && this.activeEffects.length - 1 === this.saveAtDepth) {
|
|
139
|
+
this.ctx.restore();
|
|
140
|
+
this.didSave = false;
|
|
141
|
+
this.saveAtDepth = -1;
|
|
142
|
+
}
|
|
89
143
|
this.activeEffects.pop();
|
|
90
|
-
|
|
144
|
+
// If the batch had ONLY lightweight effects (no save was performed),
|
|
145
|
+
// reset properties to their defaults. We only do this when all effects
|
|
146
|
+
// have been popped (activeEffects is empty), so properties don't
|
|
147
|
+
// flip mid-batch.
|
|
148
|
+
if (!this.didSave && this.activeEffects.length === 0) {
|
|
149
|
+
this.ctx.globalAlpha = 1;
|
|
150
|
+
this.ctx.globalCompositeOperation = 'source-over';
|
|
151
|
+
this.ctx.filter = 'none';
|
|
152
|
+
this.ctx.shadowOffsetX = 0;
|
|
153
|
+
this.ctx.shadowOffsetY = 0;
|
|
154
|
+
this.ctx.shadowBlur = 0;
|
|
155
|
+
this.ctx.shadowColor = 'rgba(0, 0, 0, 0)';
|
|
156
|
+
}
|
|
91
157
|
}
|
|
92
158
|
/**
|
|
93
|
-
* Get the current number of active effects
|
|
94
|
-
*
|
|
95
|
-
* @returns Number of active effects
|
|
159
|
+
* Get the current number of active effects.
|
|
96
160
|
*/
|
|
97
161
|
getActiveEffectCount() {
|
|
98
162
|
return this.activeEffects.length;
|
|
99
163
|
}
|
|
100
164
|
/**
|
|
101
|
-
* Check if there are any active effects
|
|
102
|
-
*
|
|
103
|
-
* @returns True if there are active effects
|
|
165
|
+
* Check if there are any active effects.
|
|
104
166
|
*/
|
|
105
167
|
hasActiveEffects() {
|
|
106
168
|
return this.activeEffects.length > 0;
|
|
@@ -93,10 +93,36 @@ const getTextStrokeLineJoin = () => {
|
|
|
93
93
|
*/
|
|
94
94
|
class TextRenderer {
|
|
95
95
|
constructor(deps) {
|
|
96
|
+
/**
|
|
97
|
+
* Per-render glyph width cache. Keyed by glyph + font string,
|
|
98
|
+
* avoids redundant `measureText` calls when letter-spacing is non-zero.
|
|
99
|
+
* Cleared at the start of each renderTextNode call.
|
|
100
|
+
*/
|
|
101
|
+
this.glyphWidthCache = null;
|
|
96
102
|
this.ctx = deps.ctx;
|
|
103
|
+
this.fontMetrics = deps.fontMetrics;
|
|
97
104
|
this.options = deps.options;
|
|
98
105
|
this.decorationRenderer = new text_decoration_renderer_1.TextDecorationRenderer(deps.ctx);
|
|
99
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Returns the cached width for a glyph+font pair, measuring via
|
|
109
|
+
* measureText if not yet cached. Only used when letterSpacing !== 0
|
|
110
|
+
* (the `canRenderWholeText` fast path handles the zero-spacing case).
|
|
111
|
+
*/
|
|
112
|
+
cachedMeasureText(glyph, fontString) {
|
|
113
|
+
let cache = this.glyphWidthCache;
|
|
114
|
+
if (!cache) {
|
|
115
|
+
cache = new Map();
|
|
116
|
+
this.glyphWidthCache = cache;
|
|
117
|
+
}
|
|
118
|
+
const key = glyph + fontString;
|
|
119
|
+
let w = cache.get(key);
|
|
120
|
+
if (w === undefined) {
|
|
121
|
+
w = this.ctx.measureText(glyph).width;
|
|
122
|
+
cache.set(key, w);
|
|
123
|
+
}
|
|
124
|
+
return w;
|
|
125
|
+
}
|
|
100
126
|
/**
|
|
101
127
|
* Iterate grapheme clusters one-by-one, applying correct letter-spacing and
|
|
102
128
|
* per-script baseline for each character.
|
|
@@ -119,6 +145,7 @@ class TextRenderer {
|
|
|
119
145
|
}
|
|
120
146
|
const letters = (0, text_1.segmentGraphemes)(text.text);
|
|
121
147
|
const y = text.bounds.top + baseline;
|
|
148
|
+
const fontString = this.ctx.font;
|
|
122
149
|
let left = text.bounds.left;
|
|
123
150
|
for (const letter of letters) {
|
|
124
151
|
if ((0, exports.hasCJKCharacters)(letter)) {
|
|
@@ -130,29 +157,53 @@ class TextRenderer {
|
|
|
130
157
|
else {
|
|
131
158
|
renderFn(letter, left, y);
|
|
132
159
|
}
|
|
133
|
-
left += this.
|
|
160
|
+
left += this.cachedMeasureText(letter, fontString) + letterSpacing;
|
|
134
161
|
}
|
|
135
162
|
}
|
|
136
163
|
iterateVerticalGlyphs(text, letterSpacing, baseline, writingMode, renderFn) {
|
|
137
164
|
const letters = (0, text_1.segmentGraphemes)(text.text);
|
|
165
|
+
const fontString = this.ctx.font;
|
|
166
|
+
const rotationAngle = writingMode === 4 /* WRITING_MODE.SIDEWAYS_LR */ ? -Math.PI / 2 : Math.PI / 2;
|
|
138
167
|
let top = text.bounds.top;
|
|
139
|
-
for (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
renderFn(letter, 0, 0);
|
|
145
|
-
this.ctx.restore();
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
168
|
+
for (let i = 0; i < letters.length;) {
|
|
169
|
+
const letter = letters[i];
|
|
170
|
+
const isSideways = (0, writing_mode_1.isSidewaysWritingMode)(writingMode) || (!(0, exports.hasCJKCharacters)(letter) && letter.trim().length > 0);
|
|
171
|
+
if (!isSideways) {
|
|
172
|
+
// CJK glyph in vertical writing mode — render upright
|
|
148
173
|
const savedBaseline = this.ctx.textBaseline;
|
|
149
174
|
if ((0, exports.hasCJKCharacters)(letter)) {
|
|
150
175
|
this.ctx.textBaseline = 'ideographic';
|
|
151
176
|
}
|
|
152
177
|
renderFn(letter, text.bounds.left, top + baseline);
|
|
153
178
|
this.ctx.textBaseline = savedBaseline;
|
|
179
|
+
top += this.cachedMeasureText(letter, fontString) + letterSpacing;
|
|
180
|
+
i++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// ── Batch consecutive sideways glyphs into one save/restore ──
|
|
184
|
+
const runStart = i;
|
|
185
|
+
while (i < letters.length) {
|
|
186
|
+
const ch = letters[i];
|
|
187
|
+
if (!(0, writing_mode_1.isSidewaysWritingMode)(writingMode) && !(0, exports.hasCJKCharacters)(ch) && ch.trim().length === 0)
|
|
188
|
+
break;
|
|
189
|
+
if ((0, writing_mode_1.isSidewaysWritingMode)(writingMode) || (!(0, exports.hasCJKCharacters)(ch) && ch.trim().length > 0)) {
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Render entire sideways run in one save/restore
|
|
197
|
+
this.ctx.save();
|
|
198
|
+
this.ctx.translate(text.bounds.left + baseline, top);
|
|
199
|
+
this.ctx.rotate(rotationAngle);
|
|
200
|
+
let runOffset = 0;
|
|
201
|
+
for (let j = runStart; j < i; j++) {
|
|
202
|
+
renderFn(letters[j], 0, runOffset);
|
|
203
|
+
runOffset += this.cachedMeasureText(letters[j], fontString) + letterSpacing;
|
|
154
204
|
}
|
|
155
|
-
|
|
205
|
+
this.ctx.restore();
|
|
206
|
+
top += runOffset;
|
|
156
207
|
}
|
|
157
208
|
}
|
|
158
209
|
/**
|
|
@@ -186,21 +237,21 @@ class TextRenderer {
|
|
|
186
237
|
});
|
|
187
238
|
}
|
|
188
239
|
}
|
|
189
|
-
renderTextStrokeWithStyle(text, styles) {
|
|
240
|
+
renderTextStrokeWithStyle(text, styles, baseline) {
|
|
190
241
|
if (!styles.webkitTextStrokeWidth || !text.text.trim().length) {
|
|
191
242
|
return;
|
|
192
243
|
}
|
|
193
244
|
this.ctx.strokeStyle = (0, color_utilities_1.asString)(styles.webkitTextStrokeColor);
|
|
194
245
|
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
|
|
195
246
|
this.ctx.lineJoin = getTextStrokeLineJoin();
|
|
196
|
-
this.renderStrokeText(text, styles.letterSpacing,
|
|
247
|
+
this.renderStrokeText(text, styles.letterSpacing, baseline, styles.writingMode);
|
|
197
248
|
this.ctx.strokeStyle = '';
|
|
198
249
|
this.ctx.lineWidth = 0;
|
|
199
250
|
this.ctx.lineJoin = 'miter';
|
|
200
251
|
}
|
|
201
|
-
renderTextFillWithShadows(text, styles) {
|
|
252
|
+
renderTextFillWithShadows(text, styles, baseline) {
|
|
202
253
|
this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
|
|
203
|
-
this.renderTextWithLetterSpacing(text, styles.letterSpacing,
|
|
254
|
+
this.renderTextWithLetterSpacing(text, styles.letterSpacing, baseline, styles.writingMode);
|
|
204
255
|
const textShadows = styles.textShadow;
|
|
205
256
|
if (textShadows.length && text.text.trim().length) {
|
|
206
257
|
textShadows
|
|
@@ -211,7 +262,7 @@ class TextRenderer {
|
|
|
211
262
|
this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
|
|
212
263
|
this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
|
|
213
264
|
this.ctx.shadowBlur = textShadow.blur.number;
|
|
214
|
-
this.renderTextWithLetterSpacing(text, styles.letterSpacing,
|
|
265
|
+
this.renderTextWithLetterSpacing(text, styles.letterSpacing, baseline, styles.writingMode);
|
|
215
266
|
});
|
|
216
267
|
this.ctx.shadowColor = '';
|
|
217
268
|
this.ctx.shadowOffsetX = 0;
|
|
@@ -223,15 +274,15 @@ class TextRenderer {
|
|
|
223
274
|
* Helper method to render text with paint order support
|
|
224
275
|
* Reduces code duplication in line-clamp and normal rendering
|
|
225
276
|
*/
|
|
226
|
-
renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers) {
|
|
277
|
+
renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers, baseline) {
|
|
227
278
|
paintOrderLayers.forEach((paintOrderLayer) => {
|
|
228
279
|
switch (paintOrderLayer) {
|
|
229
280
|
case 0 /* PAINT_ORDER_LAYER.FILL */:
|
|
230
281
|
this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
|
|
231
|
-
this.renderTextWithLetterSpacing(textBound, styles.letterSpacing,
|
|
282
|
+
this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, baseline, styles.writingMode);
|
|
232
283
|
break;
|
|
233
284
|
case 1 /* PAINT_ORDER_LAYER.STROKE */:
|
|
234
|
-
this.renderTextStrokeWithStyle(textBound, styles);
|
|
285
|
+
this.renderTextStrokeWithStyle(textBound, styles, baseline);
|
|
235
286
|
break;
|
|
236
287
|
}
|
|
237
288
|
});
|
|
@@ -253,6 +304,8 @@ class TextRenderer {
|
|
|
253
304
|
// kerning and ligatures when rendering multiple glyphs together, so
|
|
254
305
|
// measuring them as one string is more precise than summing individual widths.
|
|
255
306
|
// Binary search reduces measurements from O(n) to O(log n).
|
|
307
|
+
// NOTE: can't use cachedMeasureText here — the binary search measures
|
|
308
|
+
// concatenated substrings, and kerning/ligatures depend on the full string.
|
|
256
309
|
const fits = (n) => this.ctx.measureText(graphemes.slice(0, n).join('')).width + ellipsisWidth <= maxWidth;
|
|
257
310
|
let lo = 0;
|
|
258
311
|
let hi = graphemes.length;
|
|
@@ -268,10 +321,11 @@ class TextRenderer {
|
|
|
268
321
|
return graphemes.slice(0, lo).join('') + ellipsis;
|
|
269
322
|
}
|
|
270
323
|
else {
|
|
324
|
+
const fontString = this.ctx.font;
|
|
271
325
|
let width = ellipsisWidth;
|
|
272
326
|
const result = [];
|
|
273
327
|
for (const letter of graphemes) {
|
|
274
|
-
const glyphWidth = this.
|
|
328
|
+
const glyphWidth = this.cachedMeasureText(letter, fontString);
|
|
275
329
|
// Check against glyph width only (no trailing spacing): letter-spacing
|
|
276
330
|
// is applied *between* characters, not after the final glyph. Using
|
|
277
331
|
// `glyphWidth + letterSpacing` would incorrectly discard letters that
|
|
@@ -309,7 +363,7 @@ class TextRenderer {
|
|
|
309
363
|
* Groups text bounds by their Y position into visual lines, then renders
|
|
310
364
|
* only the first N-1 complete lines followed by an ellipsis on the Nth line.
|
|
311
365
|
*/
|
|
312
|
-
renderLineClampedText(text, styles, paintOrder, containerBounds) {
|
|
366
|
+
renderLineClampedText(text, styles, paintOrder, baseline, containerBounds) {
|
|
313
367
|
const lineHeight = styles.fontSize.number * 1.5;
|
|
314
368
|
const lines = [];
|
|
315
369
|
let currentLine = [];
|
|
@@ -332,7 +386,7 @@ class TextRenderer {
|
|
|
332
386
|
return false; // fall through to normal rendering
|
|
333
387
|
// Render full lines (0..N-2)
|
|
334
388
|
for (let i = 0; i < maxLines - 1; i++) {
|
|
335
|
-
lines[i].forEach((tb) => this.renderTextBoundWithPaintOrder(tb, styles, paintOrder));
|
|
389
|
+
lines[i].forEach((tb) => this.renderTextBoundWithPaintOrder(tb, styles, paintOrder, baseline));
|
|
336
390
|
}
|
|
337
391
|
// Nth line: truncated with ellipsis
|
|
338
392
|
const lastLine = lines[maxLines - 1];
|
|
@@ -345,10 +399,10 @@ class TextRenderer {
|
|
|
345
399
|
for (const layer of paintOrder) {
|
|
346
400
|
if (layer === 0 /* PAINT_ORDER_LAYER.FILL */) {
|
|
347
401
|
this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
|
|
348
|
-
this.renderTextWithLetterSpacing(bounds, styles.letterSpacing,
|
|
402
|
+
this.renderTextWithLetterSpacing(bounds, styles.letterSpacing, baseline, styles.writingMode);
|
|
349
403
|
}
|
|
350
404
|
else if (layer === 1 /* PAINT_ORDER_LAYER.STROKE */) {
|
|
351
|
-
this.renderTextStrokeWithStyle(bounds, styles);
|
|
405
|
+
this.renderTextStrokeWithStyle(bounds, styles, baseline);
|
|
352
406
|
}
|
|
353
407
|
}
|
|
354
408
|
}
|
|
@@ -358,7 +412,7 @@ class TextRenderer {
|
|
|
358
412
|
* Render single-line text with text-overflow: ellipsis.
|
|
359
413
|
* Returns true if ellipsis was applied (caller should skip normal rendering).
|
|
360
414
|
*/
|
|
361
|
-
renderEllipsisText(text, styles, paintOrder, containerBounds) {
|
|
415
|
+
renderEllipsisText(text, styles, paintOrder, baseline, containerBounds) {
|
|
362
416
|
const lineHeight = styles.fontSize.number * 1.5;
|
|
363
417
|
const firstTop = text.textBounds[0].bounds.top;
|
|
364
418
|
const isSingleLine = text.textBounds.every((tb) => Math.abs(tb.bounds.top - firstTop) < lineHeight * 0.5);
|
|
@@ -376,25 +430,33 @@ class TextRenderer {
|
|
|
376
430
|
const bounds = new text_1.TextBounds(truncated, text.textBounds[0].bounds);
|
|
377
431
|
for (const layer of paintOrder) {
|
|
378
432
|
if (layer === 0 /* PAINT_ORDER_LAYER.FILL */)
|
|
379
|
-
this.renderTextFillWithShadows(bounds, styles);
|
|
433
|
+
this.renderTextFillWithShadows(bounds, styles, baseline);
|
|
380
434
|
else if (layer === 1 /* PAINT_ORDER_LAYER.STROKE */)
|
|
381
|
-
this.renderTextStrokeWithStyle(bounds, styles);
|
|
435
|
+
this.renderTextStrokeWithStyle(bounds, styles, baseline);
|
|
382
436
|
}
|
|
383
437
|
return true;
|
|
384
438
|
}
|
|
385
439
|
async renderTextNode(text, styles, containerBounds) {
|
|
386
|
-
|
|
440
|
+
// Reset glyph width cache at the start of each text node render —
|
|
441
|
+
// the font may change between nodes.
|
|
442
|
+
this.glyphWidthCache = null;
|
|
443
|
+
const [fontString, fontFamily, fontSize] = this.createFontStyle(styles);
|
|
444
|
+
this.ctx.font = fontString;
|
|
387
445
|
this.ctx.direction = styles.direction === 1 /* DIRECTION.RTL */ ? 'rtl' : 'ltr';
|
|
388
446
|
this.ctx.textAlign = 'left';
|
|
389
447
|
this.ctx.textBaseline = 'alphabetic';
|
|
390
448
|
const paintOrder = styles.paintOrder;
|
|
449
|
+
// Use the measured baseline from FontMetrics instead of fontSize,
|
|
450
|
+
// which correctly accounts for fonts with large descenders (e.g. Paul)
|
|
451
|
+
// where the alphabetic baseline is closer to the top of the em-square.
|
|
452
|
+
const { baseline } = this.fontMetrics.getMetrics(fontFamily, fontSize);
|
|
391
453
|
// -webkit-line-clamp
|
|
392
454
|
const clamp = styles.webkitLineClamp > 0 &&
|
|
393
455
|
(styles.display & 2 /* DISPLAY.BLOCK */) !== 0 &&
|
|
394
456
|
styles.overflowY === 1 /* OVERFLOW.HIDDEN */ &&
|
|
395
457
|
text.textBounds.length > 0;
|
|
396
458
|
if (clamp) {
|
|
397
|
-
if (this.renderLineClampedText(text, styles, paintOrder, containerBounds))
|
|
459
|
+
if (this.renderLineClampedText(text, styles, paintOrder, baseline, containerBounds))
|
|
398
460
|
return;
|
|
399
461
|
}
|
|
400
462
|
// text-overflow: ellipsis (single-line only)
|
|
@@ -402,18 +464,18 @@ class TextRenderer {
|
|
|
402
464
|
containerBounds &&
|
|
403
465
|
styles.overflowX === 1 /* OVERFLOW.HIDDEN */ &&
|
|
404
466
|
text.textBounds.length > 0;
|
|
405
|
-
if (ellipsis && this.renderEllipsisText(text, styles, paintOrder, containerBounds))
|
|
467
|
+
if (ellipsis && this.renderEllipsisText(text, styles, paintOrder, baseline, containerBounds))
|
|
406
468
|
return;
|
|
407
469
|
// Normal rendering: fill + stroke + decorations per text bound
|
|
408
470
|
text.textBounds.forEach((tb) => {
|
|
409
471
|
paintOrder.forEach((layer) => {
|
|
410
472
|
if (layer === 0 /* PAINT_ORDER_LAYER.FILL */) {
|
|
411
|
-
this.renderTextFillWithShadows(tb, styles);
|
|
473
|
+
this.renderTextFillWithShadows(tb, styles, baseline);
|
|
412
474
|
if (styles.textDecorationLine.length)
|
|
413
475
|
this.renderTextDecoration(tb.bounds, styles);
|
|
414
476
|
}
|
|
415
477
|
else if (layer === 1 /* PAINT_ORDER_LAYER.STROKE */) {
|
|
416
|
-
this.renderTextStrokeWithStyle(tb, styles);
|
|
478
|
+
this.renderTextStrokeWithStyle(tb, styles, baseline);
|
|
417
479
|
}
|
|
418
480
|
});
|
|
419
481
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.parseStackingContexts = exports.ElementPaint = exports.StackingContext = void 0;
|
|
3
|
+
exports.parseStackingContexts = exports.buildClipPathEffect = exports.resolveAxisRadius = exports.hasOverflowClip = exports.ElementPaint = exports.StackingContext = void 0;
|
|
4
4
|
const bitwise_1 = require("../core/bitwise");
|
|
5
5
|
const bound_curves_1 = require("./bound-curves");
|
|
6
6
|
const effects_1 = require("./effects");
|
|
@@ -51,7 +51,7 @@ class ElementPaint {
|
|
|
51
51
|
const matrix = this.container.styles.transform;
|
|
52
52
|
this.effects.push(new effects_1.TransformEffect(offsetX, offsetY, matrix));
|
|
53
53
|
}
|
|
54
|
-
if (hasOverflowClip(this.container.styles)) {
|
|
54
|
+
if ((0, exports.hasOverflowClip)(this.container.styles)) {
|
|
55
55
|
const borderBox = (0, bound_curves_1.calculateBorderBoxPath)(this.curves);
|
|
56
56
|
const paddingBox = (0, bound_curves_1.calculatePaddingBoxPath)(this.curves);
|
|
57
57
|
if ((0, path_1.equalPath)(borderBox, paddingBox)) {
|
|
@@ -63,7 +63,7 @@ class ElementPaint {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
if (this.container.styles.clipPath.type !== 0 /* CLIP_PATH_TYPE.NONE */) {
|
|
66
|
-
const clipPathEffect = buildClipPathEffect(this.container.styles.clipPath, this.container.bounds);
|
|
66
|
+
const clipPathEffect = (0, exports.buildClipPathEffect)(this.container.styles.clipPath, this.container.bounds);
|
|
67
67
|
if (clipPathEffect) {
|
|
68
68
|
this.effects.push(clipPathEffect);
|
|
69
69
|
}
|
|
@@ -91,7 +91,7 @@ class ElementPaint {
|
|
|
91
91
|
const croplessEffects = parent.effects.filter((effect) => !(0, effects_1.isClipEffect)(effect));
|
|
92
92
|
if (inFlow || parent.container.styles.position !== 0 /* POSITION.STATIC */ || !parent.parent) {
|
|
93
93
|
inFlow = [2 /* POSITION.ABSOLUTE */, 3 /* POSITION.FIXED */].indexOf(parent.container.styles.position) === -1;
|
|
94
|
-
if (hasOverflowClip(parent.container.styles)) {
|
|
94
|
+
if ((0, exports.hasOverflowClip)(parent.container.styles)) {
|
|
95
95
|
const borderBox = (0, bound_curves_1.calculateBorderBoxPath)(parent.curves);
|
|
96
96
|
const paddingBox = (0, bound_curves_1.calculatePaddingBoxPath)(parent.curves);
|
|
97
97
|
if (!(0, path_1.equalPath)(borderBox, paddingBox)) {
|
|
@@ -109,7 +109,9 @@ class ElementPaint {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
exports.ElementPaint = ElementPaint;
|
|
112
|
+
/** @internal – exported for testing only. */
|
|
112
113
|
const hasOverflowClip = (styles) => styles.overflowX !== 0 /* OVERFLOW.VISIBLE */ || styles.overflowY !== 0 /* OVERFLOW.VISIBLE */;
|
|
114
|
+
exports.hasOverflowClip = hasOverflowClip;
|
|
113
115
|
/**
|
|
114
116
|
* Resolve a `closest-side` or `farthest-side` shape-radius keyword to pixels
|
|
115
117
|
* for a single axis. Used by both `circle()` (per-axis) and `ellipse()`.
|
|
@@ -119,6 +121,7 @@ const hasOverflowClip = (styles) => styles.overflowX !== 0 /* OVERFLOW.VISIBLE *
|
|
|
119
121
|
* @param start - Absolute start of the reference box on this axis.
|
|
120
122
|
* @param end - Absolute end of the reference box on this axis.
|
|
121
123
|
* @param dimRef - Reference dimension for resolving a length-percentage value.
|
|
124
|
+
* @internal – exported for testing only.
|
|
122
125
|
*/
|
|
123
126
|
const resolveAxisRadius = (r, center, start, end, dimRef) => {
|
|
124
127
|
if (r === 'closest-side')
|
|
@@ -127,12 +130,14 @@ const resolveAxisRadius = (r, center, start, end, dimRef) => {
|
|
|
127
130
|
return Math.max(center - start, end - center);
|
|
128
131
|
return (0, length_percentage_1.getAbsoluteValue)(r, dimRef);
|
|
129
132
|
};
|
|
133
|
+
exports.resolveAxisRadius = resolveAxisRadius;
|
|
130
134
|
/**
|
|
131
135
|
* Convert a parsed ClipPathValue + element bounds into a ClipPathEffect whose
|
|
132
136
|
* `applyClip` callback draws the clip shape directly onto the canvas context.
|
|
133
137
|
*
|
|
134
138
|
* All coordinates are computed in page-absolute space at construction time so
|
|
135
139
|
* the callback itself is allocation-free and executes synchronously.
|
|
140
|
+
* @internal – exported for testing only.
|
|
136
141
|
*/
|
|
137
142
|
const buildClipPathEffect = (clipPath, bounds) => {
|
|
138
143
|
const { left: bLeft, top: bTop, width: bWidth, height: bHeight } = bounds;
|
|
@@ -174,8 +179,8 @@ const buildClipPathEffect = (clipPath, bounds) => {
|
|
|
174
179
|
case 3 /* CLIP_PATH_TYPE.ELLIPSE */: {
|
|
175
180
|
const cx = bLeft + (0, length_percentage_1.getAbsoluteValue)(clipPath.cx, bWidth);
|
|
176
181
|
const cy = bTop + (0, length_percentage_1.getAbsoluteValue)(clipPath.cy, bHeight);
|
|
177
|
-
const rx = resolveAxisRadius(clipPath.rx, cx, bLeft, bLeft + bWidth, bWidth);
|
|
178
|
-
const ry = resolveAxisRadius(clipPath.ry, cy, bTop, bTop + bHeight, bHeight);
|
|
182
|
+
const rx = (0, exports.resolveAxisRadius)(clipPath.rx, cx, bLeft, bLeft + bWidth, bWidth);
|
|
183
|
+
const ry = (0, exports.resolveAxisRadius)(clipPath.ry, cy, bTop, bTop + bHeight, bHeight);
|
|
179
184
|
return new effects_1.ClipPathEffect((ctx) => {
|
|
180
185
|
ctx.beginPath();
|
|
181
186
|
ctx.ellipse(cx, cy, Math.max(0, rx), Math.max(0, ry), 0, 0, Math.PI * 2);
|
|
@@ -239,6 +244,7 @@ const buildClipPathEffect = (clipPath, bounds) => {
|
|
|
239
244
|
}
|
|
240
245
|
}
|
|
241
246
|
};
|
|
247
|
+
exports.buildClipPathEffect = buildClipPathEffect;
|
|
242
248
|
const parseStackTree = (parent, stackingContext, realStackingContext, listItems) => {
|
|
243
249
|
parent.container.elements.forEach((child) => {
|
|
244
250
|
const treatAsRealStackingContext = child.createsRealStackingContext;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AbortSignal helper utilities.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Throw a DOMException if the given AbortSignal is already aborted.
|
|
6
|
+
* Used at checkpoints throughout the rendering pipeline to support
|
|
7
|
+
* cancellable renders.
|
|
8
|
+
*
|
|
9
|
+
* @param signal - The AbortSignal to check. If undefined or not aborted, this is a no-op.
|
|
10
|
+
* @throws {DOMException} With name 'AbortError' if the signal has been aborted.
|
|
11
|
+
*/
|
|
12
|
+
export declare const throwIfAborted: (signal?: AbortSignal) => void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Color } from '../css/types/color';
|
|
2
|
+
import type { Context } from './context';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the background colour for the rendered canvas, following CSS
|
|
5
|
+
* background propagation rules:
|
|
6
|
+
*
|
|
7
|
+
* 1. If the target element is `<html>`, inherit the first opaque ancestor
|
|
8
|
+
* (doc → body → fallback).
|
|
9
|
+
* 2. Otherwise use the user-supplied backgroundColor, or opaque white.
|
|
10
|
+
*
|
|
11
|
+
* @param context - Current rendering context.
|
|
12
|
+
* @param element - The root element being rendered.
|
|
13
|
+
* @param backgroundColorOverride - User-supplied override (string | null).
|
|
14
|
+
* @returns A resolved colour value suitable for filling the canvas.
|
|
15
|
+
*/
|
|
16
|
+
export declare const parseBackgroundColor: (context: Context, element: HTMLElement, backgroundColorOverride?: string | null) => Color;
|
|
@@ -13,8 +13,33 @@ export declare class Cache {
|
|
|
13
13
|
private readonly _cache;
|
|
14
14
|
private readonly maxSize;
|
|
15
15
|
private readonly _pendingOperations;
|
|
16
|
+
/** When true, addImage() collects URLs without starting loads. */
|
|
17
|
+
private _deferMode;
|
|
18
|
+
/** URLs collected during defer mode, pending batch preload. */
|
|
19
|
+
private _collectedUrls;
|
|
16
20
|
constructor(context: Context, _options: ResourceOptions);
|
|
21
|
+
/**
|
|
22
|
+
* Enter defer mode: subsequent addImage() calls only collect URLs.
|
|
23
|
+
* Call preloadAll() to exit defer mode and batch-load all URLs.
|
|
24
|
+
*/
|
|
25
|
+
startDefer(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Exit defer mode and load all collected URLs in parallel, respecting
|
|
28
|
+
* an optional concurrency cap to avoid overwhelming the browser's network
|
|
29
|
+
* stack (default: 10 concurrent loads).
|
|
30
|
+
* After this call returns, all images are either loaded (cache hit) or
|
|
31
|
+
* failed (logged). Subsequent cache.match() calls return immediately.
|
|
32
|
+
*
|
|
33
|
+
* @param concurrency - Max concurrent image loads (1–100, default 10).
|
|
34
|
+
*/
|
|
35
|
+
preloadAll(concurrency?: number): Promise<void>;
|
|
17
36
|
addImage(src: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Shared helper: enqueues loading for a single URL with pending-operation
|
|
39
|
+
* deduplication and error swallowing. Used by both addImage() (normal mode)
|
|
40
|
+
* and preloadAll() (batch mode).
|
|
41
|
+
*/
|
|
42
|
+
private _addImageWithPending;
|
|
18
43
|
private _addImageInternal;
|
|
19
44
|
private withTimeout;
|
|
20
45
|
match(src: string): Promise<HTMLImageElement | HTMLCanvasElement | undefined> | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Options } from '../options';
|
|
2
|
+
import type { Html2CanvasConfig } from '../config';
|
|
3
|
+
import type { ResourceOptions } from './cache-storage';
|
|
4
|
+
import type { ContextOptions } from './context';
|
|
5
|
+
import type { CloneConfigurations } from '../dom/document-cloner';
|
|
6
|
+
import type { RenderConfigurations } from '../render/canvas/canvas-renderer';
|
|
7
|
+
/**
|
|
8
|
+
* Coerce known numeric options from string (or other) values to actual numbers.
|
|
9
|
+
* Mutates the opts object in place — this is intentionally a normalisation
|
|
10
|
+
* (not pure) step that runs once at the beginning of renderElement.
|
|
11
|
+
*/
|
|
12
|
+
export declare const coerceNumberOptions: (opts: Partial<Options>) => void;
|
|
13
|
+
/** Assemble resource loading options from user-provided Options. */
|
|
14
|
+
export declare const assembleResourceOptions: (opts: Partial<Options>) => ResourceOptions;
|
|
15
|
+
/** Assemble context (logging + cache) options, extending resource options. */
|
|
16
|
+
export declare const assembleContextOptions: (opts: Partial<Options>, config: Html2CanvasConfig, resourceOptions: ResourceOptions) => ContextOptions;
|
|
17
|
+
/** Assemble window / viewport options. */
|
|
18
|
+
export interface AssembledWindowOptions {
|
|
19
|
+
windowWidth: number;
|
|
20
|
+
windowHeight: number;
|
|
21
|
+
scrollX: number;
|
|
22
|
+
scrollY: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const assembleWindowOptions: (opts: Partial<Options>, defaultView: Window) => AssembledWindowOptions;
|
|
25
|
+
/** Assemble DOM cloning options. */
|
|
26
|
+
export declare const assembleCloneOptions: (opts: Partial<Options>, config: Html2CanvasConfig, foreignObjectRendering: boolean) => CloneConfigurations;
|
|
27
|
+
/** Assemble canvas rendering options. */
|
|
28
|
+
export declare const assembleRenderOptions: (opts: Partial<Options>, backgroundColor: number | null, left: number, top: number, width: number, height: number, devicePixelRatio: number) => RenderConfigurations;
|