import { describe, it, expect, vi } from 'vitest';
import { snapdom } from '../src/api/snapdom.js';

describe('snapdom API (direct)', () => {
  it('throws on null element', async () => {
    await expect(snapdom(null)).rejects.toThrow();
  });

  it('snapdom.capture returns export methods', async () => {
    const el = document.createElement('div');
    el.style.width = '100px';
    el.style.height = '50px';
    document.body.appendChild(el);
    const result = await snapdom.capture(el);
    expect(result).toHaveProperty('toRaw');
    expect(result).toHaveProperty('toImg');
    expect(result).toHaveProperty('download');
    document.body.removeChild(el);
  });

  it('snapdom.toRaw, toImg, toCanvas, toBlob, toPng, toJpg, toWebp, download', async () => {
    const el = document.createElement('div');
    el.style.width = '100px';
    el.style.height = '50px';
    document.body.appendChild(el);
    await snapdom.toRaw(el);
    await snapdom.toImg(el);
    await snapdom.toCanvas(el);
    await snapdom.toBlob(el);
    await snapdom.toPng(el);
    await snapdom.toJpg(el);
    await snapdom.toWebp(el);
    await snapdom.download(el, { format: 'png', filename: 'test' });
    document.body.removeChild(el);
  });

  it('cubre rama Safari en toImg',

);

  it('cubre rama de download SVG', async () => {
    const el = document.createElement('div');
    el.style.width = '10px';
    el.style.height = '10px';
    document.body.appendChild(el);
    // Mock a.click y URL.createObjectURL
    const a = document.createElement('a');
    document.body.appendChild(a);
    const origCreate = URL.createObjectURL;
    URL.createObjectURL = () => 'blob:url';
    const origClick = a.click;
    a.click = () => {};
    HTMLAnchorElement.prototype.click = () => {};
    const { snapdom } = await import('../src/api/snapdom.js');
    await snapdom.download(el, { format: 'svg', filename: 'testsvg' });
    URL.createObjectURL = origCreate;
    a.click = origClick;
    document.body.removeChild(a);
    document.body.removeChild(el);
  });

  it('snapdom.toBlob supports type options ', async () => {
  const el = document.createElement('div');
  el.style.width = '50px';
  el.style.height = '30px';
  document.body.appendChild(el);

  const result = await snapdom.capture(el);

  const pngBlob = await result.toBlob({ type: 'png' });
  expect(pngBlob).toBeInstanceOf(Blob);
  expect(pngBlob.type).toBe('image/png');

  const jpgBlob = await result.toBlob({ type: 'jpeg', quality: 0.8 });
  expect(jpgBlob).toBeInstanceOf(Blob);
  expect(jpgBlob.type).toBe('image/jpeg');

  const webpBlob = await result.toBlob({ type: 'webp', quality: 0.9 });
  expect(webpBlob).toBeInstanceOf(Blob);
  expect(webpBlob.type).toBe('image/webp');

  // default fallback
  const svgBlob = await result.toBlob();
  expect(svgBlob).toBeInstanceOf(Blob);
  expect(svgBlob.type).toBe('image/svg+xml');

  document.body.removeChild(el);
});

it('toPng, toJpg, toWebp return HTMLImageElement with  URLs', async () => {
  const el = document.createElement('div');
  el.style.width = '60px';
  el.style.height = '40px';
  document.body.appendChild(el);
  const snap = await snapdom.capture(el);

  const pngImg = await snap.toPng();
  expect(pngImg).toBeInstanceOf(HTMLImageElement);
  expect(typeof pngImg.src).toBe('string');
expect(pngImg.src.startsWith('data:image/png')).toBe(true);

  const jpgImg = await snap.toJpg();
  expect(jpgImg).toBeInstanceOf(HTMLImageElement);
  expect(typeof jpgImg.src).toBe('string');
expect(jpgImg.src.startsWith('data:image/jpeg')).toBe(true);

  const webpImg = await snap.toWebp();
  expect(webpImg).toBeInstanceOf(HTMLImageElement);
  expect(typeof webpImg.src).toBe('string');
expect(webpImg.src.startsWith('data:image/webp')).toBe(true);
  document.body.removeChild(el);
});

it('snapdom should support exclude option to filter out elements by CSS selectors', async () => {
  const el = document.createElement('div');
  el.innerHTML = `

Title

Should be excluded

Private data

This should remain

  `;
  document.body.appendChild(el);

  const result = await snapdom(el, { exclude: ['.exclude-me', '[data-private]'] });

  const svg = result.toRaw();
  const decoded = decodeURIComponent(svg.split(",")[1]);
  expect(decoded).not.toContain('Should be excluded');
  expect(decoded).not.toContain('Private data');
  expect(decoded).toContain('Title');
  expect(decoded).toContain('This should remain');
});

it('snapdom should support filter option to exclude elements with custom logic', async () => {
  const el = document.createElement('div');
  el.innerHTML = `

Level 1

Level 2

Level 3

  `;
  document.body.appendChild(el);
  const result = await snapdom(el, {
    filter: (element) => !element.classList.contains('level-3')
  });

    const svg = result.toRaw();
  const decoded = decodeURIComponent(svg.split(",")[1]);
  expect(decoded).toContain('Level 1');
  expect(decoded).toContain('Level 2');
  expect(decoded).not.toContain('Level 3');
});

it('snapdom should support combining exclude and filter options', async () => {
  const el = document.createElement('div');
  el.innerHTML = `

Exclude by selector

Exclude by filter

Keep this content

  `;
  document.body.appendChild(el);

  const result = await snapdom(el, {
    exclude: ['.exclude-by-selector'],
    filter: (element) => !element.classList.contains('exclude-by-filter')
  });

   const svg = result.toRaw();
  const decoded = decodeURIComponent(svg.split(",")[1]);
  expect(decoded).not.toContain('Exclude by selector');
  expect(decoded).not.toContain('Exclude by filter');
  expect(decoded).toContain('Keep this content');
});

});

async () => {
    vi.resetModules();
    vi.mock('../utils/helpers.js', async () => {
      const actual = await vi.importActual('../utils/helpers.js');
      return { ...actual, isSafari: true };
    });
    const { snapdom } = await import('../src/api/snapdom.js');
    const el = document.createElement('div');
    el.style.width = '10px';
    el.style.height = '10px';
    document.body.appendChild(el);
    const img = new Image();
    img.width = 10;
    img.height = 10;
    img.decode = () => Promise.resolve();
    globalThis.Image = function() { return img; };
    const res = await snapdom.capture(el);
    await res.toImg();
    document.body.removeChild(el);
    vi.resetModules();
  }

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { snapdom } from '../src/index';

describe('snapdom API', () => {
  let testElement;

  beforeEach(() => {
    testElement = document.createElement('div');
    testElement.style.width = '100px';
    testElement.style.height = '50px';
    document.body.appendChild(testElement );
  });

  afterEach(() => {
    document.body.removeChild(testElement );
  });

  describe('snapdom.toRaw', () => {
    it('should return a SVG data URL', async () => {
      const result = await snapdom.toRaw(testElement );
      expect(result).toMatch(/^data:image\/svg\+xml/);
    });
  });

  describe('snapdom', () => {
    it('toImg should return an HTMLImageElement', async () => {

      const img = await snapdom.toImg(testElement );
      expect(img).toBeInstanceOf(HTMLImageElement);
      expect(img.src).toMatch(/^data:image\/svg\+xml/);

    });

    it('toCanvas should return a HTMLCanvasElement', async () => {
     // const decodeMock = vi.spyOn(window.Image.prototype, 'decode').mockResolvedValue();
      const canvas = await snapdom.toCanvas(testElement );
      expect(canvas).toBeInstanceOf(HTMLCanvasElement);
    //  decodeMock.mockRestore();
    });

    it('toPng should return an HTMLImageElement with PNG data URL', async () => {
    //  const decodeMock = vi.spyOn(window.Image.prototype, 'decode').mockResolvedValue();
      const img = await snapdom.toPng(testElement );
      expect(img).toBeInstanceOf(HTMLImageElement);
      expect(img.src).toMatch(/^data:image\/png/);
    //  decodeMock.mockRestore();
    });

    it('toJpg should return an HTMLImageElement with JPEG data URL', async () => {
     // const decodeMock = vi.spyOn(window.Image.prototype, 'decode').mockResolvedValue();
      const img = await snapdom.toJpg(testElement );
      expect(img).toBeInstanceOf(HTMLImageElement);
      expect(img.src).toMatch(/^data:image\/jpeg/);
     // decodeMock.mockRestore();
    });

    it('toWebp should return an HTMLImageElement with WebP data URL', async () => {
    //  const decodeMock = vi.spyOn(window.Image.prototype, 'decode').mockResolvedValue();
      const img = await snapdom.toWebp(testElement );
      expect(img).toBeInstanceOf(HTMLImageElement);
      expect(img.src).toMatch(/^data:image\/webp/);
    //  decodeMock.mockRestore();
    });

    it('toBlob should return a Blob of type image/svg+xml', async () => {
      const blob = await snapdom.toBlob(testElement );
      expect(blob).toBeInstanceOf(Blob);
      expect(blob.type).toBe('image/svg+xml');
    });
  });
});

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { snapdom } from  '../src/index';

describe('snapdom advanced tests', () => {
  let testElement;

  beforeEach(() => {
    testElement = document.createElement('div');
    testElement.style.width = '100px';
    testElement.style.height = '50px';
    testElement.innerHTML = '
Hello World
';
    document.body.appendChild(testElement);
  });

  afterEach(() => {
    document.body.removeChild(testElement);
  });

  it('should generate different SVGs for different scales', async () => {
    const svg1 = await snapdom.toImg(testElement, { scale: 1 });
    const svg2 = await snapdom.toImg(testElement, { scale: 2 });
    expect(svg1).not.toBe(svg2);
  });

  it('captured SVG should contain inner text content', async () => {
    const svgDataUrl = await snapdom.toRaw(testElement);
    const svgText = decodeURIComponent(svgDataUrl.split(',')[1]);
    expect(svgText).toContain('Hello World');
  });

  it('should throw an error if element is null', async () => {
    await expect(() => snapdom.toRaw(null)).rejects.toThrow();
  });

  it('should generate SVG with correct attributes', async () => {
    const svgDataUrl = await snapdom.toRaw(testElement);
    const svgText = decodeURIComponent(svgDataUrl.split(',')[1]);
    const parser = new DOMParser();
    const doc = parser.parseFromString(svgText, 'image/svg+xml');
    const svg = doc.querySelector('svg');

    expect(svg).not.toBeNull();
    expect(svg.getAttribute('width')).toBe('100');
    expect(svg.getAttribute('height')).toBe('50');
    expect(svg.getAttribute('viewBox')).toBe('0 0 100 50');
  });

  it('snapdom.toBlob should contain valid SVG content', async () => {
    const blob = await snapdom.toBlob(testElement);
    const text = await blob.text();
    expect(text).toContain('<svg');
    expect(text).toContain('</svg>');
  });

  it('should replace iframes with fallback visuals', async () => {
  const iframe = document.createElement('iframe');
  iframe.style.width = '200px';
  iframe.style.height = '100px';
  testElement.appendChild(iframe);

  const svgDataUrl = await snapdom.toRaw(testElement);
  const svgText = decodeURIComponent(svgDataUrl.split(',')[1]);

  const parser = new DOMParser();
  const doc = parser.parseFromString(svgText, 'image/svg+xml');

  // No debe haber iframes en el SVG
  expect(doc.querySelectorAll('iframe').length).toBe(0);

  // Buscamos divs con estilo inline que contengan repeating-linear-gradient
  const fallbackDivs = Array.from(doc.querySelectorAll('div')).filter(div => {
    const styleAttr = div.getAttribute('style') || '';
    return styleAttr.includes('repeating-linear-gradient');
  });

  expect(fallbackDivs.length).toBeGreaterThan(0);
});

});

/**
 * Main API for snapDOM: capture DOM elements as SVG and raster images.
 * Provides utilities for exporting, downloading, and converting DOM captures.
 * @module snapdom
 */

import { captureDOM } from '../core/capture';
import { isSafari } from '../utils/helpers.js';
import { extendIconFonts } from '../modules/iconFonts.js';

/**
 * Converts an SVG data URL to an HTMLImageElement.
 *
 * @param {string} url - SVG data URL
 * @param {Object} options
 * @param {number} [options.dpr=1] - Device pixel ratio
 * @param {number} [options.scale=1] - Scale multiplier
 * @returns {Promise<HTMLImageElement>} The resulting image
 */

 async function toImg(url, { dpr = 1, scale = 1 }) {
  const img = new Image();
  img.src = url;
  await img.decode();
  img.width = img.width * scale ;
  img.height = img.height * scale ;

  return img;
}

/**
 * Converts an SVG data URL to a Canvas element.
 *
 * @param {string} url - SVG data URL
 * @param {Object} options
 * @param {number} [options.dpr=1] - Device pixel ratio
 * @param {number} [options.scale=1] - Scale multiplier
 * @returns {Promise<HTMLCanvasElement>} The resulting canvas
 */

