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.
Files changed (46) hide show
  1. package/dist/html2canvas-pro.cjs +11221 -0
  2. package/dist/html2canvas-pro.cjs.map +1 -0
  3. package/dist/html2canvas-pro.esm.js +2812 -2587
  4. package/dist/html2canvas-pro.esm.js.map +1 -1
  5. package/dist/html2canvas-pro.js +2812 -2587
  6. package/dist/html2canvas-pro.js.map +1 -1
  7. package/dist/html2canvas-pro.min.js +4 -5
  8. package/dist/lib/core/abort-helper.js +20 -0
  9. package/dist/lib/core/background-parser.js +41 -0
  10. package/dist/lib/core/cache-storage.js +63 -7
  11. package/dist/lib/core/config-assembler.js +88 -0
  12. package/dist/lib/core/lru-map.js +66 -0
  13. package/dist/lib/core/render-element.js +24 -103
  14. package/dist/lib/css/property-descriptors/background-position.js +1 -11
  15. package/dist/lib/css/property-descriptors/background-size.js +3 -1
  16. package/dist/lib/css/syntax/token-constants.js +214 -0
  17. package/dist/lib/css/syntax/token-singletons.js +36 -0
  18. package/dist/lib/css/syntax/token-types.js +10 -0
  19. package/dist/lib/css/syntax/tokenizer.js +175 -307
  20. package/dist/lib/css/types/length-percentage.js +63 -3
  21. package/dist/lib/dom/document-cloner.js +23 -22
  22. package/dist/lib/dom/node-parser.js +1 -21
  23. package/dist/lib/dom/slot-cloner.js +8 -8
  24. package/dist/lib/render/background.js +4 -1
  25. package/dist/lib/render/canvas/background-renderer.js +27 -39
  26. package/dist/lib/render/canvas/canvas-renderer.js +3 -3
  27. package/dist/lib/render/canvas/effects-renderer.js +94 -32
  28. package/dist/lib/render/canvas/text-renderer.js +94 -32
  29. package/dist/lib/render/stacking-context.js +12 -6
  30. package/dist/types/core/abort-helper.d.ts +12 -0
  31. package/dist/types/core/background-parser.d.ts +16 -0
  32. package/dist/types/core/cache-storage.d.ts +25 -0
  33. package/dist/types/core/config-assembler.d.ts +28 -0
  34. package/dist/types/core/lru-map.d.ts +37 -0
  35. package/dist/types/css/syntax/token-constants.d.ts +74 -0
  36. package/dist/types/css/syntax/token-singletons.d.ts +22 -0
  37. package/dist/types/css/syntax/token-types.d.ts +72 -0
  38. package/dist/types/css/syntax/tokenizer.d.ts +6 -73
  39. package/dist/types/css/types/length-percentage.d.ts +42 -1
  40. package/dist/types/dom/document-cloner.d.ts +5 -0
  41. package/dist/types/dom/node-parser.d.ts +0 -2
  42. package/dist/types/render/canvas/background-renderer.d.ts +0 -10
  43. package/dist/types/render/canvas/effects-renderer.d.ts +42 -17
  44. package/dist/types/render/canvas/text-renderer.d.ts +15 -2
  45. package/dist/types/render/stacking-context.d.ts +26 -1
  46. 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
- // Clear all existing effects
64
+ // ── 1. Pop all previously active effects ──────────────────────
34
65
  while (this.activeEffects.length) {
35
66
  this.popEffect();
36
67
  }
37
- // Apply new effects
38
- effects.forEach((effect) => this.applyEffect(effect));
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
- this.ctx.save();
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
- // Apply all filters except drop-shadow() via ctx.filter.
71
- // drop-shadow() is rendered separately through ctx.shadow*
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 the canvas state before the effect was applied
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
- this.ctx.restore();
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.ctx.measureText(letter).width + letterSpacing;
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 (const letter of letters) {
140
- if ((0, writing_mode_1.isSidewaysWritingMode)(writingMode) || (!(0, exports.hasCJKCharacters)(letter) && letter.trim().length > 0)) {
141
- this.ctx.save();
142
- this.ctx.translate(text.bounds.left + baseline, top);
143
- this.ctx.rotate(writingMode === 4 /* WRITING_MODE.SIDEWAYS_LR */ ? -Math.PI / 2 : Math.PI / 2);
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
- top += this.ctx.measureText(letter).width + letterSpacing;
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, styles.fontSize.number, styles.writingMode);
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, styles.fontSize.number, styles.writingMode);
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, styles.fontSize.number, styles.writingMode);
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, styles.fontSize.number, styles.writingMode);
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.ctx.measureText(letter).width;
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, styles.fontSize.number, styles.writingMode);
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
- this.ctx.font = this.createFontStyle(styles)[0];
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;