html2canvas-pro 2.4.3 → 2.4.5

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.
@@ -0,0 +1,270 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.releaseSurface = exports.FilterSurfaceError = void 0;
4
+ exports.parseSimpleFilter = parseSimpleFilter;
5
+ exports.filterOutset = filterOutset;
6
+ exports.supportsNativeFilters = supportsNativeFilters;
7
+ exports.renderFilterSurface = renderFilterSurface;
8
+ exports.renderSvgFilterSurface = renderSvgFilterSurface;
9
+ const effects_1 = require("../effects");
10
+ const abort_helper_1 = require("../../core/abort-helper");
11
+ // Only errors in the optional surface path are eligible for legacy fallback.
12
+ class FilterSurfaceError extends Error {
13
+ }
14
+ exports.FilterSurfaceError = FilterSurfaceError;
15
+ const releaseSurface = (canvas) => {
16
+ canvas.width = canvas.height = 0;
17
+ };
18
+ exports.releaseSurface = releaseSurface;
19
+ // This first renderer integration handles computed blur followed by one shadow.
20
+ // Unsupported chains retain the existing renderer until general filter support lands.
21
+ function parseSimpleFilter(value) {
22
+ if (!value || value === 'none')
23
+ return { blur: 0 };
24
+ const effect = new effects_1.FilterEffect(value);
25
+ const shadows = value.match(/drop-shadow\(/g) || [];
26
+ if (shadows.length > 1 || (shadows.length === 1 && !effect.shadow))
27
+ return null;
28
+ const blur = effect.safeFilterString.match(/^blur\((\d+(?:\.\d+)?)px\)$/);
29
+ if (effect.safeFilterString && !blur)
30
+ return null;
31
+ if (blur && shadows.length && value.indexOf('blur(') > value.indexOf('drop-shadow('))
32
+ return null;
33
+ return {
34
+ blur: blur ? Number(blur[1]) : 0,
35
+ shadow: effect.shadow
36
+ ? {
37
+ x: effect.shadow.offsetX,
38
+ y: effect.shadow.offsetY,
39
+ blur: effect.shadow.blur,
40
+ color: effect.shadow.color
41
+ }
42
+ : undefined
43
+ };
44
+ }
45
+ function filterOutset(filter) {
46
+ return Math.ceil(3 * (filter.blur + (filter.shadow?.blur ?? 0)) +
47
+ Math.max(Math.abs(filter.shadow?.x ?? 0), Math.abs(filter.shadow?.y ?? 0)));
48
+ }
49
+ function canvasLike(source) {
50
+ const canvas = document.createElement('canvas');
51
+ canvas.width = source.width;
52
+ canvas.height = source.height;
53
+ return canvas;
54
+ }
55
+ const decodeSurface = (image, signal) => new Promise((resolve, reject) => {
56
+ const finish = (error) => {
57
+ clearTimeout(timer);
58
+ signal?.removeEventListener('abort', abort);
59
+ if (error)
60
+ reject(error);
61
+ else
62
+ resolve();
63
+ };
64
+ const abort = () => finish(new DOMException('The operation was aborted.', 'AbortError'));
65
+ const timer = setTimeout(() => finish(new FilterSurfaceError('Filter surface image decode timed out')), 10000);
66
+ signal?.addEventListener('abort', abort, { once: true });
67
+ if (signal?.aborted)
68
+ abort();
69
+ else
70
+ image.decode().then(() => finish(), () => finish(new FilterSurfaceError('Filter surface image decode failed')));
71
+ });
72
+ // Cache only a capability result, never raster storage. Separate Documents may use
73
+ // different Canvas implementations (including embedded webviews and test realms).
74
+ const canvasFilterSupport = new WeakMap();
75
+ function supportsNativeFilters(owner = document) {
76
+ const cached = canvasFilterSupport.get(owner);
77
+ if (cached !== undefined)
78
+ return cached;
79
+ const source = owner.createElement('canvas');
80
+ const target = owner.createElement('canvas');
81
+ let supported = false;
82
+ try {
83
+ source.width = source.height = target.width = target.height = 32;
84
+ const input = source.getContext('2d');
85
+ const context = target.getContext('2d');
86
+ // Check before assignment: on older WebKit assigning filter creates an
87
+ // expando property, not a working implementation.
88
+ if (input && context && 'filter' in context) {
89
+ input.fillStyle = '#fff';
90
+ input.fillRect(8, 8, 8, 8);
91
+ context.filter = 'blur(2px)';
92
+ context.drawImage(source, 0, 0);
93
+ const blurred = context.getImageData(6, 12, 1, 1).data[3] > 0;
94
+ context.clearRect(0, 0, 32, 32);
95
+ context.filter = 'drop-shadow(12px 0px 0px rgba(255, 0, 0, 0.5))';
96
+ context.drawImage(source, 0, 0);
97
+ const shadow = context.getImageData(24, 12, 1, 1).data;
98
+ supported = blurred && shadow[0] > 240 && shadow[1] < 10 && shadow[3] >= 126 && shadow[3] <= 129;
99
+ }
100
+ }
101
+ catch {
102
+ // Readback can itself be unavailable. Be conservative and retain SVG.
103
+ }
104
+ finally {
105
+ (0, exports.releaseSurface)(source);
106
+ (0, exports.releaseSurface)(target);
107
+ }
108
+ canvasFilterSupport.set(owner, supported);
109
+ return supported;
110
+ }
111
+ function tryNativeFilterSurface(source, { blur, shadow }, opacity, scale, signal) {
112
+ let filtered;
113
+ let output;
114
+ let complete = false;
115
+ try {
116
+ (0, abort_helper_1.throwIfAborted)(signal);
117
+ filtered = canvasLike(source);
118
+ const context = filtered.getContext('2d');
119
+ if (!context || !('filter' in context))
120
+ return null;
121
+ const functions = [];
122
+ if (blur)
123
+ functions.push(`blur(${blur * scale}px)`);
124
+ if (shadow)
125
+ functions.push(`drop-shadow(${shadow.x * scale}px ${shadow.y * scale}px ${shadow.blur * scale}px ${shadow.color})`);
126
+ context.filter = functions.join(' ');
127
+ if (context.filter === 'none')
128
+ return null;
129
+ // Canvas applies globalAlpha before filtering. Filter the complete layer
130
+ // at alpha 1, then composite opacity separately, including its shadow.
131
+ context.drawImage(source, 0, 0);
132
+ (0, abort_helper_1.throwIfAborted)(signal);
133
+ output = canvasLike(source);
134
+ const composite = output.getContext('2d');
135
+ if (!composite)
136
+ return null;
137
+ composite.globalAlpha = opacity;
138
+ composite.drawImage(filtered, 0, 0);
139
+ (0, abort_helper_1.throwIfAborted)(signal);
140
+ complete = true;
141
+ return output;
142
+ }
143
+ catch {
144
+ // A native allocation/draw failure may still succeed through SVG, but
145
+ // cancellation must never silently degrade to a successful fallback.
146
+ (0, abort_helper_1.throwIfAborted)(signal);
147
+ return null;
148
+ }
149
+ finally {
150
+ if (filtered)
151
+ (0, exports.releaseSurface)(filtered);
152
+ if (output && !complete)
153
+ (0, exports.releaseSurface)(output);
154
+ }
155
+ }
156
+ // Backend selection is internal; the public html2canvas options are unchanged.
157
+ async function renderFilterSurface(source, filter, opacity, scale, signal) {
158
+ (0, abort_helper_1.throwIfAborted)(signal);
159
+ if ((filter.blur || filter.shadow) && supportsNativeFilters()) {
160
+ const output = tryNativeFilterSurface(source, filter, opacity, scale, signal);
161
+ if (output)
162
+ return output;
163
+ }
164
+ return renderSvgFilterSurface(source, filter, opacity, scale, signal);
165
+ }
166
+ // Explicit SVG entry is internal and allows independent fallback benchmarks/tests.
167
+ // No foreignObject or external image is used.
168
+ async function renderSvgFilterSurface(source, { blur, shadow }, opacity, scale, signal) {
169
+ (0, abort_helper_1.throwIfAborted)(signal);
170
+ if (!blur && !shadow) {
171
+ const output = canvasLike(source);
172
+ const context = output.getContext('2d');
173
+ if (!context) {
174
+ (0, exports.releaseSurface)(output);
175
+ throw new FilterSurfaceError('Filter surface canvas is unavailable');
176
+ }
177
+ context.globalAlpha = opacity;
178
+ try {
179
+ context.drawImage(source, 0, 0);
180
+ return output;
181
+ }
182
+ catch {
183
+ (0, exports.releaseSurface)(output);
184
+ throw new FilterSurfaceError('Filter surface image cannot be drawn');
185
+ }
186
+ }
187
+ const namespace = 'http://www.w3.org/2000/svg';
188
+ const element = (tag, attributes = {}) => {
189
+ const node = document.createElementNS(namespace, tag);
190
+ Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, String(value)));
191
+ return node;
192
+ };
193
+ const svg = element('svg', {
194
+ width: source.width,
195
+ height: source.height,
196
+ viewBox: `0 0 ${source.width} ${source.height}`
197
+ });
198
+ const defs = element('defs');
199
+ const filter = element('filter', {
200
+ id: 'composited-filter',
201
+ filterUnits: 'userSpaceOnUse',
202
+ x: 0,
203
+ y: 0,
204
+ width: source.width,
205
+ height: source.height,
206
+ 'color-interpolation-filters': 'sRGB'
207
+ });
208
+ if (blur) {
209
+ filter.appendChild(element('feGaussianBlur', { stdDeviation: blur * scale, result: 'blurred-source' }));
210
+ }
211
+ if (shadow) {
212
+ // Explicit primitives avoid the narrower feDropShadow blur observed in WebKit.
213
+ filter.appendChild(element('feGaussianBlur', {
214
+ in: blur ? 'blurred-source' : 'SourceAlpha',
215
+ stdDeviation: shadow.blur * scale
216
+ }));
217
+ filter.appendChild(element('feOffset', { dx: shadow.x * scale, dy: shadow.y * scale, result: 'offset-shadow' }));
218
+ filter.appendChild(element('feFlood', { 'flood-color': shadow.color }));
219
+ filter.appendChild(element('feComposite', { in2: 'offset-shadow', operator: 'in' }));
220
+ const merge = element('feMerge');
221
+ merge.appendChild(element('feMergeNode'));
222
+ merge.appendChild(element('feMergeNode', { in: blur ? 'blurred-source' : 'SourceGraphic' }));
223
+ filter.appendChild(merge);
224
+ }
225
+ defs.appendChild(filter);
226
+ svg.appendChild(defs);
227
+ let sourceUrl;
228
+ try {
229
+ sourceUrl = source.toDataURL('image/png');
230
+ if (sourceUrl === 'data:,')
231
+ throw new Error('Canvas size is unsupported');
232
+ }
233
+ catch {
234
+ // allowTaint may intentionally produce an unreadable canvas. Keep that
235
+ // API behavior by letting the caller render the subtree on the old path.
236
+ throw new FilterSurfaceError('Filter surface pixels cannot be serialized');
237
+ }
238
+ svg.appendChild(element('image', {
239
+ width: source.width,
240
+ height: source.height,
241
+ href: sourceUrl,
242
+ filter: 'url(#composited-filter)'
243
+ }));
244
+ const image = new Image();
245
+ let output;
246
+ try {
247
+ image.src = `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
248
+ await decodeSurface(image, signal);
249
+ (0, abort_helper_1.throwIfAborted)(signal);
250
+ output = canvasLike(source);
251
+ const context = output.getContext('2d');
252
+ if (!context)
253
+ throw new FilterSurfaceError('Filter surface canvas is unavailable');
254
+ context.globalAlpha = opacity;
255
+ context.drawImage(image, 0, 0);
256
+ return output;
257
+ }
258
+ catch (error) {
259
+ if (output)
260
+ (0, exports.releaseSurface)(output);
261
+ if (signal?.aborted)
262
+ (0, abort_helper_1.throwIfAborted)(signal);
263
+ throw error instanceof FilterSurfaceError
264
+ ? error
265
+ : new FilterSurfaceError('Filter surface image cannot be drawn');
266
+ }
267
+ finally {
268
+ image.removeAttribute('src');
269
+ }
270
+ }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_SURFACE_SIDE = exports.MAX_SURFACE_PIXELS = void 0;
4
+ exports.reserveSurface = reserveSurface;
5
+ exports.surfaceBounds = surfaceBounds;
6
+ exports.cropSurface = cropSurface;
7
+ const bounds_1 = require("../../css/layout/bounds");
8
+ const length_percentage_1 = require("../../css/types/length-percentage");
9
+ const filter_surface_1 = require("./filter-surface");
10
+ // Budget backing stores across all active nested surfaces in one capture.
11
+ // Reserve source, filtered output and decoded SVG/PNG rasters conservatively.
12
+ // Browser encoder/decoder overhead and the caller's output canvas are separate.
13
+ exports.MAX_SURFACE_PIXELS = 16 * 1024 * 1024;
14
+ exports.MAX_SURFACE_SIDE = 8192;
15
+ function reserveSurface(budget, width, height) {
16
+ const pixels = width * height * 4;
17
+ if (!Number.isFinite(pixels) ||
18
+ width <= 0 ||
19
+ height <= 0 ||
20
+ width > exports.MAX_SURFACE_SIDE ||
21
+ height > exports.MAX_SURFACE_SIDE ||
22
+ budget.pixels + pixels > exports.MAX_SURFACE_PIXELS)
23
+ return 0;
24
+ budget.pixels += pixels;
25
+ return pixels;
26
+ }
27
+ const expand = (b, margin) => b.add(-margin, -margin, margin * 2, margin * 2);
28
+ const union = (a, b) => new bounds_1.Bounds(Math.min(a.left, b.left), Math.min(a.top, b.top), Math.max(a.left + a.width, b.left + b.width) - Math.min(a.left, b.left), Math.max(a.top + a.height, b.top + b.height) - Math.min(a.top, b.top));
29
+ // Conservative paint bounds for the untransformed subtree supported by this path.
30
+ // Include overflow descendants, text ink padding, box/text shadows and nested filters.
31
+ function surfaceBounds(container) {
32
+ const styles = container.styles;
33
+ let bounds = container.bounds;
34
+ for (const shadow of styles.boxShadow) {
35
+ if (!shadow.inset) {
36
+ const spread = Math.max(0, shadow.spread.number) + 3 * shadow.blur.number;
37
+ bounds = union(bounds, expand(container.bounds.add(shadow.offsetX.number, shadow.offsetY.number, 0, 0), spread));
38
+ }
39
+ }
40
+ for (const text of container.textNodes) {
41
+ for (const part of text.textBounds) {
42
+ const ink = expand(part.bounds, 2 * (0, length_percentage_1.getAbsoluteValue)(styles.fontSize, container.bounds.height) + styles.webkitTextStrokeWidth);
43
+ bounds = union(bounds, ink);
44
+ for (const shadow of styles.textShadow) {
45
+ bounds = union(bounds, expand(ink.add(shadow.offsetX.number, shadow.offsetY.number, 0, 0), 3 * shadow.blur.number));
46
+ }
47
+ }
48
+ }
49
+ for (const child of container.elements) {
50
+ if (child.styles.isVisible())
51
+ bounds = union(bounds, surfaceBounds(child));
52
+ }
53
+ const filter = (0, filter_surface_1.parseSimpleFilter)(styles.filter);
54
+ return expand(bounds, filter ? (0, filter_surface_1.filterOutset)(filter) : 0);
55
+ }
56
+ function cropSurface(bounds, viewport, margin, scale) {
57
+ // Pixels just outside the destination still contribute through blur/shadow.
58
+ const area = expand(viewport, margin);
59
+ const left = Math.floor(Math.max(bounds.left, area.left) * scale) / scale;
60
+ const top = Math.floor(Math.max(bounds.top, area.top) * scale) / scale;
61
+ const right = Math.ceil(Math.min(bounds.left + bounds.width, area.left + area.width) * scale) / scale;
62
+ const bottom = Math.ceil(Math.min(bounds.top + bounds.height, area.top + area.height) * scale) / scale;
63
+ return new bounds_1.Bounds(left, top, Math.max(0, right - left), Math.max(0, bottom - top));
64
+ }
@@ -27,8 +27,8 @@ const iOSBrokenFonts = ['-apple-system', 'system-ui'];
27
27
  const TEXT_OVERFLOW_EPSILON = 1;
28
28
  /**
29
29
  * Detect CJK (Chinese, Japanese, Korean) characters in a string.
30
- * CJK characters use the ideographic baseline in browsers, which differs
31
- * from the alphabetic baseline used for Latin script.
30
+ * Vertical writing modes use it to keep CJK glyphs upright and on the
31
+ * ideographic baseline while other scripts are rotated.
32
32
  *
33
33
  * Covers:
34
34
  * U+2E80–U+2FFF CJK Radicals Supplement, Kangxi Radicals
@@ -128,15 +128,17 @@ class TextRenderer {
128
128
  return w;
129
129
  }
130
130
  /**
131
- * Iterate grapheme clusters one-by-one, applying correct letter-spacing and
132
- * per-script baseline for each character.
131
+ * Iterate grapheme clusters one-by-one, applying correct letter-spacing.
133
132
  *
134
133
  * Issue #73: When letter-spacing is non-zero, text must be rendered character by
135
- * character. This helper centralises two fixes applied during that iteration:
136
- * 1. Add `letterSpacing` to each character's advance width (was previously
137
- * omitted, causing characters to render without any spacing).
138
- * 2. Switch to the ideographic baseline for CJK glyphs so their vertical
139
- * position matches how browsers lay them out in the DOM.
134
+ * character, adding `letterSpacing` to each character's advance width.
135
+ *
136
+ * In horizontal text every glyph keeps the alphabetic baseline, CJK included:
137
+ * inline layout aligns a whole line on its alphabetic baseline regardless of
138
+ * script, and `baseline` was measured for that. Drawing CJK graphemes at the
139
+ * same y with `textBaseline = 'ideographic'` lifts them above the Latin text
140
+ * next to them, and away from where the `letterSpacing === 0` whole-string
141
+ * path draws them.
140
142
  *
141
143
  * The `renderFn` callback receives (letter, x, y) and performs the actual draw
142
144
  * call (fillText or strokeText), allowing fill and stroke paths to share one
@@ -152,15 +154,7 @@ class TextRenderer {
152
154
  const fontString = this.ctx.font;
153
155
  let left = text.bounds.left;
154
156
  for (const letter of letters) {
155
- if ((0, exports.hasCJKCharacters)(letter)) {
156
- const savedBaseline = this.ctx.textBaseline;
157
- this.ctx.textBaseline = 'ideographic';
158
- renderFn(letter, left, y);
159
- this.ctx.textBaseline = savedBaseline;
160
- }
161
- else {
162
- renderFn(letter, left, y);
163
- }
157
+ renderFn(letter, left, y);
164
158
  left += this.cachedMeasureText(letter, fontString) + letterSpacing;
165
159
  }
166
160
  }
@@ -213,7 +207,7 @@ class TextRenderer {
213
207
  /**
214
208
  * Render text with letter-spacing applied (fill pass).
215
209
  * When letterSpacing is 0 the whole string is drawn in one call; otherwise each
216
- * grapheme is drawn individually so spacing and CJK baseline are applied correctly.
210
+ * grapheme is drawn individually so spacing is applied correctly.
217
211
  */
218
212
  renderTextWithLetterSpacing(text, letterSpacing, baseline, writingMode = 0 /* WRITING_MODE.HORIZONTAL_TB */) {
219
213
  this.renderFillText(text, letterSpacing, baseline, writingMode);
@@ -83,12 +83,13 @@ class ElementPaint {
83
83
  this.effects.push(new effects_1.TransformEffect(offsetX, offsetY, zoomMatrix));
84
84
  }
85
85
  }
86
- getEffects(target) {
86
+ getEffects(target, surfaceRoot) {
87
87
  let inFlow = [2 /* POSITION.ABSOLUTE */, 3 /* POSITION.FIXED */].indexOf(this.container.styles.position) === -1;
88
- let parent = this.parent;
89
- const effects = this.effects.slice(0);
88
+ let parent = this === surfaceRoot ? null : this.parent;
89
+ const sourceEffects = (paint) => paint.effects.filter((effect) => paint !== surfaceRoot || (!(0, effects_1.isFilterEffect)(effect) && !(0, effects_1.isOpacityEffect)(effect)));
90
+ const effects = sourceEffects(this);
90
91
  while (parent) {
91
- const croplessEffects = parent.effects.filter((effect) => !(0, effects_1.isClipEffect)(effect));
92
+ const croplessEffects = sourceEffects(parent).filter((effect) => !(0, effects_1.isClipEffect)(effect));
92
93
  if (inFlow || parent.container.styles.position !== 0 /* POSITION.STATIC */ || !parent.parent) {
93
94
  inFlow = [2 /* POSITION.ABSOLUTE */, 3 /* POSITION.FIXED */].indexOf(parent.container.styles.position) === -1;
94
95
  if ((0, exports.hasOverflowClip)(parent.container.styles)) {
@@ -103,7 +104,7 @@ class ElementPaint {
103
104
  else {
104
105
  effects.unshift(...croplessEffects);
105
106
  }
106
- parent = parent.parent;
107
+ parent = parent === surfaceRoot ? null : parent.parent;
107
108
  }
108
109
  return effects.filter((effect) => (0, bitwise_1.contains)(effect.target, target));
109
110
  }
@@ -0,0 +1,3 @@
1
+ import { Path } from '../path';
2
+ export declare const shadowRadius: (radius: number, spread: number) => number;
3
+ export declare const spreadShadowPath: (paths: Path[], spread: number) => Path[];
@@ -0,0 +1,13 @@
1
+ import { BoxShadow } from '../../css/property-descriptors/box-shadow';
2
+ import { ElementPaint } from '../stacking-context';
3
+ import { SurfaceBudget } from './surface-bounds';
4
+ interface ShadowOptions {
5
+ x: number;
6
+ y: number;
7
+ width: number;
8
+ height: number;
9
+ scale: number;
10
+ signal?: AbortSignal;
11
+ }
12
+ export declare const paintBoxShadow: (ctx: CanvasRenderingContext2D, paint: ElementPaint, shadow: BoxShadow[number], options: ShadowOptions, budget: SurfaceBudget) => Promise<void>;
13
+ export {};
@@ -0,0 +1,16 @@
1
+ import { Bounds } from '../../css/layout/bounds';
2
+ export interface ShadowTransform {
3
+ a: number;
4
+ b: number;
5
+ c: number;
6
+ d: number;
7
+ e: number;
8
+ f: number;
9
+ }
10
+ export interface ShadowSpace {
11
+ matrix: ShadowTransform;
12
+ viewport: Bounds;
13
+ scale: number;
14
+ uniform: boolean;
15
+ }
16
+ export declare const shadowSpace: (matrix: ShadowTransform, width: number, height: number) => ShadowSpace | null;
@@ -4,6 +4,7 @@ import { ElementContainer } from '../../dom/element-container';
4
4
  import { Path } from '../path';
5
5
  import { BoundCurves } from '../bound-curves';
6
6
  import { ReplacedElementContainer } from '../../dom/replaced-elements';
7
+ import { SurfaceBudget } from './surface-bounds';
7
8
  import { Context } from '../../core/context';
8
9
  export type RenderConfigurations = RenderOptions & {
9
10
  backgroundColor: Color | null;
@@ -33,6 +34,8 @@ export interface RenderOptions {
33
34
  height: number;
34
35
  }
35
36
  export declare class CanvasRenderer {
37
+ private readonly surfaceRoot?;
38
+ private readonly surfaceBudget;
36
39
  canvas: HTMLCanvasElement;
37
40
  ctx: CanvasRenderingContext2D;
38
41
  private readonly context;
@@ -42,8 +45,11 @@ export declare class CanvasRenderer {
42
45
  private readonly borderImageRenderer;
43
46
  private readonly effectsRenderer;
44
47
  private readonly textRenderer;
45
- constructor(context: Context, options: RenderConfigurations);
48
+ private legacySubtree;
49
+ constructor(context: Context, options: RenderConfigurations, surfaceRoot?: ElementPaint | undefined, surfaceBudget?: SurfaceBudget);
46
50
  renderStack(stack: StackingContext): Promise<void>;
51
+ private canComposite;
52
+ private renderCompositedStack;
47
53
  renderNode(paint: ElementPaint): Promise<void>;
48
54
  /**
49
55
  * Helper method to render text with paint order support
@@ -55,6 +61,7 @@ export declare class CanvasRenderer {
55
61
  mask(paths: Path[]): void;
56
62
  path(paths: Path[]): void;
57
63
  formatPath(paths: Path[]): void;
64
+ private renderSurfaceBoxShadow;
58
65
  renderNodeBackgroundAndBorders(paint: ElementPaint): Promise<void>;
59
66
  render(element: ElementContainer): Promise<HTMLCanvasElement>;
60
67
  /**
@@ -0,0 +1,17 @@
1
+ export declare class FilterSurfaceError extends Error {
2
+ }
3
+ export declare const releaseSurface: (canvas: HTMLCanvasElement) => void;
4
+ export interface SimpleFilter {
5
+ blur: number;
6
+ shadow?: {
7
+ x: number;
8
+ y: number;
9
+ blur: number;
10
+ color: string;
11
+ };
12
+ }
13
+ export declare function parseSimpleFilter(value: string | null): SimpleFilter | null;
14
+ export declare function filterOutset(filter: SimpleFilter): number;
15
+ export declare function supportsNativeFilters(owner?: Document): boolean;
16
+ export declare function renderFilterSurface(source: HTMLCanvasElement, filter: SimpleFilter, opacity: number, scale: number, signal?: AbortSignal): Promise<HTMLCanvasElement>;
17
+ export declare function renderSvgFilterSurface(source: HTMLCanvasElement, { blur, shadow }: SimpleFilter, opacity: number, scale: number, signal?: AbortSignal): Promise<HTMLCanvasElement>;
@@ -0,0 +1,10 @@
1
+ import { Bounds } from '../../css/layout/bounds';
2
+ import { ElementContainer } from '../../dom/element-container';
3
+ export declare const MAX_SURFACE_PIXELS: number;
4
+ export declare const MAX_SURFACE_SIDE = 8192;
5
+ export interface SurfaceBudget {
6
+ pixels: number;
7
+ }
8
+ export declare function reserveSurface(budget: SurfaceBudget, width: number, height: number): number;
9
+ export declare function surfaceBounds(container: ElementContainer): Bounds;
10
+ export declare function cropSurface(bounds: Bounds, viewport: Bounds, margin: number, scale: number): Bounds;
@@ -49,15 +49,17 @@ export declare class TextRenderer {
49
49
  */
50
50
  private cachedMeasureText;
51
51
  /**
52
- * Iterate grapheme clusters one-by-one, applying correct letter-spacing and
53
- * per-script baseline for each character.
52
+ * Iterate grapheme clusters one-by-one, applying correct letter-spacing.
54
53
  *
55
54
  * Issue #73: When letter-spacing is non-zero, text must be rendered character by
56
- * character. This helper centralises two fixes applied during that iteration:
57
- * 1. Add `letterSpacing` to each character's advance width (was previously
58
- * omitted, causing characters to render without any spacing).
59
- * 2. Switch to the ideographic baseline for CJK glyphs so their vertical
60
- * position matches how browsers lay them out in the DOM.
55
+ * character, adding `letterSpacing` to each character's advance width.
56
+ *
57
+ * In horizontal text every glyph keeps the alphabetic baseline, CJK included:
58
+ * inline layout aligns a whole line on its alphabetic baseline regardless of
59
+ * script, and `baseline` was measured for that. Drawing CJK graphemes at the
60
+ * same y with `textBaseline = 'ideographic'` lifts them above the Latin text
61
+ * next to them, and away from where the `letterSpacing === 0` whole-string
62
+ * path draws them.
61
63
  *
62
64
  * The `renderFn` callback receives (letter, x, y) and performs the actual draw
63
65
  * call (fillText or strokeText), allowing fill and stroke paths to share one
@@ -68,7 +70,7 @@ export declare class TextRenderer {
68
70
  /**
69
71
  * Render text with letter-spacing applied (fill pass).
70
72
  * When letterSpacing is 0 the whole string is drawn in one call; otherwise each
71
- * grapheme is drawn individually so spacing and CJK baseline are applied correctly.
73
+ * grapheme is drawn individually so spacing is applied correctly.
72
74
  */
73
75
  renderTextWithLetterSpacing(text: TextBounds, letterSpacing: number, baseline: number, writingMode?: WRITING_MODE): void;
74
76
  private canRenderWholeText;
@@ -21,7 +21,7 @@ export declare class ElementPaint {
21
21
  readonly curves: BoundCurves;
22
22
  listValue?: string;
23
23
  constructor(container: ElementContainer, parent: ElementPaint | null);
24
- getEffects(target: EffectTarget): IElementEffect[];
24
+ getEffects(target: EffectTarget, surfaceRoot?: ElementPaint): IElementEffect[];
25
25
  }
26
26
  /** @internal – exported for testing only. */
27
27
  export declare const hasOverflowClip: (styles: ElementContainer["styles"]) => boolean;
package/package.json CHANGED
@@ -19,7 +19,7 @@
19
19
  "default": "./dist/html2canvas-pro.esm.js"
20
20
  }
21
21
  },
22
- "version": "2.4.3",
22
+ "version": "2.4.5",
23
23
  "author": {
24
24
  "name": "yorickshan",
25
25
  "email": "yorickshan@gmail.com",
@@ -67,6 +67,7 @@
67
67
  "express": "^5.2.1",
68
68
  "filenamify-url": "4.0.0",
69
69
  "glob": "^13.0.0",
70
+ "html2canvas-pro-baseline": "npm:html2canvas-pro@2.4.3",
70
71
  "husky": "^9.1.7",
71
72
  "jsdom": "^29.1.1",
72
73
  "karma": "^6.3.2",
@@ -82,6 +83,7 @@
82
83
  "node-simctl": "^8.1.2",
83
84
  "pixelmatch": "^7.2.0",
84
85
  "platform": "^1.3.6",
86
+ "playwright": "1.60.0",
85
87
  "pngjs": "^7.0.0",
86
88
  "prettier": "^3.3.3",
87
89
  "puppeteer": "^24.34.0",
@@ -110,7 +112,7 @@
110
112
  },
111
113
  "scripts": {
112
114
  "prebuild": "rimraf dist/ && rimraf build/ && mkdir -p dist && mkdir -p build",
113
- "build": "tsc --module commonjs -p tsconfig.build.json && rolldown -c rolldown.config.ts && pnpm build:create-reftest-list && rolldown -c tests/rolldown.testrunner.config.ts",
115
+ "build": "tsc --module commonjs -p tsconfig.build.json && rolldown -c rolldown.config.ts && pnpm build:create-reftest-list && rolldown -c tests/rolldown.testrunner.config.ts && node scripts/copy-filter-baseline.mjs",
114
116
  "build:testrunner": "rolldown -c tests/rolldown.testrunner.config.ts",
115
117
  "build:reftest-result-list": "tsx scripts/create-reftest-result-list.ts",
116
118
  "build:create-reftest-list": "tsx scripts/create-reftest-list.ts tests/reftests/ignore.txt build/reftests.js",