diff --git a/src/snapdom/clone.js b/src/snapdom/clone.js
new file mode 100644
index 00000000..b0767d09
--- /dev/null
+++ b/src/snapdom/clone.js
@@ -0,0 +1,212 @@
+// https://github.com/zumerlab/snapdom
+//
+// MIT License
+//
+// Copyright (c) 2025 ZumerLab
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+/**
+ * Deep cloning utilities for DOM elements, including styles and shadow DOM.
+ * @module clone
+ */
+
+
+/**
+ * Freeze the responsive selection of an
that has srcset/sizes.
+ * Copies a concrete URL into `src` and removes `srcset`/`sizes` so the clone
+ * doesn't need layout to resolve a candidate.
+ * Works with because currentSrc reflects the chosen source.
+ * @param {HTMLImageElement} original - Image in the live DOM.
+ * @param {HTMLImageElement} cloned - Just-created cloned
.
+ */
+function freezeImgSrcset(original, cloned) {
+ try {
+ const chosen = original.currentSrc || original.src || '';
+ if (!chosen) return;
+ cloned.setAttribute('src', chosen);
+ cloned.removeAttribute('srcset');
+ cloned.removeAttribute('sizes');
+ // Hint deterministic decode/load for capture
+ cloned.loading = 'eager';
+ cloned.decoding = 'sync';
+ } catch {
+ // no-op
+ }
+}
+
+
+/**
+ * Creates a deep clone of a DOM node, including styles, shadow DOM, and special handling for excluded/placeholder/canvas nodes.
+ *
+ * @param {Node} node - Node to clone
+ * @returns {Node|null} Cloned node with styles and shadow DOM content, or null for empty text nodes or filtered elements
+ */
+
+
+export function deepCloneBasic(node) {
+ if (!node) throw new Error('Invalid node');
+
+ // Local set to avoid duplicates in slot processing
+ const clonedAssignedNodes = new Set();
+ let pendingSelectValue = null; // Track select value for later fix
+
+ // 1. Text nodes
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node.cloneNode(true);
+ }
+
+ // 2. Non-element nodes (comments, etc.)
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return node.cloneNode(true);
+ }
+
+ // 6. Special case: iframe → fallback pattern
+ if (node.tagName === "IFRAME") {
+ const fallback = document.createElement("div");
+ fallback.style.cssText = `width:${node.offsetWidth}px;height:${node.offsetHeight}px;background-image:repeating-linear-gradient(45deg,#ddd,#ddd 5px,#f9f9f9 5px,#f9f9f9 10px);display:flex;align-items:center;justify-content:center;font-size:12px;color:#555;border:1px solid #aaa;`;
+ return fallback;
+ }
+
+ // 8. Canvas → convert to image
+ if (node.tagName === "CANVAS") {
+ const dataURL = node.toDataURL();
+ const img = document.createElement("img");
+ img.src = dataURL;
+ img.width = node.width;
+ img.height = node.height;
+ return img;
+ }
+
+ // 9. Base clone (without children)
+ let clone;
+ try {
+ clone = node.cloneNode(false);
+
+ if (node.tagName === 'IMG') {
+ freezeImgSrcset(node, clone);
+ }
+ } catch (err) {
+ console.error("[Snapdom] Failed to clone node:", node, err);
+ throw err;
+ }
+
+ // Special handling: textarea (keep size and value)
+ if (node instanceof HTMLTextAreaElement) {
+ clone.textContent = node.value;
+ clone.value = node.value;
+ const rect = node.getBoundingClientRect();
+ clone.style.boxSizing = 'border-box';
+ clone.style.width = `${rect.width}px`;
+ clone.style.height = `${rect.height}px`;
+ return clone;
+ }
+
+ // Special handling: input
+ if (node instanceof HTMLInputElement) {
+ if (node.hasAttribute("value")) {
+ clone.value = node.value;
+ clone.setAttribute("value", node.value);
+ }
+ if (node.checked !== void 0) {
+ clone.checked = node.checked;
+ if (node.checked) clone.setAttribute("checked", "");
+ if (node.indeterminate) clone.indeterminate = node.indeterminate;
+ }
+ // return clone;
+ }
+
+ // Special handling: select → postpone value adjustment
+ if (node instanceof HTMLSelectElement) {
+ pendingSelectValue = node.value;
+ }
+
+ // 12. ShadowRoot logic
+ if (node.shadowRoot) {
+ const hasSlot = Array.from(node.shadowRoot.querySelectorAll("slot")).length > 0;
+
+ if (hasSlot) {
+ } else {
+ // ShadowRoot without slots: clone full content
+ const shadowFrag = document.createDocumentFragment();
+ for (const child of node.shadowRoot.childNodes) {
+ if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "STYLE") {
+ continue;
+ }
+ const clonedChild = deepCloneBasic(child);
+ if (clonedChild) shadowFrag.appendChild(clonedChild);
+ }
+ clone.appendChild(shadowFrag);
+ }
+ }
+
+ // 13. Slot outside ShadowRoot
+ if (node.tagName === "SLOT") {
+ const assigned = node.assignedNodes?.({ flatten: true }) || [];
+ const nodesToClone = assigned.length > 0 ? assigned : Array.from(node.childNodes);
+ const fragment = document.createDocumentFragment();
+
+ for (const child of nodesToClone) {
+ const clonedChild = deepCloneBasic(child);
+ if (clonedChild) fragment.appendChild(clonedChild);
+ }
+ return fragment;
+ }
+
+ // 14. Clone children (light DOM), skipping duplicates
+ for (const child of node.childNodes) {
+ if (clonedAssignedNodes.has(child)) continue;
+
+ const clonedChild = deepCloneBasic(child);
+ if (clonedChild) clone.appendChild(clonedChild);
+ }
+
+ // Adjust select value after children are cloned
+ if (pendingSelectValue !== null && clone instanceof HTMLSelectElement) {
+ clone.value = pendingSelectValue;
+ for (const opt of clone.options) {
+ if (opt.value === pendingSelectValue) {
+ opt.setAttribute("selected", "");
+ } else {
+ opt.removeAttribute("selected");
+ }
+ }
+ }
+
+ // Fix scrolling (taken from prepareClone).
+ const scrollX = node.scrollLeft;
+ const scrollY = node.scrollTop;
+ const hasScroll = scrollX || scrollY;
+ if (hasScroll && clone instanceof HTMLElement) {
+ clone.style.overflow = "hidden";
+ clone.style.scrollbarWidth = "none";
+ clone.style.msOverflowStyle = "none";
+ const inner = document.createElement("div");
+ inner.style.transform = `translate(${-scrollX}px, ${-scrollY}px)`;
+ inner.style.willChange = "transform";
+ inner.style.display = "inline-block";
+ inner.style.width = "100%";
+ while (clone.firstChild) {
+ inner.appendChild(clone.firstChild);
+ }
+ clone.appendChild(inner);
+ }
+
+ return clone;
+}
diff --git a/src/utils.js b/src/utils.js
index ce0c4137..6a0d7273 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -28,37 +28,6 @@ export const createElement = function createElement(tagName, opt) {
return el;
};
-// Deep-clone a node and preserve contents/properties.
-export const cloneNode = function cloneNode(node, javascriptEnabled) {
- // Recursively clone the node.
- var clone = node.nodeType === 3 ? document.createTextNode(node.nodeValue) : node.cloneNode(false);
- for (var child = node.firstChild; child; child = child.nextSibling) {
- if (javascriptEnabled === true || child.nodeType !== 1 || child.nodeName !== 'SCRIPT') {
- clone.appendChild(cloneNode(child, javascriptEnabled));
- }
- }
-
- if (node.nodeType === 1) {
- // Preserve contents/properties of special nodes.
- if (node.nodeName === 'CANVAS') {
- clone.width = node.width;
- clone.height = node.height;
- clone.getContext('2d').drawImage(node, 0, 0);
- } else if (node.nodeName === 'TEXTAREA' || node.nodeName === 'SELECT') {
- clone.value = node.value;
- }
-
- // Preserve the node's scroll position when it loads.
- clone.addEventListener('load', function() {
- clone.scrollTop = node.scrollTop;
- clone.scrollLeft = node.scrollLeft;
- }, true);
- }
-
- // Return the cloned node.
- return clone;
-}
-
// Convert units from px using the conversion value 'k' from jsPDF.
export const unitConvert = function unitConvert(obj, k) {
if (objType(obj) === 'number') {
diff --git a/src/worker.js b/src/worker.js
index e858ec1e..47ee4725 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -1,6 +1,7 @@
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas';
-import { objType, createElement, cloneNode, toPx } from './utils.js';
+import { deepCloneBasic } from './snapdom/clone.js';
+import { objType, createElement, toPx } from './utils.js';
/* ----- CONSTRUCTOR ----- */
@@ -116,7 +117,7 @@ Worker.prototype.toContainer = function toContainer() {
overlayCSS.opacity = 0;
// Create and attach the elements.
- var source = cloneNode(this.prop.src, this.opt.html2canvas.javascriptEnabled);
+ var source = deepCloneBasic(this.prop.src);
this.prop.overlay = createElement('div', { className: 'html2pdf__overlay', style: overlayCSS });
this.prop.container = createElement('div', { className: 'html2pdf__container', style: containerCSS });
this.prop.container.appendChild(source);
diff --git a/test/vdiff/golden/html2pdf/chromium/lorem-ipsum-legacy.png b/test/vdiff/golden/html2pdf/chromium/lorem-ipsum-legacy.png
index 14232a5a..a8885b46 100644
Binary files a/test/vdiff/golden/html2pdf/chromium/lorem-ipsum-legacy.png and b/test/vdiff/golden/html2pdf/chromium/lorem-ipsum-legacy.png differ