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