html2canvas-pro 1.6.5 → 1.6.7

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 1.6.5 <https://yorickshan.github.io/html2canvas-pro/>
2
+ * html2canvas-pro 1.6.7 <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
  */
@@ -4488,6 +4488,30 @@ const webkitTextStrokeWidth = {
4488
4488
  }
4489
4489
  };
4490
4490
 
4491
+ /**
4492
+ * -webkit-line-clamp property descriptor
4493
+ * Used with display: -webkit-box and -webkit-box-orient: vertical
4494
+ * to limit text to a specific number of lines
4495
+ */
4496
+ const webkitLineClamp = {
4497
+ name: '-webkit-line-clamp',
4498
+ initialValue: 'none',
4499
+ prefix: true,
4500
+ type: 0 /* PropertyDescriptorParsingType.VALUE */,
4501
+ parse: (_context, token) => {
4502
+ // 'none' means no line clamping
4503
+ if (token.type === 20 /* TokenType.IDENT_TOKEN */ && token.value === 'none') {
4504
+ return 0;
4505
+ }
4506
+ // Number value specifies the number of lines
4507
+ if (token.type === 17 /* TokenType.NUMBER_TOKEN */) {
4508
+ return Math.max(0, Math.floor(token.number));
4509
+ }
4510
+ // Default to 0 (no clamping)
4511
+ return 0;
4512
+ }
4513
+ };
4514
+
4491
4515
  const objectFit = {
4492
4516
  name: 'objectFit',
4493
4517
  initialValue: 'fill',
@@ -4601,6 +4625,7 @@ class CSSParsedDeclaration {
4601
4625
  this.visibility = parse(context, visibility, declaration.visibility);
4602
4626
  this.webkitTextStrokeColor = parse(context, webkitTextStrokeColor, declaration.webkitTextStrokeColor);
4603
4627
  this.webkitTextStrokeWidth = parse(context, webkitTextStrokeWidth, declaration.webkitTextStrokeWidth);
4628
+ this.webkitLineClamp = parse(context, webkitLineClamp, declaration.webkitLineClamp);
4604
4629
  this.wordBreak = parse(context, wordBreak, declaration.wordBreak);
4605
4630
  this.zIndex = parse(context, zIndex, declaration.zIndex);
4606
4631
  this.objectFit = parse(context, objectFit, declaration.objectFit);
@@ -6357,20 +6382,161 @@ class DocumentCloner {
6357
6382
  }
6358
6383
  }
6359
6384
  }