async function toCanvas(url, { dpr = 1, scale = 1 } = {}) {
  const img = new Image();
  img.src = url;
  img.crossOrigin = 'anonymous';
  img.loading = 'eager';
  img.decoding = 'sync';

  const isSafariBrowser = isSafari();
  let appended = false;

  if (isSafariBrowser) {
    document.body.appendChild(img);
    appended = true;
  }

  await img.decode();

  if (isSafariBrowser) {
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  if (img.width === 0 || img.height === 0) {
    if (appended) img.remove();
    throw new Error('Image failed to load or has no dimensions');
  }

  const width = img.width * scale;
  const height = img.height * scale;

  const canvas = document.createElement('canvas');
  canvas.width = Math.ceil(width * dpr);
  canvas.height = Math.ceil(height * dpr);
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;

  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  ctx.drawImage(img, 0, 0, width, height);

  if (appended) img.remove();

  return canvas;
}

/**
 * Converts a DOM snapshot (SVG data URL) into a Blob of the specified format.
 *
 * @param {string} url - SVG data URL
 * @param {Object} [options]
 * @param {string} [options.format="svg"] - Output format: "svg", "png", "jpeg", "webp"
 * @param {number} [options.dpr=1] - Device pixel ratio
 * @param {number} [options.scale=1] - Scale multiplier
 * @param {string} [options.backgroundColor="#fff"] - Background for raster formats
 * @param {number} [options.quality] - JPEG/WebP quality (0–1)
 * @returns {Promise<Blob>} The resulting Blob
 */
async function toBlob(url, {
  type = "svg",
  scale = 1,
  backgroundColor = "#fff",
  quality
} = {}) {
  const mime = {
    jpg: "image/jpeg",
    jpeg: "image/jpeg",
    png: "image/png",
    webp: "image/webp",
  }[type] || "image/png";

  if (type === "svg") {
    const svgText = decodeURIComponent(url.split(",")[1]);
    return new Blob([svgText], { type: "image/svg+xml" });
  }

  const canvas = await createBackground(url, { dpr: 1, scale }, backgroundColor);
  return new Promise((resolve) => {
    canvas.toBlob(blob => resolve(blob), `${mime}`, quality);
  });
}

/**
 * Creates a canvas with a background color from an SVG data URL.
 *
 * @param {string} url - SVG data URL
 * @param {Object} options
 * @param {number} [options.dpr=1] - Device pixel ratio
 * @param {number} [options.scale=1] - Scale multiplier
 * @param {string} [backgroundColor] - Background color to apply
 * @returns {Promise<HTMLCanvasElement>} The resulting canvas
 */

 async function createBackground(url, { dpr = 1, scale = 1 }, backgroundColor) {
  const baseCanvas = await toCanvas(url, { dpr, scale });
  if (!backgroundColor) return baseCanvas;

  const temp = document.createElement("canvas");
  temp.width = baseCanvas.width;
  temp.height = baseCanvas.height;
  const ctx = temp.getContext("2d");

  ctx.fillStyle = backgroundColor;
  ctx.fillRect(0, 0, temp.width, temp.height);
  ctx.drawImage(baseCanvas, 0, 0);

  return temp;
}

/**
 * Converts an SVG data URL to a raster image (PNG, JPEG, WebP).
 *
 * @param {string} url - SVG data URL
 * @param {Object} options
 * @param {number} [options.dpr=1] - Device pixel ratio
 * @param {number} [options.scale=1] - Scale multiplier
 * @param {string} [options.backgroundColor="#fff"] - Background color for rasterization
 * @param {number} [options.quality] - Image quality (for JPEG/WebP)
 * @param {string} [format="png"] - Output format: "png", "jpeg", or "webp"
 * @returns {Promise<HTMLImageElement>} The resulting raster image
 */

 async function toRasterImg(url, { dpr = 1, scale = 1, backgroundColor, quality }, format = "png") {
  const defaultBg = ["jpg", "jpeg", "webp"].includes(format) ? "#fff" : undefined;
  const finalBg = backgroundColor ?? defaultBg;

  const canvas = await createBackground(url, { dpr, scale }, finalBg);

  const img = new Image();
  img.src = canvas.toDataURL(`image/${format}`, quality);
  await img.decode();

  img.style.width = `${canvas.width / dpr}px`;
  img.style.height = `${canvas.height / dpr}px`;

  return img;
}

/**
 * Downloads a captured image in the specified format.
 *
 * @param {string} url - SVG data URL
 * @param {Object} options
 * @param {number} [options.dpr=1] - Device pixel ratio
 * @param {number} [options.scale=1] - Scale multiplier
 * @param {string} [options.backgroundColor="#fff"] - Background color for rasterization
 * @param {string} [options.format="png"] - Output format
 * @param {string} [options.filename="capture"] - Download filename
 * @returns {Promise<void>} Resolves when download is triggered
 */

 async function download(url,{ dpr = 1, scale = 1, backgroundColor, format = "png", filename = "snapDOM"} = {}) {
  if (format === "svg") {
    const blob = await toBlob(url);
    const objectURL = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = objectURL;
    a.download = `${filename}.svg`;
    a.click();
    URL.revokeObjectURL(objectURL);
    return;
  }

  const defaultBg = ["jpg", "jpeg", "webp"].includes(format) ? "#fff" : undefined;
  const finalBg = backgroundColor ?? defaultBg;

  const canvas = await createBackground(url, { dpr, scale }, finalBg);
  const mime = {
    jpg: "image/jpeg",
    jpeg: "image/jpeg",
    png: "image/png",
    webp: "image/webp",
  }[format] || "image/png";

  const dataURL = canvas.toDataURL(mime);

  const a = document.createElement("a");
  a.href = dataURL;
  a.download = `${filename}.${format}`;
  a.click();
}

/**
 * Main entry point: captures a DOM element and returns an object with export methods.
 *
 * @param {Element} element - DOM element to capture
 * @param {Object} [options={}] - Capture options
 * @returns {Promise<Object>} Object with export methods (toRaw, toImg, toCanvas, etc.)
 */

export async function snapdom(element, options = {}) {
  options = { scale: 1, ...options };
  if (!element) throw new Error("Element cannot be null or undefined");
  if (options.iconFonts) {
    extendIconFonts(options.iconFonts);
  }
  return await snapdom.capture(element, options);
}

/**
 * Captures a DOM element and returns an object with export methods (internal use).
 *
 * @param {Element} el - DOM element to capture
 * @param {Object} [options={}] - Capture options
 * @returns {Promise<Object>} Object with export methods
 */

snapdom.capture = async (el, options = {}) => {
  const url = await captureDOM(el, options);
  const dpr = window.devicePixelRatio || 1;
  const scale = options.scale || 1;

  return {
    url,
    options,
    toRaw: () => url,
    toImg: () => toImg(url, { dpr, scale }),
    toCanvas: () => toCanvas(url, { dpr, scale }),
    toBlob: (options) => toBlob(url, { dpr, scale, ...options }),
    toPng: (options) => toRasterImg(url, { dpr, scale, ...options  }, "png"),
    toJpg: (options) => toRasterImg(url, { dpr, scale, ...options }, "jpeg"),
    toWebp: (options) => toRasterImg(url, { dpr, scale, ...options }, "webp"),
    download: ({ format = "png", filename = "capture", backgroundColor } = {}) => download(url, { dpr, scale, backgroundColor, format, filename})
  };
};

// Compatibilidad
snapdom.toRaw = async (el, options) => (await snapdom.capture(el, options)).toRaw();
snapdom.toImg = async (el, options) => (await snapdom.capture(el, options)).toImg();
snapdom.toCanvas = async (el, options) => (await snapdom.capture(el, options)).toCanvas();
snapdom.toBlob = async (el, options) => (await snapdom.capture(el, options)).toBlob(options);
snapdom.toPng = async (el, options) => (await snapdom.capture(el, options)).toPng(options);
snapdom.toJpg = async (el, options) => (await snapdom.capture(el, options)).toJpg(options);
snapdom.toWebp = async (el, options) => (await snapdom.capture(el, options)).toWebp(options);
snapdom.download = async (el, options = {}) => {
  const {
    format = "png",
    filename = "capture",
    backgroundColor,
    ...rest
  } = options;

  const capture = await snapdom.capture(el, rest);
  return await capture.download({ format, filename, backgroundColor });
};

import { describe, it, expect, vi } from 'vitest';
import { getStyle, parseContent, extractURL, isIconFont, snapshotComputedStyle, isSafari, stripTranslate, safeEncodeURI, idle, fetchImage } from '../src/utils/helpers.js';

describe('getStyle', () => {
  it('returns a CSSStyleDeclaration', () => {
    const el = document.createElement('div');
    document.body.appendChild(el);
    const style = getStyle(el);
    expect(style).toBeInstanceOf(CSSStyleDeclaration);
    document.body.removeChild(el);
  });
});

describe('parseContent', () => {
  it('parses CSS content correctly', () => {
    expect(parseContent('"★"')).toBe('★');
    expect(parseContent('\\2605')).toBe('★');
  });
});

describe('parseContent edge cases', () => {
  it('returns \u0000 if parseInt fails (not hex)', () => {
    expect(parseContent('\\nothex')).toBe('\u0000');
  });
  it('returns clean if String.fromCharCode throws', () => {
    const orig = String.fromCharCode;
    String.fromCharCode = () => { throw new Error('fail'); };
    expect(parseContent('\\2605')).toBe('\\2605');
    String.fromCharCode = orig;
  });
});

describe('extractURL', () => {
  it('extracts the URL from background-image', () => {
    expect(extractURL('url("https://test.com/img.png")')).toBe('https://test.com/img.png');
    expect(extractURL('none')).toBeNull();
  });
});

describe('isIconFont', () => {
  it('detects icon fonts', () => {
    expect(isIconFont('Font Awesome')).toBe(true);
    expect(isIconFont('Arial')).toBe(false);
  });
});

describe('snapshotComputedStyle', () => {
  it('returns a style snapshot', () => {
    const el = document.createElement('div');
    document.body.appendChild(el);
    const style = getComputedStyle(el);
    const snap = snapshotComputedStyle(style);
    expect(typeof snap).toBe('object');
    document.body.removeChild(el);
  });
});

describe('isSafari', () => {
  it('returns a boolean', () => {
    expect(typeof isSafari()).toBe('boolean');
  });
});

describe('stripTranslate', () => {
  it('removes translate transforms', () => {
    expect(stripTranslate('translateX(10px) scale(2)')).toContain('scale(2)');
  });
  it('stripTranslate removes matrix and matrix3d', () => {
    expect(stripTranslate('matrix(1,0,0,1,10,20)')).not.toContain('10,20');
    expect(stripTranslate('matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,10,20,30,1)')).not.toContain('10,20,30');
  });
});

describe('safeEncodeURI', () => {
  it('returns an encoded string', () => {
    expect(typeof safeEncodeURI('https://test.com/á')).toBe('string');
  });
  it('safeEncodeURI handles invalid URIs gracefully', () => {
    expect(typeof safeEncodeURI('%E0%A4%A')).toBe('string');
  });
});

describe('idle', () => {
  it('calls fn immediately if fast is true', () => {
    let called = false;
    idle(() => { called = true; }, { fast: true });
    expect(called).toBe(true);
  });
  it('uses requestIdleCallback if available', () => {
    const orig = window.requestIdleCallback;
    let called = false;
    window.requestIdleCallback = (fn) => { called = true; fn(); };
    idle(() => { called = true; });
    expect(called).toBe(true);
    window.requestIdleCallback = orig;
  });
  it('falls back to setTimeout if requestIdleCallback not available', async () => {
    const orig = window.requestIdleCallback;
    delete window.requestIdleCallback;
    let called = false;
    idle(() => { called = true; });
    await new Promise(r => setTimeout(r, 10));
    expect(called).toBe(true);
    window.requestIdleCallback = orig;
  });
});

describe('fetchImage', () => {
  it('rejects on image error', async () => {
    await expect(fetchImage('invalid-url', { timeout: 100 }))
  .rejects.toThrow('Fetch fallback failed and no proxy provided');

  });
  it('rejects on timeout', async () => {
    // Simula una imagen que nunca carga
    const origImage = window.Image;
    window.Image = class { set src(_){} onload(){} onerror(){} };
    await expect(fetchImage('timeout-url', { timeout: 10 })).rejects.toThrow('Image load timed out');
    window.Image = origImage;
  });

});

describe('fetchImage cache', () => {
  it('returns cached image if present', async () => {
    const { cache } = await import('../src/core/cache.js');
    cache.image.set('cached-url', 'data:image/png;base64,abc');
    const result = await fetchImage('cached-url', { useProxy: false });
    expect(result).toBe('data:image/png;base64,abc');
    cache.image.delete('cached-url');
  });
});

describe('fetchImage error propagation', () => {
  it('rejects with original error if decode fails', async () => {
    const origImage = window.Image;
    window.Image = class {
      constructor() { setTimeout(() => this.onload(), 1); }
      set src(_){}
      decode() { return Promise.reject(new Error('decode fail')); }
      get width() { return 1; }
      get height() { return 1; }
    };
   await expect(fetchImage('decode-fail-url', { timeout: 100 }))
  .rejects.toThrow('Fetch fallback failed and no proxy provided');
    window.Image = origImage;
  });
});

describe('stripTranslate edge cases', () => {
  it('returns empty string for empty or none', () => {
    expect(stripTranslate('')).toBe('');
    expect(stripTranslate('none')).toBe('');
  });
  it('returns original for malformed matrix', () => {
    expect(stripTranslate('matrix(1,2,3)')).toBe('matrix(1,2,3)');
    expect(stripTranslate('matrix3d(1,2,3)')).toBe('matrix3d(1,2,3)');
  });
});

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { snapdom } from  '../src/index';

describe('snapdom capture attributes', () => {
  let container;

  beforeEach(() => {
    container = document.createElement('div');
    container.style.width = '300px';
    container.style.height = '150px';
    document.body.appendChild(container);
  });

  afterEach(() => {
    document.body.removeChild(container);
  });

  it('should exclude elements with data-capture="exclude"', async () => {
    const excluded = document.createElement('div');
    excluded.setAttribute('data-capture', 'exclude');
    excluded.textContent = 'Should be excluded';
    container.appendChild(excluded);

    const svgDataUrl = await snapdom.toRaw(container);
    const svgText = decodeURIComponent(svgDataUrl.split(',')[1]);

    expect(svgText).not.toContain('Should be excluded');
  });

  it('should replace elements with data-capture="placeholder" and show placeholder text', async () => {
    const placeholder = document.createElement('div');
    placeholder.setAttribute('data-capture', 'placeholder');
    placeholder.setAttribute('data-placeholder-text', 'Placeholder here');
    placeholder.textContent = 'Original text';
    container.appendChild(placeholder);

    const svgDataUrl = await snapdom.toRaw(container);
    const svgText = decodeURIComponent(svgDataUrl.split(',')[1]);

    expect(svgText).toContain('Placeholder here');
    expect(svgText).not.toContain('Original text');
  });
});

import { describe, it, expect, beforeEach } from 'vitest';
import { preCache } from '../src/api/preCache.js';
import { cache } from '../src/core/cache.js';

describe('preCache', () => {
  beforeEach(() => {
    cache.reset()
  });

  it('pre-caches images and backgrounds', async () => {
    const el = document.createElement('div');
    const img = document.createElement('img');
    img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAn8B9p6Q2wAAAABJRU5ErkJggg==';
    el.appendChild(img);
    document.body.appendChild(el);
    await preCache(el);
    expect(cache.image.has(img.src)).toBe(true);
    document.body.removeChild(el);
  });

  it('should handle preCache with embedFonts false', async () => {
    const el = document.createElement('div');
    await expect(preCache(el, { embedFonts: false })).resolves.toBeUndefined();
  });

  it('should handle preCache with images that fail to load', async () => {
    const el = document.createElement('div');
    const img = document.createElement('img');
    img.src = 'invalid-url.png';
    el.appendChild(img);
    await expect(preCache(el)).resolves.toBeUndefined();
  });

  it('should handle preCache with backgrounds that fail to load', async () => {
    const el = document.createElement('div');
    el.style.backgroundImage = 'url(invalid-url.png)';
    document.body.appendChild(el);
    await expect(preCache(el)).resolves.toBeUndefined();
    document.body.removeChild(el);
  });

  it('limpia los caches y retorna si reset=true', async () => {
    // Prellenar los caches
    cache.snapshotKey.set('foo', 'bar');
    cache.preStyleMap.set('foo', 'bar');
    cache.preNodeMap.set('foo', 'bar');
    await preCache(document, { reset: true });
    expect(cache.snapshotKey.size).toBe(0);
    expect(cache.preStyleMap.size).toBe(0);
    expect(cache.preNodeMap.size).toBe(0);
  });

  it('procesa múltiples backgrounds en un solo elemento', async () => {
    const el = document.createElement('div');
    el.style.backgroundImage = 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAn8B9p6Q2wAAAABJRU5ErkJggg==), url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAn8B9p6Q2wAAAABJRU5ErkJggg==)';
    document.body.appendChild(el);
    await preCache(el);
    // No assertion estricta porque depende de helpers, pero no debe lanzar error
    document.body.removeChild(el);
  });

});

