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
  */
@@ -3959,6 +3959,7 @@ const backgroundClip = {
3959
3959
  if (isIdentToken(token)) switch (token.value) {
3960
3960
  case "padding-box": return 1;
3961
3961
  case "content-box": return 2;
3962
+ case "text": return 3;
3962
3963
  }
3963
3964
  return 0;
3964
3965
  });
@@ -8947,60 +8948,6 @@ const calculateBackgroundRepeatPath = (repeat, [x, y], [width, height], backgrou
8947
8948
  }
8948
8949
  };
8949
8950
  //#endregion
8950
- //#region src/core/util.ts
8951
- const SMALL_IMAGE = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
8952
- //#endregion
8953
- //#region src/render/font-metrics.ts
8954
- const SAMPLE_TEXT = "Hidden Text";
8955
- var FontMetrics = class {
8956
- constructor(document) {
8957
- this._data = {};
8958
- this._document = document;
8959
- }
8960
- parseMetrics(fontFamily, fontSize) {
8961
- const container = this._document.createElement("div");
8962
- const img = this._document.createElement("img");
8963
- const span = this._document.createElement("span");
8964
- const body = this._document.body;
8965
- container.style.visibility = "hidden";
8966
- container.style.fontFamily = fontFamily;
8967
- container.style.fontSize = fontSize;
8968
- container.style.margin = "0";
8969
- container.style.padding = "0";
8970
- container.style.whiteSpace = "nowrap";
8971
- body.appendChild(container);
8972
- img.src = SMALL_IMAGE;
8973
- img.width = 1;
8974
- img.height = 1;
8975
- img.style.margin = "0";
8976
- img.style.padding = "0";
8977
- img.style.verticalAlign = "baseline";
8978
- span.style.fontFamily = fontFamily;
8979
- span.style.fontSize = fontSize;
8980
- span.style.margin = "0";
8981
- span.style.padding = "0";
8982
- span.appendChild(this._document.createTextNode(SAMPLE_TEXT));
8983
- container.appendChild(span);
8984
- container.appendChild(img);
8985
- const baseline = img.offsetTop - span.offsetTop + 2;
8986
- container.removeChild(span);
8987
- container.appendChild(this._document.createTextNode(SAMPLE_TEXT));
8988
- container.style.lineHeight = "normal";
8989
- img.style.verticalAlign = "super";
8990
- const middle = img.offsetTop - container.offsetTop + 2;
8991
- body.removeChild(container);
8992
- return {
8993
- baseline,
8994
- middle
8995
- };
8996
- }
8997
- getMetrics(fontFamily, fontSize) {
8998
- const key = `${fontFamily} ${fontSize}`;
8999
- if (typeof this._data[key] === "undefined") this._data[key] = this.parseMetrics(fontFamily, fontSize);
9000
- return this._data[key];
9001
- }
9002
- };
9003
- //#endregion
9004
8951
  //#region src/render/canvas/text/text-decoration-renderer.ts
