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.
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * html2canvas-pro 2.3.1 <https://yorickshan.github.io/html2canvas-pro/>
2
+ * html2canvas-pro 2.3.3 <https://yorickshan.github.io/html2canvas-pro/>
3
3
  * Copyright (c) 2024-present yorickshan and html2canvas-pro contributors
4
4
  * Released under MIT License
5
5
  */
@@ -3955,6 +3955,7 @@ const backgroundClip = {
3955
3955
  if (isIdentToken(token)) switch (token.value) {
3956
3956
  case "padding-box": return 1;
3957
3957
  case "content-box": return 2;
3958
+ case "text": return 3;
3958
3959
  }
3959
3960
  return 0;
3960
3961
  });
@@ -8943,60 +8944,6 @@ const calculateBackgroundRepeatPath = (repeat, [x, y], [width, height], backgrou
8943
8944
  }
8944
8945
  };
8945
8946
  //#endregion
8946
- //#region src/core/util.ts
8947
- const SMALL_IMAGE = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
8948
- //#endregion
8949
- //#region src/render/font-metrics.ts
8950
- const SAMPLE_TEXT = "Hidden Text";
8951
- var FontMetrics = class {
8952
- constructor(document) {
8953
- this._data = {};
8954
- this._document = document;
8955
- }
8956
- parseMetrics(fontFamily, fontSize) {
8957
- const container = this._document.createElement("div");
8958
- const img = this._document.createElement("img");
8959
- const span = this._document.createElement("span");
8960
- const body = this._document.body;
8961
- container.style.visibility = "hidden";
8962
- container.style.fontFamily = fontFamily;
8963
- container.style.fontSize = fontSize;
8964
- container.style.margin = "0";
8965
- container.style.padding = "0";
8966
- container.style.whiteSpace = "nowrap";
8967
- body.appendChild(container);
8968
- img.src = SMALL_IMAGE;
8969
- img.width = 1;
8970
- img.height = 1;
8971
- img.style.margin = "0";
8972
- img.style.padding = "0";
8973
- img.style.verticalAlign = "baseline";
8974
- span.style.fontFamily = fontFamily;
8975
- span.style.fontSize = fontSize;
8976
- span.style.margin = "0";
8977
- span.style.padding = "0";
8978
- span.appendChild(this._document.createTextNode(SAMPLE_TEXT));
8979
- container.appendChild(span);
8980
- container.appendChild(img);
8981
- const baseline = img.offsetTop - span.offsetTop + 2;
8982
- container.removeChild(span);
8983
- container.appendChild(this._document.createTextNode(SAMPLE_TEXT));
8984
- container.style.lineHeight = "normal";
8985
- img.style.verticalAlign = "super";
8986
- const middle = img.offsetTop - container.offsetTop + 2;
8987
- body.removeChild(container);
8988
- return {
8989
- baseline,
8990
- middle
8991
- };
8992
- }
8993
- getMetrics(fontFamily, fontSize) {
8994
- const key = `${fontFamily} ${fontSize}`;
8995
- if (typeof this._data[key] === "undefined") this._data[key] = this.parseMetrics(fontFamily, fontSize);
8996
- return this._data[key];
8997
- }
8998
- };
8999
- //#endregion
9000
8947
  //#region src/render/canvas/text/text-decoration-renderer.ts