import { describe, it, expect, vi } from 'vitest';
import { deepClone } from '../src/core/clone.js';

describe('deepClone', () => {
  it('clones a simple div', () => {
    const el = document.createElement('div');
    el.textContent = 'hello';
    const styleMap = new Map();
    const styleCache = new WeakMap();
    const nodeMap = new Map();
    const clone = deepClone(el, styleMap, styleCache, nodeMap, false);
    expect(clone).not.toBe(el);
    expect(clone.textContent).toBe('hello');
  });

  it('clones canvas as an image', () => {
    const canvas = document.createElement('canvas');
    canvas.width = 10;
    canvas.height = 10;
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = 'red';
    ctx.fillRect(0,0,10,10);
    const styleMap = new Map();
    const styleCache = new WeakMap();
    const nodeMap = new Map();
    const clone = deepClone(canvas, styleMap, styleCache, nodeMap, false);
    expect(clone.tagName).toBe('IMG');
    expect(clone.src.startsWith('data:image/')).toBe(true);
  });

  it('deepClone handles data-capture="exclude"', () => {
    const el = document.createElement('div');
    el.setAttribute('data-capture', 'exclude');
    const clone = deepClone(el, true, {});
    expect(clone).not.toBeNull();
  });

  it('deepClone handles data-capture="placeholder"', () => {
    const el = document.createElement('div');
    el.setAttribute('data-capture', 'placeholder');
    el.setAttribute('data-placeholder-text', 'Placeholder!');
    const clone = deepClone(el, true, {});
    expect(clone.textContent).toContain('Placeholder!');
  });

  it('deepClone handles iframe', () => {
    const iframe = document.createElement('iframe');
    iframe.width = 100;
    iframe.height = 50;
    const clone = deepClone(iframe, true, {});
    expect(clone.tagName).toBe('DIV');
  });

  it('deepClone handles input, textarea, select', () => {
    const input = document.createElement('input');
    input.value = 'foo';
    input.checked = true;
    const textarea = document.createElement('textarea');
    textarea.value = 'bar';
    const select = document.createElement('select');
    const opt = document.createElement('option');
    opt.value = 'baz';
    select.appendChild(opt);
    select.value = 'baz';
    [input, textarea, select].forEach(el => {
      const clone = deepClone(el, true, {});
      expect(clone.value).toBe(el.value);
    });
  });

  it('deepClone handles shadow DOM', () => {
    const el = document.createElement('div');
    const shadow = el.attachShadow({mode:'open'});
    const span = document.createElement('span');
    span.textContent = 'shadow';
    shadow.appendChild(span);
    const clone = deepClone(el, true, {});
    expect(clone).not.toBeNull();
  });
});

describe('deepClone edge cases', () => {
  it('clones unsupported node (Comment) as a new Comment', () => {
    const fake = document.createComment('not supported');
    const result = deepClone(fake, true, {});
    expect(result.nodeType).toBe(Node.COMMENT_NODE);
    expect(result.textContent).toBe('not supported');
    expect(result).not.toBe(fake); // Es un clon, no el mismo objeto
  });
  it('clones attributes and children', () => {
    const el = document.createElement('div');
    el.setAttribute('data-test', '1');
    const result = deepClone(el, true, {});
    expect(result.getAttribute('data-test')).toBe('1');
  });
});

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { snapdom } from  '../src/index';

describe('snapdom.toJpg backgroundColor option', () => {
  let container;

  beforeEach(() => {
    container = document.createElement('div');
    container.style.width = '100px';
    container.style.height = '100px';
    container.style.background = 'transparent';
    document.body.appendChild(container);
  });

  it('applies white background by default', async () => {
    const img = await snapdom.toJpg(container );
    const canvas = document.createElement('canvas');
    canvas.width = img.width;
    canvas.height = img.height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0);
    const pixel = ctx.getImageData(0, 0, 1, 1).data;
    // JPEG compresses, but for a solid color it should be near white
    expect(pixel[0]).toBeGreaterThan(240);
    expect(pixel[1]).toBeGreaterThan(240);
    expect(pixel[2]).toBeGreaterThan(240);
  });

  it('applies custom background color', async () => {
    const img = await snapdom.toJpg(container, { backgroundColor: '#00ff00'  });
    const canvas = document.createElement('canvas');
    canvas.width = img.width;
    canvas.height = img.height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0);
    const pixel = ctx.getImageData(0, 0, 1, 1).data;
    // Green check (JPEG lossy, so check near values)
    expect(pixel[0]).toBeLessThan(30);    // red
    expect(pixel[1]).toBeGreaterThan(200); // green
    expect(pixel[2]).toBeLessThan(30);    // blue
  });
});

import { describe, it, expect, vi } from 'vitest';
import { prepareClone } from '../src/core/prepare.js';

describe('prepareClone', () => {
  it('prepares a clone of a div', async () => {
    const el = document.createElement('div');
    el.textContent = 'test';
    const { clone, classCSS } = await prepareClone(el);
    expect(clone).not.toBeNull();
    expect(typeof classCSS).toBe('string');
  });
});

describe('prepareClone edge cases', () => {
  it('throws for null node', async () => {
    await expect(prepareClone(null)).rejects.toThrow();
  });
  it('returns a comment clone for unsupported node', async () => {
    const fake = document.createComment('not supported');
    const result = await prepareClone(fake);
    expect(result.clone.nodeType).toBe(Node.COMMENT_NODE);
  });
  it('handles error in internal logic', async () => {
    const el = document.createElement('div');
    vi.spyOn(el, 'cloneNode').mockImplementation(() => { throw new Error('fail'); });
    await expect(prepareClone(el)).rejects.toThrow('fail');
  });
  it('clones attributes and children', async () => {
    const el = document.createElement('div');
    el.setAttribute('data-test', '1');
    const result = await prepareClone(el);
    expect(result.clone.getAttribute('data-test')).toBe('1');
  });
});

import { describe, it, expect, vi } from 'vitest';
import { captureDOM } from '../src/core/capture.js';

describe('captureDOM edge cases', () => {
  it('throws for unsupported element (unknown nodeType)', async () => {
    // Simulate a node with an invalid nodeType
    const fakeNode = { nodeType: 999 };
    await expect(captureDOM(fakeNode)).rejects.toThrow();
  });

  it('throws if element is null', async () => {
    await expect(captureDOM(null)).rejects.toThrow();
  });

  it('throws error if getBoundingClientRect fails', async () => {
    const el = document.createElement('div');
    vi.spyOn(el, 'getBoundingClientRect').mockImplementation(() => { throw new Error('fail'); });
    await expect(captureDOM(el)).rejects.toThrow('fail');
  });
});

describe('captureDOM functional', () => {
  it('captures a simple div and returns an SVG dataURL', async () => {
    const el = document.createElement('div');
    el.textContent = 'test';
    const url = await captureDOM(el);
    expect(url.startsWith('data:image/svg+xml')).toBe(true);
  });

  it('supports scale and width/height options', async () => {
    const el = document.createElement('div');
    el.style.width = '100px';
    el.style.height = '50px';
    await captureDOM(el, { scale: 2 });
    await captureDOM(el, { width: 200 });
    await captureDOM(el, { height: 100 });
  });

  it('supports fast=false', async () => {
    const el = document.createElement('div');
    await captureDOM(el, { fast: false });
  });

  it('supports embedFonts', async () => {
    const el = document.createElement('div');
    await captureDOM(el, { embedFonts: true });
  });
});

vi.mock('../src/utils/helpers.js', async () => {
  const actual = await vi.importActual('../src/utils/helpers.js');
  return {
    ...actual,
    fetchImage: vi.fn(),
  };
});
vi.mock('../src/modules/fonts.js', async () => {
  const actual = await vi.importActual('../src/modules/fonts.js');
  return {
    ...actual,
    iconToImage: vi.fn(),
  };
});

import { describe, it, expect, vi } from 'vitest';
import { inlinePseudoElements } from '../src/modules/pseudo.js';
import * as helpers from '../src/utils/helpers.js';
import * as fonts from '../src/modules/fonts.js';

