html2canvas-pro 2.3.1 → 2.3.3

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.
@@ -15,6 +15,8 @@ exports.backgroundClip = {
15
15
  return 1 /* BACKGROUND_CLIP.PADDING_BOX */;
16
16
  case 'content-box':
17
17
  return 2 /* BACKGROUND_CLIP.CONTENT_BOX */;
18
+ case 'text':
19
+ return 3 /* BACKGROUND_CLIP.TEXT */;
18
20
  }
19
21
  }
20
22
  return 0 /* BACKGROUND_CLIP.BORDER_BOX */;
@@ -7,7 +7,6 @@ const path_1 = require("../path");
7
7
  const bound_curves_1 = require("../bound-curves");
8
8
  const background_1 = require("../background");
9
9
  const box_sizing_1 = require("../box-sizing");
10
- const font_metrics_1 = require("../font-metrics");
11
10
  const text_renderer_1 = require("./text-renderer");
12
11
  const background_renderer_1 = require("./background-renderer");
13
12
  const border_renderer_1 = require("./border-renderer");
@@ -17,6 +16,9 @@ const canvas_path_1 = require("./canvas-path");
17
16
  const object_fit_1 = require("../object-fit");
18
17
  const content_renderer_1 = require("./content-renderer");
19
18
  const constants_1 = require("../../core/constants");
