html2canvas-pro 2.2.3 → 2.3.0
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 +11218 -0
- package/dist/html2canvas-pro.cjs.map +1 -0
- package/dist/html2canvas-pro.esm.js +2714 -2388
- package/dist/html2canvas-pro.esm.js.map +1 -1
- package/dist/html2canvas-pro.js +2714 -2388
- 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 +2 -2
- package/dist/lib/render/canvas/effects-renderer.js +94 -32
- package/dist/lib/render/canvas/text-renderer.js +68 -12
- package/dist/lib/render/effects.js +134 -13
- 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 +12 -0
- package/dist/types/render/effects.d.ts +35 -1
- 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,35 @@ 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;
|
|
97
103
|
this.options = deps.options;
|
|
98
104
|
this.decorationRenderer = new text_decoration_renderer_1.TextDecorationRenderer(deps.ctx);
|
|
99
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Returns the cached width for a glyph+font pair, measuring via
|
|
108
|
+
* measureText if not yet cached. Only used when letterSpacing !== 0
|
|
109
|
+
* (the `canRenderWholeText` fast path handles the zero-spacing case).
|
|
110
|
+
*/
|
|
111
|
+
cachedMeasureText(glyph, fontString) {
|
|
112
|
+
let cache = this.glyphWidthCache;
|
|
113
|
+
if (!cache) {
|
|
114
|
+
cache = new Map();
|
|
115
|
+
this.glyphWidthCache = cache;
|
|
116
|
+
}
|
|
117
|
+
const key = glyph + fontString;
|
|
118
|
+
let w = cache.get(key);
|
|
119
|
+
if (w === undefined) {
|
|
120
|
+
w = this.ctx.measureText(glyph).width;
|
|
121
|
+
cache.set(key, w);
|
|
122
|
+
}
|
|
123
|
+
return w;
|
|
124
|
+
}
|
|
100
125
|
/**
|
|
101
126
|
* Iterate grapheme clusters one-by-one, applying correct letter-spacing and
|
|
102
127
|
* per-script baseline for each character.
|
|
@@ -119,6 +144,7 @@ class TextRenderer {
|
|
|
119
144
|
}
|
|
120
145
|
const letters = (0, text_1.segmentGraphemes)(text.text);
|
|
121
146
|
const y = text.bounds.top + baseline;
|
|
147
|
+
const fontString = this.ctx.font;
|
|
122
148
|
let left = text.bounds.left;
|
|
123
149
|
for (const letter of letters) {
|
|
124
150
|
if ((0, exports.hasCJKCharacters)(letter)) {
|
|
@@ -130,29 +156,53 @@ class TextRenderer {
|
|
|
130
156
|
else {
|
|
131
157
|
renderFn(letter, left, y);
|
|
132
158
|
}
|
|
133
|
-
left += this.
|
|
159
|
+
left += this.cachedMeasureText(letter, fontString) + letterSpacing;
|
|
134
160
|
}
|
|
135
161
|
}
|
|
136
162
|
iterateVerticalGlyphs(text, letterSpacing, baseline, writingMode, renderFn) {
|
|
137
163
|
const letters = (0, text_1.segmentGraphemes)(text.text);
|
|
164
|
+
const fontString = this.ctx.font;
|
|
165
|
+
const rotationAngle = writingMode === 4 /* WRITING_MODE.SIDEWAYS_LR */ ? -Math.PI / 2 : Math.PI / 2;
|
|
138
166
|
let top = text.bounds.top;
|
|
139
|
-
for (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
renderFn(letter, 0, 0);
|
|
145
|
-
this.ctx.restore();
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
167
|
+
for (let i = 0; i < letters.length;) {
|
|
168
|
+
const letter = letters[i];
|
|
169
|
+
const isSideways = (0, writing_mode_1.isSidewaysWritingMode)(writingMode) || (!(0, exports.hasCJKCharacters)(letter) && letter.trim().length > 0);
|
|
170
|
+
if (!isSideways) {
|
|
171
|
+
// CJK glyph in vertical writing mode — render upright
|
|
148
172
|
const savedBaseline = this.ctx.textBaseline;
|
|
149
173
|
if ((0, exports.hasCJKCharacters)(letter)) {
|
|
150
174
|
this.ctx.textBaseline = 'ideographic';
|
|
151
175
|
}
|
|
152
176
|
renderFn(letter, text.bounds.left, top + baseline);
|
|
153
177
|
this.ctx.textBaseline = savedBaseline;
|
|
178
|
+
top += this.cachedMeasureText(letter, fontString) + letterSpacing;
|
|
179
|
+
i++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
// ── Batch consecutive sideways glyphs into one save/restore ──
|
|
183
|
+
const runStart = i;
|
|
184
|
+
while (i < letters.length) {
|
|
185
|
+
const ch = letters[i];
|
|
186
|
+
if (!(0, writing_mode_1.isSidewaysWritingMode)(writingMode) && !(0, exports.hasCJKCharacters)(ch) && ch.trim().length === 0)
|
|
187
|
+
break;
|
|
188
|
+
if ((0, writing_mode_1.isSidewaysWritingMode)(writingMode) || (!(0, exports.hasCJKCharacters)(ch) && ch.trim().length > 0)) {
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Render entire sideways run in one save/restore
|
|
196
|
+
this.ctx.save();
|
|
197
|
+
this.ctx.translate(text.bounds.left + baseline, top);
|
|
198
|
+
this.ctx.rotate(rotationAngle);
|
|
199
|
+
let runOffset = 0;
|
|
200
|
+
for (let j = runStart; j < i; j++) {
|
|
201
|
+
renderFn(letters[j], 0, runOffset);
|
|
202
|
+
runOffset += this.cachedMeasureText(letters[j], fontString) + letterSpacing;
|
|
154
203
|
}
|
|
155
|
-
|
|
204
|
+
this.ctx.restore();
|
|
205
|
+
top += runOffset;
|
|
156
206
|
}
|
|
157
207
|
}
|
|
158
208
|
/**
|
|
@@ -253,6 +303,8 @@ class TextRenderer {
|
|
|
253
303
|
// kerning and ligatures when rendering multiple glyphs together, so
|
|
254
304
|
// measuring them as one string is more precise than summing individual widths.
|
|
255
305
|
// Binary search reduces measurements from O(n) to O(log n).
|
|
306
|
+
// NOTE: can't use cachedMeasureText here — the binary search measures
|
|
307
|
+
// concatenated substrings, and kerning/ligatures depend on the full string.
|
|
256
308
|
const fits = (n) => this.ctx.measureText(graphemes.slice(0, n).join('')).width + ellipsisWidth <= maxWidth;
|
|
257
309
|
let lo = 0;
|
|
258
310
|
let hi = graphemes.length;
|
|
@@ -268,10 +320,11 @@ class TextRenderer {
|
|
|
268
320
|
return graphemes.slice(0, lo).join('') + ellipsis;
|
|
269
321
|
}
|
|
270
322
|
else {
|
|
323
|
+
const fontString = this.ctx.font;
|
|
271
324
|
let width = ellipsisWidth;
|
|
272
325
|
const result = [];
|
|
273
326
|
for (const letter of graphemes) {
|
|
274
|
-
const glyphWidth = this.
|
|
327
|
+
const glyphWidth = this.cachedMeasureText(letter, fontString);
|
|
275
328
|
// Check against glyph width only (no trailing spacing): letter-spacing
|
|
276
329
|
// is applied *between* characters, not after the final glyph. Using
|
|
277
330
|
// `glyphWidth + letterSpacing` would incorrectly discard letters that
|
|
@@ -383,6 +436,9 @@ class TextRenderer {
|
|
|
383
436
|
return true;
|
|
384
437
|
}
|
|
385
438
|
async renderTextNode(text, styles, containerBounds) {
|
|
439
|
+
// Reset glyph width cache at the start of each text node render —
|
|
440
|
+
// the font may change between nodes.
|
|
441
|
+
this.glyphWidthCache = null;
|
|
386
442
|
this.ctx.font = this.createFontStyle(styles)[0];
|
|
387
443
|
this.ctx.direction = styles.direction === 1 /* DIRECTION.RTL */ ? 'rtl' : 'ltr';
|
|
388
444
|
this.ctx.textAlign = 'left';
|
|
@@ -78,21 +78,142 @@ class FilterEffect {
|
|
|
78
78
|
constructor(filterString) {
|
|
79
79
|
this.type = 5 /* EffectType.FILTER */;
|
|
80
80
|
this.target = 2 /* EffectTarget.BACKGROUND_BORDERS */ | 4 /* EffectTarget.CONTENT */;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
const parsed = FilterEffect.parseDropShadow(filterString);
|
|
82
|
+
this.shadow = parsed.shadow;
|
|
83
|
+
this.safeFilterString = parsed.safeFilterString;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse a CSS filter string, extracting drop-shadow() parameters for
|
|
87
|
+
* rendering via ctx.shadow* (which avoids canvas taint), and producing
|
|
88
|
+
* a safe filter string with all drop-shadow() calls removed.
|
|
89
|
+
*
|
|
90
|
+
* Uses paren-depth tracking to correctly handle nested function
|
|
91
|
+
* arguments — e.g. rgba() / hsla() inside drop-shadow() — which
|
|
92
|
+
* the previous regex-based approach ([^)]+) could not handle.
|
|
93
|
+
*
|
|
94
|
+
* Per CSS spec the shadow value is: <color>? && <length>{2,3}
|
|
95
|
+
* (components can appear in any order).
|
|
96
|
+
*/
|
|
97
|
+
static parseDropShadow(filterString) {
|
|
98
|
+
if (!filterString) {
|
|
99
|
+
return { safeFilterString: '' };
|
|
100
|
+
}
|
|
101
|
+
const matches = FilterEffect.findDropShadows(filterString);
|
|
102
|
+
if (matches.length === 0) {
|
|
103
|
+
return { safeFilterString: filterString };
|
|
104
|
+
}
|
|
105
|
+
// Parse the first drop-shadow body to extract shadow params
|
|
106
|
+
const shadow = FilterEffect.parseDropShadowBody(matches[0].body);
|
|
107
|
+
// Build safe filter string by removing ALL drop-shadow() occurrences
|
|
108
|
+
let result = '';
|
|
109
|
+
let lastEnd = 0;
|
|
110
|
+
for (const m of matches) {
|
|
111
|
+
result += filterString.slice(lastEnd, m.start);
|
|
112
|
+
lastEnd = m.end;
|
|
92
113
|
}
|
|
93
|
-
|
|
94
|
-
|
|
114
|
+
result += filterString.slice(lastEnd);
|
|
115
|
+
return {
|
|
116
|
+
shadow,
|
|
117
|
+
safeFilterString: result.replace(/\s+/g, ' ').trim()
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Find all drop-shadow() function calls in a CSS filter string.
|
|
122
|
+
* Uses paren-depth tracking to correctly skip over nested
|
|
123
|
+
* parentheses (e.g. rgba(0, 0, 0, 0.15)).
|
|
124
|
+
*/
|
|
125
|
+
static findDropShadows(str) {
|
|
126
|
+
const results = [];
|
|
127
|
+
const re = /drop-shadow\(/gi;
|
|
128
|
+
let m;
|
|
129
|
+
while ((m = re.exec(str)) !== null) {
|
|
130
|
+
const openParen = m.index + 'drop-shadow('.length - 1; // position of '('
|
|
131
|
+
const start = m.index;
|
|
132
|
+
let depth = 1;
|
|
133
|
+
let pos = openParen + 1;
|
|
134
|
+
while (pos < str.length && depth > 0) {
|
|
135
|
+
const ch = str[pos];
|
|
136
|
+
if (ch === '(')
|
|
137
|
+
depth++;
|
|
138
|
+
else if (ch === ')')
|
|
139
|
+
depth--;
|
|
140
|
+
pos++;
|
|
141
|
+
}
|
|
142
|
+
if (depth !== 0)
|
|
143
|
+
break; // malformed — stop scanning
|
|
144
|
+
const body = str.slice(openParen + 1, pos - 1);
|
|
145
|
+
results.push({ body, start, end: pos });
|
|
146
|
+
}
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Parse the body of a single drop-shadow() function.
|
|
151
|
+
* Body format (order-independent): <offsetX> <offsetY> [<blur>] [<color>?]
|
|
152
|
+
*/
|
|
153
|
+
static parseDropShadowBody(body) {
|
|
154
|
+
const tokens = FilterEffect.tokenizeFilterArgs(body.trim());
|
|
155
|
+
const lengths = [];
|
|
156
|
+
let color;
|
|
157
|
+
for (const token of tokens) {
|
|
158
|
+
if (FilterEffect.isCSSLength(token)) {
|
|
159
|
+
lengths.push(parseFloat(token));
|
|
160
|
+
}
|
|
161
|
+
else if (token) {
|
|
162
|
+
color = token;
|
|
163
|
+
}
|
|
95
164
|
}
|
|
165
|
+
if (lengths.length < 2 || lengths.length > 3) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
offsetX: lengths[0],
|
|
170
|
+
offsetY: lengths[1],
|
|
171
|
+
blur: lengths[2] ?? 0,
|
|
172
|
+
color: color ?? 'rgba(0,0,0,1)'
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Split whitespace-separated tokens while keeping parenthesised
|
|
177
|
+
* expressions intact.
|
|
178
|
+
*
|
|
179
|
+
* e.g. "rgba(0, 0, 0, 0.15) 0px 1px 2px"
|
|
180
|
+
* → ["rgba(0, 0, 0, 0.15)", "0px", "1px", "2px"]
|
|
181
|
+
*/
|
|
182
|
+
static tokenizeFilterArgs(str) {
|
|
183
|
+
const tokens = [];
|
|
184
|
+
let current = '';
|
|
185
|
+
let depth = 0;
|
|
186
|
+
for (let i = 0; i < str.length; i++) {
|
|
187
|
+
const ch = str[i];
|
|
188
|
+
if (ch === '(') {
|
|
189
|
+
depth++;
|
|
190
|
+
current += ch;
|
|
191
|
+
}
|
|
192
|
+
else if (ch === ')') {
|
|
193
|
+
depth--;
|
|
194
|
+
current += ch;
|
|
195
|
+
}
|
|
196
|
+
else if (/\s/.test(ch)) {
|
|
197
|
+
if (depth > 0) {
|
|
198
|
+
current += ch;
|
|
199
|
+
}
|
|
200
|
+
else if (current) {
|
|
201
|
+
tokens.push(current);
|
|
202
|
+
current = '';
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
current += ch;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (current) {
|
|
210
|
+
tokens.push(current);
|
|
211
|
+
}
|
|
212
|
+
return tokens;
|
|
213
|
+
}
|
|
214
|
+
/** True when token looks like a CSS length: number with optional unit. */
|
|
215
|
+
static isCSSLength(token) {
|
|
216
|
+
return /^-?[\d.]+(px|em|rem|pt|cm|mm|in|pc|ex|ch|vw|vh|vmin|vmax|%)?$/i.test(token);
|
|
96
217
|
}
|
|
97
218
|
}
|
|
98
219
|
exports.FilterEffect = FilterEffect;
|
|
@@ -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;
|