9001
8948
  var TextDecorationRenderer = class {
9002
8949
  constructor(ctx) {
@@ -9085,6 +9032,33 @@ var TextDecorationRenderer = class {
9085
9032
  }
9086
9033
  };
9087
9034
  //#endregion
9035
+ //#region src/render/canvas/font-utils.ts
9036
+ /**
9037
+ * Font measurement utility
9038
+ *
9039
+ * Provides Canvas API-based baseline measurement that works correctly with
9040
+ * webfonts (which may not be loaded in the original document) and system fonts.
9041
+ *
9042
+ * Fallback chain:
9043
+ * 1. fontBoundingBoxAscent — font-level metric (Chrome 99+, FF 116+, Safari 17.4+)
9044
+ * 2. actualBoundingBoxAscent — glyph-level metric (widely supported)
9045
+ * 3. fallback value — coarse CSS fallback
9046
+ */
9047
+ const SAMPLE_TEXT = "Mg";
9048
+ /**
9049
+ * Measure the baseline ascent for the currently set font.
9050
+ *
9051
+ * @param ctx - Canvas 2D rendering context with ctx.font already set
9052
+ * @param fallback - Fallback value when no metrics are available (e.g. fontSize.number)
9053
+ * @returns The distance from the text baseline to the top of the bounding box
9054
+ */
9055
+ const measureBaseline = (ctx, fallback) => {
9056
+ const tm = ctx.measureText(SAMPLE_TEXT);
9057
+ const fmAscent = tm.fontBoundingBoxAscent;
9058
+ const ascent = fmAscent !== void 0 && !Number.isNaN(fmAscent) ? fmAscent : tm.actualBoundingBoxAscent;
9059
+ return ascent !== void 0 && !Number.isNaN(ascent) ? ascent : fallback;
9060
+ };
9061
+ //#endregion
9088
9062
  //#region src/render/canvas/text-renderer.ts
9089
9063
  const iOSBrokenFonts = ["-apple-system", "system-ui"];
9090
9064
  /**
@@ -9137,7 +9111,6 @@ var TextRenderer = class {
9137
9111
  constructor(deps) {
9138
9112
  this.glyphWidthCache = null;
9139
9113
  this.ctx = deps.ctx;
9140
- this.fontMetrics = deps.fontMetrics;
9141
9114
  this.options = deps.options;
9142
9115
  this.decorationRenderer = new TextDecorationRenderer(deps.ctx);
9143
9116
  }
@@ -9398,13 +9371,13 @@ var TextRenderer = class {
9398
9371
  }
9399
9372
  async renderTextNode(text, styles, containerBounds) {
9400
9373
  this.glyphWidthCache = null;
9401
- const [fontString, fontFamily, fontSize] = this.createFontStyle(styles);
9374
+ const [fontString] = this.createFontStyle(styles);
9402
9375
  this.ctx.font = fontString;
9403
9376
  this.ctx.direction = styles.direction === 1 ? "rtl" : "ltr";
9404
9377
  this.ctx.textAlign = "left";
9405
9378
  this.ctx.textBaseline = "alphabetic";
9406
9379
  const paintOrder = styles.paintOrder;
9407
- const { baseline } = this.fontMetrics.getMetrics(fontFamily, fontSize);
9380
+ const baseline = measureBaseline(this.ctx, styles.fontSize.number);
9408
9381
  if (styles.webkitLineClamp > 0 && (styles.display & 2) !== 0 && styles.overflowY === 1 && text.textBounds.length > 0) {
9409
9382
  if (this.renderLineClampedText(text, styles, paintOrder, baseline, containerBounds)) return;
9410
9383
  }
@@ -10300,7 +10273,7 @@ async function renderReplacedElements(ctx, context, options, iframeRendererFacto
10300
10273
  /**
10301
10274
  * Render form element content: checkbox, radio, text input.
10302
10275
  */
10303
- function renderFormElements(ctx, fontMetrics, textRenderer, pathFn, container, styles) {
10276
+ function renderFormElements(ctx, textRenderer, pathFn, container, styles) {
10304
10277
  if (container instanceof InputElementContainer) {
10305
10278
  const size = Math.min(container.bounds.width, container.bounds.height);
10306
10279
  if (container.type === "checkbox" && container.checked) {
@@ -10327,9 +10300,9 @@ function renderFormElements(ctx, fontMetrics, textRenderer, pathFn, container, s
10327
10300
  }
10328
10301
  }
10329
10302
  if (isTextInputElement(container) && container.value.length) {
10330
- const [font, fontFamily, fontSize] = textRenderer.createFontStyle(styles);
10331
- const { baseline } = fontMetrics.getMetrics(fontFamily, fontSize);
10303
+ const [font] = textRenderer.createFontStyle(styles);
10332
10304
  ctx.font = font;
10305
+ const baseline = measureBaseline(ctx, getAbsoluteValue(styles.fontSize, 0));
10333
10306
  ctx.fillStyle = container instanceof InputElementContainer && container.isPlaceholder ? asString(PLACEHOLDER_COLOR) : asString(styles.color);
10334
10307
  ctx.textBaseline = "alphabetic";
10335
10308
  ctx.textAlign = canvasTextAlign(container.styles.textAlign);
@@ -10427,7 +10400,6 @@ var CanvasRenderer = class CanvasRenderer {
10427
10400
  this.canvas.style.width = `${options.width}px`;
10428
10401
  this.canvas.style.height = `${options.height}px`;
10429
10402
  }
10430
- this.fontMetrics = new FontMetrics(document);
10431
10403
  this.ctx.scale(this.options.scale, this.options.scale);
10432
10404
  this.ctx.translate(-options.x, -options.y);
10433
10405
  this.ctx.textBaseline = "bottom";
@@ -10451,7 +10423,6 @@ var CanvasRenderer = class CanvasRenderer {
10451
10423
  this.effectsRenderer = new EffectsRenderer({ ctx: this.ctx }, { path: (paths) => this.path(paths) });
10452
10424
  this.textRenderer = new TextRenderer({
10453
10425
  ctx: this.ctx,
10454
- fontMetrics: this.fontMetrics,
10455
10426
  options: { scale: options.scale }
10456
10427
  });
10457
10428
  this.context.logger.debug(`Canvas renderer initialized (${options.width}x${options.height}) with scale ${options.scale}`);
@@ -10499,7 +10470,7 @@ var CanvasRenderer = class CanvasRenderer {
10499
10470
  width: this.options.width,
10500
10471
  height: this.options.height
10501
10472
  }, (ctx, opts) => new CanvasRenderer(ctx, opts), container, curves, styles, (c, cv, img) => this.renderReplacedElement(c, cv, img));
10502
- renderFormElements(this.ctx, this.fontMetrics, this.textRenderer, this.path.bind(this), container, styles);
10473
+ renderFormElements(this.ctx, this.textRenderer, this.path.bind(this), container, styles);
10503
10474
  await renderListMarker(this.ctx, this.context, this.textRenderer, paint, container, styles);
10504
10475
  }
10505
10476
  async renderStackContent(stack) {
@@ -10551,6 +10522,7 @@ var CanvasRenderer = class CanvasRenderer {
10551
10522
  this.effectsRenderer.applyEffects(paint.getEffects(2));
10552
10523
  const styles = paint.container.styles;
10553
10524
  const hasBackground = !isTransparent(styles.backgroundColor) || styles.backgroundImage.length;
10525
+ const hasTextClip = hasTextBackgroundClip(styles);
10554
10526
  const borders = [
10555
10527
  {
10556
10528
  style: styles.borderTopStyle,
@@ -10573,17 +10545,21 @@ var CanvasRenderer = class CanvasRenderer {
10573
10545
  width: styles.borderLeftWidth
10574
10546
  }
10575
10547
  ];
10576
- const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(getBackgroundValueForIndex(styles.backgroundClip, 0), paint.curves);
10548
+ const backgroundClipValue = getBackgroundValueForIndex(styles.backgroundClip, 0);
10549
+ const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(backgroundClipValue, paint.curves);
10577
10550
  if (hasBackground || styles.boxShadow.length) {
10578
- this.ctx.save();
10579
- this.path(backgroundPaintingArea);
10580
- this.ctx.clip();
10581
- if (!isTransparent(styles.backgroundColor)) {
10582
- this.ctx.fillStyle = asString(styles.backgroundColor);
10583
- this.ctx.fill();
10551
+ if (hasTextClip && paint.container.textNodes.length > 0) await this.renderTextClippedBackground(paint);
10552
+ else {
10553
+ this.ctx.save();
10554
+ this.path(backgroundPaintingArea);
10555
+ this.ctx.clip();
10556
+ if (!isTransparent(styles.backgroundColor)) {
10557
+ this.ctx.fillStyle = asString(styles.backgroundColor);
10558
+ this.ctx.fill();
10559
+ }
10560
+ await this.backgroundRenderer.renderBackgroundImage(paint.container);
10561
+ this.ctx.restore();
10584
10562
  }
10585
- await this.backgroundRenderer.renderBackgroundImage(paint.container);
10586
- this.ctx.restore();
10587
10563
  styles.boxShadow.slice(0).reverse().forEach((shadow) => {
10588
10564
  this.ctx.save();
10589
10565
  const borderBoxArea = calculateBorderBoxPath(paint.curves);
@@ -10642,6 +10618,82 @@ var CanvasRenderer = class CanvasRenderer {
10642
10618
  this.effectsRenderer.applyEffects([]);
10643
10619
  return this.canvas;
10644
10620
  }
10621
+ /**
10622
+ * Render background clipped to text shape using offscreen canvas with destination-in compositing.
10623
+ * This implements background-clip: text support.
10624
+ *
10625
+ * Algorithm:
10626
+ * 1. Draw background (color + images) onto an offscreen canvas
10627
+ * 2. Use destination-in compositing to clip the background to the text glyph shapes
10628
+ * 3. Composite the result back onto the main canvas
10629
+ *
10630
+ * The offscreen canvas uses CSS-pixel dimensions (not device-pixel) because the
10631
+ * background renderer handles scaling internally via BackgroundRenderer options.
10632
+ */
10633
+ async renderTextClippedBackground(paint) {
10634
+ const container = paint.container;
10635
+ const styles = container.styles;
10636
+ const bounds = container.bounds;
10637
+ if (bounds.width <= 0 || bounds.height <= 0) return;
10638
+ const offscreen = (this.canvas.ownerDocument ?? document).createElement("canvas");
10639
+ const width = Math.ceil(bounds.width);
10640
+ const height = Math.ceil(bounds.height);
10641
+ offscreen.width = width;
10642
+ offscreen.height = height;
10643
+ const offCtx = offscreen.getContext("2d");
10644
+ if (!offCtx) return;
10645
+ const [fontString] = this.textRenderer.createFontStyle(styles);
10646
+ offCtx.font = fontString;
10647
+ offCtx.textBaseline = "alphabetic";
10648
+ offCtx.textAlign = "left";
10649
+ offCtx.direction = styles.direction === 1 ? "rtl" : "ltr";
10650
+ const baseline = measureBaseline(offCtx, styles.fontSize.number);
10651
+ const bgRenderer = new BackgroundRenderer({
10652
+ ctx: offCtx,
10653
+ context: this.context,
10654
+ canvas: offscreen,
10655
+ options: {
10656
+ width,
10657
+ height,
10658
+ scale: 1
10659
+ }
10660
+ });
10661
+ if (!isTransparent(styles.backgroundColor)) {
10662
+ offCtx.fillStyle = asString(styles.backgroundColor);
10663
+ offCtx.fillRect(0, 0, width, height);
10664
+ }
10665
+ offCtx.save();
10666
+ offCtx.translate(-bounds.left, -bounds.top);
10667
+ await bgRenderer.renderBackgroundImage(container);
10668
+ offCtx.restore();
10669
+ offCtx.globalCompositeOperation = "destination-in";
10670
+ offCtx.fillStyle = "#000";
10671
+ const writingMode = styles.writingMode;
10672
+ const letterSpacing = styles.letterSpacing;
10673
+ for (const textNode of container.textNodes) for (const textBound of textNode.textBounds) {
10674
+ const localLeft = textBound.bounds.left - bounds.left;
10675
+ const localTop = textBound.bounds.top - bounds.top + baseline;
10676
+ if (letterSpacing > 0) this.renderTextMaskWithLetterSpacing(offCtx, textBound, letterSpacing, localLeft, localTop, writingMode);
10677
+ else offCtx.fillText(textBound.text, localLeft, localTop);
10678
+ }
10679
+ this.ctx.drawImage(offscreen, bounds.left, bounds.top);
10680
+ }
10681
+ /**
10682
+ * Render text glyphs one-by-one as a mask, applying letter-spacing between characters.
10683
+ * This ensures the mask matches how the text renderer lays out the characters.
10684
+ */
10685
+ renderTextMaskWithLetterSpacing(ctx, text, letterSpacing, x, y, writingMode) {
10686
+ const letters = segmentGraphemes(text.text);
10687
+ let offset = x;
10688
+ for (const letter of letters) {
10689
+ if (isVerticalWritingMode(writingMode)) {
10690
+ ctx.fillText(text.text, x, y);
10691
+ return;
10692
+ }
10693
+ ctx.fillText(letter, offset, y);
10694
+ offset += ctx.measureText(letter).width + letterSpacing;
10695
+ }
10696
+ }
10645
10697
  };
10646
10698
  const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
10647
10699
  switch (clip) {
@@ -10650,6 +10702,12 @@ const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
10650
10702
  default: return calculatePaddingBoxPath(curves);
10651
10703
  }
10652
10704
  };
10705
+ /**
10706
+ * Check if any background layer uses background-clip: text
10707
+ */
10708
+ const hasTextBackgroundClip = (styles) => {
10709
+ return styles.backgroundClip.some((clip) => clip === 3);
10710
+ };
10653
10711
  //#endregion
10654
10712
  //#region src/render/canvas/foreignobject-renderer.ts
10655
10713
  var ForeignObjectRenderer = class {