19
+ const writing_mode_1 = require("../../css/property-descriptors/writing-mode");
20
+ const font_utils_1 = require("./font-utils");
21
+ const text_1 = require("../../css/layout/text");
20
22
  class CanvasRenderer {
21
23
  constructor(context, options) {
22
24
  this.context = context;
@@ -33,7 +35,6 @@ class CanvasRenderer {
33
35
  this.canvas.style.width = `${options.width}px`;
34
36
  this.canvas.style.height = `${options.height}px`;
35
37
  }
36
- this.fontMetrics = new font_metrics_1.FontMetrics(document);
37
38
  this.ctx.scale(this.options.scale, this.options.scale);
38
39
  this.ctx.translate(-options.x, -options.y);
39
40
  this.ctx.textBaseline = 'bottom';
@@ -63,7 +64,6 @@ class CanvasRenderer {
63
64
  this.effectsRenderer = new effects_renderer_1.EffectsRenderer({ ctx: this.ctx }, { path: (paths) => this.path(paths) });
64
65
  this.textRenderer = new text_renderer_1.TextRenderer({
65
66
  ctx: this.ctx,
66
- fontMetrics: this.fontMetrics,
67
67
  options: { scale: options.scale }
68
68
  });
69
69
  this.context.logger.debug(`Canvas renderer initialized (${options.width}x${options.height}) with scale ${options.scale}`);
@@ -120,7 +120,7 @@ class CanvasRenderer {
120
120
  width: this.options.width,
121
121
  height: this.options.height
122
122
  }, (ctx, opts) => new CanvasRenderer(ctx, opts), container, curves, styles, (c, cv, img) => this.renderReplacedElement(c, cv, img));
123
- (0, content_renderer_1.renderFormElements)(this.ctx, this.fontMetrics, this.textRenderer, this.path.bind(this), container, styles);
123
+ (0, content_renderer_1.renderFormElements)(this.ctx, this.textRenderer, this.path.bind(this), container, styles);
124
124
  await (0, content_renderer_1.renderListMarker)(this.ctx, this.context, this.textRenderer, paint, container, styles);
125
125
  }
126
126
  async renderStackContent(stack) {
@@ -208,23 +208,31 @@ class CanvasRenderer {
208
208
  this.effectsRenderer.applyEffects(paint.getEffects(2 /* EffectTarget.BACKGROUND_BORDERS */));
209
209
  const styles = paint.container.styles;
210
210
  const hasBackground = !(0, color_utilities_1.isTransparent)(styles.backgroundColor) || styles.backgroundImage.length;
211
+ const hasTextClip = hasTextBackgroundClip(styles);
211
212
  const borders = [
212
213
  { style: styles.borderTopStyle, color: styles.borderTopColor, width: styles.borderTopWidth },
213
214
  { style: styles.borderRightStyle, color: styles.borderRightColor, width: styles.borderRightWidth },
214
215
  { style: styles.borderBottomStyle, color: styles.borderBottomColor, width: styles.borderBottomWidth },
215
216
  { style: styles.borderLeftStyle, color: styles.borderLeftColor, width: styles.borderLeftWidth }
216
217
  ];
217
- const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea((0, background_1.getBackgroundValueForIndex)(styles.backgroundClip, 0), paint.curves);
218
+ const backgroundClipValue = (0, background_1.getBackgroundValueForIndex)(styles.backgroundClip, 0);
219
+ const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(backgroundClipValue, paint.curves);
218
220
  if (hasBackground || styles.boxShadow.length) {
219
- this.ctx.save();
220
- this.path(backgroundPaintingArea);
221
- this.ctx.clip();
222
- if (!(0, color_utilities_1.isTransparent)(styles.backgroundColor)) {
223
- this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.backgroundColor);
224
- this.ctx.fill();
221
+ // Handle background-clip: text
222
+ if (hasTextClip && paint.container.textNodes.length > 0) {
223
+ await this.renderTextClippedBackground(paint);
224
+ }
225
+ else {
226
+ this.ctx.save();
227
+ this.path(backgroundPaintingArea);
228
+ this.ctx.clip();
229
+ if (!(0, color_utilities_1.isTransparent)(styles.backgroundColor)) {
230
+ this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.backgroundColor);
231
+ this.ctx.fill();
232
+ }
233
+ await this.backgroundRenderer.renderBackgroundImage(paint.container);
234
+ this.ctx.restore();
225
235
  }
226
- await this.backgroundRenderer.renderBackgroundImage(paint.container);
227
- this.ctx.restore();
228
236
  styles.boxShadow
229
237
  .slice(0)
230
238
  .reverse()
@@ -300,6 +308,103 @@ class CanvasRenderer {
300
308
  this.effectsRenderer.applyEffects([]);
301
309
  return this.canvas;
302
310
  }
311
+ /**
312
+ * Render background clipped to text shape using offscreen canvas with destination-in compositing.
313
+ * This implements background-clip: text support.
314
+ *
315
+ * Algorithm:
316
+ * 1. Draw background (color + images) onto an offscreen canvas
317
+ * 2. Use destination-in compositing to clip the background to the text glyph shapes
318
+ * 3. Composite the result back onto the main canvas
319
+ *
320
+ * The offscreen canvas uses CSS-pixel dimensions (not device-pixel) because the
321
+ * background renderer handles scaling internally via BackgroundRenderer options.
322
+ */
323
+ async renderTextClippedBackground(paint) {
324
+ const container = paint.container;
325
+ const styles = container.styles;
326
+ const bounds = container.bounds;
327
+ if (bounds.width <= 0 || bounds.height <= 0) {
328
+ return;
329
+ }
330
+ const ownerDocument = this.canvas.ownerDocument ?? document;
331
+ const offscreen = ownerDocument.createElement('canvas');
332
+ const width = Math.ceil(bounds.width);
333
+ const height = Math.ceil(bounds.height);
334
+ offscreen.width = width;
335
+ offscreen.height = height;
336
+ const offCtx = offscreen.getContext('2d');
337
+ if (!offCtx) {
338
+ return;
339
+ }
340
+ // ── Set up font matching the main context ──
341
+ const [fontString] = this.textRenderer.createFontStyle(styles);
342
+ offCtx.font = fontString;
343
+ offCtx.textBaseline = 'alphabetic';
344
+ offCtx.textAlign = 'left';
345
+ offCtx.direction = styles.direction === 1 /* DIRECTION.RTL */ ? 'rtl' : 'ltr';
346
+ // Measure baseline from the actual rendered font
347
+ const baseline = (0, font_utils_1.measureBaseline)(offCtx, styles.fontSize.number);
348
+ // ── Draw background onto offscreen canvas ──
349
+ const bgRenderer = new background_renderer_1.BackgroundRenderer({
350
+ ctx: offCtx,
351
+ context: this.context,
352
+ canvas: offscreen,
353
+ options: { width, height, scale: 1 }
354
+ });
355
+ if (!(0, color_utilities_1.isTransparent)(styles.backgroundColor)) {
356
+ offCtx.fillStyle = (0, color_utilities_1.asString)(styles.backgroundColor);
357
+ offCtx.fillRect(0, 0, width, height);
358
+ }
359
+ // Background images are positioned relative to the element bounds,
360
+ // so we need the BackgroundRenderer to compute offsets in the same
361
+ // coordinate space as the main renderer. We do that by translating
362
+ // the offscreen context so that the element origin (bounds.left, bounds.top)
363
+ // falls at (0, 0).
364
+ offCtx.save();
365
+ offCtx.translate(-bounds.left, -bounds.top);
366
+ await bgRenderer.renderBackgroundImage(container);
367
+ offCtx.restore();
368
+ // ── Clip background to text glyphs ──
369
+ // destination-in: keep background pixels only where the source (text) is non-transparent.
370
+ offCtx.globalCompositeOperation = 'destination-in';
371
+ offCtx.fillStyle = '#000';
372
+ const writingMode = styles.writingMode;
373
+ const letterSpacing = styles.letterSpacing;
374
+ for (const textNode of container.textNodes) {
375
+ for (const textBound of textNode.textBounds) {
376
+ // Offset from element bounds to canvas-local coordinates
377
+ const localLeft = textBound.bounds.left - bounds.left;
378
+ const localTop = textBound.bounds.top - bounds.top + baseline;
379
+ if (letterSpacing > 0) {
380
+ this.renderTextMaskWithLetterSpacing(offCtx, textBound, letterSpacing, localLeft, localTop, writingMode);
381
+ }
382
+ else {
383
+ offCtx.fillText(textBound.text, localLeft, localTop);
384
+ }
385
+ }
386
+ }
387
+ // ── Composite back to main canvas ──
388
+ this.ctx.drawImage(offscreen, bounds.left, bounds.top);
389
+ }
390
+ /**
391
+ * Render text glyphs one-by-one as a mask, applying letter-spacing between characters.
392
+ * This ensures the mask matches how the text renderer lays out the characters.
393
+ */
394
+ renderTextMaskWithLetterSpacing(ctx, text, letterSpacing, x, y, writingMode) {
395
+ const letters = (0, text_1.segmentGraphemes)(text.text);
396
+ let offset = x;
397
+ for (const letter of letters) {
398
+ if ((0, writing_mode_1.isVerticalWritingMode)(writingMode)) {
399
+ // Vertical writing mode: not yet supported for text-clip;
400
+ // fall back to single fillText call.
401
+ ctx.fillText(text.text, x, y);
402
+ return;
403
+ }
404
+ ctx.fillText(letter, offset, y);
405
+ offset += ctx.measureText(letter).width + letterSpacing;
406
+ }
407
+ }
303
408
  }
304
409
  exports.CanvasRenderer = CanvasRenderer;
305
410
  const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
@@ -313,3 +418,9 @@ const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
313
418
  return (0, bound_curves_1.calculatePaddingBoxPath)(curves);
314
419
  }
315
420
  };