9005
8952
  var TextDecorationRenderer = class {
9006
8953
  constructor(ctx) {
@@ -9089,6 +9036,33 @@ var TextDecorationRenderer = class {
9089
9036
  }
9090
9037
  };
9091
9038
  //#endregion
9039
+ //#region src/render/canvas/font-utils.ts
9040
+ /**
9041
+ * Font measurement utility
9042
+ *
9043
+ * Provides Canvas API-based baseline measurement that works correctly with
9044
+ * webfonts (which may not be loaded in the original document) and system fonts.
9045
+ *
9046
+ * Fallback chain:
9047
+ * 1. fontBoundingBoxAscent — font-level metric (Chrome 99+, FF 116+, Safari 17.4+)
9048
+ * 2. actualBoundingBoxAscent — glyph-level metric (widely supported)
9049
+ * 3. fallback value — coarse CSS fallback
9050
+ */
9051
+ const SAMPLE_TEXT = "Mg";
9052
+ /**
9053
+ * Measure the baseline ascent for the currently set font.
9054
+ *
9055
+ * @param ctx - Canvas 2D rendering context with ctx.font already set
9056
+ * @param fallback - Fallback value when no metrics are available (e.g. fontSize.number)
9057
+ * @returns The distance from the text baseline to the top of the bounding box
9058
+ */
9059
+ const measureBaseline = (ctx, fallback) => {
9060
+ const tm = ctx.measureText(SAMPLE_TEXT);
9061
+ const fmAscent = tm.fontBoundingBoxAscent;
9062
+ const ascent = fmAscent !== void 0 && !Number.isNaN(fmAscent) ? fmAscent : tm.actualBoundingBoxAscent;
9063
+ return ascent !== void 0 && !Number.isNaN(ascent) ? ascent : fallback;
9064
+ };
9065
+ //#endregion
9092
9066
  //#region src/render/canvas/text-renderer.ts
9093
9067
  const iOSBrokenFonts = ["-apple-system", "system-ui"];
9094
9068
  /**
@@ -9141,7 +9115,6 @@ var TextRenderer = class {
9141
9115
  constructor(deps) {
9142
9116
  this.glyphWidthCache = null;
9143
9117
  this.ctx = deps.ctx;
9144
- this.fontMetrics = deps.fontMetrics;
9145
9118
  this.options = deps.options;
9146
9119
  this.decorationRenderer = new TextDecorationRenderer(deps.ctx);
9147
9120
  }
@@ -9402,13 +9375,13 @@ var TextRenderer = class {
9402
9375
  }
9403
9376
  async renderTextNode(text, styles, containerBounds) {
9404
9377
  this.glyphWidthCache = null;
9405
- const [fontString, fontFamily, fontSize] = this.createFontStyle(styles);
9378
+ const [fontString] = this.createFontStyle(styles);
9406
9379
  this.ctx.font = fontString;
9407
9380
  this.ctx.direction = styles.direction === 1 ? "rtl" : "ltr";
9408
9381
  this.ctx.textAlign = "left";
9409
9382
  this.ctx.textBaseline = "alphabetic";
9410
9383
  const paintOrder = styles.paintOrder;
9411
- const { baseline } = this.fontMetrics.getMetrics(fontFamily, fontSize);
9384
+ const baseline = measureBaseline(this.ctx, styles.fontSize.number);
9412
9385
  if (styles.webkitLineClamp > 0 && (styles.display & 2) !== 0 && styles.overflowY === 1 && text.textBounds.length > 0) {
9413
9386
  if (this.renderLineClampedText(text, styles, paintOrder, baseline, containerBounds)) return;
9414
9387
  }
@@ -10304,7 +10277,7 @@ async function renderReplacedElements(ctx, context, options, iframeRendererFacto
10304
10277
  /**
10305
10278
  * Render form element content: checkbox, radio, text input.
10306
10279
  */
10307
- function renderFormElements(ctx, fontMetrics, textRenderer, pathFn, container, styles) {
10280
+ function renderFormElements(ctx, textRenderer, pathFn, container, styles) {
10308
10281
  if (container instanceof InputElementContainer) {
10309
10282
  const size = Math.min(container.bounds.width, container.bounds.height);
10310
10283
  if (container.type === "checkbox" && container.checked) {
@@ -10331,9 +10304,9 @@ function renderFormElements(ctx, fontMetrics, textRenderer, pathFn, container, s
10331
10304
  }
10332
10305
  }
10333
10306
  if (isTextInputElement(container) && container.value.length) {
10334
- const [font, fontFamily, fontSize] = textRenderer.createFontStyle(styles);
10335
- const { baseline } = fontMetrics.getMetrics(fontFamily, fontSize);
10307
+ const [font] = textRenderer.createFontStyle(styles);
10336
10308
  ctx.font = font;
10309
+ const baseline = measureBaseline(ctx, getAbsoluteValue(styles.fontSize, 0));
10337
10310
  ctx.fillStyle = container instanceof InputElementContainer && container.isPlaceholder ? asString(PLACEHOLDER_COLOR) : asString(styles.color);
10338
10311
  ctx.textBaseline = "alphabetic";
10339
10312
  ctx.textAlign = canvasTextAlign(container.styles.textAlign);
@@ -10431,7 +10404,6 @@ var CanvasRenderer = class CanvasRenderer {
10431
10404
  this.canvas.style.width = `${options.width}px`;
10432
10405
  this.canvas.style.height = `${options.height}px`;
10433
10406
  }
10434
- this.fontMetrics = new FontMetrics(document);
10435
10407
  this.ctx.scale(this.options.scale, this.options.scale);
10436
10408
  this.ctx.translate(-options.x, -options.y);
10437
10409
  this.ctx.textBaseline = "bottom";
@@ -10455,7 +10427,6 @@ var CanvasRenderer = class CanvasRenderer {
10455
10427
  this.effectsRenderer = new EffectsRenderer({ ctx: this.ctx }, { path: (paths) => this.path(paths) });
10456
10428
  this.textRenderer = new TextRenderer({
10457
10429
  ctx: this.ctx,
10458
- fontMetrics: this.fontMetrics,
10459
10430
  options: { scale: options.scale }
10460
10431
  });
10461
10432
  this.context.logger.debug(`Canvas renderer initialized (${options.width}x${options.height}) with scale ${options.scale}`);
@@ -10503,7 +10474,7 @@ var CanvasRenderer = class CanvasRenderer {
10503
10474
  width: this.options.width,
10504
10475
  height: this.options.height
10505
10476
  }, (ctx, opts) => new CanvasRenderer(ctx, opts), container, curves, styles, (c, cv, img) => this.renderReplacedElement(c, cv, img));
10506
- renderFormElements(this.ctx, this.fontMetrics, this.textRenderer, this.path.bind(this), container, styles);
10477
+ renderFormElements(this.ctx, this.textRenderer, this.path.bind(this), container, styles);
10507
10478
  await renderListMarker(this.ctx, this.context, this.textRenderer, paint, container, styles);
10508
10479
  }
10509
10480
  async renderStackContent(stack) {
@@ -10555,6 +10526,7 @@ var CanvasRenderer = class CanvasRenderer {
10555
10526
  this.effectsRenderer.applyEffects(paint.getEffects(2));
10556
10527
  const styles = paint.container.styles;
10557
10528
  const hasBackground = !isTransparent(styles.backgroundColor) || styles.backgroundImage.length;
10529
+ const hasTextClip = hasTextBackgroundClip(styles);
10558
10530
  const borders = [
10559
10531
  {
10560
10532
  style: styles.borderTopStyle,
@@ -10577,17 +10549,21 @@ var CanvasRenderer = class CanvasRenderer {
10577
10549
  width: styles.borderLeftWidth
10578
10550
  }
10579
10551
  ];
10580
- const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(getBackgroundValueForIndex(styles.backgroundClip, 0), paint.curves);
10552
+ const backgroundClipValue = getBackgroundValueForIndex(styles.backgroundClip, 0);
10553
+ const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(backgroundClipValue, paint.curves);
10581
10554
  if (hasBackground || styles.boxShadow.length) {
10582
- this.ctx.save();
10583
- this.path(backgroundPaintingArea);
10584
- this.ctx.clip();
10585
- if (!isTransparent(styles.backgroundColor)) {
10586
- this.ctx.fillStyle = asString(styles.backgroundColor);
10587
- this.ctx.fill();
10555
+ if (hasTextClip && paint.container.textNodes.length > 0) await this.renderTextClippedBackground(paint);
10556
+ else {
10557
+ this.ctx.save();
10558
+ this.path(backgroundPaintingArea);
10559
+ this.ctx.clip();
10560
+ if (!isTransparent(styles.backgroundColor)) {
10561
+ this.ctx.fillStyle = asString(styles.backgroundColor);
10562
+ this.ctx.fill();
10563
+ }
10564
+ await this.backgroundRenderer.renderBackgroundImage(paint.container);
10565
+ this.ctx.restore();
10588
10566
  }
10589
- await this.backgroundRenderer.renderBackgroundImage(paint.container);
10590
- this.ctx.restore();
10591
10567
  styles.boxShadow.slice(0).reverse().forEach((shadow) => {
10592
10568
  this.ctx.save();
10593
10569
  const borderBoxArea = calculateBorderBoxPath(paint.curves);
@@ -10646,6 +10622,82 @@ var CanvasRenderer = class CanvasRenderer {
10646
10622
  this.effectsRenderer.applyEffects([]);
10647
10623
  return this.canvas;
10648
10624
  }
10625
+ /**
10626
+ * Render background clipped to text shape using offscreen canvas with destination-in compositing.
10627
+ * This implements background-clip: text support.
10628
+ *
10629
+ * Algorithm:
10630
+ * 1. Draw background (color + images) onto an offscreen canvas
10631
+ * 2. Use destination-in compositing to clip the background to the text glyph shapes
10632
+ * 3. Composite the result back onto the main canvas
10633
+ *
10634
+ * The offscreen canvas uses CSS-pixel dimensions (not device-pixel) because the
10635
+ * background renderer handles scaling internally via BackgroundRenderer options.
10636
+ */
10637
+ async renderTextClippedBackground(paint) {
10638
+ const container = paint.container;
10639
+ const styles = container.styles;
10640
+ const bounds = container.bounds;
10641
+ if (bounds.width <= 0 || bounds.height <= 0) return;
10642
+ const offscreen = (this.canvas.ownerDocument ?? document).createElement("canvas");
10643
+ const width = Math.ceil(bounds.width);
10644
+ const height = Math.ceil(bounds.height);
10645
+ offscreen.width = width;
10646
+ offscreen.height = height;
10647
+ const offCtx = offscreen.getContext("2d");
10648
+ if (!offCtx) return;
10649
+ const [fontString] = this.textRenderer.createFontStyle(styles);
10650
+ offCtx.font = fontString;
10651
+ offCtx.textBaseline = "alphabetic";
10652
+ offCtx.textAlign = "left";
10653
+ offCtx.direction = styles.direction === 1 ? "rtl" : "ltr";
10654
+ const baseline = measureBaseline(offCtx, styles.fontSize.number);
10655
+ const bgRenderer = new BackgroundRenderer({
10656
+ ctx: offCtx,
10657
+ context: this.context,
10658
+ canvas: offscreen,
10659
+ options: {
10660
+ width,
10661
+ height,
10662
+ scale: 1
10663
+ }
10664
+ });
10665
+ if (!isTransparent(styles.backgroundColor)) {
10666
+ offCtx.fillStyle = asString(styles.backgroundColor);
10667
+ offCtx.fillRect(0, 0, width, height);
10668
+ }
10669
+ offCtx.save();
10670
+ offCtx.translate(-bounds.left, -bounds.top);
10671
+ await bgRenderer.renderBackgroundImage(container);
10672
+ offCtx.restore();
10673
+ offCtx.globalCompositeOperation = "destination-in";
10674
+ offCtx.fillStyle = "#000";
10675
+ const writingMode = styles.writingMode;
10676
+ const letterSpacing = styles.letterSpacing;
10677
+ for (const textNode of container.textNodes) for (const textBound of textNode.textBounds) {
10678
+ const localLeft = textBound.bounds.left - bounds.left;
10679
+ const localTop = textBound.bounds.top - bounds.top + baseline;
10680
+ if (letterSpacing > 0) this.renderTextMaskWithLetterSpacing(offCtx, textBound, letterSpacing, localLeft, localTop, writingMode);
10681
+ else offCtx.fillText(textBound.text, localLeft, localTop);
10682
+ }
10683
+ this.ctx.drawImage(offscreen, bounds.left, bounds.top);
10684
+ }
10685
+ /**
10686
+ * Render text glyphs one-by-one as a mask, applying letter-spacing between characters.
10687
+ * This ensures the mask matches how the text renderer lays out the characters.
10688
+ */
10689
+ renderTextMaskWithLetterSpacing(ctx, text, letterSpacing, x, y, writingMode) {
10690
+ const letters = segmentGraphemes(text.text);
10691
+ let offset = x;
10692
+ for (const letter of letters) {
10693
+ if (isVerticalWritingMode(writingMode)) {
10694
+ ctx.fillText(text.text, x, y);
10695
+ return;
10696
+ }
10697
+ ctx.fillText(letter, offset, y);
10698
+ offset += ctx.measureText(letter).width + letterSpacing;
10699
+ }
10700
+ }
10649
10701
  };
10650
10702
  const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
10651
10703
  switch (clip) {
@@ -10654,6 +10706,12 @@ const calculateBackgroundCurvedPaintingArea = (clip, curves) => {
10654
10706
  default: return calculatePaddingBoxPath(curves);
10655
10707
  }
10656
10708
  };
10709
+ /**
10710
+ * Check if any background layer uses background-clip: text
10711
+ */
10712
+ const hasTextBackgroundClip = (styles) => {
10713
+ return styles.backgroundClip.some((clip) => clip === 3);
10714
+ };
10657
10715
  //#endregion
10658
10716
  //#region src/render/canvas/foreignobject-renderer.ts
10659
10717
  var ForeignObjectRenderer = class {