@zumer/snapdom 1.7.1 → 1.8.0

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.
package/dist/snapdom.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
 
2
2
  /*
3
3
  * snapdom
4
- * v.1.7.1
4
+ * v.1.8.0
5
5
  * Author Juan Martin Muda
6
6
  * License MIT
7
7
  **/
8
8
 
9
+
9
10
  // src/core/cache.js
10
11
  var imageCache = /* @__PURE__ */ new Map();
11
12
  var bgCache = /* @__PURE__ */ new Map();
@@ -13,6 +14,7 @@ var resourceCache = /* @__PURE__ */ new Map();
13
14
  var defaultStylesCache = /* @__PURE__ */ new Map();
14
15
  var baseCSSCache = /* @__PURE__ */ new Map();
15
16
  var computedStyleCache = /* @__PURE__ */ new WeakMap();
17
+ var processedFontURLs = /* @__PURE__ */ new Set();
16
18
 
17
19
  // src/utils/cssTools.js
18
20
  var commonTags = [
@@ -152,6 +154,27 @@ function generateCSSClasses(styleMap) {
152
154
  }
153
155
 
154
156
  // src/utils/helpers.js
157
+ async function inlineSingleBackgroundEntry(entry, options = {}) {
158
+ const isUrl = entry.startsWith("url(");
159
+ const isGradient = /^((repeating-)?(linear|radial|conic)-gradient)\(/i.test(entry);
160
+ if (isUrl) {
161
+ const rawUrl = extractURL(entry);
162
+ if (!rawUrl) return entry;
163
+ const encodedUrl = safeEncodeURI(rawUrl);
164
+ if (bgCache.has(encodedUrl)) {
165
+ return options.skipInline ? void 0 : `url(${bgCache.get(encodedUrl)})`;
166
+ } else {
167
+ const crossOrigin = options.crossOrigin ? options.crossOrigin(encodedUrl) : "anonymous";
168
+ const dataUrl = await fetchImage(encodedUrl, 3e3, crossOrigin);
169
+ bgCache.set(encodedUrl, dataUrl);
170
+ return options.skipInline ? void 0 : `url(${dataUrl})`;
171
+ }
172
+ }
173
+ if (isGradient || entry === "none") {
174
+ return entry;
175
+ }
176
+ return entry;
177
+ }
155
178
  function idle(fn, { fast = false } = {}) {
156
179
  if (fast) return fn();
157
180
  if ("requestIdleCallback" in window) {
@@ -205,7 +228,8 @@ function isIconFont(familyOrUrl) {
205
228
  /feather/i,
206
229
  /bootstrap\s*icons/i,
207
230
  /remix\s*icons/i,
208
- /heroicons/i
231
+ /heroicons/i,
232
+ /lucide/i
209
233
  ];
210
234
  return iconFontPatterns.some((rx) => rx.test(familyOrUrl));
211
235
  }
@@ -256,6 +280,49 @@ function snapshotComputedStyle(style) {
256
280
  function isSafari() {
257
281
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
258
282
  }
283
+ function stripTranslate(transform) {
284
+ if (!transform || transform === "none") return "";
285
+ let cleaned = transform.replace(/translate[XY]?\([^)]*\)/g, "");
286
+ cleaned = cleaned.replace(/matrix\(([^)]+)\)/g, (_, values) => {
287
+ const parts = values.split(",").map((s) => s.trim());
288
+ if (parts.length !== 6) return `matrix(${values})`;
289
+ parts[4] = "0";
290
+ parts[5] = "0";
291
+ return `matrix(${parts.join(", ")})`;
292
+ });
293
+ cleaned = cleaned.replace(/matrix3d\(([^)]+)\)/g, (_, values) => {
294
+ const parts = values.split(",").map((s) => s.trim());
295
+ if (parts.length !== 16) return `matrix3d(${values})`;
296
+ parts[12] = "0";
297
+ parts[13] = "0";
298
+ return `matrix3d(${parts.join(", ")})`;
299
+ });
300
+ return cleaned.trim().replace(/\s{2,}/g, " ");
301
+ }
302
+ function safeEncodeURI(uri) {
303
+ try {
304
+ const newURI = decodeURI(uri);
305
+ return encodeURI(newURI);
306
+ } catch {
307
+ return uri;
308
+ }
309
+ }
310
+ function splitBackgroundImage(bg) {
311
+ const parts = [];
312
+ let depth = 0;
313
+ let lastIndex = 0;
314
+ for (let i = 0; i < bg.length; i++) {
315
+ const char = bg[i];
316
+ if (char === "(") depth++;
317
+ if (char === ")") depth--;
318
+ if (char === "," && depth === 0) {
319
+ parts.push(bg.slice(lastIndex, i).trim());
320
+ lastIndex = i + 1;
321
+ }
322
+ }
323
+ parts.push(bg.slice(lastIndex).trim());
324
+ return parts;
325
+ }
259
326
 
260
327
  // src/modules/styles.js
261
328
  function inlineAllStyles(source, clone, styleMap, cache, compress) {
@@ -281,7 +348,7 @@ function deepClone(node, styleMap, styleCache, nodeMap, compress) {
281
348
  if (node.tagName === "IFRAME") {
282
349
  const fallback = document.createElement("div");
283
350
  fallback.textContent = "";
284
- fallback.style.cssText = `width: ${node.offsetWidth}px; height: ${node.offsetHeight}px; background: 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;`;
351
+ 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;`;
285
352
  return fallback;
286
353
  }
287
354
  if (node.nodeType === Node.ELEMENT_NODE && node.getAttribute("data-capture") === "placeholder") {
@@ -301,8 +368,8 @@ function deepClone(node, styleMap, styleCache, nodeMap, compress) {
301
368
  img.width = node.width;
302
369
  img.height = node.height;
303
370
  img.style.display = "inline-block";
304
- img.style.width = `${node.width}px`;
305
- img.style.height = `${node.height}px`;
371
+ img.style.width = node.style.width || `${node.width}px`;
372
+ img.style.height = node.style.height || `${node.height}px`;
306
373
  return img;
307
374
  }
308
375
  if (node.nodeType === Node.TEXT_NODE) {
@@ -375,36 +442,66 @@ async function iconToImage(unicodeChar, fontFamily, fontWeight, fontSize = 32, c
375
442
  ctx.fillText(unicodeChar, 0, ascent);
376
443
  return canvas.toDataURL();
377
444
  }
445
+ function isStylesheetLoaded(href) {
446
+ return Array.from(document.styleSheets).some((sheet) => sheet.href === href);
447
+ }
448
+ function injectLinkIfMissing(href) {
449
+ return new Promise((resolve) => {
450
+ if (isStylesheetLoaded(href)) return resolve(null);
451
+ const link = document.createElement("link");
452
+ link.rel = "stylesheet";
453
+ link.href = href;
454
+ link.setAttribute("data-snapdom", "injected-import");
455
+ link.onload = () => resolve(link);
456
+ link.onerror = () => resolve(null);
457
+ document.head.appendChild(link);
458
+ });
459
+ }
378
460
  async function embedCustomFonts({ ignoreIconFonts = true, preCached = false } = {}) {
461
+ if (resourceCache.has("fonts-embed-css")) {
462
+ if (preCached) {
463
+ const style = document.createElement("style");
464
+ style.setAttribute("data-snapdom", "embedFonts");
465
+ style.textContent = resourceCache.get("fonts-embed-css");
466
+ document.head.appendChild(style);
467
+ }
468
+ return resourceCache.get("fonts-embed-css");
469
+ }
470
+ const importRegex = /@import\s+url\(["']?([^"')]+)["']?\)/g;
471
+ const styleImports = [];
472
+ for (const styleTag of document.querySelectorAll("style")) {
473
+ const cssText = styleTag.textContent || "";
474
+ const matches = Array.from(cssText.matchAll(importRegex));
475
+ for (const match of matches) {
476
+ const importUrl = match[1];
477
+ if (!isStylesheetLoaded(importUrl)) {
478
+ styleImports.push(importUrl);
479
+ }
480
+ }
481
+ }
482
+ await Promise.all(styleImports.map(injectLinkIfMissing));
379
483
  const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter((link) => link.href);
380
484
  let finalCSS = "";
381
485
  for (const link of links) {
382
486
  try {
383
487
  const res = await fetch(link.href);
384
488
  const cssText = await res.text();
385
- if (ignoreIconFonts && (isIconFont(link.href) || isIconFont(cssText))) {
386
- continue;
387
- }
388
- const urlRegex = /url\(([^)]+)\)/g;
489
+ if (ignoreIconFonts && (isIconFont(link.href) || isIconFont(cssText))) continue;
490
+ const urlRegex = /url\((["']?)([^"')]+)\1\)/g;
389
491
  const inlinedCSS = await Promise.all(
390
492
  Array.from(cssText.matchAll(urlRegex)).map(async (match) => {
391
493
  let rawUrl = extractURL(match[0]);
392
494
  if (!rawUrl) return null;
393
495
  let url = rawUrl;
394
- if (!url.startsWith("http")) {
496
+ if (!url.startsWith("http") && !url.startsWith("data:")) {
395
497
  url = new URL(url, link.href).href;
396
498
  }
397
- try {
398
- url = encodeURI(url);
399
- } catch (e) {
400
- console.warn("[snapdom] Failed to encode font URL:", url);
401
- }
402
- if (ignoreIconFonts && isIconFont(url)) {
403
- return null;
404
- }
499
+ if (ignoreIconFonts && isIconFont(url)) return null;
405
500
  if (resourceCache.has(url)) {
501
+ processedFontURLs.add(url);
406
502
  return { original: match[0], inlined: `url(${resourceCache.get(url)})` };
407
503
  }
504
+ if (processedFontURLs.has(url)) return null;
408
505
  try {
409
506
  const fontRes = await fetch(url);
410
507
  const blob = await fontRes.blob();
@@ -414,18 +511,17 @@ async function embedCustomFonts({ ignoreIconFonts = true, preCached = false } =
414
511
  reader.readAsDataURL(blob);
415
512
  });
416
513
  resourceCache.set(url, b64);
514
+ processedFontURLs.add(url);
417
515
  return { original: match[0], inlined: `url(${b64})` };
418
- } catch (err) {
419
- console.warn("[snapdom] Failed to fetch font:", url);
516
+ } catch (e) {
517
+ console.warn("[snapdom] Failed to fetch font resource:", url);
420
518
  return null;
421
519
  }
422
520
  })
423
521
  );
424
522
  let cssFinal = cssText;
425
523
  for (const r of inlinedCSS) {
426
- if (r) {
427
- cssFinal = cssFinal.replace(r.original, r.inlined);
428
- }
524
+ if (r) cssFinal = cssFinal.replace(r.original, r.inlined);
429
525
  }
430
526
  finalCSS += cssFinal + "\n";
431
527
  } catch (e) {
@@ -439,23 +535,23 @@ async function embedCustomFonts({ ignoreIconFonts = true, preCached = false } =
439
535
  if (rule.type === CSSRule.FONT_FACE_RULE) {
440
536
  const src = rule.style.getPropertyValue("src");
441
537
  if (!src) continue;
442
- const urlRegex = /url\(([^)]+)\)/g;
538
+ const urlRegex = /url\((["']?)([^"')]+)\1\)/g;
443
539
  let inlinedSrc = src;
444
540
  const matches = Array.from(src.matchAll(urlRegex));
445
541
  for (const match of matches) {
446
- let rawUrl = match[1].trim().replace(/^["']|["']$/g, "");
542
+ let rawUrl = match[2].trim();
447
543
  if (!rawUrl) continue;
448
544
  let url = rawUrl;
449
545
  if (!url.startsWith("http") && !url.startsWith("data:")) {
450
546
  url = new URL(url, sheet.href || location.href).href;
451
547
  }
452
- if (ignoreIconFonts && isIconFont(url)) {
453
- continue;
454
- }
548
+ if (ignoreIconFonts && isIconFont(url)) continue;
455
549
  if (resourceCache.has(url)) {
550
+ processedFontURLs.add(url);
456
551
  inlinedSrc = inlinedSrc.replace(match[0], `url(${resourceCache.get(url)})`);
457
552
  continue;
458
553
  }
554
+ if (processedFontURLs.has(url)) continue;
459
555
  try {
460
556
  const res = await fetch(url);
461
557
  const blob = await res.blob();
@@ -465,6 +561,7 @@ async function embedCustomFonts({ ignoreIconFonts = true, preCached = false } =
465
561
  reader.readAsDataURL(blob);
466
562
  });
467
563
  resourceCache.set(url, b64);
564
+ processedFontURLs.add(url);
468
565
  inlinedSrc = inlinedSrc.replace(match[0], `url(${b64})`);
469
566
  } catch (e) {
470
567
  console.warn("[snapdom] Failed to fetch font URL:", url);
@@ -490,7 +587,8 @@ async function embedCustomFonts({ ignoreIconFonts = true, preCached = false } =
490
587
  if (!b64.startsWith("data:")) {
491
588
  if (resourceCache.has(font._snapdomSrc)) {
492
589
  b64 = resourceCache.get(font._snapdomSrc);
493
- } else {
590
+ processedFontURLs.add(font._snapdomSrc);
591
+ } else if (!processedFontURLs.has(font._snapdomSrc)) {
494
592
  try {
495
593
  const res = await fetch(font._snapdomSrc);
496
594
  const blob = await res.blob();
@@ -500,6 +598,7 @@ async function embedCustomFonts({ ignoreIconFonts = true, preCached = false } =
500
598
  reader.readAsDataURL(blob);
501
599
  });
502
600
  resourceCache.set(font._snapdomSrc, b64);
601
+ processedFontURLs.add(font._snapdomSrc);
503
602
  } catch (e) {
504
603
  console.warn("[snapdom] Failed to fetch dynamic font src:", font._snapdomSrc);
505
604
  continue;
@@ -515,11 +614,14 @@ async function embedCustomFonts({ ignoreIconFonts = true, preCached = false } =
515
614
  `;
516
615
  }
517
616
  }
518
- if (finalCSS && preCached) {
519
- const style = document.createElement("style");
520
- style.setAttribute("data-snapdom", "embedFonts");
521
- style.textContent = finalCSS;
522
- document.head.appendChild(style);
617
+ if (finalCSS) {
618
+ resourceCache.set("fonts-embed-css", finalCSS);
619
+ if (preCached) {
620
+ const style = document.createElement("style");
621
+ style.setAttribute("data-snapdom", "embedFonts");
622
+ style.textContent = finalCSS;
623
+ document.head.appendChild(style);
624
+ }
523
625
  }
524
626
  return finalCSS;
525
627
  }
@@ -527,15 +629,41 @@ async function embedCustomFonts({ ignoreIconFonts = true, preCached = false } =
527
629
  // src/modules/pseudo.js
528
630
  async function inlinePseudoElements(source, clone, styleMap, styleCache, compress, embedFonts = false) {
529
631
  if (!(source instanceof Element) || !(clone instanceof Element)) return;
530
- for (const pseudo of ["::before", "::after"]) {
632
+ for (const pseudo of ["::before", "::after", "::first-letter"]) {
531
633
  try {
532
634
  const style = getStyle(source, pseudo);
533
- if (!style) continue;
635
+ if (!style || typeof style[Symbol.iterator] !== "function") continue;
636
+ if (pseudo === "::first-letter") {
637
+ const normal = getComputedStyle(source);
638
+ const isMeaningful = style.color !== normal.color || style.fontSize !== normal.fontSize || style.fontWeight !== normal.fontWeight;
639
+ if (!isMeaningful) continue;
640
+ const textNode = Array.from(clone.childNodes).find(
641
+ (n) => n.nodeType === Node.TEXT_NODE && n.textContent && n.textContent.trim().length > 0
642
+ );
643
+ if (!textNode) continue;
644
+ const text = textNode.textContent;
645
+ const match = text.match(/^([^\p{L}\p{N}\s]*[\p{L}\p{N}](?:['’])?)/u);
646
+ const first = match?.[0];
647
+ const rest = text.slice(first?.length || 0);
648
+ if (!first || /[\uD800-\uDFFF]/.test(first)) continue;
649
+ const span = document.createElement("span");
650
+ span.textContent = first;
651
+ span.dataset.snapdomPseudo = "::first-letter";
652
+ const snapshot = snapshotComputedStyle(style);
653
+ const key = getStyleKey(snapshot, "span", compress);
654
+ styleMap.set(span, key);
655
+ const restNode = document.createTextNode(rest);
656
+ clone.replaceChild(restNode, textNode);
657
+ clone.insertBefore(span, restNode);
658
+ continue;
659
+ }
534
660
  const content = style.getPropertyValue("content");
535
661
  const bg = style.getPropertyValue("background-image");
536
- const hasContent = content && content !== "none" && content !== '""' && content !== "''";
537
- const hasBg = bg && bg.startsWith("url(");
538
- if (hasContent || hasBg) {
662
+ const bgColor = style.getPropertyValue("background-color");
663
+ const hasContent = content !== "none";
664
+ const hasBg = bg && bg !== "none";
665
+ const hasBgColor = bgColor && bgColor !== "transparent" && bgColor !== "rgba(0, 0, 0, 0)";
666
+ if (hasContent || hasBg || hasBgColor) {
539
667
  const fontFamily = style.getPropertyValue("font-family");
540
668
  const fontSize = parseInt(style.getPropertyValue("font-size")) || 32;
541
669
  const fontWeight = parseInt(style.getPropertyValue("font-weight")) || false;
@@ -545,27 +673,21 @@ async function inlinePseudoElements(source, clone, styleMap, styleCache, compres
545
673
  const snapshot = snapshotComputedStyle(style);
546
674
  const key = getStyleKey(snapshot, "span", compress);
547
675
  styleMap.set(pseudoEl, key);
548
- const isIconFont2 = fontFamily && /font.*awesome|material|bootstrap|glyphicons|ionicons|remixicon|simple-line-icons|octicons|feather|typicons|weathericons/i.test(
676
+ const isIconFont2 = fontFamily && /font.*awesome|material|bootstrap|glyphicons|ionicons|remixicon|simple-line-icons|octicons|feather|typicons|weathericons|lucide/i.test(
549
677
  fontFamily
550
678
  );
551
- let cleanContent = parseContent(content);
679
+ const cleanContent = parseContent(content);
552
680
  if (!embedFonts && isIconFont2 && cleanContent.length === 1) {
553
681
  const imgEl = document.createElement("img");
554
- imgEl.src = await iconToImage(
555
- cleanContent,
556
- fontFamily,
557
- fontWeight,
558
- fontSize,
559
- color
560
- );
682
+ imgEl.src = await iconToImage(cleanContent, fontFamily, fontWeight, fontSize, color);
561
683
  imgEl.style = "display:block;width:100%;height:100%;object-fit:contain;";
562
684
  pseudoEl.appendChild(imgEl);
563
685
  } else if (cleanContent.startsWith("url(")) {
564
686
  const rawUrl = extractURL(cleanContent);
565
- if (rawUrl) {
687
+ if (rawUrl && rawUrl.trim() !== "") {
566
688
  try {
567
689
  const imgEl = document.createElement("img");
568
- const dataUrl = await fetchImage(encodeURI(rawUrl));
690
+ const dataUrl = await fetchImage(safeEncodeURI(rawUrl));
569
691
  imgEl.src = dataUrl;
570
692
  imgEl.style = "display:block;width:100%;height:100%;object-fit:contain;";
571
693
  pseudoEl.appendChild(imgEl);
@@ -576,24 +698,22 @@ async function inlinePseudoElements(source, clone, styleMap, styleCache, compres
576
698
  } else if (!isIconFont2 && cleanContent && cleanContent !== "none") {
577
699
  pseudoEl.textContent = cleanContent;
578
700
  }
579
- if (hasBg && bg) {
580
- const rawUrl = extractURL(bg);
581
- if (rawUrl) {
582
- try {
583
- const dataUrl = await fetchImage(encodeURI(rawUrl));
584
- pseudoEl.style.backgroundImage = `url(${dataUrl})`;
585
- } catch (e) {
586
- console.warn(`[snapdom] Failed to inline background-image for ${pseudo}`, e);
587
- }
701
+ if (hasBg) {
702
+ try {
703
+ const bgSplits = splitBackgroundImage(bg);
704
+ const newBgParts = await Promise.all(
705
+ bgSplits.map((entry) => inlineSingleBackgroundEntry(entry))
706
+ );
707
+ pseudoEl.style.backgroundImage = newBgParts.join(", ");
708
+ } catch (e) {
709
+ console.warn(`[snapdom] Failed to inline background-image for ${pseudo}`, e);
588
710
  }
589
711
  }
712
+ if (hasBgColor) pseudoEl.style.backgroundColor = bgColor;
590
713
  const hasContent2 = pseudoEl.childNodes.length > 0 || pseudoEl.textContent && pseudoEl.textContent.trim() !== "";
591
- if (!hasContent2 && !hasBg) continue;
592
- if (pseudo === "::before") {
593
- clone.insertBefore(pseudoEl, clone.firstChild);
594
- } else {
595
- clone.appendChild(pseudoEl);
596
- }
714
+ const hasVisibleBox = hasContent2 || hasBg || hasBgColor;
715
+ if (!hasVisibleBox) continue;
716
+ pseudo === "::before" ? clone.insertBefore(pseudoEl, clone.firstChild) : clone.appendChild(pseudoEl);
597
717
  }
598
718
  } catch (e) {
599
719
  console.warn(`[snapdom] Failed to capture ${pseudo} for`, source, e);
@@ -667,6 +787,21 @@ async function prepareClone(element, compress = false, embedFonts = false) {
667
787
  cloneNode.appendChild(inner);
668
788
  }
669
789
  }
790
+ if (element === nodeMap.get(clone)) {
791
+ const computed = styleCache.get(element) || window.getComputedStyle(element);
792
+ styleCache.set(element, computed);
793
+ const transform = stripTranslate(computed.transform);
794
+ clone.style.margin = "0";
795
+ clone.style.position = "static";
796
+ clone.style.top = "auto";
797
+ clone.style.left = "auto";
798
+ clone.style.right = "auto";
799
+ clone.style.bottom = "auto";
800
+ clone.style.zIndex = "auto";
801
+ clone.style.float = "none";
802
+ clone.style.clear = "none";
803
+ clone.style.transform = transform || "";
804
+ }
670
805
  return { clone, classCSS, styleCache };
671
806
  }
672
807
 
@@ -702,28 +837,27 @@ async function inlineBackgroundImages(source, clone, styleCache, options = {}) {
702
837
  const style = styleCache.get(srcNode) || getStyle(srcNode);
703
838
  if (!styleCache.has(srcNode)) styleCache.set(srcNode, style);
704
839
  const bg = style.getPropertyValue("background-image");
705
- const bgSplits = bg.split(",");
706
- for (let i = 0; i < bgSplits.length; i++) {
707
- const rawUrl = extractURL(bgSplits[i]);
708
- if (rawUrl) {
709
- try {
710
- let bgUrl = encodeURI(rawUrl);
711
- let dataUrl;
712
- if (bgCache.has(bgUrl)) {
713
- dataUrl = bgCache.get(bgUrl);
714
- } else {
715
- const crossOrigin = options.crossOrigin ? options.crossOrigin(bgUrl) : "anonymous";
716
- dataUrl = await fetchImage(bgUrl, 3e3, crossOrigin);
717
- bgCache.set(bgUrl, dataUrl);
718
- }
719
- bgSplits[i] = `url(${dataUrl})`;
720
- } catch {
721
- bgSplits[i] = "none";
722
- }
840
+ const bgColor = style.getPropertyValue("background-color");
841
+ if (!bg || bg === "none") {
842
+ const sChildren2 = Array.from(srcNode.children);
843
+ const cChildren2 = Array.from(cloneNode.children);
844
+ for (let i = 0; i < Math.min(sChildren2.length, cChildren2.length); i++) {
845
+ queue.push([sChildren2[i], cChildren2[i]]);
723
846
  }
847
+ continue;
724
848
  }
725
- if (bgSplits.some((s) => s.includes("url("))) {
726
- cloneNode.style.backgroundImage = bgSplits.join(",");
849
+ const bgSplits = splitBackgroundImage(bg);
850
+ const newBgParts = await Promise.all(
851
+ bgSplits.map((entry) => inlineSingleBackgroundEntry(entry, options))
852
+ );
853
+ const hasRealBg = newBgParts.some(
854
+ (p) => p && p !== "none" && !/^url\(undefined\)/.test(p)
855
+ );
856
+ if (hasRealBg) {
857
+ cloneNode.style.backgroundImage = newBgParts.join(", ");
858
+ }
859
+ if (bgColor && bgColor !== "transparent" && bgColor !== "rgba(0, 0, 0, 0)") {
860
+ cloneNode.style.backgroundColor = bgColor;
727
861
  }
728
862
  const sChildren = Array.from(srcNode.children);
729
863
  const cChildren = Array.from(cloneNode.children);
@@ -736,6 +870,7 @@ async function inlineBackgroundImages(source, clone, styleCache, options = {}) {
736
870
  // src/core/capture.js
737
871
  async function captureDOM(element, options = {}) {
738
872
  if (!element) throw new Error("Element cannot be null or undefined");
873
+ if (!(element instanceof Element)) throw new Error("captureDOM: Only Element nodes are supported");
739
874
  const { compress = true, embedFonts = false, fast = true, scale = 1 } = options;
740
875
  let clone, classCSS, styleCache;
741
876
  let fontsCSS = "";
@@ -781,11 +916,37 @@ async function captureDOM(element, options = {}) {
781
916
  await new Promise((resolve) => {
782
917
  idle(() => {
783
918
  const rect = element.getBoundingClientRect();
784
- const w = Math.ceil(rect.width);
785
- const h = Math.ceil(rect.height);
919
+ let w = rect.width;
920
+ let h = rect.height;
921
+ const hasW = Number.isFinite(options.width);
922
+ const hasH = Number.isFinite(options.height);
923
+ const hasScale = typeof scale === "number" && scale !== 1;
924
+ if (!hasScale) {
925
+ const aspect = rect.width / rect.height;
926
+ if (hasW && hasH) {
927
+ w = options.width;
928
+ h = options.height;
929
+ } else if (hasW) {
930
+ w = options.width;
931
+ h = w / aspect;
932
+ } else if (hasH) {
933
+ h = options.height;
934
+ w = h * aspect;
935
+ }
936
+ }
937
+ w = Math.ceil(w);
938
+ h = Math.ceil(h);
786
939
  clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
787
940
  clone.style.transformOrigin = "top left";
788
- if (scale !== 1 && isSafari()) {
941
+ if (!hasScale && (hasW || hasH)) {
942
+ const originalW = rect.width;
943
+ const originalH = rect.height;
944
+ const scaleX = w / originalW;
945
+ const scaleY = h / originalH;
946
+ const existingTransform = clone.style.transform || "";
947
+ const scaleTransform = `scale(${scaleX}, ${scaleY})`;
948
+ clone.style.transform = `${scaleTransform} ${existingTransform}`.trim();
949
+ } else if (hasScale && isSafari()) {
789
950
  clone.style.scale = `${scale}`;
790
951
  }
791
952
  const svgNS = "http://www.w3.org/2000/svg";
@@ -939,7 +1100,6 @@ async function preCache(root = document, options = {}) {
939
1100
  bgCache.clear();
940
1101
  resourceCache.clear();
941
1102
  baseCSSCache.clear();
942
- computedStyleCache.clear();
943
1103
  return;
944
1104
  }
945
1105
  await document.fonts.ready;
@@ -961,14 +1121,18 @@ async function preCache(root = document, options = {}) {
961
1121
  }
962
1122
  }
963
1123
  for (const el of allEls) {
964
- const bg = getComputedStyle(el).backgroundImage;
965
- const url = extractURL(bg);
966
- if (url && !bgCache.has(url)) {
967
- const crossOrigin = crossOriginFn ? crossOriginFn(url) : "anonymous";
968
- promises.push(
969
- fetchImage(url, 3e3, crossOrigin).then((dataURL) => bgCache.set(url, dataURL)).catch(() => {
970
- })
971
- );
1124
+ const bg = getStyle(el).backgroundImage;
1125
+ if (bg && bg !== "none") {
1126
+ const bgSplits = splitBackgroundImage(bg);
1127
+ for (const entry of bgSplits) {
1128
+ const isUrl = entry.startsWith("url(");
1129
+ if (isUrl) {
1130
+ promises.push(
1131
+ inlineSingleBackgroundEntry(entry, { crossOrigin: crossOriginFn, skipInline: true }).catch(() => {
1132
+ })
1133
+ );
1134
+ }
1135
+ }
972
1136
  }
973
1137
  }
974
1138
  if (embedFonts) {
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@zumer/snapdom",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "snapDOM captures HTML elements to images with exceptional speed and accuracy.",
5
5
  "main": "dist/snapdom.js",
6
6
  "module": "dist/snapdom.mjs",
7
7
  "scripts": {
8
8
  "test": "npx vitest run --browser.headless --reporter=verbose",
9
9
  "test:benchmark": "npx vitest bench --browser.headless --watch=false",
10
- "compile-browser": "npx @zumerbox/build -n snapdom -j src/index.browser.js",
10
+ "compile-browser": "npx @zumerbox/build -n snapdom -j src/index.browser.js -t es2015",
11
11
  "compile-esm": "npx @zumerbox/build -n snapdom -j src/index.js -p neutral -x",
12
12
  "compile": "npm run compile-browser && npm run compile-esm",
13
13
  "build": "npm run compile && npm pack",
@@ -66,6 +66,7 @@
66
66
  "homepage": "https://zumerlab.github.io/snapdom/",
67
67
  "devDependencies": {
68
68
  "@vitest/browser": "^3.1.2",
69
+ "@vitest/coverage-v8": "^3.1.2",
69
70
  "playwright": "^1.52.0"
70
71
  }
71
72
  }