421
+ /**
422
+ * Check if any background layer uses background-clip: text
423
+ */
424
+ const hasTextBackgroundClip = (styles) => {
425
+ return styles.backgroundClip.some((clip) => clip === 3 /* BACKGROUND_CLIP.TEXT */);
426
+ };
@@ -15,6 +15,7 @@ const bounds_1 = require("../../css/layout/bounds");
15
15
  const text_1 = require("../../css/layout/text");
16
16
  const vector_1 = require("../vector");
17
17
  const box_sizing_1 = require("../box-sizing");
18
+ const font_utils_1 = require("./font-utils");
18
19
  const image_rendering_1 = require("../../css/property-descriptors/image-rendering");
19
20
  const length_percentage_1 = require("../../css/types/length-percentage");
20
21
  const line_height_1 = require("../../css/property-descriptors/line-height");
@@ -73,7 +74,7 @@ async function renderReplacedElements(ctx, context, options, iframeRendererFacto
73
74
  /**
74
75
  * Render form element content: checkbox, radio, text input.
75
76
  */
76
- function renderFormElements(ctx, fontMetrics, textRenderer, pathFn, container, styles) {
77
+ function renderFormElements(ctx, textRenderer, pathFn, container, styles) {
77
78
  if (container instanceof input_element_container_1.InputElementContainer) {
78
79
  const size = Math.min(container.bounds.width, container.bounds.height);
79
80
  if (container.type === input_element_container_1.CHECKBOX && container.checked) {
@@ -101,9 +102,10 @@ function renderFormElements(ctx, fontMetrics, textRenderer, pathFn, container, s
101
102
  }
102
103
  }
103
104
  if (isTextInputElement(container) && container.value.length) {
104
- const [font, fontFamily, fontSize] = textRenderer.createFontStyle(styles);
105
- const { baseline } = fontMetrics.getMetrics(fontFamily, fontSize);
105
+ const [font] = textRenderer.createFontStyle(styles);
106
+ // Use Canvas API to measure baseline from the actual rendered font
106
107
  ctx.font = font;
108
+ const baseline = (0, font_utils_1.measureBaseline)(ctx, (0, length_percentage_1.getAbsoluteValue)(styles.fontSize, 0));
107
109
  const isPlaceholder = container instanceof input_element_container_1.InputElementContainer && container.isPlaceholder;
108
110
  ctx.fillStyle = isPlaceholder ? (0, color_utilities_1.asString)(input_element_container_1.PLACEHOLDER_COLOR) : (0, color_utilities_1.asString)(styles.color);
109
111
  ctx.textBaseline = 'alphabetic';
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ /**
3
+ * Font measurement utility
4
+ *
5
+ * Provides Canvas API-based baseline measurement that works correctly with
6
+ * webfonts (which may not be loaded in the original document) and system fonts.
7
+ *
8
+ * Fallback chain:
9
+ * 1. fontBoundingBoxAscent — font-level metric (Chrome 99+, FF 116+, Safari 17.4+)
10
+ * 2. actualBoundingBoxAscent — glyph-level metric (widely supported)
11
+ * 3. fallback value — coarse CSS fallback
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.measureBaseline = void 0;
15
+ const SAMPLE_TEXT = 'Mg'; // characters with both ascender and descender
16
+ /**
17
+ * Measure the baseline ascent for the currently set font.
18
+ *
19
+ * @param ctx - Canvas 2D rendering context with ctx.font already set
20
+ * @param fallback - Fallback value when no metrics are available (e.g. fontSize.number)
21
+ * @returns The distance from the text baseline to the top of the bounding box
22
+ */
23
+ const measureBaseline = (ctx, fallback) => {
24
+ const tm = ctx.measureText(SAMPLE_TEXT);
25
+ const fmAscent = tm.fontBoundingBoxAscent;
26
+ const ascent = fmAscent !== undefined && !Number.isNaN(fmAscent) ? fmAscent : tm.actualBoundingBoxAscent;
27
+ return ascent !== undefined && !Number.isNaN(ascent) ? ascent : fallback;
28
+ };
29
+ exports.measureBaseline = measureBaseline;
@@ -18,6 +18,7 @@ const color_utilities_1 = require("../../css/types/color-utilities");
18
18
  const parser_1 = require("../../css/syntax/parser");
19
19
  const writing_mode_1 = require("../../css/property-descriptors/writing-mode");
20
20
  const text_decoration_renderer_1 = require("./text/text-decoration-renderer");
21
+ const font_utils_1 = require("./font-utils");
21
22
  // iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
22
23
  const iOSBrokenFonts = ['-apple-system', 'system-ui'];
23
24
  /**
@@ -100,7 +101,6 @@ class TextRenderer {
100
101
  */
101
102
  this.glyphWidthCache = null;
102
103
  this.ctx = deps.ctx;
103
- this.fontMetrics = deps.fontMetrics;
104
104
  this.options = deps.options;
105
105
  this.decorationRenderer = new text_decoration_renderer_1.TextDecorationRenderer(deps.ctx);
106
106
  }
@@ -440,16 +440,17 @@ class TextRenderer {
440
440
  // Reset glyph width cache at the start of each text node render —
441
441
  // the font may change between nodes.
442
442
  this.glyphWidthCache = null;
443
- const [fontString, fontFamily, fontSize] = this.createFontStyle(styles);
443
+ const [fontString] = this.createFontStyle(styles);
444
444
  this.ctx.font = fontString;
445
445
  this.ctx.direction = styles.direction === 1 /* DIRECTION.RTL */ ? 'rtl' : 'ltr';
446
446
  this.ctx.textAlign = 'left';
447
447
  this.ctx.textBaseline = 'alphabetic';
448
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);
449
+ // Compute baseline using the actual rendered font via Canvas API.
450
+ // This ensures correct positioning whether using webfonts (which may not
451
+ // be loaded in the original document but are active in the Canvas) or
452
+ // system fonts.
453
+ const baseline = (0, font_utils_1.measureBaseline)(this.ctx, styles.fontSize.number);
453
454
  // -webkit-line-clamp
454
455
  const clamp = styles.webkitLineClamp > 0 &&
455
456
  (styles.display & 2 /* DISPLAY.BLOCK */) !== 0 &&
@@ -2,7 +2,8 @@ import { IPropertyListDescriptor } from '../property-descriptor';
2
2
  export declare const enum BACKGROUND_CLIP {
3
3
  BORDER_BOX = 0,
4
4
  PADDING_BOX = 1,
5
- CONTENT_BOX = 2
5
+ CONTENT_BOX = 2,
6
+ TEXT = 3
6
7
  }
7
8
  export type BackgroundClip = BACKGROUND_CLIP[];
8
9
  export declare const backgroundClip: IPropertyListDescriptor<BackgroundClip>;
@@ -37,7 +37,6 @@ export declare class CanvasRenderer {
37
37
  ctx: CanvasRenderingContext2D;
38
38
  private readonly context;
39
39
  private readonly options;
40
- private readonly fontMetrics;
41
40
  private readonly backgroundRenderer;
42
41
  private readonly borderRenderer;
43
42
  private readonly borderImageRenderer;
@@ -58,4 +57,22 @@ export declare class CanvasRenderer {
58
57
  formatPath(paths: Path[]): void;
59
58
  renderNodeBackgroundAndBorders(paint: ElementPaint): Promise<void>;
60
59
  render(element: ElementContainer): Promise<HTMLCanvasElement>;
60
+ /**
61
+ * Render background clipped to text shape using offscreen canvas with destination-in compositing.
62
+ * This implements background-clip: text support.
63
+ *
64
+ * Algorithm:
65
+ * 1. Draw background (color + images) onto an offscreen canvas
66
+ * 2. Use destination-in compositing to clip the background to the text glyph shapes
67
+ * 3. Composite the result back onto the main canvas
68
+ *
69
+ * The offscreen canvas uses CSS-pixel dimensions (not device-pixel) because the
70
+ * background renderer handles scaling internally via BackgroundRenderer options.
71
+ */
72
+ private renderTextClippedBackground;
73
+ /**
74
+ * Render text glyphs one-by-one as a mask, applying letter-spacing between characters.
75
+ * This ensures the mask matches how the text renderer lays out the characters.
76
+ */
77
+ private renderTextMaskWithLetterSpacing;
61
78
  }
@@ -5,7 +5,6 @@ import { BoundCurves } from '../bound-curves';
5
5
  import { Vector } from '../vector';
6
6
  import { Context } from '../../core/context';
7
7
  import { TextRenderer } from './text-renderer';
8
- import { FontMetrics } from '../font-metrics';
9
8
  import { Color } from '../../css/types/color';
10
9
  import { CSSParsedDeclaration } from '../../css/index';
11
10
  /**
@@ -36,7 +35,7 @@ export declare function renderReplacedElements(ctx: CanvasRenderingContext2D, co
36
35
  /**
37
36
  * Render form element content: checkbox, radio, text input.
38
37
  */
39
- export declare function renderFormElements(ctx: CanvasRenderingContext2D, fontMetrics: FontMetrics, textRenderer: TextRenderer, pathFn: (paths: Vector[]) => void, container: ElementContainer, styles: CSSParsedDeclaration): void;
38
+ export declare function renderFormElements(ctx: CanvasRenderingContext2D, textRenderer: TextRenderer, pathFn: (paths: Vector[]) => void, container: ElementContainer, styles: CSSParsedDeclaration): void;
40
39
  /**
41
40
  * Render list-item marker (image or text).
42
41
  */
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Font measurement utility
3
+ *
4
+ * Provides Canvas API-based baseline measurement that works correctly with
5
+ * webfonts (which may not be loaded in the original document) and system fonts.
6
+ *
7
+ * Fallback chain:
8
+ * 1. fontBoundingBoxAscent — font-level metric (Chrome 99+, FF 116+, Safari 17.4+)
9
+ * 2. actualBoundingBoxAscent — glyph-level metric (widely supported)
10
+ * 3. fallback value — coarse CSS fallback
11
+ */
12
+ /**
13
+ * Measure the baseline ascent for the currently set font.
14
+ *
15
+ * @param ctx - Canvas 2D rendering context with ctx.font already set
16
+ * @param fallback - Fallback value when no metrics are available (e.g. fontSize.number)
17
+ * @returns The distance from the text baseline to the top of the bounding box
18
+ */
19
+ export declare const measureBaseline: (ctx: CanvasRenderingContext2D, fallback: number) => number;
@@ -15,13 +15,11 @@ import { CSSParsedDeclaration } from '../../css';
15
15
  import { Bounds } from '../../css/layout/bounds';
16
16
  import { TextBounds } from '../../css/layout/text';
17
17
  import { WRITING_MODE } from '../../css/property-descriptors/writing-mode';
18
- import { FontMetrics } from '../font-metrics';
19
18
  /**
20
19
  * Dependencies required for TextRenderer
21
20
  */
22
21
  export interface TextRendererDependencies {
23
22
  ctx: CanvasRenderingContext2D;
24
- fontMetrics: FontMetrics;
25
23
  options: {
26
24
  scale: number;
27
25
  };
@@ -35,7 +33,6 @@ export declare const hasCJKCharacters: (text: string) => boolean;
35
33
  */
36
34
  export declare class TextRenderer {
37
35
  private readonly ctx;
38
- private readonly fontMetrics;
39
36
  private readonly options;
40
37
  private readonly decorationRenderer;
41
38
  /**
package/package.json CHANGED
@@ -19,7 +19,7 @@
19
19
  "default": "./dist/html2canvas-pro.esm.js"
20
20
  }
21
21
  },
22
- "version": "2.3.1",
22
+ "version": "2.3.3",
23
23
  "author": {
24
24
  "name": "yorickshan",
25
25
  "email": "yorickshan@gmail.com",