6360
- cloneChildNodes(node, clone, copyStyles) {
6361
- // Clone shadow DOM content if it exists
6362
- if (node.shadowRoot && clone.shadowRoot) {
6363
- for (let child = node.shadowRoot.firstChild; child; child = child.nextSibling) {
6364
- // Clone all shadow DOM children including <slot> elements
6365
- // The browser will automatically handle slot assignment
6366
- clone.shadowRoot.appendChild(this.cloneNode(child, copyStyles));
6385
+ /**
6386
+ * Check if a child node should be cloned based on filtering rules
6387
+ * Filters out: scripts, ignored elements, and optionally styles
6388
+ */
6389
+ shouldCloneChild(child) {
6390
+ return (!isElementNode(child) ||
6391
+ (!isScriptElement(child) &&
6392
+ !child.hasAttribute(IGNORE_ATTRIBUTE) &&
6393
+ (typeof this.options.ignoreElements !== 'function' || !this.options.ignoreElements(child))));
6394
+ }
6395
+ /**
6396
+ * Check if a style element should be cloned based on copyStyles option
6397
+ */
6398
+ shouldCloneStyleElement(child) {
6399
+ return !this.options.copyStyles || !isElementNode(child) || !isStyleElement(child);
6400
+ }
6401
+ /**
6402
+ * Safely append a cloned child to a target, applying all filtering rules
6403
+ */
6404
+ safeAppendClonedChild(target, child, copyStyles) {
6405
+ if (this.shouldCloneChild(child) && this.shouldCloneStyleElement(child)) {
6406
+ target.appendChild(this.cloneNode(child, copyStyles));
6407
+ }
6408
+ }
6409
+ /**
6410
+ * Clone assigned nodes from a slot element to the target
6411
+ */
6412
+ cloneAssignedNodes(assignedNodes, target, copyStyles) {
6413
+ assignedNodes.forEach((node) => {
6414
+ this.safeAppendClonedChild(target, node, copyStyles);
6415
+ });
6416
+ }
6417
+ /**
6418
+ * Clone fallback content from a slot element when no nodes are assigned
6419
+ */
6420
+ cloneSlotFallbackContent(slot, target, copyStyles) {
6421
+ for (let child = slot.firstChild; child; child = child.nextSibling) {
6422
+ this.safeAppendClonedChild(target, child, copyStyles);
6423
+ }
6424
+ }
6425
+ /**
6426
+ * Handle cloning of a slot element, including assigned nodes or fallback content
6427
+ */
6428
+ cloneSlotElement(slot, targetShadowRoot, copyStyles) {
6429
+ if (!isSlotElement(slot)) {
6430
+ return;
6431
+ }
6432
+ const slotElement = slot;
6433
+ // Defensive check: ensure assignedNodes method exists
6434
+ if (typeof slotElement.assignedNodes !== 'function') {
6435
+ this.context.logger.warn('HTMLSlotElement.assignedNodes is not available', slot);
6436
+ this.cloneSlotFallbackContent(slot, targetShadowRoot, copyStyles);
6437
+ return;
6438
+ }
6439
+ const assignedNodes = slotElement.assignedNodes();
6440
+ // Defensive check: ensure assignedNodes returns an array
6441
+ if (!assignedNodes || !Array.isArray(assignedNodes)) {
6442
+ this.context.logger.warn('assignedNodes() did not return a valid array', slot);
6443
+ this.cloneSlotFallbackContent(slot, targetShadowRoot, copyStyles);
6444
+ return;
6445
+ }
6446
+ if (assignedNodes.length > 0) {
6447
+ // Clone assigned nodes
6448
+ this.cloneAssignedNodes(assignedNodes, targetShadowRoot, copyStyles);
6449
+ }
6450
+ else {
6451
+ // Clone fallback content
6452
+ this.cloneSlotFallbackContent(slot, targetShadowRoot, copyStyles);
6453
+ }
6454
+ }
6455
+ /**
6456
+ * Clone shadow DOM children to the target shadow root
6457
+ */
6458
+ cloneShadowDOMChildren(shadowRoot, targetShadowRoot, copyStyles) {
6459
+ for (let child = shadowRoot.firstChild; child; child = child.nextSibling) {
6460
+ if (isElementNode(child) && isSlotElement(child)) {
6461
+ // Handle slot elements specially
6462
+ this.cloneSlotElement(child, targetShadowRoot, copyStyles);
6463
+ }
6464
+ else {
6465
+ // Clone regular elements
6466
+ this.safeAppendClonedChild(targetShadowRoot, child, copyStyles);
6367
6467
  }
6368
6468
  }
6369
- // Clone light DOM content (always, even if shadow DOM exists)
6469
+ }
6470
+ /**
6471
+ * Clone light DOM children to the target element
6472
+ */
6473
+ cloneLightDOMChildren(node, clone, copyStyles) {
6370
6474
  for (let child = node.firstChild; child; child = child.nextSibling) {
6371
6475
  this.appendChildNode(clone, child, copyStyles);
6372
6476
  }
6373
6477
  }
6478
+ /**
6479
+ * Clone slot element as light DOM when shadow root creation failed
6480
+ */
6481
+ cloneSlotElementAsLightDOM(slot, clone, copyStyles) {
6482
+ if (!isSlotElement(slot)) {
6483
+ return;
6484
+ }
6485
+ const slotElement = slot;
6486
+ if (typeof slotElement.assignedNodes !== 'function') {
6487
+ // Fallback: clone slot's children
6488
+ for (let child = slot.firstChild; child; child = child.nextSibling) {
6489
+ this.appendChildNode(clone, child, copyStyles);
6490
+ }
6491
+ return;
6492
+ }
6493
+ const assignedNodes = slotElement.assignedNodes();
6494
+ if (assignedNodes && Array.isArray(assignedNodes) && assignedNodes.length > 0) {
6495
+ // Clone assigned nodes as light DOM
6496
+ assignedNodes.forEach((node) => this.appendChildNode(clone, node, copyStyles));
6497
+ }
6498
+ else {
6499
+ // Clone fallback content as light DOM
6500
+ for (let child = slot.firstChild; child; child = child.nextSibling) {
6501
+ this.appendChildNode(clone, child, copyStyles);
6502
+ }
6503
+ }
6504
+ }
6505
+ /**
6506
+ * Clone shadow DOM content as light DOM when shadow root creation failed
6507
+ * This is a fallback mechanism to ensure content is not lost
6508
+ */
6509
+ cloneShadowDOMAsLightDOM(shadowRoot, clone, copyStyles) {
6510
+ for (let child = shadowRoot.firstChild; child; child = child.nextSibling) {
6511
+ if (isElementNode(child) && isSlotElement(child)) {
6512
+ this.cloneSlotElementAsLightDOM(child, clone, copyStyles);
6513
+ }
6514
+ else {
6515
+ this.appendChildNode(clone, child, copyStyles);
6516
+ }
6517
+ }
6518
+ }
6519
+ /**
6520
+ * Clone child nodes from source element to clone element
6521
+ * Handles shadow DOM, slots, and light DOM appropriately
6522
+ */
6523
+ cloneChildNodes(node, clone, copyStyles) {
6524
+ if (node.shadowRoot && clone.shadowRoot) {
6525
+ // Both original and clone have shadow roots - clone shadow DOM content
6526
+ this.cloneShadowDOMChildren(node.shadowRoot, clone.shadowRoot, copyStyles);
6527
+ // Also clone light DOM (slot content sources)
6528
+ this.cloneLightDOMChildren(node, clone, copyStyles);
6529
+ }
6530
+ else if (node.shadowRoot && !clone.shadowRoot) {
6531
+ // Original has shadow root but clone doesn't (creation failed)
6532
+ // Fallback: clone shadow DOM content as light DOM to preserve content
6533
+ this.cloneShadowDOMAsLightDOM(node.shadowRoot, clone, copyStyles);
6534
+ }
6535
+ else {
6536
+ // No shadow DOM - just clone light DOM children
6537
+ this.cloneLightDOMChildren(node, clone, copyStyles);
6538
+ }
6539
+ }
6374
6540
  cloneNode(node, copyStyles) {
6375
6541
  if (isTextNode(node)) {
6376
6542
  return document.createTextNode(node.data);
@@ -7638,6 +7804,28 @@ class CanvasRenderer extends Renderer {
7638
7804
  }, text.bounds.left);
7639
7805
  }
7640
7806
  }
7807
+ /**
7808
+ * Helper method to render text with paint order support
7809
+ * Reduces code duplication in line-clamp and normal rendering
7810
+ */
7811
+ renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers) {
7812
+ paintOrderLayers.forEach((paintOrderLayer) => {
7813
+ switch (paintOrderLayer) {
7814
+ case 0 /* PAINT_ORDER_LAYER.FILL */:
7815
+ this.ctx.fillStyle = asString(styles.color);
7816
+ this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
7817
+ break;
7818
+ case 1 /* PAINT_ORDER_LAYER.STROKE */:
7819
+ if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
7820
+ this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
7821
+ this.ctx.lineWidth = styles.webkitTextStrokeWidth;
7822
+ this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
7823
+ this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
7824
+ }
7825
+ break;
7826
+ }
7827
+ });
7828
+ }
7641
7829
  renderTextDecoration(bounds, styles) {
7642
7830
  this.ctx.fillStyle = asString(styles.textDecorationColor || styles.color);
7643
7831
  // Calculate decoration line thickness
@@ -7787,24 +7975,120 @@ class CanvasRenderer extends Renderer {
7787
7975
  this.ctx.textAlign = 'left';
7788
7976
  this.ctx.textBaseline = 'alphabetic';
7789
7977
  const paintOrder = styles.paintOrder;
7978
+ // Calculate line height for text layout detection (used by both line-clamp and ellipsis)
7979
+ const lineHeight = styles.fontSize.number * 1.5;
7980
+ // Check if we need to apply -webkit-line-clamp
7981
+ // This limits text to a specific number of lines with ellipsis
7982
+ const shouldApplyLineClamp = styles.webkitLineClamp > 0 &&
7983
+ (styles.display & 2 /* DISPLAY.BLOCK */) !== 0 &&
7984
+ styles.overflowY === 1 /* OVERFLOW.HIDDEN */ &&
7985
+ text.textBounds.length > 0;
7986
+ if (shouldApplyLineClamp) {
7987
+ // Group text bounds by lines based on their Y position
7988
+ const lines = [];
7989
+ let currentLine = [];
7990
+ let currentLineTop = text.textBounds[0].bounds.top;
7991
+ text.textBounds.forEach((tb) => {
7992
+ // If this text bound is on a different line, start a new line
7993
+ if (Math.abs(tb.bounds.top - currentLineTop) >= lineHeight * 0.5) {
7994
+ if (currentLine.length > 0) {
7995
+ lines.push(currentLine);
7996
+ }
7997
+ currentLine = [tb];
7998
+ currentLineTop = tb.bounds.top;
7999
+ }
8000
+ else {
8001
+ currentLine.push(tb);
8002
+ }
8003
+ });
8004
+ // Don't forget the last line
8005
+ if (currentLine.length > 0) {
8006
+ lines.push(currentLine);
8007
+ }
8008
+ // Only render up to webkitLineClamp lines
8009
+ const maxLines = styles.webkitLineClamp;
8010
+ if (lines.length > maxLines) {
8011
+ // Render only the first (maxLines - 1) complete lines
8012
+ for (let i = 0; i < maxLines - 1; i++) {
8013
+ lines[i].forEach((textBound) => {
8014
+ this.renderTextBoundWithPaintOrder(textBound, styles, paintOrder);
8015
+ });
8016
+ }
8017
+ // For the last line, truncate with ellipsis
8018
+ const lastLine = lines[maxLines - 1];
8019
+ if (lastLine && lastLine.length > 0 && containerBounds) {
8020
+ const lastLineText = lastLine.map((tb) => tb.text).join('');
8021
+ const firstBound = lastLine[0];
8022
+ const availableWidth = containerBounds.width - (firstBound.bounds.left - containerBounds.left);
8023
+ const truncatedText = this.truncateTextWithEllipsis(lastLineText, availableWidth, styles.letterSpacing);
8024
+ paintOrder.forEach((paintOrderLayer) => {
8025
+ switch (paintOrderLayer) {
8026
+ case 0 /* PAINT_ORDER_LAYER.FILL */:
8027
+ this.ctx.fillStyle = asString(styles.color);
8028
+ if (styles.letterSpacing === 0) {
8029
+ this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8030
+ }
8031
+ else {
8032
+ const letters = segmentGraphemes(truncatedText);
8033
+ letters.reduce((left, letter) => {
8034
+ this.ctx.fillText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8035
+ return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8036
+ }, firstBound.bounds.left);
8037
+ }
8038
+ break;
8039
+ case 1 /* PAINT_ORDER_LAYER.STROKE */:
8040
+ if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
8041
+ this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
8042
+ this.ctx.lineWidth = styles.webkitTextStrokeWidth;
8043
+ this.ctx.lineJoin = !!window.chrome ? 'miter' : 'round';
8044
+ if (styles.letterSpacing === 0) {
8045
+ this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
8046
+ }
8047
+ else {
8048
+ const letters = segmentGraphemes(truncatedText);
8049
+ letters.reduce((left, letter) => {
8050
+ this.ctx.strokeText(letter, left, firstBound.bounds.top + styles.fontSize.number);
8051
+ return left + this.ctx.measureText(letter).width + styles.letterSpacing;
8052
+ }, firstBound.bounds.left);
8053
+ }
8054
+ }
8055
+ break;
8056
+ }
8057
+ });
8058
+ }
8059
+ return; // Don't render anything else
8060
+ }
8061
+ // If lines.length <= maxLines, fall through to normal rendering
8062
+ }
7790
8063
  // Check if we need to apply text-overflow: ellipsis
7791
- const shouldApplyEllipsis = styles.textOverflow === 1 /* TEXT_OVERFLOW.ELLIPSIS */ && containerBounds && styles.overflowX === 1 /* OVERFLOW.HIDDEN */;
8064
+ // Issue #203: Only apply ellipsis for single-line text overflow
8065
+ // Multi-line text truncation (like -webkit-line-clamp) should not be affected
8066
+ const shouldApplyEllipsis = styles.textOverflow === 1 /* TEXT_OVERFLOW.ELLIPSIS */ &&
8067
+ containerBounds &&
8068
+ styles.overflowX === 1 /* OVERFLOW.HIDDEN */ &&
8069
+ text.textBounds.length > 0;
7792
8070
  // Calculate total text width if ellipsis might be needed
7793
8071
  let needsEllipsis = false;
7794
8072
  let truncatedText = '';
7795
- if (shouldApplyEllipsis && text.textBounds.length > 0) {
7796
- // Measure the full text content
7797
- // Note: text.textBounds may contain whitespace characters from HTML formatting
7798
- // We need to collapse them like the browser does for white-space: nowrap
7799
- let fullText = text.textBounds.map((tb) => tb.text).join('');
7800
- // Collapse whitespace: replace sequences of whitespace (including newlines) with single spaces
7801
- // and trim leading/trailing whitespace
7802
- fullText = fullText.replace(/\s+/g, ' ').trim();
7803
- const fullTextWidth = this.ctx.measureText(fullText).width;
7804
- const availableWidth = containerBounds.width;
7805
- if (fullTextWidth > availableWidth) {
7806
- needsEllipsis = true;
7807
- truncatedText = this.truncateTextWithEllipsis(fullText, availableWidth, styles.letterSpacing);
8073
+ if (shouldApplyEllipsis) {
8074
+ // Check if all text bounds are on approximately the same line (single-line scenario)
8075
+ // For multi-line text (like -webkit-line-clamp), textBounds will have different Y positions
8076
+ const firstTop = text.textBounds[0].bounds.top;
8077
+ const isSingleLine = text.textBounds.every((tb) => Math.abs(tb.bounds.top - firstTop) < lineHeight * 0.5);
8078
+ if (isSingleLine) {
8079
+ // Measure the full text content
8080
+ // Note: text.textBounds may contain whitespace characters from HTML formatting
8081
+ // We need to collapse them like the browser does for white-space: nowrap
8082
+ let fullText = text.textBounds.map((tb) => tb.text).join('');
8083
+ // Collapse whitespace: replace sequences of whitespace (including newlines) with single spaces
8084
+ // and trim leading/trailing whitespace
8085
+ fullText = fullText.replace(/\s+/g, ' ').trim();
8086
+ const fullTextWidth = this.ctx.measureText(fullText).width;
8087
+ const availableWidth = containerBounds.width;
8088
+ if (fullTextWidth > availableWidth) {
8089
+ needsEllipsis = true;
8090
+ truncatedText = this.truncateTextWithEllipsis(fullText, availableWidth, styles.letterSpacing);
8091
+ }
7808
8092
  }
7809
8093
  }
7810
8094
  // If ellipsis is needed, render the truncated text once