describe('inlinePseudoElements', () => {
  it('does not fail with simple elements', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    await expect(inlinePseudoElements(el, clone, new Map(), new WeakMap(), false)).resolves.toBeUndefined();
  });

  it('handles ::before with text content', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? '"★"' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? 'transparent' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('handles ::before with icon font', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? '"★"' : prop === 'font-family' ? 'Font Awesome' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? 'transparent' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Font Awesome',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    fonts.iconToImage.mockResolvedValue('data:image/png;base64,icon');
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('handles ::before with url content', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? 'url("https://test.com/img.png")' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? 'transparent' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    helpers.fetchImage.mockResolvedValue('data:image/png;base64,img');
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('handles ::before with background-image (data url)', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? 'none' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'url("data:image/png;base64,abc")' : prop === 'background-color' ? 'transparent' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('handles ::before with background-image (fetch)', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? 'none' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'url("https://test.com/img.png")' : prop === 'background-color' ? 'transparent' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    helpers.fetchImage.mockResolvedValue('data:image/png;base64,img');
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('handles ::before with background-image fetch error', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? 'none' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'url("https://test.com/img.png")' : prop === 'background-color' ? 'transparent' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    helpers.fetchImage.mockRejectedValue(new Error('fail'));
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('handles ::before with no visible box', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: () => 'none', color: '', fontSize: '', fontWeight: '', fontFamily: ''
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('handles ::first-letter with no textNode', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
      getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: ''
    }));
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('handles error in pseudo processing', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation(() => { throw new Error('fail'); });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('ignores if source no es Element', async () => {
    const notElement = {};
    const clone = document.createElement('div');
    await expect(inlinePseudoElements(notElement, clone, new Map(), new WeakMap(), false)).resolves.toBeUndefined();
  });

  it('ignores if clone no es Element', async () => {
    const el = document.createElement('div');
    const notElement = {};
    await expect(inlinePseudoElements(el, notElement, new Map(), new WeakMap(), false)).resolves.toBeUndefined();
  });

  it('inserta pseudoEl como ::after', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::after') return {
        getPropertyValue: (prop) => prop === 'content' ? '"after"' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? '#f00' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('inserta pseudoEl como ::before', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? '"before"' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? '#f00' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('no inserta pseudoEl si no hay visible box', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => 'none', color: '', fontSize: '', fontWeight: '', fontFamily: ''
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('maneja ::first-letter meaningful', async () => {
    const el = document.createElement('div');
    el.textContent = 'Test';
    const clone = document.createElement('div');
    clone.textContent = 'Test';
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::first-letter') return {
        getPropertyValue: (prop) => prop === 'color' ? '#f00' : '', color: '#f00', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('inserta ambos pseudoEl ::before y ::after en una sola ejecución', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? '"before"' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? '#f00' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      if (pseudo === '::after') return {
        getPropertyValue: (prop) => prop === 'content' ? '"after"' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? '#0f0' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });

  it('should inline ::first-letter when style is meaningful', async () => {
  const el = document.createElement('p');
  el.textContent = '¡Hola mundo!';
  el.style.setProperty('color', 'black');

  document.body.appendChild(el);
  const clone = el.cloneNode(true);
  const styleMap = new Map();
  const styleCache = new WeakMap();

  const style = document.createElement('style');
  style.textContent = `
    p::first-letter {
      color: red;
      font-size: 200%;
    }
  `;
  document.head.appendChild(style);

  await inlinePseudoElements(el, clone, styleMap, styleCache, false);
  const firstLetterEl = clone.querySelector('[data-snapdom-pseudo="::first-letter"]');
  expect(firstLetterEl).toBeTruthy();
  expect(firstLetterEl.textContent.length).toBeGreaterThan(0);
});

it('should inline background-image entries for pseudo-element', async () => {
  const el = document.createElement('div');
  document.body.appendChild(el);

  const style = document.createElement('style');
  style.textContent = `
    div::after {
      content: " ";
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='10' height='10' fill='blue'/%3E%3C/svg%3E");
      display: inline-block;
      width: 10px;
      height: 10px;
    }
  `;
  document.head.appendChild(style);

  const clone = el.cloneNode(true);
  const styleMap = new Map();
  const styleCache = new WeakMap();

  await inlinePseudoElements(el, clone, styleMap, styleCache, false);
  const pseudoAfter = clone.querySelector('[data-snapdom-pseudo="::after"]');
  expect(pseudoAfter).toBeTruthy();
  expect(pseudoAfter.style.backgroundImage.startsWith("url(\"data:image/")).toBeTruthy();
});

});

import { bench, describe, beforeEach, afterEach } from 'vitest';
import { domToDataUrl } from 'https://unpkg.com/modern-screenshot';
import * as htmlToImage from 'https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/+esm';
//import { toPng, toJpeg, toBlob, toPixelData, toSvg } from 'https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js';
import { snapdom as sd } from 'https://cdn.jsdelivr.net/npm/@zumer/snapdom@1.8.0/dist/snapdom.mjs';
import { snapdom } from '../src/index';

let html2canvasLoaded = false;

async function loadHtml2Canvas() {
  if (html2canvasLoaded) return;
  await new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
    script.onload = () => resolve();
    script.onerror = reject;
    document.head.appendChild(script);
  });
  html2canvasLoaded = true;
}

await loadHtml2Canvas();

const sizes = [
  { width: 200, height: 100, label: 'Small element (200x100)' },
  { width: 400, height: 300, label: 'Modal size (400x300)' },
  { width: 1200, height: 800, label: 'Page view (1200x800)' },
  { width: 2000, height: 1500, label: 'Large scroll area (2000x1500)' },
  { width: 4000, height: 2000, label: 'Very large element (4000x2000)' },
];

for (const size of sizes) {
  describe(`Benchmark simple node at ${size.label}`, () => {
    let container;

    async function setupContainer() {
      if (container && document.body.contains(container)) {
        return;
      }
      container = document.createElement('div');
      container.style.width = `${size.width}px`;
      container.style.height = `${size.height}px`;
      container.style.background = 'linear-gradient(to right, red, blue)';
      container.style.fontFamily = 'Arial, sans-serif';
      container.style.display = 'flex';
      container.style.alignItems = 'center';
      container.style.justifyContent = 'center';
      container.style.fontSize = '24px';
      container.innerHTML = `<h1>${size.label}</h1>`;
      document.body.appendChild(container);
    }

    /*   async function setupContainer() {
        if (container && document.body.contains(container)) return;

        container = document.createElement('div');
        container.style.width = `${size.width}px`;
        container.style.height = `${size.height}px`;
        container.style.padding = '20px';
        container.style.overflow = 'auto';
        container.style.background = 'white';
        container.style.border = '2px solid black';
        container.style.fontFamily = 'Arial, sans-serif';
        container.style.color = '#333';
        container.style.position = 'relative';

        const grid = document.createElement('div');
        grid.style.display = 'grid';
        grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(120px, 1fr))';
        grid.style.gap = '10px';

        for (let i = 0; i < Math.floor((size.width * size.height) / 20000); i++) {
          const card = document.createElement('div');
          card.style.padding = '10px';
          card.style.borderRadius = '8px';
          card.style.background = i % 2 === 0 ? '#f0f0f0' : '#e0eaff';
          card.style.boxShadow = '0 2px 5px rgba(0,0,0,0.1)';
          card.style.display = 'flex';
          card.style.flexDirection = 'column';
          card.style.alignItems = 'center';

          const title = document.createElement('h3');
          title.textContent = `Card ${i + 1}`;
          title.style.margin = '0 0 10px 0';
          title.style.fontSize = '14px';

          const icon = document.createElement('div');
          icon.style.width = '30px';
          icon.style.height = '30px';
          icon.style.borderRadius = '50%';
          icon.style.background = i % 2 === 0 ? 'red' : 'blue';
          icon.style.marginBottom = '10px';

          const text = document.createElement('p');
          text.textContent = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
          text.style.fontSize = '12px';
          text.style.textAlign = 'center';

          card.appendChild(icon);
          card.appendChild(title);
          card.appendChild(text);
          grid.appendChild(card);
        }

        container.appendChild(grid);
        document.body.appendChild(container);
      }
       */
    afterEach(() => {
      if (container) {
        container.remove();
        container = null;
      }
    });

    bench('snapDOM current version', async () => {
      await setupContainer();
      await snapdom.toRaw(container);
    });

     bench('snapDOM V1.8.0', async () => {
      await setupContainer();
      await sd.toRaw(container);
    });

    bench('html2canvas capture', async () => {
      await setupContainer();
      const canvas = await window.html2canvas(container, { logging: false, scale: 1 });
      await canvas.toDataURL();
    });

    bench('modern-screenshot capture', async () => {
      await setupContainer();
      await domToDataUrl(container);
    });

    bench('html-to-image capture', async () => {
      await setupContainer();
      await htmlToImage.toSvg(container);
    });
  });
}

import { describe, test, expect, afterEach, afterAll, beforeEach } from 'vitest';
import { snapdom, preCache } from '../src/index';
import { cache } from '../src/core/cache';

const sizes = [
  { width: 200, height: 100, label: 'Small element (200x100)' },
  { width: 400, height: 300, label: 'Modal size (400x300)' },
  { width: 1200, height: 800, label: 'Page view (1200x800)' },
];
let results = [];
function createContainer(size) {
  const container = document.createElement('div');
  container.style.width = `${size.width}px`;
  container.style.height = `${size.height}px`;
  container.style.padding = '20px';
  container.style.overflow = 'auto';
  container.style.background = 'white';
  container.style.border = '2px solid black';
  container.style.fontFamily = 'Arial, sans-serif';
  container.style.color = '#333';
  container.style.position = 'relative';

  const grid = document.createElement('div');
  grid.style.display = 'grid';
  grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(120px, 1fr))';
  grid.style.gap = '10px';

  const cardCount = Math.floor((size.width * size.height) / 20000);
  for (let i = 0; i < cardCount; i++) {
    const card = document.createElement('div');
    card.style.padding = '10px';
    card.style.borderRadius = '8px';
    card.style.background = i % 2 === 0 ? '#f0f0f0' : '#e0eaff';
    card.style.boxShadow = '0 2px 5px rgba(0,0,0,0.1)';
    card.style.display = 'flex';
    card.style.flexDirection = 'column';
    card.style.alignItems = 'center';

    const title = document.createElement('h3');
    title.textContent = `Card ${i + 1}`;
    title.style.margin = '0 0 10px 0';
    title.style.fontSize = '14px';

    const icon = document.createElement('div');
    icon.style.width = '30px';
    icon.style.height = '30px';
    icon.style.borderRadius = '50%';
    icon.style.background = i % 2 === 0 ? 'red' : 'blue';
    icon.style.marginBottom = '10px';

    const text = document.createElement('p');
    text.textContent = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
    text.style.fontSize = '12px';
    text.style.textAlign = 'center';

    card.appendChild(icon);
    card.appendChild(title);
    card.appendChild(text);
    grid.appendChild(card);
  }

  container.appendChild(grid);
  return container;
}

function waitForNextFrame() {
  return new Promise((resolve) => {
    requestAnimationFrame(() => setTimeout(resolve, 0));
  });
}
beforeEach(() => {
  cache.image.clear();
  cache.background.clear();
  cache.resource.clear();
});
afterAll(() => {
  for (const r of results) {
    console.log(r.log);
  }
  results = [];

      document.body.innerHTML = '';
});
for (const size of sizes) {
  describe(`snapDOM performance test (may not be accurate) - ${size.label}`, () => {
    let container;

    afterEach( () => {
      container?.remove();
      container = null;
      document.body.innerHTML = '';

    });

    test('without preCache', async () => {
      container = createContainer(size);
      document.body.appendChild(container);
      await waitForNextFrame();

      const start = performance.now();
      await snapdom.toRaw(container, { compress: true });
      const end = performance.now();

       let log = `[${size.label}] WITHOUT preCache: capture ${(end - start).toFixed(2)}ms`;
       results.push({ log });
      expect(true).toBe(true);

    });

    test('with preCache', async () => {
      container = createContainer(size);
      document.body.appendChild(container);
      await waitForNextFrame();

      const startPre = performance.now();
      await preCache();
      const endPre = performance.now();

      const startCap = performance.now();
      await snapdom.toRaw(container, { compress: true });
      const endCap = performance.now();

      const precacheTime = (endPre - startPre).toFixed(2);
      const captureTime = (endCap - startCap).toFixed(2);

     let log = `[${size.label}] WITH preCache:  capture ${captureTime}ms  (preCache ${precacheTime}ms)  `;

      results.push({ log });

      expect(true).toBe(true);
    });

  });
}

import { describe, it, beforeEach, afterEach, afterAll } from 'vitest';
import { snapdom }  from '../src/index';
//ok

describe('Output file size snapdom vs html2canvas (cdn with averaging)', () => {
  let container;
  let html2canvas;
  let report;

  beforeEach(async () => {
    container = document.createElement('div');
    container.style.width = '400px';
    container.style.height = '300px';
    container.style.background = 'linear-gradient(to right, red, blue)';
    container.innerHTML = '
Hello Benchmark

Testing multiple runs...
';
    document.body.appendChild(container);

    if (!html2canvas) {
      // Cargar html2canvas desde CDN
      await new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
        script.onload = () => {
          html2canvas = window.html2canvas;
          resolve();
        };
        script.onerror = reject;
        document.head.appendChild(script);
      });
    }
  });

  afterEach(() => {
    document.body.removeChild(container);
  });

  afterAll(() => {
    console.log(report)
  })

  it('snapdom output file size should be smaller than html2canvas', async () => {

    // SnapDOM capture
    const snapdomDataURL = await snapdom.toRaw(container, {compress: true});
    const snapdomSizeKB = (snapdomDataURL.length * 3 / 4) / 1024; // Base64 to bytes approx

    // html2canvas capture
    const canvas = await html2canvas(container, { logging: false });
    const html2canvasDataURL = await canvas.toDataURL();
    const html2canvasSizeKB = (html2canvasDataURL.length * 3 / 4) / 1024; // Base64 to bytes approx

    const differencePercent = ((html2canvasSizeKB - snapdomSizeKB) / html2canvasSizeKB) * 100;

    report =`snapDom Captured file size is ${differencePercent.toFixed(2)}% smaller compared to html2canvas (${snapdomSizeKB.toFixed(2)} KB vs. ${html2canvasSizeKB.toFixed(2)} KB)`

})

})

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { inlineImages } from '../src/modules/images.js';

describe('inlineImages', () => {
  let container;
  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
  });
  afterEach(() => {
    document.body.removeChild(container);
  });

  it('converts <img> to dataURL if the image loads', async () => {
    const img = document.createElement('img');
    img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAn8B9p6Q2wAAAABJRU5ErkJggg==';
    container.appendChild(img);
    await inlineImages(container);
    expect(img.src.startsWith('data:image/')).toBe(true);
  });

  it('replaces <img> with a fallback if the image fails', async () => {
    const img = document.createElement('img');
    img.src = 'invalid-url.png';
    container.appendChild(img);
    await inlineImages(container);
    expect(container.querySelector('div')).not.toBeNull();
    expect(container.querySelector('img')).toBeNull();
  });
});

import { bench, describe, beforeEach, afterEach } from 'vitest';
import { domToDataUrl } from 'https://unpkg.com/modern-screenshot';
import * as htmlToImage from 'https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/+esm';
import { snapdom as sd } from 'https://cdn.jsdelivr.net/npm/@zumer/snapdom@1.8.0/dist/snapdom.mjs';
import { snapdom } from '../src/index';

let html2canvasLoaded = false;

async function loadHtml2Canvas() {
  if (html2canvasLoaded) return;
  await new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
    script.onload = () => resolve();
    script.onerror = reject;
    document.head.appendChild(script);
  });
  html2canvasLoaded = true;
}

await loadHtml2Canvas();

const sizes = [
  { width: 200, height: 100, label: 'Small element (200x100)' },
  { width: 400, height: 300, label: 'Modal size (400x300)' },
  { width: 1200, height: 800, label: 'Page view (1200x800)' },
  { width: 2000, height: 1500, label: 'Large scroll area (2000x1500)' },
  { width: 4000, height: 2000, label: 'Very large element (4000x2000)' },
];

for (const size of sizes) {
  describe(`Benchmark complex node at ${size.label}`, () => {
    let container;

  async function setupContainer() {
        if (container && document.body.contains(container)) return;

        container = document.createElement('div');
        container.style.width = `${size.width}px`;
        container.style.height = `${size.height}px`;
        container.style.padding = '20px';
        container.style.overflow = 'auto';
        container.style.background = 'white';
        container.style.border = '2px solid black';
        container.style.fontFamily = 'Arial, sans-serif';
        container.style.color = '#333';
        container.style.position = 'relative';

        const grid = document.createElement('div');
        grid.style.display = 'grid';
        grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(120px, 1fr))';
        grid.style.gap = '10px';

        for (let i = 0; i < Math.floor((size.width * size.height) / 20000); i++) {
          const card = document.createElement('div');
          card.style.padding = '10px';
          card.style.borderRadius = '8px';
          card.style.background = i % 2 === 0 ? '#f0f0f0' : '#e0eaff';
          card.style.boxShadow = '0 2px 5px rgba(0,0,0,0.1)';
          card.style.display = 'flex';
          card.style.flexDirection = 'column';
          card.style.alignItems = 'center';

          const title = document.createElement('h3');
          title.textContent = `Card ${i + 1}`;
          title.style.margin = '0 0 10px 0';
          title.style.fontSize = '14px';

          const icon = document.createElement('div');
          icon.style.width = '30px';
          icon.style.height = '30px';
          icon.style.borderRadius = '50%';
          icon.style.background = i % 2 === 0 ? 'red' : 'blue';
          icon.style.marginBottom = '10px';

          const text = document.createElement('p');
          text.textContent = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
          text.style.fontSize = '12px';
          text.style.textAlign = 'center';

          card.appendChild(icon);
          card.appendChild(title);
          card.appendChild(text);
          grid.appendChild(card);
        }

        container.appendChild(grid);
        document.body.appendChild(container);

      }

    afterEach (async () => {
      if (container) {
        container.remove();
        container = null;
      }

       document.body.innerHTML = ''
    });

    bench('snapDOM current version', async () => {
      await setupContainer();
      await snapdom.toRaw(container, {compress: true, fast: true});
    });

     bench('snapDOM V1.8.0', async () => {
      await setupContainer();
      await sd.toRaw(container);
    });

    bench('html2canvas capture', async () => {
      await setupContainer();
      const canvas = await window.html2canvas(container, { logging: false, scale: 1 });
      await canvas.toDataURL();
    });

    bench('modern-screenshot capture', async () => {
      await setupContainer();
      await domToDataUrl(container);
    });
    bench('html-to-image capture', async () => {
      await setupContainer();
      await htmlToImage.toSvg(container);
    });
  });
}

import { describe, it, beforeEach, afterEach, afterAll } from 'vitest';
import { domToDataUrl} from 'https://unpkg.com/modern-screenshot';
import { snapdom }  from '../src/index';
//ok

describe('Output file size snapdom vs modern-screeenshot (cdn with averaging)', () => {
  let container;

  let report;

  beforeEach(async () => {
    container = document.createElement('div');
    container.style.width = '400px';
    container.style.height = '300px';
    container.style.background = 'linear-gradient(to right, red, blue)';
    container.innerHTML = '
Hello Benchmark

Testing multiple runs...
';
    document.body.appendChild(container);
  });

  afterEach(() => {
    document.body.removeChild(container);
  });

  afterAll(() => {
    console.log(report)
  })

  it('snapdom output file size should be smaller than modern-screenshot', async () => {

    // SnapDOM capture
    const snapdomDataURL = await snapdom.toRaw(container, {compress: true});
    const snapdomSizeKB = (snapdomDataURL.length * 3 / 4) / 1024; // Base64 to bytes approx

    // domToDataUrl capture
    const domToDataUrlDataURL = await domToDataUrl(container);
    const domToDataUrlSizeKB = (domToDataUrlDataURL.length * 3 / 4) / 1024; // Base64 to bytes approx

    const differencePercent = ((domToDataUrlSizeKB - snapdomSizeKB) / domToDataUrlSizeKB) * 100;

    report =`snapdom captured file size is ${differencePercent.toFixed(2)}% smaller compared to modern-screenshot (${snapdomSizeKB.toFixed(2)} KB vs. ${domToDataUrlSizeKB.toFixed(2)} KB)`

})

})

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { iconToImage, embedCustomFonts } from '../src/modules/fonts.js';
import { cache } from '../src/core/cache.js';

// Utilidad para limpiar estilos y links antes de cada test
function cleanFontEnvironment() {
  document.querySelectorAll('style,link[rel="stylesheet"]').forEach(s => s.remove());
}

function addStyleTag(css) {
  const style = document.createElement('style');
  style.setAttribute('data-test-style', 'true');
  style.textContent = css;
  document.head.appendChild(style);
  return style;
}

describe('iconToImage', () => {
  it('genera un dataURL para un carácter unicode', async () => {
    const url = await iconToImage('★', 'Arial', 'bold', 32, '#000');
    expect(url.startsWith('data:image/')).toBe(true);
  });

  it('maneja diferentes pesos y colores de fuente', async () => {
    const url = await iconToImage('★', 'Arial', 700, 40, '#ff0000');
    expect(url.startsWith('data:image/')).toBe(true);
  });

  it('usa valores por defecto si no hay métricas', async () => {
    const orig = HTMLCanvasElement.prototype.getContext;
    HTMLCanvasElement.prototype.getContext = function() {
      return {
        font: '',
        scale: vi.fn(),
        textAlign: '',
        textBaseline: '',
        fillStyle: '',
        fillText: vi.fn(),
        measureText: () => ({ width: 10 })
      };
    };
    const url = await iconToImage('★', 'Arial', 'bold', 32, '#000');
    expect(url.startsWith('data:image/')).toBe(true);
    HTMLCanvasElement.prototype.getContext = orig;
  });
});

describe('embedCustomFonts', () => {
  beforeEach(() => {
    cache.reset();
    cache.font.clear();
    cache.resource.clear();
    cleanFontEnvironment();
    vi.restoreAllMocks();
  });

  it('conserva @font-face con solo local() en src', async () => {
    const style = addStyleTag(`
      @font-face {
        font-family: OnlyLocal;
        src: local("Arial");
        font-style: normal;
        font-weight: normal;
      }
    `);

    const css = await embedCustomFonts();
    expect(css).toMatch(/font-family:\s*OnlyLocal/);
    expect(css).toMatch(/src:\s*local\(["']Arial["']\)/);
    document.head.removeChild(style);
  });

  it('conserva @font-face con local() y sin url()', async () => {
    const style = addStyleTag(`
      @font-face {
        font-family: LocalFont;
        src: local('MyFont'), local('FallbackFont');
        font-style: italic;
        font-weight: bold;
      }
    `);

    const css = await embedCustomFonts();
    expect(css).toMatch(/font-family:\s*LocalFont/);
    expect(css).toMatch(/src:\s*local\(['"]MyFont['"]\),\s*local\(['"]FallbackFont['"]\)/);
   expect(css).toMatch(/font-style:\s*italic/);
    document.head.removeChild(style);
  });
});

import { cache } from "../core/cache";

/**
 * Fetches and inlines a single background-image entry to a data URL (with caching).
 * - If entry is a gradient or "none", returns unchanged.
 * - If entry is a url(...), fetches the image as data URL and caches it.
 *
 * @param {string} entry - Single background-image entry (e.g., "url(...)").
 * @param {Object} [options={}] - Options like crossOrigin.
 * @param {boolean} [options.skipInline=false] - If true, only fetches & caches, doesn't return a replacement.
 * @returns {Promise<string|void>} - The processed entry (unless skipInline is true).
 */
export async function inlineSingleBackgroundEntry(entry, options = {}) {
  const rawUrl = extractURL(entry)

  const isGradient = /^((repeating-)?(linear|radial|conic)-gradient)\(/i.test(entry);

  if (rawUrl) {
    const encodedUrl = safeEncodeURI(rawUrl);
    if (cache.background.has(encodedUrl)) {
      return options.skipInline ? void 0 : `url(${cache.background.get(encodedUrl)})`;
    } else {
      const dataUrl = await fetchImage(encodedUrl, { useProxy: options.useProxy });
      cache.background.set(encodedUrl, dataUrl);
      return options.skipInline ? void 0 : `url("${dataUrl}")`;
    }
  }

  if (isGradient || entry === "none") {
    return entry;
  }

  return entry;
}

/**
 * Creates a promise that resolves after the specified delay
 * @param {number} [ms=0] - Milliseconds to delay
 * @returns {Promise<void>} Promise that resolves after the delay
 */

export function idle(fn, { fast = false } = {}) {
  if (fast) return fn();
  if ('requestIdleCallback' in window) {
    requestIdleCallback(fn, { timeout: 50 });
  } else {
    setTimeout(fn, 1);
  }
}
/**
 * Gets the computed style for an element or pseudo-element, with caching.
 *
 * @param {Element} el - The element
 * @param {string|null} [pseudo=null] - The pseudo-element
 * @returns {CSSStyleDeclaration} The computed style
 */
export function getStyle(el, pseudo = null) {
  if (!(el instanceof Element)) {
    return window.getComputedStyle(el, pseudo);
  }

  let map = cache.computedStyle.get(el);
  if (!map) {
    map = new Map();
    cache.computedStyle.set(el, map);
  }

  if (!map.has(pseudo)) {
    const st = window.getComputedStyle(el, pseudo);
    map.set(pseudo, st);
  }

  return map.get(pseudo);
}
/**
 * Parses the CSS content property value, handling unicode escapes.
 *
 * @param {string} content - The CSS content value
 * @returns {string} The parsed content
 */
export function parseContent(content) {
  let clean = content.replace(/^['"]|['"]$/g, "");
  if (clean.startsWith("\\")) {
    try {
      return String.fromCharCode(parseInt(clean.replace("\\", ""), 16));
    } catch {
      return clean;
    }
  }
  return clean;
}
/**
 * Extracts a URL from a CSS value like background-image.
 *
 * @param {string} value - The CSS value
 * @returns {string|null} The extracted URL or null
 */

export function extractURL(value) {
  const match = value.match(/url\((['"]?)(.*?)(\1)\)/);
  if (!match) return null;

  const url = match[2].trim();
  if (url.startsWith('#')) return null;
  return url;
}

/**
 * Determines if a font family or URL is an icon font.
 *
 * @param {string} familyOrUrl - The font family or URL
 * @returns {boolean} True if it is an icon font
 */
export function isIconFont(familyOrUrl) {
  const iconFontPatterns = [
    /font\s*awesome/i,
    /material\s*icons/i,
    /ionicons/i,
    /glyphicons/i,
    /feather/i,
    /bootstrap\s*icons/i,
    /remix\s*icons/i,
    /heroicons/i,
    /layui/i,
    /lucide/i
  ];
  return iconFontPatterns.some(rx => rx.test(familyOrUrl));
}
/**
 *
 *
 * @export
 * @param {*} src
 * @param {number} [timeout=3000]
 * @return {*}
 */

export function fetchImage(src, { timeout = 3000, useProxy = '' } = {}) {
  function getCrossOriginMode(url) {
    try {
      const parsed = new URL(url, window.location.href);
      return parsed.origin === window.location.origin ? "use-credentials" : "anonymous";
    } catch {
      return "anonymous";
    }
  }

  // Función común para fallback vía fetch + proxy
  async function fetchWithFallback(url) {
    const fetchBlobAsDataURL = (fetchUrl) =>
      fetch(fetchUrl, {
        mode: "cors",
        credentials: getCrossOriginMode(fetchUrl) === "use-credentials" ? "include" : "omit",
      })
        .then(r => r.blob())
        .then(blob => new Promise((resolve, reject) => {
          const reader = new FileReader();
          reader.onloadend = () => {
            const base64 = reader.result;
            if (typeof base64 !== "string" || !base64.startsWith("data:image/")) {
              reject(new Error("Invalid image data URL"));
              return;
            }
            resolve(base64);
          };
          reader.onerror = () => reject(new Error("FileReader error"));
          reader.readAsDataURL(blob);
        }));

    try {
      return await fetchBlobAsDataURL(url);
    } catch (e) {
      if (useProxy && typeof useProxy === "string") {
        const proxied = useProxy.replace(/\/$/, "") + safeEncodeURI(url);
        try {
          return await fetchBlobAsDataURL(proxied);
        } catch {

          throw new Error("[SnapDOM - fetchImage] CORS restrictions prevented image capture (even via proxy)");
        }
      } else {

        throw new Error("[SnapDOM - fetchImage] Fetch fallback failed and no proxy provided");
      }
    }
  }

  const crossOriginValue = getCrossOriginMode(src);

  if (cache.image.has(src)) {
    return Promise.resolve(cache.image.get(src));
  }

  // Detectamos si es un data URI, si sí, devolvemos directo sin fetch
  const isDataURI = src.startsWith("data:image/");
  if (isDataURI) {
    cache.image.set(src, src);
    return Promise.resolve(src);
  }

  // Mejor detección SVG, incluyendo query strings
  const isSVG = /\.svg(\?.*)?$/i.test(src);

  if (isSVG) {
    return (async () => {
      try {
        const response = await fetch(src, {
          mode: "cors",
          credentials: crossOriginValue === "use-credentials" ? "include" : "omit"
        });
        const svgText = await response.text();
        const encoded = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`;
        cache.image.set(src, encoded);
        return encoded;
      } catch {
        return fetchWithFallback(src);
      }
    })();
  }

  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      reject(new Error("[SnapDOM - fetchImage] Image load timed out"));
    }, timeout);

    const image = new Image();
    image.crossOrigin = crossOriginValue;

    image.onload = async () => {
      clearTimeout(timeoutId);
      try {
        await image.decode();
        const canvas = document.createElement("canvas");
        canvas.width = image.width;
        canvas.height = image.height;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
        const dataURL = canvas.toDataURL("image/png");
        cache.image.set(src, dataURL);
        resolve(dataURL);
      } catch {
        try {
          const fallbackDataURL = await fetchWithFallback(src);
          cache.image.set(src, fallbackDataURL);
          resolve(fallbackDataURL);
        } catch (e) {
          reject(e);
        }
      }
    };

    image.onerror = async () => {
      clearTimeout(timeoutId);
      console.error(`[SnapDOM - fetchImage] Image failed to load: ${src}`);
      try {
        const fallbackDataURL = await fetchWithFallback(src);
        cache.image.set(src, fallbackDataURL);
        resolve(fallbackDataURL);
      } catch (e) {
        reject(e);
      }
    };

    image.src = src;
  });
}

/**
 *
 *
 * @export
 * @param {*} style
 * @return {*}
 */
export function snapshotComputedStyle(style) {
  const snap = {};
  for (let prop of style) {
    snap[prop] = style.getPropertyValue(prop);
  }
  return snap;
}

export function isSafari() {
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}

export function stripTranslate(transform) {
  if (!transform || transform === 'none') return '';

  let cleaned = transform.replace(/translate[XY]?\([^)]*\)/g, '');

  cleaned = cleaned.replace(/matrix\(([^)]+)\)/g, (_, values) => {
    const parts = values.split(',').map(s => s.trim());
    if (parts.length !== 6) return `matrix(${values})`;
    parts[4] = '0';
    parts[5] = '0';
    return `matrix(${parts.join(', ')})`;
  });

  cleaned = cleaned.replace(/matrix3d\(([^)]+)\)/g, (_, values) => {
    const parts = values.split(',').map(s => s.trim());
    if (parts.length !== 16) return `matrix3d(${values})`;
    parts[12] = '0';
    parts[13] = '0';
    return `matrix3d(${parts.join(', ')})`;
  });

  return cleaned.trim().replace(/\s{2,}/g, ' ');
}

export function safeEncodeURI(uri) {
  if (/%[0-9A-Fa-f]{2}/.test(uri)) return uri; // prevent reencode
  try {
    return encodeURI(uri);
  } catch {
    return uri;
  }
}

export function splitBackgroundImage(bg) {
  const parts = [];
  let depth = 0;
  let lastIndex = 0;
  for (let i = 0; i < bg.length; i++) {
    const char = bg[i];
    if (char === '(') depth++;
    if (char === ')') depth--;
    if (char === ',' && depth === 0) {
      parts.push(bg.slice(lastIndex, i).trim());
      lastIndex = i + 1;
    }
  }
  parts.push(bg.slice(lastIndex).trim());
  return parts;
}

import { describe, it, expect } from 'vitest';
import { getStyleKey, collectUsedTagNames, getDefaultStyleForTag } from '../src/utils/cssTools.js';

describe('getStyleKey', () => {
  it('generates a non-empty style key', () => {
    const snapshot = { color: 'red', 'font-size': '12px' };
    const key = getStyleKey(snapshot, 'div');
    expect(typeof key).toBe('string');
    expect(key.length).toBeGreaterThan(0);
  });

  it('getStyleKey works with compress true', () => {
    const snapshot = { color: 'red', 'font-size': '12px' };
    const key = getStyleKey(snapshot, 'div', true);
    expect(typeof key).toBe('string');
  });
});

describe('collectUsedTagNames', () => {
  it('returns unique tag names', () => {
    const root = document.createElement('div');
    root.innerHTML = '

';
    const tags = collectUsedTagNames(root);
    expect(tags).toContain('div');
    expect(tags).toContain('span');
    expect(tags).toContain('p');
  });
});

describe('getDefaultStyleForTag', () => {
  it('returns a default style object', () => {
    const defaults = getDefaultStyleForTag('div');
    expect(typeof defaults).toBe('object');
  });

  it('getDefaultStyleForTag skips special tags', () => {
    expect(getDefaultStyleForTag('script')).toEqual({});
  });
});

/**
 * Utilities for working with CSS styles, defaults, and class generation.
 * @module cssTools
 */

import { cache } from "../core/cache"

const commonTags = [
  'div', 'span', 'p', 'a', 'img', 'ul', 'li', 'button', 'input',
  'select', 'textarea', 'label', 'section', 'article', 'header',
  'footer', 'nav', 'main', 'aside', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
  'svg', 'path', 'circle', 'rect', 'line', 'g', 'table', 'thead', 'tbody', 'tr', 'td', 'th'
];

export function precacheCommonTags() {
  for (let tag of commonTags) {
    getDefaultStyleForTag(tag);
  }
}
/**
 * Retrieves default CSS property values from a temporary element.
 *
 * @param {string} tagName - The tag name to get default styles for
 * @returns {Object} Object containing default values for all CSS properties
 */
export function getDefaultStyleForTag(tagName) {
  if (cache.defaultStyle.has(tagName)) {
    return cache.defaultStyle.get(tagName);
  }

  const skipTags = new Set(['script', 'style', 'meta', 'link', 'noscript', 'template']);
  if (skipTags.has(tagName)) {
    const empty = {};
    cache.defaultStyle.set(tagName, empty);
    return empty;
  }

  let sandbox = document.getElementById('snapdom-sandbox');
  if (!sandbox) {
    sandbox = document.createElement('div');
    sandbox.id = 'snapdom-sandbox';
    sandbox.style.position = 'absolute';
    sandbox.style.left = '-9999px';
    sandbox.style.top = '-9999px';
    sandbox.style.width = '0';
    sandbox.style.height = '0';
    sandbox.style.overflow = 'hidden';
    document.body.appendChild(sandbox);
  }

  const el = document.createElement(tagName);
  el.style.all = 'initial';
  sandbox.appendChild(el);

  const styles = getComputedStyle(el);
  const defaults = {};
  for (let prop of styles) {
    defaults[prop] = styles.getPropertyValue(prop);
  }

  sandbox.removeChild(el);
  cache.defaultStyle.set(tagName, defaults);
  return defaults;
}

/**
 * Creates a unique key from an element's computed style that differs from defaults.
 *
 * @param {Object} snapshot - Computed style snapshot
 * @param {string} tagName - The tag name of the element
 * @param {boolean} [compress=false] - Whether to compress style keys
 * @returns {string} Semi-colon separated list of non-default properties
 */

export function getStyleKey(snapshot, tagName, compress = false) {
  const entries = [];
  const defaultStyles = getDefaultStyleForTag(tagName);
  for (let [prop, value] of Object.entries(snapshot)) {
    if (!compress) {
      if (value) {
        entries.push(`${prop}:${value}`);
      }
    } else {
      const defaultValue = defaultStyles[prop];
      if (value && value !== defaultValue) {
        entries.push(`${prop}:${value}`);
      }
    }
  }

  return entries.sort().join(";");
}

/**
 * Collects all unique tag names used in the DOM tree rooted at the given node.
 *
 * @param {Node} root - The root node to search
 * @returns {string[]} Array of unique tag names
 */
export function collectUsedTagNames(root) {
  const tagSet = new Set();
  if (root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
    return [];
  }
  if (root.tagName) {
    tagSet.add(root.tagName.toLowerCase());
  }
  if (typeof root.querySelectorAll === 'function') {
    root.querySelectorAll("*").forEach(el => tagSet.add(el.tagName.toLowerCase()));
  }
  return Array.from(tagSet);
}

/**
 * Generates deduplicated base CSS for the given tag names.
 *
 * @param {string[]} usedTagNames - Array of tag names
 * @returns {string} CSS string
 */
export function generateDedupedBaseCSS(usedTagNames) {
  const groups = new Map();

  for (let tagName of usedTagNames) {
    const styles = cache.defaultStyle.get(tagName);
    if (!styles) continue;

    // Creamos la "firma" del bloque CSS para comparar
    const key = Object.entries(styles)
      .map(([k, v]) => `${k}:${v};`)
      .sort()
      .join('');

    // Agrupamos por firma
    if (!groups.has(key)) {
      groups.set(key, []);
    }
    groups.get(key).push(tagName);
  }

  // Ahora generamos el CSS optimizado
  let css = '';
  for (let [styleBlock, tagList] of groups.entries()) {
    css += `${tagList.join(',')} { ${styleBlock} }\n`;
  }

  return css;
}
/**
 * Generates CSS classes from a style map.
 *
 * @returns {Map} Map of style keys to class names
 */
export function generateCSSClasses() {
  const keySet = new Set(cache.preStyleMap.values());
  const classMap = new Map();
  let counter = 1;
  for (const key of keySet) {
    classMap.set(key, `c${counter++}`);
  }
  return classMap;
}

vi.mock('../src/utils/helpers.js', async () => {
  const actual = await vi.importActual('../src/utils/helpers.js');
  return {
    ...actual,
    inlineSingleBackgroundEntry: vi.fn().mockResolvedValue('url("data:image/png;base64,abc")'),
  };
});

import { describe, it, vi } from 'vitest';
import { inlinePseudoElements } from '../src/modules/pseudo.js';

describe('inlinePseudoElements background-image inlining (success)', () => {
  it('cubre el inlining exitoso de background-image en pseudo', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? 'none' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'url("data:image/png;base64,abc")' : prop === 'background-color' ? 'transparent' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });
});

vi.mock('../src/utils/helpers.js', async () => {
  const actual = await vi.importActual('../src/utils/helpers.js');
  return {
    ...actual,
    inlineSingleBackgroundEntry: vi.fn().mockRejectedValue(new Error('fail')),
  };
});

import { describe, it, vi } from 'vitest';
import { inlinePseudoElements } from '../src/modules/pseudo.js';

describe('inlinePseudoElements background-image inlining (error)', () => {
  it('cubre el catch de error en inlining de background-image en pseudo', async () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? 'none' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'url("data:image/png;base64,abc")' : prop === 'background-color' ? 'transparent' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial',
      };
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' };
    });
    await inlinePseudoElements(el, clone, new Map(), new WeakMap(), false);
    window.getComputedStyle.mockRestore();
  });
});

/**
 * Core logic for capturing DOM elements as SVG data URLs.
 * @module capture
 */

import { prepareClone } from './prepare.js';
import { inlineImages } from '../modules/images.js';
import { inlineBackgroundImages } from '../modules/background.js';
import { idle, isSafari } from '../utils/helpers.js';
import { collectUsedTagNames, generateDedupedBaseCSS } from '../utils/cssTools.js';
import { embedCustomFonts } from '../modules/fonts.js';
import { cache } from '../core/cache.js'

/**
 * Captures an HTML element as an SVG data URL, inlining styles, images, backgrounds, and optionally fonts.
 *
 * @param {Element} element - DOM element to capture
 * @param {Object} [options={}] - Capture options
 * @param {boolean} [options.compress=true] - Whether to compress style keys
 * @param {boolean} [options.embedFonts=false] - Whether to embed custom fonts
 * @param {boolean} [options.fast=true] - Whether to skip idle delay for faster results
 * @param {number} [options.scale=1] - Output scale multiplier
 * @param {string[]} [options.exclude] - CSS selectors for elements to exclude
 * @param {Function} [options.filter] - Custom filter function
 * @returns {Promise<string>} Promise that resolves to an SVG data URL
 */

export async function captureDOM(element, options = {}) {
  if (!element) throw new Error("Element cannot be null or undefined");
  cache.reset()
  const { compress = true, embedFonts = false, fast = true, scale = 1, useProxy = ''} = options;
  let clone, classCSS;
  let fontsCSS = "";
  let baseCSS = "";
  let dataURL;
  let svgString;

  ({ clone, classCSS } = await prepareClone(element, compress, embedFonts, options));

  await new Promise((resolve) => {
    idle(async () => {
      await inlineImages(clone, options);
      resolve();
    }, { fast });
  });
  await new Promise((resolve) => {
    idle(async () => {
      await inlineBackgroundImages(element, clone, options);
      resolve();
    }, { fast });
  });
  if (embedFonts) {
    await new Promise((resolve) => {
      idle(async () => {
        fontsCSS = await embedCustomFonts();
        resolve();
      }, { fast });
    });
  }
  if (compress) {
    const usedTags = collectUsedTagNames(clone).sort();
    const tagKey = usedTags.join(",");
    if (cache.baseStyle.has(tagKey)) {
      baseCSS = cache.baseStyle.get(tagKey);
    } else {
      await new Promise((resolve) => {
        idle(() => {
          baseCSS = generateDedupedBaseCSS(usedTags);
          cache.baseStyle.set(tagKey, baseCSS);
          resolve();
        }, { fast });
      });
    }
  }
  await new Promise((resolve) => {
    idle(() => {
      const rect = element.getBoundingClientRect();
      let w = rect.width;
      let h = rect.height;
      const hasW = Number.isFinite(options.width);
      const hasH = Number.isFinite(options.height);
      const hasScale = typeof scale === "number" && scale !== 1;
      if (!hasScale) {
        const aspect = rect.width / rect.height;
        if (hasW && hasH) {
          w = options.width;
          h = options.height;
        } else if (hasW) {
          w = options.width;
          h = w / aspect;
        } else if (hasH) {
          h = options.height;
          w = h * aspect;
        }
      }
      w = Math.ceil(w);
      h = Math.ceil(h);
      clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
      clone.style.transformOrigin = "top left";
      if (!hasScale && (hasW || hasH)) {
        const originalW = rect.width;
        const originalH = rect.height;
        const scaleX = w / originalW;
        const scaleY = h / originalH;
        const existingTransform = clone.style.transform || "";
        const scaleTransform = `scale(${scaleX}, ${scaleY})`;
        clone.style.transform = `${scaleTransform} ${existingTransform}`.trim();
      }
      const svgNS = "http://www.w3.org/2000/svg";
      const fo = document.createElementNS(svgNS, "foreignObject");
      fo.setAttribute("width", "100%");
      fo.setAttribute("height", "100%");
      const styleTag = document.createElement("style");
      styleTag.textContent = baseCSS + fontsCSS + "svg{overflow:visible;}" + classCSS;
      fo.appendChild(styleTag);
      fo.appendChild(clone);
      const serializer = new XMLSerializer();
      const foString = serializer.serializeToString(fo);
      const svgHeader = `<svg xmlns="${svgNS}" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`;
      const svgFooter = "</svg>";
      svgString = svgHeader + foString + svgFooter;
      dataURL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
      resolve();
    }, { fast });
  });
  const sandbox = document.getElementById("snapdom-sandbox");
  if (sandbox && sandbox.style.position === "absolute") sandbox.remove();
  return dataURL;
}

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { inlineBackgroundImages } from '../src/modules/background.js';

describe('inlineBackgroundImages', () => {
  let source, clone;
  beforeEach(() => {
    source = document.createElement('div');
    clone = document.createElement('div');
    document.body.appendChild(source);
    document.body.appendChild(clone);
  });
  afterEach(() => {
    document.body.removeChild(source);
    document.body.removeChild(clone);
  });

  it('does not fail if there is no background-image', async () => {
    source.style.background = 'none';
    await expect(inlineBackgroundImages(source, clone, new WeakMap())).resolves.toBeUndefined();
  });

  it('processes a valid background-image', async () => {
    source.style.backgroundImage = 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAn8B9p6Q2wAAAABJRU5ErkJggg==")';
    await expect(inlineBackgroundImages(source, clone, new WeakMap())).resolves.toBeUndefined();
  });
});

/**
 * Utilities for handling and embedding web fonts and icon fonts.
 * @module fonts
 */

import { extractURL} from "../utils/helpers"
import { cache } from "../core/cache"
import { isIconFont } from '../modules/iconFonts.js';

/**
 * Converts a unicode character from an icon font into a data URL image.
 *
 * @export
 * @param {string} unicodeChar - The unicode character to render
 * @param {string} fontFamily - The font family name
 * @param {string|number} fontWeight - The font weight
 * @param {number} [fontSize=32] - The font size in pixels
 * @param {string} [color="#000"] - The color to use
 * @returns {Promise<string>} Data URL of the rendered icon
 */

export async function iconToImage(unicodeChar, fontFamily, fontWeight, fontSize = 32, color = "#000") {
  fontFamily = fontFamily.replace(/^['"]+|['"]+$/g, "");
  const dpr = window.devicePixelRatio || 1;

  // Create temporary context to measure
  const tempCanvas = document.createElement("canvas");
  const tempCtx = tempCanvas.getContext("2d");
  tempCtx.font = fontWeight
    ? `${fontWeight} ${fontSize}px "${fontFamily}"`
    : `${fontSize}px "${fontFamily}"`;

  const metrics = tempCtx.measureText(unicodeChar);
  const ascent = metrics.actualBoundingBoxAscent || fontSize * 0.8;
  const descent = metrics.actualBoundingBoxDescent || fontSize * 0.2;
  const height = ascent + descent;
  const width = metrics.width;

  const canvas = document.createElement("canvas");
  canvas.width = Math.ceil(width * dpr);
  canvas.height = Math.ceil(height * dpr);

  const ctx = canvas.getContext("2d");
  ctx.scale(dpr, dpr);
  ctx.font = tempCtx.font;
  ctx.textAlign = "left";
  ctx.textBaseline = "alphabetic"; // aligns with baseline
  ctx.fillStyle = color;

  // Draw at (0, ascent) so the full glyph fits vertically
  ctx.fillText(unicodeChar, 0, ascent);

  return canvas.toDataURL();
}

function isStylesheetLoaded(href) {
  return Array.from(document.styleSheets).some(sheet => sheet.href === href);
}

function injectLinkIfMissing(href) {
  return new Promise((resolve) => {
    if (isStylesheetLoaded(href)) return resolve(null);
    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = href;
    link.setAttribute("data-snapdom", "injected-import");
    link.onload = () => resolve(link);
    link.onerror = () => resolve(null);
    document.head.appendChild(link);
  });
}

/**
 * Embeds custom fonts found in the document as data URLs in CSS.
 *
 * @export
 * @param {Object} options
 * @param {boolean} [options.preCached=false] - Whether to use pre-cached resources
 * @returns {Promise<string>} The inlined CSS for custom fonts
 */

export async function embedCustomFonts({preCached = false } = {}) {
  if (cache.resource.has("fonts-embed-css")) {
    if (preCached) {
      const style = document.createElement("style");
      style.setAttribute("data-snapdom", "embedFonts");
      style.textContent = cache.resource.get("fonts-embed-css");
      document.head.appendChild(style);
    }
    return cache.resource.get("fonts-embed-css");
  }

  const importRegex = /@import\s+url\(["']?([^"')]+)["']?\)/g;
  const styleImports = [];

  for (const styleTag of document.querySelectorAll("style")) {
    const cssText = styleTag.textContent || "";
    const matches = Array.from(cssText.matchAll(importRegex));
    for (const match of matches) {
      const importUrl = match[1];
      if (isIconFont(importUrl)) continue;
      if (!isStylesheetLoaded(importUrl)) {
        styleImports.push(importUrl);
      }
    }
  }

  await Promise.all(styleImports.map(injectLinkIfMissing));

  const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter((link) => link.href);
  let finalCSS = "";

  for (const link of links) {
    try {
      const res = await fetch(link.href);
      const cssText = await res.text();

      if ((isIconFont(link.href) || isIconFont(cssText))) continue;

      const urlRegex = /url\((["']?)([^"')]+)\1\)/g;
      const inlinedCSS = await Promise.all(
        Array.from(cssText.matchAll(urlRegex)).map(async (match) => {
          let rawUrl = extractURL(match[0]);
          if (!rawUrl) return null;
          let url = rawUrl;
          if (!url.startsWith("http") && !url.startsWith("data:")) {
            url = new URL(url, link.href).href;
          }
          if (isIconFont(url)) return null;
          if (cache.resource.has(url)) {
            cache.font.add(url);
            return { original: match[0], inlined: `url(${cache.resource.get(url)})` };
          }
          if (cache.font.has(url)) return null;
          try {
            const fontRes = await fetch(url);
            const blob = await fontRes.blob();
            const b64 = await new Promise((resolve) => {
              const reader = new FileReader();
              reader.onload = () => resolve(reader.result);
              reader.readAsDataURL(blob);
            });
            cache.resource.set(url, b64);
            cache.font.add(url);
            return { original: match[0], inlined: `url(${b64})` };
          } catch (e) {
            console.warn("[snapdom] Failed to fetch font resource:", url);
            return null;
          }
        })
      );

      let cssFinal = cssText;
      for (const r of inlinedCSS) {
        if (r) cssFinal = cssFinal.replace(r.original, r.inlined);
      }
      finalCSS += cssFinal + "\n";
    } catch (e) {
      console.warn("[snapdom] Failed to fetch CSS:", link.href);
    }
  }

  for (const sheet of document.styleSheets) {
    try {
      if (!sheet.href || links.every((link) => link.href !== sheet.href)) {
        for (const rule of sheet.cssRules) {
          if (rule.type === CSSRule.FONT_FACE_RULE) {
            const src = rule.style.getPropertyValue("src");
            const family = rule.style.getPropertyValue("font-family");
            if (!src || isIconFont(family)) continue;

            const urlRegex = /url\((["']?)([^"')]+)\1\)/g;
            const localRegex = /local\((["']?)[^)]+?\1\)/g;
            const hasURL = !!src.match(urlRegex);
            const hasLocal = !!src.match(localRegex);

            if (!hasURL && hasLocal) {
              // Solo local(), conservar en línea compacta
              finalCSS += `@font-face{font-family:${family};src:${src};font-style:${rule.style.getPropertyValue("font-style") || "normal"};font-weight:${rule.style.getPropertyValue("font-weight") || "normal"};}`;
              continue;
            }

            // Embebido para src con url()
            let inlinedSrc = src;
            const matches = Array.from(src.matchAll(urlRegex));
            for (const match of matches) {
              let rawUrl = match[2].trim();
              if (!rawUrl) continue;
              let url = rawUrl;
              if (!url.startsWith("http") && !url.startsWith("data:")) {
                url = new URL(url, sheet.href || location.href).href;
              }
              if (isIconFont(url)) continue;
              if (cache.resource.has(url)) {
                cache.font.add(url);
                inlinedSrc = inlinedSrc.replace(match[0], `url(${cache.resource.get(url)})`);
                continue;
              }
              if (cache.font.has(url)) continue;
              try {
                const res = await fetch(url);
                const blob = await res.blob();
                const b64 = await new Promise((resolve) => {
                  const reader = new FileReader();
                  reader.onload = () => resolve(reader.result);
                  reader.readAsDataURL(blob);
                });
                cache.resource.set(url, b64);
                cache.font.add(url);
                inlinedSrc = inlinedSrc.replace(match[0], `url(${b64})`);
              } catch (e) {
                console.warn("[snapdom] Failed to fetch font URL:", url);
              }
            }

            finalCSS += `@font-face{font-family:${family};src:${inlinedSrc};font-style:${rule.style.getPropertyValue("font-style") || "normal"};font-weight:${rule.style.getPropertyValue("font-weight") || "normal"};}`;
          }
        }
      }
    } catch (e) {
      console.warn("[snapdom] Cannot access stylesheet", sheet.href, e);
    }
  }

  for (const font of document.fonts) {
    if (font.family && font.status === "loaded" && font._snapdomSrc) {
      if (isIconFont(font.family)) continue;
      let b64 = font._snapdomSrc;
      if (!b64.startsWith("data:")) {
        if (cache.resource.has(font._snapdomSrc)) {
          b64 = cache.resource.get(font._snapdomSrc);
          cache.font.add(font._snapdomSrc);
        } else if (!cache.font.has(font._snapdomSrc)) {
          try {
            const res = await fetch(font._snapdomSrc);
            const blob = await res.blob();
            b64 = await new Promise((resolve) => {
              const reader = new FileReader();
              reader.onload = () => resolve(reader.result);
              reader.readAsDataURL(blob);
            });
            cache.resource.set(font._snapdomSrc, b64);
            cache.font.add(font._snapdomSrc);
          } catch (e) {
            console.warn("[snapdom] Failed to fetch dynamic font src:", font._snapdomSrc);
            continue;
          }
        }
      }

      finalCSS += `@font-face{font-family:'${font.family}';src:url(${b64});font-style:${font.style || "normal"};font-weight:${font.weight || "normal"};}`;
    }
  }

  if (finalCSS) {
    cache.resource.set("fonts-embed-css", finalCSS);
    if (preCached) {
      const style = document.createElement("style");
      style.setAttribute("data-snapdom", "embedFonts");
      style.textContent = finalCSS;
      document.head.appendChild(style);
    }
  }

  return finalCSS;
}

/**
 * Deep cloning utilities for DOM elements, including styles and shadow DOM.
 * @module clone
 */

import { inlineAllStyles } from '../modules/styles.js';
import { cache } from '../core/cache.js'

/**
 * 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
 * @param {boolean} compress - Whether to compress style keys
 * @param {Object} [options={}] - Capture options including exclude and filter
 * @param {Node} [originalRoot] - Original root element being captured
 * @returns {Node|null} Cloned node with styles and shadow DOM content, or null for empty text nodes or filtered elements
 */

export function deepClone(node, compress, options = {}, originalRoot) {
  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);
  }

  // 3. Exclude by attribute
  if (node.getAttribute("data-capture") === "exclude") {
    const spacer = document.createElement("div");
    const rect = node.getBoundingClientRect();
    spacer.style.cssText = `display:inline-block;width:${rect.width}px;height:${rect.height}px;visibility:hidden;`;
    return spacer;
  }

  // 4. Exclude by selectors
  if (options.exclude && Array.isArray(options.exclude)) {
    for (const selector of options.exclude) {
      try {
        if (node.matches?.(selector)) {
          const spacer = document.createElement("div");
          const rect = node.getBoundingClientRect();
          spacer.style.cssText = `display:inline-block;width:${rect.width}px;height:${rect.height}px;visibility:hidden;`;
          return spacer;
        }
      } catch (err) {
        console.warn(`Invalid selector in exclude option: ${selector}`, err);
      }
    }
  }

  // 5. Custom filter function
  if (typeof options.filter === "function") {
    try {
      if (!options.filter(node, originalRoot || node)) {
        const spacer = document.createElement("div");
        const rect = node.getBoundingClientRect();
        spacer.style.cssText = `display:inline-block;width:${rect.width}px;height:${rect.height}px;visibility:hidden;`;
        return spacer;
      }
    } catch (err) {
      console.warn("Error in filter function:", err);
    }
  }

  // 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;
  }

  // 7. Placeholder nodes
  if (node.getAttribute("data-capture") === "placeholder") {
    const clone2 = node.cloneNode(false);
    cache.preNodeMap.set(clone2, node);
    inlineAllStyles(node, clone2, compress);
    const placeholder = document.createElement("div");
    placeholder.textContent = node.getAttribute("data-placeholder-text") || "";
    placeholder.style.cssText = `color:#666;font-size:12px;text-align:center;line-height:1.4;padding:0.5em;box-sizing:border-box;`;
    clone2.appendChild(placeholder);
    return clone2;
  }

  // 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;
    cache.preNodeMap.set(img, node);
    inlineAllStyles(node, img, compress);
    return img;
  }

  // 9. Base clone (without children)
  let clone;
  try {
    clone = node.cloneNode(false);
    cache.preNodeMap.set(clone, node);
  } 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.width = `${rect.width}px`;
    clone.style.height = `${rect.height}px`;
    return clone;
  }

  // Special handling: input
  if (node instanceof HTMLInputElement) {
    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;
  }

  // 11. Inline styles
  inlineAllStyles(node, clone, compress);

  // 12. ShadowRoot logic
  if (node.shadowRoot) {
    const hasSlot = Array.from(node.shadowRoot.querySelectorAll("slot")).length > 0;

    if (hasSlot) {
      // ShadowRoot with slots: only store styles
      for (const child of node.shadowRoot.childNodes) {
        if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "STYLE") {
          const cssText = child.textContent || "";
          if (cssText.trim() && compress) {
            if (!cache.preStyle) cache.preStyle = new WeakMap();
            cache.preStyle.set(child, cssText);
          }
        }
      }
    } 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") {
          const cssText = child.textContent || "";
          if (cssText.trim() && compress) {
            if (!cache.preStyle) cache.preStyle = new WeakMap();
            cache.preStyle.set(child, cssText);
          }
          continue;
        }
        const clonedChild = deepClone(child, compress, options, originalRoot || node);
        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 = deepClone(child, compress, options, originalRoot || node);
      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 = deepClone(child, compress, options, originalRoot || node);
    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");
      }
    }
  }

  return clone;
}

import { describe, it, expect } from 'vitest';
import { inlineAllStyles } from '../src/modules/styles.js';
import { cache } from '../src/core/cache.js';

describe('inlineAllStyles', () => {
  it('adds a style key to the styleMap', () => {
    const el = document.createElement('div');
    const clone = document.createElement('div');
    inlineAllStyles(el, clone, false);
    expect(cache.preStyleMap.has(clone)).toBe(true);
  });
});

/**
 * Utilities for inlining <img> elements as data URLs or placeholders.
 * @module images
 */

import { fetchImage } from '../utils/helpers.js';

/**
 * Converts all <img> elements in the clone to data URLs or replaces them with placeholders if loading fails.
 *
 * @param {Element} clone - Clone of the original element
 * @param {Object} [options={}] - Options for image processing
 * @returns {Promise<void>} Promise that resolves when all images are processed
 */
export async function inlineImages(clone, options = {}) {
  const imgs = Array.from(clone.querySelectorAll("img"));
  const processImg = async (img) => {
    const src = img.src;
    try {
      const dataUrl = await fetchImage(src, { useProxy: options.useProxy });
      img.src = dataUrl;
      if (!img.width) img.width = img.naturalWidth || 100;
      if (!img.height) img.height = img.naturalHeight || 100;
    } catch {
      const fallback = document.createElement("div");
      fallback.style = `width: ${img.width || 100}px; height: ${img.height || 100}px; background: #ccc; display: inline-block; text-align: center; line-height: ${img.height || 100}px; color: #666; font-size: 12px;`;
      fallback.innerText = "img";
      img.replaceWith(fallback);
    }
  };
  for (let i = 0; i < imgs.length; i += 4) {
    const group = imgs.slice(i, i + 4).map(processImg);
    await Promise.allSettled(group);
  }
}

/**
 * Utilities for inlining ::before and ::after pseudo-elements.
 * @module pseudo
 */

import { getStyle, snapshotComputedStyle, parseContent, extractURL, safeEncodeURI, fetchImage, inlineSingleBackgroundEntry, splitBackgroundImage } from '../utils/helpers.js';
import { getStyleKey } from '../utils/cssTools.js';
import { iconToImage } from '../modules/fonts.js';
import { isIconFont } from '../modules/iconFonts.js';
import { cache } from '../core/cache.js'

/**
 * Creates elements to represent ::before, ::after, and ::first-letter pseudo-elements, inlining their styles and content.
 *
 * @param {Element} source - Original element
 * @param {Element} clone - Cloned element
 * @param {boolean} compress - Whether to compress style keys
 * @param {boolean} embedFonts - Whether to embed icon fonts as images
 * @returns {Promise} Promise that resolves when all pseudo-elements are processed
 */

export async function inlinePseudoElements(source, clone, compress, embedFonts = false, useProxy) {
  if (!(source instanceof Element) || !(clone instanceof Element)) return;
  for (const pseudo of ["::before", "::after", "::first-letter"]) {
    try {
      const style = getStyle(source, pseudo);
      if (!style || typeof style[Symbol.iterator] !== "function") continue;
      if (pseudo === "::first-letter") {
        const normal = getComputedStyle(source);
        const isMeaningful = style.color !== normal.color || style.fontSize !== normal.fontSize || style.fontWeight !== normal.fontWeight;
        if (!isMeaningful) continue;
        const textNode = Array.from(clone.childNodes).find(
          (n) => n.nodeType === Node.TEXT_NODE && n.textContent && n.textContent.trim().length > 0
        );
        if (!textNode) continue;
        const text = textNode.textContent;
        const match = text.match(/^([^\p{L}\p{N}\s]*[\p{L}\p{N}](?:['’])?)/u);
        const first = match?.[0];
        const rest = text.slice(first?.length || 0);
        if (!first || /[\uD800-\uDFFF]/.test(first)) continue;
        const span = document.createElement("span");
        span.textContent = first;
        span.dataset.snapdomPseudo = "::first-letter";
        const snapshot = snapshotComputedStyle(style);
        const key = getStyleKey(snapshot, "span", compress);
        cache.preStyleMap.set(span, key);
        const restNode = document.createTextNode(rest);
        clone.replaceChild(restNode, textNode);
        clone.insertBefore(span, restNode);
        continue;
      }
const content = style.getPropertyValue("content");
const bg = style.getPropertyValue("background-image");
const bgColor = style.getPropertyValue("background-color");

const fontFamily = style.getPropertyValue("font-family");
const fontSize = parseInt(style.getPropertyValue("font-size")) || 32;
const fontWeight = parseInt(style.getPropertyValue("font-weight")) || false;
const color = style.getPropertyValue("color") || "#000";
const display = style.getPropertyValue("display");
const width = parseFloat(style.getPropertyValue("width"));
const height = parseFloat(style.getPropertyValue("height"));
const borderStyle = style.getPropertyValue("border-style");
const transform = style.getPropertyValue("transform");

const isIconFont2 = isIconFont(fontFamily);

// Detect counter() || counters()
let cleanContent;
if (/counter\s*\(|counters\s*\(/.test(content)) {
  cleanContent = "- ";
} else {
  cleanContent = parseContent(content);
}

const hasContent = content !== "none";
const hasExplicitContent = hasContent && cleanContent !== "";
const hasBg = bg && bg !== "none";
const hasBgColor = bgColor && bgColor !== "transparent" && bgColor !== "rgba(0, 0, 0, 0)";
const hasBox = display !== "inline" && (width > 0 || height > 0);
const hasBorder = borderStyle && borderStyle !== "none";
const hasTransform = transform && transform !== "none";

const shouldRender =
  hasExplicitContent || hasBg || hasBgColor || hasBox || hasBorder || hasTransform;

if (!shouldRender) continue;

const pseudoEl = document.createElement("span");
pseudoEl.dataset.snapdomPseudo = pseudo;
const snapshot = snapshotComputedStyle(style);
const key = getStyleKey(snapshot, "span", compress);
cache.preStyleMap.set(pseudoEl, key);

if (isIconFont2 && cleanContent.length === 1) {
  const imgEl = document.createElement("img");
  imgEl.src = await iconToImage(cleanContent, fontFamily, fontWeight, fontSize, color);
  imgEl.style = `width:${fontSize}px;height:auto;object-fit:contain;`;
  pseudoEl.appendChild(imgEl);
} else if (cleanContent.startsWith("url(")) {
  const rawUrl = extractURL(cleanContent);
  if (rawUrl && rawUrl.trim() !== "") {
    try {
      const imgEl = document.createElement("img");
      const dataUrl = await fetchImage(safeEncodeURI(rawUrl, { useProxy }));
      imgEl.src = dataUrl;
      imgEl.style = `width:${fontSize}px;height:auto;object-fit:contain;`;
      pseudoEl.appendChild(imgEl);
    } catch (e) {
      console.error(`[snapdom] Error in pseudo ${pseudo} for`, source, e);
    }
  }
} else if (!isIconFont2 && hasExplicitContent) {
  pseudoEl.textContent = cleanContent;
}

if (hasBg) {
  try {
    const bgSplits = splitBackgroundImage(bg);
    const newBgParts = await Promise.all(
      bgSplits.map((entry) => inlineSingleBackgroundEntry(entry))
    );
    pseudoEl.style.backgroundImage = newBgParts.join(", ");
  } catch (e) {
    console.warn(`[snapdom] Failed to inline background-image for ${pseudo}`, e);
  }
}

if (hasBgColor) pseudoEl.style.backgroundColor = bgColor;

const hasContent2 =
  pseudoEl.childNodes.length > 0 ||
  (pseudoEl.textContent && pseudoEl.textContent.trim() !== "");
const hasVisibleBox = hasContent2 || hasBg || hasBgColor || hasBox || hasBorder || hasTransform;

if (!hasVisibleBox) continue;

pseudo === "::before"
  ? clone.insertBefore(pseudoEl, clone.firstChild)
  : clone.appendChild(pseudoEl);

    } catch (e) {
      console.warn(`[snapdom] Failed to capture ${pseudo} for`, source, e);
    }
  }
  const sChildren = Array.from(source.children);
  const cChildren = Array.from(clone.children).filter((child) => !child.dataset.snapdomPseudo);
  for (let i = 0; i < Math.min(sChildren.length, cChildren.length); i++) {
    await inlinePseudoElements(
      sChildren[i],
      cChildren[i],
      compress,
      embedFonts,
      useProxy
    );
  }
}

import { getStyle, inlineSingleBackgroundEntry, fetchImage, splitBackgroundImage } from '../utils/helpers.js';
import { embedCustomFonts } from '../modules/fonts.js';
import { precacheCommonTags } from '../utils/cssTools.js';
import { cache } from '../core/cache.js';

/**
 * Preloads images, background images, and optionally fonts into cache before DOM capture.
 *
 * @param {Document|Element} [root=document] - The root node to search for resources
 * @param {Object} [options={}] - Pre-caching options
 * @returns {Promise<void>} Resolves when all resources are pre-cached
 */

export async function preCache(root = document, options = {}) {
  const { embedFonts = true, reset = false} = options;
  if (reset) {
    cache.reset()
    return;
  }
  await document.fonts.ready;
  precacheCommonTags();
  let imgEls = [], allEls = [];
  if (root?.querySelectorAll) {
    imgEls = Array.from(root.querySelectorAll("img[src]"));
    allEls = Array.from(root.querySelectorAll("*"));
  }
  const promises = [];
  for (const img of imgEls) {
    const src = img.src;
    if (!cache.image.has(src)) {

      promises.push(
        fetchImage(src, { useProxy: options.useProxy}).then((dataURL) => cache.image.set(src, dataURL)).catch(() => {
        })
      );
    }
  }
  for (const el of allEls) {
    const bg = getStyle(el).backgroundImage;
    if (bg && bg !== "none") {
      const bgSplits = splitBackgroundImage(bg);
      for (const entry of bgSplits) {
        const isUrl = entry.startsWith("url(");
        if (isUrl) {
          promises.push(
            inlineSingleBackgroundEntry(entry, options).catch(() => {
            })
          );
        }
      }
    }
  }
  if (embedFonts) {
    await embedCustomFonts({ preCached: true });
  }
  await Promise.all(promises);
}

/**
 * Inlines external `<defs>` used by `<use xlink:href="#...">` or `<use href="#...">` inside SVGs,
 * copying only the necessary definitions into each individual cloned SVG.
 *
 * This is needed because cloned SVGs using `<use>` may reference elements like `<symbol>`, `<path>`, etc.,
 * defined elsewhere in the document (e.g., in a shared `<defs>` block that is not part of the cloned subtree).
 *
 * The function finds all `<use>` elements within `root`, extracts the referenced IDs,
 * and embeds the required definitions at the top of each SVG.
 *
 * @function inlineExternalDef
 * @param {ParentNode} root - The root node containing cloned SVGs (usually the result of a DOM snapshot).
 * @returns {void}
 *
 * @example
 * const { clone } = await prepareClone(element);
 * inlineExternalDef(clone);
 */

export function inlineExternalDef(root) {
  if (!root) return;
  const defsSources = document.querySelectorAll('svg > defs');
  if (!defsSources.length) return;

  root.querySelectorAll('svg').forEach(svg => {
    const uses = svg.querySelectorAll('use');
    if (!uses.length) return;

    const usedIds = new Set();
    uses.forEach(use => {
      const href = use.getAttribute('xlink:href') || use.getAttribute('href');
      if (href && href.startsWith('#')) usedIds.add(href.slice(1));
    });
    if (!usedIds.size) return;

    const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");

    for (const id of usedIds) {
      for (const source of defsSources) {
        const def = source.querySelector(`#${CSS.escape(id)}`);
        if (def) {
          defs.appendChild(def.cloneNode(true));
          break;
        }
      }
    }

    if (defs.childNodes.length) {
      svg.insertBefore(defs, svg.firstChild);
    }
  });
}

/**
 * Prepares a deep clone of an element, inlining pseudo-elements and generating CSS classes.
 * @module prepare
 */

import { generateCSSClasses} from '../utils/cssTools.js';
import { stripTranslate} from '../utils/helpers.js';
import { deepClone } from './clone.js';
import { inlinePseudoElements } from '../modules/pseudo.js';
import { inlineExternalDef } from '../modules/svgDefs.js';
import { cache } from '../core/cache.js';

/**
 * Prepares a clone of an element for capture, inlining pseudo-elements and generating CSS classes.
 *
 * @param {Element} element - Element to clone
 * @param {boolean} [compress=false] - Whether to compress style keys
 * @param {boolean} [embedFonts=false] - Whether to embed custom fonts
 * @param {Object} [options={}] - Capture options
 * @param {string[]} [options.exclude] - CSS selectors for elements to exclude
 * @param {Function} [options.filter] - Custom filter function
 * @returns {Promise<Object>} Object containing the clone, generated CSS, and style cache
 */

export async function prepareClone(element, compress = false, embedFonts = false, options = {}) {

  let clone
  let classCSS = '';
  try {
    clone = deepClone(element, compress, options, element);
  } catch (e) {
    console.warn("deepClone failed:", e);
    throw e;
  }
  try {
    await inlinePseudoElements(element, clone, compress, embedFonts, options.useProxy);
  } catch (e) {
    console.warn("inlinePseudoElements failed:", e);
  }
  try {
    inlineExternalDef(clone);
  } catch (e) {
    console.warn("inlineExternalDef failed:", e);
  }
  if (compress) {
    const keyToClass = generateCSSClasses();
    classCSS = Array.from(keyToClass.entries()).map(([key, className]) => `.${className}{${key}}`).join("");
    for (const [node, key] of cache.preStyleMap.entries()) {
      if (node.tagName === "STYLE") continue;
      const className = keyToClass.get(key);
      if (className) node.classList.add(className);
      const bgImage = node.style?.backgroundImage;
      node.removeAttribute("style");
      if (bgImage && bgImage !== "none") node.style.backgroundImage = bgImage;
    }
  } else {
    for (const [node, key] of cache.preStyleMap.entries()) {
      if (node.tagName === "STYLE") continue;
      node.setAttribute("style", key.replace(/;/g, "; "));
    }
  }
  for (const [cloneNode, originalNode] of cache.preNodeMap.entries()) {
    const scrollX = originalNode.scrollLeft;
    const scrollY = originalNode.scrollTop;
    const hasScroll = scrollX || scrollY;
    if (hasScroll && cloneNode instanceof HTMLElement) {
      cloneNode.style.overflow = "hidden";
      cloneNode.style.scrollbarWidth = "none";
      cloneNode.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 (cloneNode.firstChild) {
        inner.appendChild(cloneNode.firstChild);
      }
      cloneNode.appendChild(inner);
    }
  }
  if (element === cache.preNodeMap.get(clone)) {
    const computed = cache.preStyle.get(element) || window.getComputedStyle(element);
    cache.preStyle.set(element, computed);
    const transform = stripTranslate(computed.transform);
    clone.style.margin = "0";
    clone.style.position = "static";
    clone.style.top = "auto";
    clone.style.left = "auto";
    clone.style.right = "auto";
    clone.style.bottom = "auto";
    clone.style.zIndex = "auto";
    clone.style.float = "none";
    clone.style.clear = "none";
    clone.style.transform = transform || "";
  }
  for (const [cloneNode, originalNode] of cache.preNodeMap.entries()) {
    if (originalNode.tagName === "PRE") {
      cloneNode.style.marginTop = "0";
      cloneNode.style.marginBlockStart = "0";
    }
  }
  return { clone, classCSS };
}

import { it, expect } from 'vitest';
import * as snapdom from '../src/index.browser.js';

it('should import the browser bundle without errors', () => {
  expect(snapdom).toBeDefined();
});

import { getStyleKey } from '../utils/cssTools.js';
import { getStyle } from '../utils/helpers.js';
import {cache} from '../core/cache.js'

function snapshotComputedStyleFull(style) {
  const result = {};
  for (let i = 0; i < style.length; i++) {
    const prop = style[i];
    let val = style.getPropertyValue(prop);

    // Opcional: evitar URLs externas que puedan romper renderizado
    if (
      (prop === 'background-image' || prop === 'content') &&
      val.includes('url(') &&
      !val.includes('data:')
    ) {
      val = 'none';
    }

    result[prop] = val;
  }
  return result;
}

export function inlineAllStyles(source, clone, compress) {
  if (source.tagName === 'STYLE') return;

  if (!cache.preStyle.has(source)) {
    cache.preStyle.set(source, getStyle(source));
  }
  const style = cache.preStyle.get(source);

  if (!cache.snapshot.has(source)) {
    const snapshot = snapshotComputedStyleFull(style);
    cache.snapshot.set(source, snapshot);
  }

  const snapshot = cache.snapshot.get(source);

  const hash = Object.entries(snapshot)
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([prop, val]) => `${prop}:${val}`)
    .join(';');

  if (cache.snapshotKey.has(hash)) {
    cache.preStyleMap.set(clone, cache.snapshotKey.get(hash));
    return;
  }

  const tagName = source.tagName?.toLowerCase() || 'div';
  const key = getStyleKey(snapshot, tagName, compress);

  cache.snapshotKey.set(hash, key);
  cache.preStyleMap.set(clone, key);
}

/**
 * Utilities for inlining background images as data URLs.
 * @module background
 */

import { getStyle, inlineSingleBackgroundEntry, splitBackgroundImage } from '../utils/helpers.js';
import { cache } from '../core/cache.js'

/**
 * Converts all background images in the cloned element tree to data URLs.
 *
 * @param {Element} source - Original element
 * @param {Element} clone - Cloned element
 * @param {Object} [options={}] - Options for image processing
 * @returns {Promise<void>} Promise that resolves when all background images are processed
 */
export async function inlineBackgroundImages(source, clone, options = {}) {
  const queue = [[source, clone]];

  const imageProps = [
    "background-image",
    "mask",
    "mask-image", "-webkit-mask-image",
    "mask-source", "mask-box-image-source",
    "mask-border-source",
    "-webkit-mask-box-image-source"
  ];

  while (queue.length) {
    const [srcNode, cloneNode] = queue.shift();
    const style = cache.preStyle.get(srcNode) || getStyle(srcNode);
    if (!cache.preStyle.has(srcNode)) cache.preStyle.set(srcNode, style);

    for (const prop of imageProps) {
      const val = style.getPropertyValue(prop);
      if (!val || val === "none") continue;

      const splits = splitBackgroundImage(val);
      const inlined = await Promise.all(
        splits.map(entry => inlineSingleBackgroundEntry(entry, options))
      );

      if (inlined.some(p => p && p !== "none" && !/^url\(undefined/.test(p))) {
        cloneNode.style.setProperty(prop, inlined.join(", "));
      }
    }

    // También preservamos el background-color como antes
    const bgColor = style.getPropertyValue("background-color");
    if (
      bgColor &&
      bgColor !== "transparent" &&
      bgColor !== "rgba(0, 0, 0, 0)"
    ) {
      cloneNode.style.backgroundColor = bgColor;
    }

    const sChildren = Array.from(srcNode.children);
    const cChildren = Array.from(cloneNode.children);
    for (let i = 0; i < Math.min(sChildren.length, cChildren.length); i++) {
      queue.push([sChildren[i], cChildren[i]]);
    }
  }
}

/**
 * Entry point for snapDOM library exports.
 *
 * @file index.browser.js
 */

import { snapdom } from './api/snapdom.js';
import { preCache } from './api/preCache.js';

if (typeof window !== 'undefined') {
  window.snapdom = snapdom;
  window.preCache = preCache;
}

var defaultIconFonts = [
// /uicons/i,
  /font\s*awesome/i,
  /material\s*icons/i,
  /ionicons/i,
  /glyphicons/i,
  /feather/i,
  /bootstrap\s*icons/i,
  /remix\s*icons/i,
  /heroicons/i,
  /layui/i,
  /lucide/i
];

var userIconFonts = [];

export function extendIconFonts(fonts) {
  const list = Array.isArray(fonts) ? fonts : [fonts];
  for (const f of list) {
    if (f instanceof RegExp) {
      userIconFonts.push(f);
    } else if (typeof f === "string") {
      userIconFonts.push(new RegExp(f, "i"));
    } else {
      console.warn("[snapdom] Ignored invalid iconFont value:", f);
    }
  }
}

export function isIconFont(input) {
  const text = typeof input === "string" ? input : "";

  const candidates = [...defaultIconFonts, ...userIconFonts];

  for (const rx of candidates) {
    if (rx instanceof RegExp && rx.test(text)) return true;
  }

  // Heurística básica
  if (
    /icon/i.test(text) ||
    /glyph/i.test(text) ||
    /symbols/i.test(text) ||
    /feather/i.test(text) ||
    /fontawesome/i.test(text)
  ) return true;

  return false;
}

/**
 * Entry point for snapDOM library exports.
 *
 * @file index.js
 */

export { snapdom } from './api/snapdom.js';
export { preCache } from './api/preCache.js';

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: 'playwright',
      // https://vitest.dev/guide/browser/playwright
      instances: [
        { browser: 'chromium' },
      ],
    },
    coverage: {
      provider: 'v8', // o 'istanbul'
      include: [
        'src/**/*.js',      // Solo archivos JS dentro de src/
      ],
    },
  },
})

/**
 * Caches for images, backgrounds, resources, and computed styles used during DOM capture.
 * @module cache
 */

export const cache = {
  image: new Map(),
  background: new Map(),
  resource: new Map(),
  defaultStyle: new Map(),
  baseStyle: new Map(),
  computedStyle: new WeakMap(),
  font: new Set(),
  snapshot: new WeakMap(),
  snapshotKey: new Map(),
  preStyleMap: new Map(),
  preStyle: new WeakMap(),
  preNodeMap: new Map(),
  reset: resetCache
};

function resetCache() {
  cache.computedStyle = new WeakMap();
  cache.snapshot = new WeakMap();
  cache.snapshotKey.clear();
  cache.preStyleMap.clear();
  cache.preStyle = new WeakMap();
  cache.preNodeMap.clear();
}

This code segment functions to: Simulate browser-specific environment conditions for image processing verification and ensure compatibility with alternative decoding mechanisms for visual output generation.

```javascript
[TASK_BEGIN]
async () => {
    const el = document.createElement('div');
    el.style.width = '10px';
    el.style.height = '10px';
    document.body.appendChild(el);

    // Mock Safari behavior where HTMLImageElement.prototype.decode doesn't exist
    const origDecode = HTMLImageElement.prototype.decode;
    HTMLImageElement.prototype.decode = vi.fn(() => Promise.reject(new Error('Safari decode error')));

    const { snapdom } = await import('../src/api/snapdom.js');
    const img = await snapdom.toImg(el);

    expect(img).toBeInstanceOf(HTMLImageElement);
    expect(typeof img.src).toBe('string');

    // Cleanup
    HTMLImageElement.prototype.decode = origDecode;
    document.body.removeChild(el);
}
[TASK_END]
```

async () => {
    const el = document.createElement('div');
    el.style.width = '10px';
    el.style.height = '10px';
    document.body.appendChild(el);
    const origDecode = HTMLImageElement.prototype.decode;
    HTMLImageElement.prototype.decode = vi.fn(() => Promise.reject(new Error('Safari decode error')));
    const { snapdom } = await import('../src/api/snapdom.js');
    const img = await snapdom.toImg(el);
    expect(img).toBeInstanceOf(HTMLImageElement);
    expect(typeof img.src).toBe('string');
    HTMLImageElement.prototype.decode = origDecode;
    document.body.removeChild(el);
}