[
  {
    "path": ".github/CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nmartinmuda@gmail.com.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "## 🤝 Contributing Guide\n\nThank you for your interest in contributing to **Snapdom**! Your help makes this project better for everyone. Below you’ll find some guidelines to get started.\n\n---\n\n### 📝 **Issues**\n\n* If you find a **bug**, please [open an issue](https://github.com/zumerlab/snapdom/issues/new) and provide as much detail as possible (steps to reproduce, expected behavior, actual behavior, etc.).\n* If you have a **feature request** or an idea for improvement:\n\n  * You can [open an issue](https://github.com/zumerlab/snapdom/issues/new).\n  * Or, start a [discussion](https://github.com/zumerlab/snapdom/discussions/new) to explore the idea with the community.\n\nEven small things matter — if you spot a **typo** in the code, docs, or comments, please let us know or submit a fix!\n\n---\n\n### 💡 **Examples**\n\nIf you build something cool using **Snapdom**, we’d love to see it! Consider starting a **discussion** to share ideas and get feedback.\n\n---\n\n### 💬 **Discussions**\n\nYou’re welcome to use [GitHub Discussions](https://github.com/zumerlab/snapdom/discussions) for:\n\n* Asking questions.\n* Sharing feedback or ideas.\n* Connecting with other users and contributors.\n\nLet’s keep the tone friendly and constructive!\n\n---\n\n### 🚀 **Pull Requests (PRs)**\n\n* Before starting, it’s a good idea to **discuss large changes** in an issue or discussion.\n* Fork the repo, create a branch (e.g. `fix-xyz`, `feature-abc`), and submit your PR.\n* Please make sure:\n\n  * Your code follows the project’s style (we can help review).\n  * You add tests or documentation if relevant.\n  * You link the related issue (if any) in the PR description.\n\nAll contributions are reviewed and merged after approval. Don’t worry if you’re new — we’re happy to help!\n\n---\n\n### ⚡ Quick links\n\n* [Open an issue](https://github.com/zumerlab/snapdom/issues/new)\n* [Start a discussion](https://github.com/zumerlab/snapdom/discussions/new)\n* [Check open PRs](https://github.com/zumerlab/snapdom/pulls)\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: tinchox5\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\npolar: # Replace with a single Polar username\nbuy_me_a_coffee: # Replace with a single Buy Me a Coffee username\nthanks_dev: # Replace with a single thanks.dev username\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: 'bug'\nassignees: ''\n\n---\n\n### Describe it simply:\n<!-- Tell us what's happening or what you'd like to see -->\n<!-- If you need help please consider using https://github.com/zumerlab/snapdom/discussions -->\n\n### Quick demo to reproduce\n<!-- A visual or code issue, screenshots, help a ton -->\n<!-- You can use the following codepen template or any playground code (CodeSandbox, etc)  -->\n<!-- https://codepen.io/pen?template=GgoWPay -->\n<!-- Or just paste relevant code snippets -->\n\n### Anything else we should know?\n<!-- For example: Device/browser/ Snapdom version -->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: 'enhancement'\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\n<!-- If you need help please consider using https://github.com/zumerlab/snapdom/discussions -->\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n"
  },
  {
    "path": ".github/labels.yml",
    "content": "# Priority labels\n- name: \"priority: critical\"\n  color: \"B60205\"\n  description: \"Core functionality broken, causes crashes or data loss, affects most users\"\n\n- name: \"priority: high\"\n  color: \"D93F0B\"\n  description: \"Important feature broken or significantly degraded, no practical workaround\"\n\n- name: \"priority: medium\"\n  color: \"E4E669\"\n  description: \"Feature partially broken or degraded, workaround exists\"\n\n- name: \"priority: low\"\n  color: \"0E8A16\"\n  description: \"Minor issue, cosmetic problem, or question\"\n\n# Type labels\n- name: \"bug\"\n  color: \"D73A4A\"\n  description: \"Something isn't working\"\n\n- name: \"enhancement\"\n  color: \"A2EEEF\"\n  description: \"New feature or request\"\n\n- name: \"question\"\n  color: \"D876E3\"\n  description: \"Further information is requested\"\n\n- name: \"documentation\"\n  color: \"0075CA\"\n  description: \"Improvements or additions to documentation\"\n\n# Status labels\n- name: \"🔧 in development\"\n  color: \"FBCA04\"\n  description: \"This issue is being actively worked on\"\n\n- name: \"need repro\"\n  color: \"E4E669\"\n  description: \"A reproducible test case is needed to investigate\"\n\n- name: \"duplicate\"\n  color: \"CFD3D7\"\n  description: \"This issue or pull request already exists\"\n\n- name: \"wontfix\"\n  color: \"FFFFFF\"\n  description: \"This will not be worked on\"\n\n- name: \"good first issue\"\n  color: \"7057FF\"\n  description: \"Good for newcomers\"\n\n# Browser/environment labels\n- name: \"safari-hates-me\"\n  color: \"0052CC\"\n  description: \"Issue specific to Safari browser\"\n\n- name: \"Fails on Firefox\"\n  color: \"FF6B35\"\n  description: \"Issue specific to Firefox browser\"\n\n# Feature area labels\n- name: \"plugin idea\"\n  color: \"BFD4F2\"\n  description: \"Could be implemented as a plugin\"\n\n- name: \"experimental\"\n  color: \"F9D0C4\"\n  description: \"Experimental feature or idea\"\n"
  },
  {
    "path": ".github/scripts/classify-issues.js",
    "content": "// .github/scripts/classify-issues.js\n// Classifies all open issues by importance and applies priority labels.\n// Usage: GITHUB_TOKEN=<token> node .github/scripts/classify-issues.js\n// Dry-run (no changes): GITHUB_TOKEN=<token> node .github/scripts/classify-issues.js --dry-run\n\nimport https from 'https';\n\nconst repo = 'zumerlab/snapdom';\nconst [owner, repoName] = repo.split('/');\nconst token = process.env.GITHUB_TOKEN;\nconst dryRun = process.argv.includes('--dry-run');\n\nif (!token) {\n  console.error('Error: GITHUB_TOKEN environment variable is required.');\n  process.exit(1);\n}\n\n// ─── Classification rules ────────────────────────────────────────────────────\n\n// Keywords for each priority tier\nconst CRITICAL_KEYWORDS = [\n  'the source image cannot be decoded',\n  'crash', 'data loss',\n  '报错',  // Chinese: \"reports an error\"\n];\n\nconst HIGH_PRIORITY_KEYWORDS = [\n  'pseudo-element', 'pseudo element',\n  'iframe',\n  'font', '字体',\n  'background-image', 'background image',\n  'cross-origin', 'cross origin',\n  'checkbox',\n  'radio button', 'radio ',\n  'webkit-text-stroke', 'text-stroke',\n  'transform', 'matrix',\n  'katex',\n  'scrollbar', 'scroll position',\n  'border style',\n  'svg image',\n  'video',\n];\n\nconst LOW_KEYWORDS = [\n  'why ', 'how can', 'how do',\n  'is it possible', 'does it support',\n  'configuration option', 'timeout',\n  'cors',\n  'wicg', 'experiment',\n  'announcement', 'inactivity',\n  'plugin idea',\n];\n\nconst SAFARI_KEYWORDS = ['safari', 'ios', 'webkit', 'iphone', 'ipad', 'apple'];\nconst FIREFOX_KEYWORDS = ['firefox', 'mozilla', 'gecko'];\n\n/**\n * Determines the priority label for a given issue based on its title and body.\n * Returns one of: 'priority: critical', 'priority: high', 'priority: medium', 'priority: low'\n */\nfunction classifyPriority(issue) {\n  const title = (issue.title || '').toLowerCase();\n  const body  = (issue.body  || '').toLowerCase();\n  const text  = title + ' ' + body;\n\n  if (CRITICAL_KEYWORDS.some(k => text.includes(k))) {\n    return 'priority: critical';\n  }\n\n  if (LOW_KEYWORDS.some(k => text.includes(k))) {\n    return 'priority: low';\n  }\n\n  if (HIGH_PRIORITY_KEYWORDS.some(k => text.includes(k))) {\n    return 'priority: high';\n  }\n\n  return 'priority: medium';\n}\n\n/**\n * Additional type/browser labels to add based on content.\n */\nfunction classifyExtra(issue) {\n  const title = (issue.title || '').toLowerCase();\n  const body  = (issue.body  || '').toLowerCase();\n  const text  = title + ' ' + body;\n  const extra = [];\n\n  if (SAFARI_KEYWORDS.some(k => text.includes(k)))  extra.push('safari-hates-me');\n  if (FIREFOX_KEYWORDS.some(k => text.includes(k))) extra.push('Fails on Firefox');\n\n  return extra;\n}\n\n// ─── GitHub API helpers ───────────────────────────────────────────────────────\n\nfunction request(method, path, body) {\n  return new Promise((resolve, reject) => {\n    const data = body ? JSON.stringify(body) : null;\n    const options = {\n      hostname: 'api.github.com',\n      path,\n      method,\n      headers: {\n        'User-Agent': 'classify-issues-script',\n        'Accept': 'application/vnd.github.v3+json',\n        'Authorization': `token ${token}`,\n        ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {})\n      }\n    };\n    const req = https.request(options, (res) => {\n      let buf = '';\n      res.on('data', c => buf += c);\n      res.on('end', () => {\n        if (res.statusCode >= 400) {\n          reject(new Error(`HTTP ${res.statusCode}: ${buf}`));\n        } else {\n          resolve(buf ? JSON.parse(buf) : null);\n        }\n      });\n    });\n    req.on('error', reject);\n    if (data) req.write(data);\n    req.end();\n  });\n}\n\nasync function fetchOpenIssues() {\n  const issues = [];\n  let page = 1;\n  while (true) {\n    const batch = await request('GET', `/repos/${owner}/${repoName}/issues?state=open&per_page=100&page=${page}`);\n    if (!batch || batch.length === 0) break;\n    // Exclude pull requests (GitHub includes PRs in /issues endpoint)\n    issues.push(...batch.filter(i => !i.pull_request));\n    if (batch.length < 100) break;\n    page++;\n  }\n  return issues;\n}\n\nasync function addLabels(issueNumber, labels) {\n  await request('POST', `/repos/${owner}/${repoName}/issues/${issueNumber}/labels`, { labels });\n}\n\n// ─── Main ─────────────────────────────────────────────────────────────────────\n\n(async () => {\n  console.log(`Fetching open issues for ${repo}…`);\n  const issues = await fetchOpenIssues();\n  console.log(`Found ${issues.length} open issues.\\n`);\n\n  if (dryRun) {\n    console.log('DRY RUN — no labels will be applied.\\n');\n  }\n\n  const summary = { critical: [], high: [], medium: [], low: [] };\n\n  for (const issue of issues) {\n    const existingLabels = issue.labels.map(l => l.name);\n    const hasPriority = existingLabels.some(l => l.startsWith('priority:'));\n\n    const priorityLabel = classifyPriority(issue);\n    const extraLabels   = classifyExtra(issue).filter(l => !existingLabels.includes(l));\n    const newLabels     = [\n      ...(hasPriority ? [] : [priorityLabel]),\n      ...extraLabels\n    ];\n\n    const level = priorityLabel.replace('priority: ', '');\n    summary[level].push(`#${issue.number}: ${issue.title}`);\n\n    if (newLabels.length === 0) {\n      console.log(`#${issue.number} — no new labels needed (existing: ${existingLabels.join(', ') || 'none'})`);\n      continue;\n    }\n\n    console.log(`#${issue.number} [${priorityLabel}] — adding: ${newLabels.join(', ')}`);\n\n    if (!dryRun) {\n      try {\n        await addLabels(issue.number, newLabels);\n      } catch (err) {\n        console.error(`  ✗ Failed to label #${issue.number}: ${err.message}`);\n      }\n    }\n  }\n\n  console.log('\\n── Classification Summary ──────────────────────────');\n  for (const [level, list] of Object.entries(summary)) {\n    console.log(`\\n${level.toUpperCase()} (${list.length}):`);\n    list.forEach(t => console.log(`  ${t}`));\n  }\n})();\n\n"
  },
  {
    "path": ".github/scripts/update-contributors.js",
    "content": "// .github/scripts/update-contributors.js\nimport { writeFileSync, readFileSync } from 'fs';\nimport https from 'https';\n\nconst repo = 'zumerlab/snapdom';\nconst readmePaths = ['README.md', 'README_CN.md']; \n\nfunction fetchContributors() {\n  const options = {\n    hostname: 'api.github.com',\n    path: `/repos/${repo}/contributors`,\n    headers: { 'User-Agent': 'GitHub Action', 'Accept': 'application/vnd.github.v3+json' }\n  };\n\n  return new Promise((resolve, reject) => {\n    https\n      .get(options, (res) => {\n        let body = '';\n        res.on('data', (chunk) => (body += chunk));\n        res.on('end', () => {\n          if (res.statusCode === 200) {\n            resolve(JSON.parse(body));\n          } else {\n            reject(new Error(`GitHub API error: ${res.statusCode}`));\n          }\n        });\n      })\n      .on('error', reject);\n  });\n}\n\nfunction buildHTML(contributors) {\n  return (\n    '\\n<p>\\n' +\n    contributors\n      .map((c) => {\n        const avatar = `<a href=\"${c.html_url}\" title=\"${c.login}\"><img src=\"${c.avatar_url}&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"${c.login}\"/></a>`;\n        return avatar;\n      })\n      .join('\\n') +\n    '\\n</p>\\n'\n  );\n}\n\nfunction updateReadmes(contributorHTML) {\n  for (const path of readmePaths) {\n    //try {\n      const content = readFileSync(path, 'utf8');\n      const updated = content.replace(\n        /<!-- CONTRIBUTORS:START -->([\\s\\S]*?)<!-- CONTRIBUTORS:END -->/,\n        `<!-- CONTRIBUTORS:START -->${contributorHTML}<!-- CONTRIBUTORS:END -->`\n      );\n      writeFileSync(path, updated);\n    //} catch () {\n    //}\n  }\n}\n\nfetchContributors()\n  .then((contributors) => {\n    const filtered = contributors.filter(\n      (c) => c.type !== 'Bot' && c.login !== 'github-actions[bot]'\n    );\n    const html = buildHTML(filtered);\n    updateReadmes(html);\n  })\n  .catch((err) => {\n    console.error('Error fetching contributors:', err);\n    process.exit(1);\n  });\n"
  },
  {
    "path": ".github/workflows/issue-triage.yml",
    "content": "name: Auto-label Issues\n\non:\n  issues:\n    types: [opened, edited]\n\njobs:\n  triage:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n\n    steps:\n      - name: Label bug reports\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const issue = context.payload.issue;\n            const title = (issue.title || '').toLowerCase();\n            const body = (issue.body || '').toLowerCase();\n            const text = title + ' ' + body;\n            const labelsToAdd = [];\n            const existingLabels = issue.labels.map(l => l.name);\n\n            // ── Keyword patterns ──────────────────────────────────────────────\n            const BUG_KEYWORDS      = /bug|error|broken|crash|fail|not work|doesn't work|cannot|can't|unable|wrong|incorrect|missing|disappear|blank|empty|distort/;\n            const FEATURE_KEYWORDS  = /feature request|suggestion|enhancement|would like|wish|please add/;\n            const QUESTION_KEYWORDS = /^(why|how|what|can you|is it possible|does it|do you)|\\bquestion\\b|\\bhelp\\b/;\n            const SAFARI_KEYWORDS   = /safari|ios|webkit|iphone|ipad|apple/;\n            const FIREFOX_KEYWORDS  = /firefox|mozilla|gecko/;\n\n            const CRITICAL_KEYWORDS = /crash|data loss|cannot capture|completely broken|decode|source image/;\n            const HIGH_KEYWORDS     = /pseudo.?element|iframe|font|background.?image|cross.?origin|checkbox|radio|text.?stroke|transform|matrix/;\n            const LOW_BUG_KEYWORDS  = /configuration option|timeout setting|cors support/;\n\n            // ── Detect issue type ─────────────────────────────────────────────\n            const isBug      = BUG_KEYWORDS.test(text);\n            const isFeature  = FEATURE_KEYWORDS.test(text) && !isBug;\n            const isQuestion = QUESTION_KEYWORDS.test(text) && !isBug;\n\n            // ── Apply type labels ─────────────────────────────────────────────\n            if (isBug     && !existingLabels.includes('bug'))         labelsToAdd.push('bug');\n            if (isFeature && !existingLabels.includes('enhancement')) labelsToAdd.push('enhancement');\n            if (isQuestion && !existingLabels.includes('question'))   labelsToAdd.push('question');\n\n            // ── Apply browser labels ──────────────────────────────────────────\n            if (SAFARI_KEYWORDS.test(text)  && !existingLabels.includes('safari-hates-me'))  labelsToAdd.push('safari-hates-me');\n            if (FIREFOX_KEYWORDS.test(text) && !existingLabels.includes('Fails on Firefox')) labelsToAdd.push('Fails on Firefox');\n\n            // ── Assign priority for bugs (skip if already labelled) ───────────\n            const hasPriority = existingLabels.some(l => l.startsWith('priority:'));\n            if (isBug && !hasPriority) {\n              if (CRITICAL_KEYWORDS.test(text)) {\n                labelsToAdd.push('priority: critical');\n              } else if (HIGH_KEYWORDS.test(text)) {\n                labelsToAdd.push('priority: high');\n              } else if (LOW_BUG_KEYWORDS.test(text)) {\n                labelsToAdd.push('priority: low');\n              } else {\n                labelsToAdd.push('priority: medium');\n              }\n            }\n\n            if (labelsToAdd.length > 0) {\n              await github.rest.issues.addLabels({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issue.number,\n                labels: labelsToAdd\n              });\n              console.log(`Added labels: ${labelsToAdd.join(', ')} to issue #${issue.number}`);\n            }\n"
  },
  {
    "path": ".github/workflows/label-sync.yml",
    "content": "name: Sync Labels\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - '.github/labels.yml'\n  workflow_dispatch:\n\njobs:\n  sync-labels:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n      contents: read\n    steps:\n      - name: Checkout repo\n        uses: actions/checkout@v4\n\n      - name: Sync labels\n        uses: EndBug/label-sync@v2\n        with:\n          config-file: .github/labels.yml\n          delete-other-labels: false\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/update-contributors.yml",
    "content": "name: Update Contributors in READMES\n\non:\n  push:\n    branches:\n      - main\n  schedule:\n    - cron: '0 0 * * 0'\n  workflow_dispatch:\n\njobs:\n  update-contributors:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repo\n        uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Generate contributors list\n        run: node .github/scripts/update-contributors.js\n\n      - name: Commit and push if changed\n        run: |\n          git config --global user.name 'github-actions[bot]'\n          git config --global user.email 'github-actions[bot]@users.noreply.github.com'\n          if ! git diff --quiet; then\n            git add README.md README_CN.md\n            git commit -m \"chore: update contributors list\"\n            git push\n          fi\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\nweb_modules/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional stylelint cache\n.stylelintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variable files\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and not Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# vuepress v2.x temp and cache directory\n.temp\n.cache\n\n# vitepress build output\n**/.vitepress/dist\n\n# vitepress cache directory\n**/.vitepress/cache\n\n# Docusaurus cache and generated files\n.docusaurus\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\npackage-lock.json\n\ndrafts/\n\ndemos/\n\n__tests__/__screenshots__\n\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"liveServer.settings.port\": 5501\n}"
  },
  {
    "path": "CHANGELOG.md",
    "content": "### Changelog\n\nAll notable changes to this project will be documented in this file. \n\n#### [v2.5.0](https://github.com/zumerlab/snapdom/compare/v2.1.0...v2.5.0)\n\n> 17 March 2026\n\n- fix: CSS vars perf, scrollbar styles, SVG image inline, nested line-clamp, iframe pseudos & isolation, Tailwind border (#334 #341 #348 #362 #371 #372 #386) [`#387`](https://github.com/zumerlab/snapdom/pull/387)\n- fix: enable image download on iOS via Web Share API [`#384`](https://github.com/zumerlab/snapdom/pull/384)\n- feat(scrollbar): implement custom scrollbar style collection for capture, ensuring styles are applied correctly. Closes #334 [`#334`](https://github.com/zumerlab/snapdom/issues/334)\n- feat(styles): normalize Tailwind border styles in capture and inlineAllStyles to ensure consistent output. Closes #362 [`#362`](https://github.com/zumerlab/snapdom/issues/362)\n- feat(lineClamp): introduce lineClampTree function to apply line-clamp to nested elements, enhancing ellipsis rendering. Closes #386 [`#386`](https://github.com/zumerlab/snapdom/issues/386)\n- test(styles): add tests for excluding CSS properties from snapshots, ensuring fidelity with CSS variables. Closes #348 [`#348`](https://github.com/zumerlab/snapdom/issues/348)\n- test(capture): add test for iframe CSS isolation to ensure wrapper div does not inherit iframe styles. Closes #372 [`#372`](https://github.com/zumerlab/snapdom/issues/372)\n- refactor(capture): replace getComputedStyle with getStyle for improved iframe support and consistency across style retrieval. Closes #371 [`#371`](https://github.com/zumerlab/snapdom/issues/371)\n- feat(pseudo): implement suppression of native ::before/::after pseudo-elements in cloned styles to prevent double rendering. Closes #359 [`#359`](https://github.com/zumerlab/snapdom/issues/359)\n- fix(capture): update Safari padding logic to avoid edge clipping by applying padding only when necessary based on bounding box transforms. Closes #333 [`#333`](https://github.com/zumerlab/snapdom/issues/333)\n- test(fonts): add test for cross-origin CSS support in embedCustomFonts function, verifying correct handling of custom CDN stylesheets. Closes #309 [`#309`](https://github.com/zumerlab/snapdom/issues/309)\n- feat(fonts): add fontStylesheetDomains option to support cross-origin CSS fetching, enhancing font loading capabilities. Closes #309, closes #370 [`#309`](https://github.com/zumerlab/snapdom/issues/309) [`#370`](https://github.com/zumerlab/snapdom/issues/370)\n- feat(styles): add support for capturing -webkit-text-stroke properties in Safari to enhance style snapshot accuracy. Closes #340 [`#340`](https://github.com/zumerlab/snapdom/issues/340)\n- fix(styles): normalize inline styles to ensure !important rules in stylesheets correctly override inline styles in clones. Fixes #328. [`#328`](https://github.com/zumerlab/snapdom/issues/328)\n- refactor: improve dimension handling in deepClone and createCheckboxRadioReplacement functions for better accuracy and consistency. Closes #321. See #378 [`#321`](https://github.com/zumerlab/snapdom/issues/321)\n- refactor: enhance checkbox/radio replacement for Firefox with SVG implementation for consistent rendering and improved styling. Closes #290 [`#290`](https://github.com/zumerlab/snapdom/issues/290)\n- fix(background): resolve relative URLs and fallback to `background` shorthand for url() when background-image is empty. Closes #343 [`#343`](https://github.com/zumerlab/snapdom/issues/343)\n- fix: enable image download on iOS via Web Share API [`#383`](https://github.com/zumerlab/snapdom/issues/383)\n- feat(tests): add comprehensive test coverage for various modules including exporters, utils, and modules to improve overall code reliability [`06cc896`](https://github.com/zumerlab/snapdom/commit/06cc8962709e5ce651b54b67c63c38fe5ecc498d)\n- feat(debug): introduce debug option to log suppressed errors for troubleshooting, enhancing error visibility during capture processes [`f107bbe`](https://github.com/zumerlab/snapdom/commit/f107bbef52521fdd92b77987b44b512313f3b88d)\n- fix: Firefox checkbox radio replacement [`b97e553`](https://github.com/zumerlab/snapdom/commit/b97e5539849d08dc871b9a2e486c9505bdfc081e)\n- refactor(cache): implement EvictingMap for cache management to limit memory usage and improve performance [`212cd4f`](https://github.com/zumerlab/snapdom/commit/212cd4f0471613b68a47427a1e23f7d076d303de)\n- refactor(styles): improve height handling for transparent wrappers to support margin collapsing and enhance layout stability [`c50ccef`](https://github.com/zumerlab/snapdom/commit/c50ccefe931e4809d12c8993dde6aba3f9b46a54)\n- fix(styles): prevent overriding border styles when using border-image, and improve getStyle fallback handling [`a5857c5`](https://github.com/zumerlab/snapdom/commit/a5857c5c49febe3d39729f214ca29914928af34a)\n- feat(images): add support for inlining SVG &lt;image&gt; elements as data URLs, addressing #341. [`f7d4616`](https://github.com/zumerlab/snapdom/commit/f7d46160913bcf5dad26be0103ef01dc7d29243c)\n- feat(safari): implement font and image decode warmup for Safari to address WebKit Bug #219770, enhancing capture reliability [`ad450ce`](https://github.com/zumerlab/snapdom/commit/ad450ce8a10a7094ecbde0ee20db626fabe78423)\n- test(getStyle): add tests to ensure getStyle never returns undefined for elements and pseudo-elements [`83c3854`](https://github.com/zumerlab/snapdom/commit/83c3854dc520a0e944a6cf1d23dbc5cdaf8e520a)\n- refactor(snapdom): streamline plugin exports by consolidating export functions into a loop for improved maintainability [`ca35387`](https://github.com/zumerlab/snapdom/commit/ca353871b2b696252ddd21572ae31106b39d3c76)\n- feat(capture): enhance DOM capture dimensions for root elements by measuring scroll dimensions and using a temporary container for accurate height and width calculations [`31e50f2`](https://github.com/zumerlab/snapdom/commit/31e50f27b3069527cc83f1fe8990c3a8c376e35c)\n- fix(css): enhance getWindowForElement and getStyle functions to handle cross-document scenarios and improve fallback logic [`b0fbc8d`](https://github.com/zumerlab/snapdom/commit/b0fbc8d44745811a7c5627e34ef764e02ff188f7)\n- refactor(context): remove inline cache policy normalization and import from cache module for improved code organization [`0492756`](https://github.com/zumerlab/snapdom/commit/04927566faf919c7a3df07287f799b152e5d4c8f)\n- feat(safari): add `safariWarmupAttempts` option to optimize font and image decoding for improved capture performance [`2474c05`](https://github.com/zumerlab/snapdom/commit/2474c0528051940e503c08ca52861042e57cc880)\n- fix(styles): prevent width constraints on inline and specific tags to avoid text wrapping issues [`674ef27`](https://github.com/zumerlab/snapdom/commit/674ef276bcad8e4107c57fee3e976083590a5dd0)\n- chore: update contributors list [`b30de75`](https://github.com/zumerlab/snapdom/commit/b30de75ab66cac137334a0ef68ca2bf9133f88c4)\n- docs: update README files to replace NPM version badge with weekly downloads badge [`8e12d01`](https://github.com/zumerlab/snapdom/commit/8e12d01fbeb861989eb1d60c534596c55ce9207a)\n- chore(.gitignore): add 'demos/' directory to .gitignore to exclude demo files from version control [`fa34905`](https://github.com/zumerlab/snapdom/commit/fa34905450d889e48b9b943c941f29386627acc5)\n- refactor(prepare): simplify deepClone call by removing redundant element argument for cleaner code [`399bfaf`](https://github.com/zumerlab/snapdom/commit/399bfaf127bb4416200068334dc069a5bfcef2ab)\n- fix(styles): adjust inline style for timestamp demo to prevent text wrapping [`b88e8d7`](https://github.com/zumerlab/snapdom/commit/b88e8d74f67fe67e3ac610972c53ff49752f6b74)\n- fix(snapdom): remove redundant safariWarmup reset to improve iteration logic [`72d3fb7`](https://github.com/zumerlab/snapdom/commit/72d3fb72968d136f1c71d234d57272ce0f3fb6e1)\n- Merge PR #384: enable image download on iOS via Web Share API [`05bc67c`](https://github.com/zumerlab/snapdom/commit/05bc67c76dbe6cc02946773de8413d48e314b3d9)\n- Merge main into dev (2.1.0) [`5f5ab34`](https://github.com/zumerlab/snapdom/commit/5f5ab345194832225e311421d177963ce3c4c59e)\n\n#### [v2.1.0](https://github.com/zumerlab/snapdom/compare/v2.0.2...v2.1.0)\n\n> 10 March 2026\n\n- fix(background): inline background-image inside shadow DOM hosts [`#379`](https://github.com/zumerlab/snapdom/pull/379)\n- Update URL handling to use location.origin in fonts.js [`#380`](https://github.com/zumerlab/snapdom/pull/380)\n- fix: use nodeMap for source-clone alignment in inlinePseudoElements [`#381`](https://github.com/zumerlab/snapdom/pull/381)\n- fix(background): properly inline background-image inside shadow DOM hosts [`#318`](https://github.com/zumerlab/snapdom/issues/318)\n- update demo site [`4de1850`](https://github.com/zumerlab/snapdom/commit/4de1850d84d1698e8574fe405007fb47d6a677ea)\n- feat: classify open issues by importance with priority labels and triage workflows [`246a4c4`](https://github.com/zumerlab/snapdom/commit/246a4c43eef13fe8b654e297f52a639a7ad670b1)\n- fix: Enhanced font embedding functionality for dynamically injected stylesheets [`3d4985a`](https://github.com/zumerlab/snapdom/commit/3d4985a6d40963c30ff188207a62ac1e287709ba)\n- fix: resolve CSS transform double-scale bug (issue #321) [`d41504b`](https://github.com/zumerlab/snapdom/commit/d41504b8dcf94454a331337c49d74928d533f49a)\n- fix: improve demo capture functionality with Safari support and locking mechanism [`2cb3856`](https://github.com/zumerlab/snapdom/commit/2cb3856e55f0f1d8e0d2ac050d549705203fc6ae)\n- refactor: update build configuration for legacy and ESM outputs, removing module structure and adding subpath exports [`94f6289`](https://github.com/zumerlab/snapdom/commit/94f62897054f37923c5cd3e4e2d3a57a0fde8db4)\n- docs: update README to reflect changes in SnapDOM ESM build structure and usage instructions [`8e5a710`](https://github.com/zumerlab/snapdom/commit/8e5a7103b45ae2d326a93218c977a371407d8cec)\n- fix: only change to location.origin when treating inline styles in font.js [`94c91c6`](https://github.com/zumerlab/snapdom/commit/94c91c61d57b79a083c75d27aa3025beb1dcb535)\n- fix: validate fallback image data before setting source [`2c754fe`](https://github.com/zumerlab/snapdom/commit/2c754fec37e3ce18c512ff3ee61e386fcf780589)\n- fix: ensure image is only appended if data URL is valid [`46957c5`](https://github.com/zumerlab/snapdom/commit/46957c51c49d766db5ad60357f5664a7cf500049)\n- fix: ensure valid CSS text is fetched for font links [`201209f`](https://github.com/zumerlab/snapdom/commit/201209faad857dd21a01ebc2ae5dc740a33819ce)\n- Merge pull request #301 from Amyuan23/fix/svg-root-font-size [`5bd53ba`](https://github.com/zumerlab/snapdom/commit/5bd53ba4887dba4664589b51651747809a89f8ab)\n- Merge pull request #350 from ZiuChen/fix/remote-katex-font [`3cbbd57`](https://github.com/zumerlab/snapdom/commit/3cbbd577eae0c751fbac41cd3b3ff0aeb251ca0e)\n- Merge pull request #378 from FlavioLimaMindera/fix-scale-image-issue-321 [`e53f2f8`](https://github.com/zumerlab/snapdom/commit/e53f2f8c4a83617a14c168c0c2615bde283d4696)\n- Fix: Inherit root font-size in SVG output [`3bdf300`](https://github.com/zumerlab/snapdom/commit/3bdf300ce417d56b928ea3c1b7258103a35f2445)\n- Merge pull request #374 from kohaiy/patch-1 [`0b21142`](https://github.com/zumerlab/snapdom/commit/0b21142b87d1874aaaa88bc7fc9630eb506ab958)\n\n#### [v2.0.2](https://github.com/zumerlab/snapdom/compare/v2.0.1...v2.0.2)\n\n> 20 January 2026\n\n- Fix bug when captured element is SVG. Closes #324 [`#324`](https://github.com/zumerlab/snapdom/issues/324)\n- Improve docs for blob [`#352`](https://github.com/zumerlab/snapdom/issues/352)\n\n#### [v2.0.1](https://github.com/zumerlab/snapdom/compare/v2.0.0...v2.0.1)\n\n> 26 November 2025\n\n- Fix spaces. Closes #326 [`#326`](https://github.com/zumerlab/snapdom/issues/326)\n- Fix download options. Closes #323 [`#323`](https://github.com/zumerlab/snapdom/issues/323)\n- Fix Safari Image Issue. See #330 [`ba80b6f`](https://github.com/zumerlab/snapdom/commit/ba80b6f66393886298cdb2fc7ce134d1824184f7)\n- Minify mjs version [`9155d4f`](https://github.com/zumerlab/snapdom/commit/9155d4fa2e2fc4bb6a0e3d7a991fc75818644084)\n- Add a re-export for preCache. See #332 [`acc5b79`](https://github.com/zumerlab/snapdom/commit/acc5b79a0c8d38de9f6dea5b98bf1179b54ef671)\n\n\n### [v2.0.0](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.4...v2.0.0)\n\n> 18 November 2025\n\n- V2 release!! [`#319`](https://github.com/zumerlab/snapdom/pull/319)\n\n\n#### [v2.0.0-dev.4](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.3...v2.0.0-dev.4)\n\n> 18 November 2025\n\n- Feature enable tree-shakeable code [`ebb7b6a`](https://github.com/zumerlab/snapdom/commit/ebb7b6add3b47c75a450e0264d628837303def5d)\n- Fix bug when img has height with % units. Closes #268 [`#268`](https://github.com/zumerlab/snapdom/issues/268)\n- Fix regression to process MathJax [`7ef116c`](https://github.com/zumerlab/snapdom/commit/7ef116cdbbfcba0793cd918b2ba8ad94e063f74f)\n- Fix bug See #316 [`7efbede`](https://github.com/zumerlab/snapdom/commit/7efbede5970ed09982c06f8cfa3fe45d990d8fdf)\n\n#### [v2.0.0-dev.3](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.2...v2.0.0-dev.3)\n\n> 11 November 2025\n\n- Reorganice helper functions [`d1fd982`](https://github.com/zumerlab/snapdom/commit/d1fd98240459f07897311a42e09c1ad3e3a48c62)\n- Perf improvement [`daf0eca`](https://github.com/zumerlab/snapdom/commit/daf0eca47c0d11828e1a702b6d64d8ab7450581d)\n- Fix placeholder dimensions when image loading fails [`e44b9d9`](https://github.com/zumerlab/snapdom/commit/e44b9d947efaf28fec8976dc13b398788d461d52)\n\n\n#### [v2.0.0-dev.2](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.1...v2.0.0-dev.2)\n\n> 9 November 2025\n\n- Integrate createBackground into toCanvas. Closes #297 [`#297`](https://github.com/zumerlab/snapdom/issues/297)\n- Add Chinese translation of README.md (readme_cn.md) [`#298`](https://github.com/zumerlab/snapdom/issues/298)\n- Adjust final dimensions when excludeMode: remove. See #294 [`a860827`](https://github.com/zumerlab/snapdom/commit/a8608271ffd9b891ec815fa7e3130c0e1be45307)\n- Improve material icon / symbols. See #304 [`526c4c8`](https://github.com/zumerlab/snapdom/commit/526c4c8e6874b2dab6110c8ac3361a29b1dc91de)\n- Add XHTML sanitize. See #282 [`0039301`](https://github.com/zumerlab/snapdom/commit/003930196ed6e11c5dd54fce75d814046a36637d)\n- Add detection to Baidu on iOS. Also detect other apps/browsers on iOS. See #295 [`97e6dff`](https://github.com/zumerlab/snapdom/commit/97e6dffa34440d157b0b2b1afc5267d7b15c1d7b)\n- add support for text-underline-offset. See #303 [`fb603bc`](https://github.com/zumerlab/snapdom/commit/fb603bca971a10577215c436b73ba5468a4255ae)\n- add support for text-underline-offset. See#303 [`0b93c0d`](https://github.com/zumerlab/snapdom/commit/0b93c0de7a720a7c6708a50a153de86ba6f2684e)\n- fix: improve CSS src property parsing in font faces [`dbe52a1`](https://github.com/zumerlab/snapdom/commit/dbe52a121a7d68441570c02ae32c607907e188dc)\n\n\n#### [v2.0.0-dev.1](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.0...v2.0.0-dev.1)\n\n> 23 October 2025\n\n- Add basic support for icons with ligature such as material-icons. Closes #275 [`#275`](https://github.com/zumerlab/snapdom/issues/275)\n- Replace straighten with outerTransforms, and noShadows with outerShadows [`902f032`](https://github.com/zumerlab/snapdom/commit/902f032a43ed4919701765d89818c7782902b403)\n- Fix straighten regression [`60c6569`](https://github.com/zumerlab/snapdom/commit/60c6569ebcfbc9ea562c9bddfe9f01c6ea4136db)\n\n\n#### [v2.0.0-dev.0](https://github.com/zumerlab/snapdom/compare/v1.9.14...v2.0.0-dev.0)\n\n> 14 October 2025\n\n- Document plugin system [`d87ac01`](https://github.com/zumerlab/snapdom/commit/d87ac01fcf0beb8779afbe2be709aa1b35cf7113)\n- First plugin and exporter draft [`5da0948`](https://github.com/zumerlab/snapdom/commit/5da09483b82e18ca0fc6873393b9d6830632fcfc)\n- Fix subpixel bug. See #261 [`465950b`](https://github.com/zumerlab/snapdom/commit/465950b18e68ce0faf94b229bd65511128cd18a7)\n- Update demos with plugins [`50e6c4f`](https://github.com/zumerlab/snapdom/commit/50e6c4f85d74bb769fcd7f753f92a3c5be4536c6)\n- Update plugin system [`4e21b47`](https://github.com/zumerlab/snapdom/commit/4e21b475fdc42c6eade9cb4dada5bb5ffeb71978)\n- Enhance external SVG defs. See #262 [`cd4a7fb`](https://github.com/zumerlab/snapdom/commit/cd4a7fbf0e2d9fc1651c06d5c5f5a3a8f0f54329)\n- First plugin system draft [`9c6b91b`](https://github.com/zumerlab/snapdom/commit/9c6b91bc4352de3d323c0746a4e3040058dad519)\n- Fix complex canvas render on Safari. See #263 [`2697207`](https://github.com/zumerlab/snapdom/commit/2697207c98373867bcbb91d4afb73d3820c878d3)\n- Enhance CSS vars detection. See #262 [`6655303`](https://github.com/zumerlab/snapdom/commit/665530377bceff05ff9c1570301019bd95370a9c)\n- Fix counter CSS reset and bug when exist background-image. See #265 [`3c29997`](https://github.com/zumerlab/snapdom/commit/3c299978e2a4cd4c2daf07d64de5772b28e880b8)\n- FIx excludeFonts defs. See #260 [`84b1770`](https://github.com/zumerlab/snapdom/commit/84b1770dd2e6e487e22afcd04c2d34cd0490528c)\n- Fix local register [`9c4508e`](https://github.com/zumerlab/snapdom/commit/9c4508e0cdeb445b76babd71483fe154e0e2ee3e)\n- Enable use built-in exporters in custom exporter [`5c6fe36`](https://github.com/zumerlab/snapdom/commit/5c6fe367101d8dfec710372d2b0a7362da3597a3)\n- Fix background-repeat. See #259 [`0ad5fa4`](https://github.com/zumerlab/snapdom/commit/0ad5fa4ee56e417016ce495ea299cf4a3c586f6a)\n- Fix export name format jpg -&gt; jpeg [`47d532a`](https://github.com/zumerlab/snapdom/commit/47d532a98be096829b592295527c1a6428d6a5d8)\n- Update roadmap [`dff59b9`](https://github.com/zumerlab/snapdom/commit/dff59b9ea62fed09a9146fd1e9d897dace30a37b)\n- Fix local plugin registration [`0997618`](https://github.com/zumerlab/snapdom/commit/0997618fb2e716ead23bfbe4824bb8365987ccd4)\n\n\n#### [v1.9.14](https://github.com/zumerlab/snapdom/compare/v1.9.13...v1.9.14)\n\n> 5 October 2025\n\n- Recompile builds [`fca9e00`](https://github.com/zumerlab/snapdom/commit/fca9e00d49bb675d6b6103ba2de3f898c85c5578)\n\n\n#### [v1.9.13](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.4...v1.9.13)\n\n> 5 October 2025\n\n- Improve CSS vars detection. Closes #255 [`#255`](https://github.com/zumerlab/snapdom/issues/255)\n- Fix toImg() dimensions when scale==1. Closes #254 [`#254`](https://github.com/zumerlab/snapdom/issues/254)\n- Enhance web fonts detection on deph relative paths. See #253 [`e2a8c45`](https://github.com/zumerlab/snapdom/commit/e2a8c454fbab7590f39f77da544193f8e5af13ab)\n- Add two new options to control transforms and shadows on root element [`8c9a75f`](https://github.com/zumerlab/snapdom/commit/8c9a75f77940221107a6005e458514cca981b2eb)\n- Add toSvg() in replacement of toImg() [`10e2043`](https://github.com/zumerlab/snapdom/commit/10e2043182672b42dfbda4268fb17f75bc3b561a), [`122317e`](https://github.com/zumerlab/snapdom/commit/122317eb0301f8375cc23ea8f3fd2361d1b759a4)\n- Improve relative path detection. See #253 [`34158e0`](https://github.com/zumerlab/snapdom/commit/34158e0f5cf8f797cd49416f090d06d2d233542d)\n- Lint code [`c754981`](https://github.com/zumerlab/snapdom/commit/c7549812a018d28809e0e2b314c973abe0cd542c)\n- Lint tests [`ee974ed`](https://github.com/zumerlab/snapdom/commit/ee974ede694f324f674b688aa7cce16f7ec30a90)\n\n\n#### [v1.9.12-dev.4](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.3...v1.9.12-dev.4)\n\n> 30 September 2025\n\n- Add basic support to sticky elements. See #232 [`02893e6`](https://github.com/zumerlab/snapdom/commit/02893e60e7f3244c1274d64232e7dbf338a283ec)\n- Update types [`19317e6`](https://github.com/zumerlab/snapdom/commit/19317e6ff34599028e627f891ae5a2d28036bac1)\n- fix formating [`58ca761`](https://github.com/zumerlab/snapdom/commit/58ca7616e3e82ec81122804264466f33d79c45c2)\n- Enhance browser detection. See #251 [`cfe753c`](https://github.com/zumerlab/snapdom/commit/cfe753c280ab0c0cda8aca88978a550257caf23f)\n- Fix pseudo capture. See #252 [`e85678e`](https://github.com/zumerlab/snapdom/commit/e85678e54fac6736c3dac8a0eaac8f1607dcbb6a)\n- Merge pull request #249 from K1ender/dev [`e497bba`](https://github.com/zumerlab/snapdom/commit/e497bbae30e2c539c53715f9dd0bed585d10cf9a)\n\n\n\n#### [v1.9.12-dev.3](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.2...v1.9.12-dev.3)\n\n> 26 September 2025\n\n- Captures CSS shadows [`050365f`](https://github.com/zumerlab/snapdom/commit/050365f8ab2087a912c71c468b4b1c234b21dd6a)\n- Improve counter simulation [`4c9e21d`](https://github.com/zumerlab/snapdom/commit/4c9e21d6bc0135614b882e1940ce31b64c5e40b2)\n- Fix scale, width, height options [`8ef48cb`](https://github.com/zumerlab/snapdom/commit/8ef48cb2e038c286eea9b7ce2baafac30c062a5e)\n- Just run safariWarmup if it is needed [`a32846d`](https://github.com/zumerlab/snapdom/commit/a32846d6f48ab8c583b1f26c41d19f3cff67ab55)\n- Safari, in case of scale, width or height options use png to ensure fidelity [`0711a77`](https://github.com/zumerlab/snapdom/commit/0711a7774f6f545cd05e0cce86f3354fe377b02d)\n- Sanitize container [`a6ba396`](https://github.com/zumerlab/snapdom/commit/a6ba396c52406bf9c5dd0ffd27f9460cd34b028e)\n\n\n#### [v1.9.12-dev.2](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.1...v1.9.12-dev.2)\n\n> 22 September 2025\n\n- Fix margin collapsing in some cases. See #243 [`7fe0a3f`](https://github.com/zumerlab/snapdom/commit/7fe0a3ffe6e9827b276f0c0337c60c8c02c4129c)\n\n\n\n#### [v1.9.12-dev.1](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.0...v1.9.12-dev.1)\n\n> 20 September 2025\n\n- Modularize counters [`024a7f9`](https://github.com/zumerlab/snapdom/commit/024a7f9b2d1b4805c7b12b5cbb62f0200295cc8f)\n- Improve CSS counter() and counters() handling. See #120, see #235 [`8fb0385`](https://github.com/zumerlab/snapdom/commit/8fb03859301075ea9b096197ec5b4dbca4223a95)\n- Feat lineClamp. See #241 [`4082cd6`](https://github.com/zumerlab/snapdom/commit/4082cd6c39ff43bcb842a81768e11b858c5e8559)\n- Fix width/height options [`7cd2111`](https://github.com/zumerlab/snapdom/commit/7cd21114d1337e48b5d743cb94989feb1a1a0d20)\n- Fix bug that hangs snapDOM on some browsers. See #236 [`bc3c400`](https://github.com/zumerlab/snapdom/commit/bc3c400356a6f32f8d1d8a986c9a427e6dd13c7f)\n- Fix bug that overrides options.width/heigth. See #241 [`49fbb63`](https://github.com/zumerlab/snapdom/commit/49fbb63ac6c28a66a8e6d9076618bee7b6beab62)\n- Improve webFonts render. See #229 [`3082a3a`](https://github.com/zumerlab/snapdom/commit/3082a3ae019f424e127f3c065cbcfa7bad59bb39)\n- Feat. detect wechat browser. See #223 [`e7c4723`](https://github.com/zumerlab/snapdom/commit/e7c4723a24ad3a9c52da5a2e021c115b354bcf66)\n- Ensure donwload file measure. See #241 [`eed1995`](https://github.com/zumerlab/snapdom/commit/eed1995a56664c0ace5d94422ba6bb8b5ef82324)\n- Add iframe support [`ec59e4b`](https://github.com/zumerlab/snapdom/commit/ec59e4bad3dbb639cde37aed929dccb42b54e6b5)\n\n#### [v1.9.12-dev.0](https://github.com/zumerlab/snapdom/compare/v1.9.11...v1.9.12-dev.0)\n\n> 11 September 2025\n\n- Try fix fallback images [`67bedd3`](https://github.com/zumerlab/snapdom/commit/67bedd3c7cb6bda3e2291fe494805a58263e6dce)\n- Two separate mode: filterMode and excludeMode [`394e7f4`](https://github.com/zumerlab/snapdom/commit/394e7f4ca2171fb1028eb382b2331d4718f6a350)\n- Workaround Safari See #231 [`593ad59`](https://github.com/zumerlab/snapdom/commit/593ad59383d0b3adbcb139f6892ae321a08c60d5)\n- Try new approach for solve Safari fonts/images decoding [`5b77738`](https://github.com/zumerlab/snapdom/commit/5b7773847f02809826a9ed459321100cfbd50518)\n\n\n#### [v1.9.11](https://github.com/zumerlab/snapdom/compare/v1.9.10...v1.9.11)\n\n> 9 September 2025\n\n- Fix Safari bug that prevents capture [`6a43e59`](https://github.com/zumerlab/snapdom/commit/6a43e59d1c311452c7d16e1adc9bb12bb89132b4)\n\n\n#### [v1.9.10](https://github.com/zumerlab/snapdom/compare/v1.9.10-dev.2...v1.9.10)\n\n> 9 September 2025\n\n- Merge dev branch  [`#225`](https://github.com/zumerlab/snapdom/pull/225)\n- Strip dev comments [`e02066b`](https://github.com/zumerlab/snapdom/commit/e02066b6ef8e6c2b1d50b86096500f914ac025af)\n- increase test coverage [`0c59fa0`](https://github.com/zumerlab/snapdom/commit/0c59fa0d276b7899cf2b721d32e7934e701df76b)\n- Update types defs [`648f4a9`](https://github.com/zumerlab/snapdom/commit/648f4a965106c58c6f63d384bf8db600a661146c)\n- Improves mask handling [`f3915ea`](https://github.com/zumerlab/snapdom/commit/f3915ea919b927b5f2ff0a9f3c865beb7b08b231)\n- fix backgroundColor regression [`21a6a39`](https://github.com/zumerlab/snapdom/commit/21a6a3923d81d2f733d6bc61136a9d4eda8f1a62)\n\n\n#### [v1.9.10-dev.2](https://github.com/zumerlab/snapdom/compare/v1.9.10-dev.1...v1.9.10-dev.2)\n\n> 8 September 2025\n\n- Fix cache disabled bug. Closes #221 [`#221`](https://github.com/zumerlab/snapdom/issues/221)\n- Add extra margin when element has transform [`6688eee`](https://github.com/zumerlab/snapdom/commit/6688eee661de2d91249f9db28948629f421b14b4)\n- Feat. handkles css trasnforms and scale rotate new props. Ref #216 [`d151da1`](https://github.com/zumerlab/snapdom/commit/d151da1993f1374d2bae16ed1a076a05f9ff8d45)\n- Add same-origin iframe support .See #222 [`f50720f`](https://github.com/zumerlab/snapdom/commit/f50720fe76d8d114c1de31ffe802ade1edd7060e)\n- Fix regression that doesnt reset origial translate [`800c427`](https://github.com/zumerlab/snapdom/commit/800c427d327f41fbcc9703dfa5a3e99b9b7c789f)\n- Fix duplicated values on textArea [`0915f8d`](https://github.com/zumerlab/snapdom/commit/0915f8d4f1b9e58800407ba28ad86e16b6cc4621)\n- 增强图像处理功能，添加图像加载失败时的后备图像源支持，并记录原始图像尺寸以便于使用。更新类型定义以包含新选项。 [`011620a`](https://github.com/zumerlab/snapdom/commit/011620a3c8dbabed9c2e509766c4516205fbad66)\n- ✨ feat: [`e3a4556`](https://github.com/zumerlab/snapdom/commit/e3a4556a4085c0968bcce9f54d7d0fb9bbcfc6a7)\n- remove iframe limitation [`77abf8f`](https://github.com/zumerlab/snapdom/commit/77abf8f617fd1942565ee141406757c3704f45ae)\n- Merge pull request #220 from Jarvis2018/main [`adb6455`](https://github.com/zumerlab/snapdom/commit/adb6455fd6ff9db128dda5a59ac7556a16c851fa)\n- Merge pull request #215 from xiaobai-web715/dev [`37be327`](https://github.com/zumerlab/snapdom/commit/37be327f7c55ae20ee65001a8469f59284dbe12f)\n\n\n#### [v1.9.10-dev.1](https://github.com/zumerlab/snapdom/compare/v1.9.10-dev.0...v1.9.10-dev.1)\n\n> 3 September 2025\n\n- Fix flickering on Safari. Closes #197 [`#197`](https://github.com/zumerlab/snapdom/issues/197)\n- Prevents default svg values overwrite custom ones. Closes #217 [`#217`](https://github.com/zumerlab/snapdom/issues/217)\n- Feature: add placeholders option to disable rendered placeholder for iframes and fallback images. Closes #137 [`#137`](https://github.com/zumerlab/snapdom/issues/137)\n- FIx textarea styles. Closes #212 [`#212`](https://github.com/zumerlab/snapdom/issues/212)\n\n\n#### [v1.9.10-dev.0](https://github.com/zumerlab/snapdom/compare/v1.9.9...v1.9.10-dev.0)\n\n> 29 August 2025\n\n- Code refactor, cache improve, options centralized [`3bd7182`](https://github.com/zumerlab/snapdom/commit/3bd71822cf72614b3bb5993039482fdb05833ceb)\n- Improve performance and cache [`8882025`](https://github.com/zumerlab/snapdom/commit/88820259d4000fd36dbf4f59bb4940a6e12e6611)\n- Enhance font handling [`cb1e04a`](https://github.com/zumerlab/snapdom/commit/cb1e04af0551f097b44eeb84ba64a97e869b6f60)\n- Improve capture fidelity [`e05f027`](https://github.com/zumerlab/snapdom/commit/e05f027b8eb3b5b90e22a9d8ce7a7279f7b1614b)\n- fix font fetching [`70dd092`](https://github.com/zumerlab/snapdom/commit/70dd092adb14899031f0596d83ec354c9ab023b9)\n- Set compress as default [`7e5ab00`](https://github.com/zumerlab/snapdom/commit/7e5ab007f653f995b09ecbbf7711d69937fdef4a)\n- optimice code [`df52437`](https://github.com/zumerlab/snapdom/commit/df524379288fcb08dd19b8957d627a24b898685e)\n- Core update: increase X3 speed capture compared 1.9.9 [`94bc57d`](https://github.com/zumerlab/snapdom/commit/94bc57dc53cb63d82532467b4a041ab26da7481a)\n- Ensure custom fonts are capured [`8125689`](https://github.com/zumerlab/snapdom/commit/81256893249b2313edfebacb9d68ec6ed4fa9ed2)\n- Fix first custom font bug on Safari [`971d976`](https://github.com/zumerlab/snapdom/commit/971d9762dd73263689057e16fb47c00d2e0eba1b)\n- Fix bug that affects overall capture fidelity [`35539a5`](https://github.com/zumerlab/snapdom/commit/35539a50da67c30e28c39272a4c1efefbf24a2e2)\n- update to avoid vitest issues [`51ef80d`](https://github.com/zumerlab/snapdom/commit/51ef80d24b693ceee3e33d75e2b64ba7037e49ea)\n\n\n#### [v1.9.9](https://github.com/zumerlab/snapdom/compare/v1.9.8...v1.9.9)\n\n> 14 August 2025\n\n- Improves external fonts handling. Closes #139, closes #146, closes #186 [`#139`](https://github.com/zumerlab/snapdom/issues/139) [`#146`](https://github.com/zumerlab/snapdom/issues/146) [`#186`](https://github.com/zumerlab/snapdom/issues/186)\n- Handles srcset. Closes #190 [`#190`](https://github.com/zumerlab/snapdom/issues/190)\n- Fix speed regression. [`542ed00`](https://github.com/zumerlab/snapdom/commit/542ed003e3f35c19bd51fd5317f5572c12ba1ac8)\n- Handles Blob scr [`fe27239`](https://github.com/zumerlab/snapdom/commit/fe27239ac3efef9a0e9e7f0feeb223dd74fd9086). Closes [`#169`](https://github.com/zumerlab/snapdom/issues/169) \n\n\n#### [v1.9.8](https://github.com/zumerlab/snapdom/compare/v1.9.7...v1.9.8)\n\n> 10 August 2025\n\n- fix(types): update `preCache` [`#166`](https://github.com/zumerlab/snapdom/pull/166)\n- Fix defs & symbols outside captured element and hidden visibility, closes #178. Stabilize layout before cloning, closes  #179. Fix inline styles, closes #177 [`#178`](https://github.com/zumerlab/snapdom/issues/178) [`#177`](https://github.com/zumerlab/snapdom/issues/177)  [`#179`](https://github.com/zumerlab/snapdom/issues/179)\n- Fix icontFont alignment and rendered size. Closes #176 [`#176`](https://github.com/zumerlab/snapdom/issues/176)\n- Ensure skip empty pseudo elements. Closes #168 [`#168`](https://github.com/zumerlab/snapdom/issues/168)\n- Add basic border-image support. Closes #159 [`#159`](https://github.com/zumerlab/snapdom/issues/159)\n- Fix input css styles. Closes #144, closes #147 [`#144`](https://github.com/zumerlab/snapdom/issues/144) [`#147`](https://github.com/zumerlab/snapdom/issues/147)\n\n\n#### [v1.9.7](https://github.com/zumerlab/snapdom/compare/v1.9.6...v1.9.7)\n\n> 27 July 2025\n\n- Fix input css styles. Closes #144, closes #147 [`#144`](https://github.com/zumerlab/snapdom/issues/144) [`#147`](https://github.com/zumerlab/snapdom/issues/147)\n- Fix Safari scale. Closes #133 [`#133`](https://github.com/zumerlab/snapdom/issues/133)\n- Fix @font-face. Closes #145 [`#145`](https://github.com/zumerlab/snapdom/issues/145)\n- Fix edge case that generates blank images on Safari. Closes #129 [`#129`](https://github.com/zumerlab/snapdom/issues/129)\n- Improve pseudo elements detection. Closes # 143 [`539e488`](https://github.com/zumerlab/snapdom/commit/539e488c018a1bf7be05e0d9d969e350c9ed4291)\n- Remove default backgroundColor on download(). Ref dissussion #142 [`a875fe3`](https://github.com/zumerlab/snapdom/commit/a875fe31c1c1fc9a1d0d59ef2934b5930a6b7c88)\n- Update docs, thanks @kohaiy[`e38d67b`](https://github.com/zumerlab/snapdom/commit/e38d67b0102edf75a9f6e742bd45eacc43be51c1)\n\n#### [v1.9.6](https://github.com/zumerlab/snapdom/compare/v1.9.5...v1.9.6)\n\n> 20 July 2025\n\n- Add options argument to toBlob function. Thanks @rbbydotdev [`#118`](https://github.com/zumerlab/snapdom/pull/118)\n- Keep canvas CSS style. Fixes #121. [`#121`](https://github.com/zumerlab/snapdom/issues/121)\n- Improve: handles local() source font. See #114 [`c088aa0`](https://github.com/zumerlab/snapdom/commit/c088aa01422bf6ea6c1be70a88d09d540eae5038)\n- Improve webcomponent clone [`a0f37a5`](https://github.com/zumerlab/snapdom/commit/a0f37a57079057548e97a973bc6c734b4141769d)\n- Perf: unifies cache [`fe3a368`](https://github.com/zumerlab/snapdom/commit/fe3a3680ddef2736fc0176dcbc210fc760149038)\n- Improve cache handling. [`183ae2f`](https://github.com/zumerlab/snapdom/commit/183ae2f90debfb472164df01616f2558136a9f8f)\n- Adjust cache reset [`ff33ed3`](https://github.com/zumerlab/snapdom/commit/ff33ed374dcc02272225ca0154a84c304e6fc19a)\n- Improve regex [`1b4d5ad`](https://github.com/zumerlab/snapdom/commit/1b4d5ada356bce55caecd008df746f891d379c48)\n- Add primitive support to css counter. See #120 [`160bc2e`](https://github.com/zumerlab/snapdom/commit/160bc2eaf984051e23031664040ea91166bca061)\n- Fix bug background-color on export formats. See #90 [`47a34a9`](https://github.com/zumerlab/snapdom/commit/47a34a971cc875ec4d9eab772266df81a94438e7)\n- Fix regression textArea duplication [`1759fd0`](https://github.com/zumerlab/snapdom/commit/1759fd0ae1681e17df946d507ae4d704efff1b18)\n- Prevent process local ids. See #128 [`659e862`](https://github.com/zumerlab/snapdom/commit/659e8627bb6545f7843de1bcf808dc6bfb4dff3e)\n- Add node version and improve docs. See #123. Thanks @miusuncle [`e457bf6`](https://github.com/zumerlab/snapdom/commit/e457bf64ca2b4294aaf237c165f865ea50cc0c14)\n\n\n#### [v1.9.5](https://github.com/zumerlab/snapdom/compare/v1.9.3...v1.9.5)\n\n> 14 July 2025\n\n Fix: add type def for `SnapOptions`. Thanks @simon1uo  [`#111`](https://github.com/zumerlab/snapdom/pull/111)\n- Add `checkbox.indeterminate`. Thanks @titoBouzout [`#104`](https://github.com/zumerlab/snapdom/pull/104)\n- Add mask-image CSS detection (closes #106) [`#106`](https://github.com/zumerlab/snapdom/issues/106)\n- Add slot detection (closes # 97) / Fix textarea content duplication (closes #110) [`#110`](https://github.com/zumerlab/snapdom/issues/110)\n- Add html-to-image to benchmark. Closes #103 [`#103`](https://github.com/zumerlab/snapdom/issues/103)\n\n#### [v1.8.0](https://github.com/zumerlab/snapdom/compare/v1.7.1...v1.8.0)\n\n> 30 June 2025\n\n- fix: encode same uri multiple times [`#65`](https://github.com/zumerlab/snapdom/pull/65)\n- Add Lucide to icon font detection [`#50`](https://github.com/zumerlab/snapdom/pull/50)\n- Avoid background-image logic duplication, closes #66 [`#66`](https://github.com/zumerlab/snapdom/issues/66)\n- Feat: sanitize rootElement to avoid CSS layout conflicts. Fixes #56, fixes #24 [`#56`](https://github.com/zumerlab/snapdom/issues/56) [`#24`](https://github.com/zumerlab/snapdom/issues/24)\n- Fix: canvas style props, closes #63 [`#63`](https://github.com/zumerlab/snapdom/issues/63)\n- Feat: handling @import and optimice cache, closes #61 [`#61`](https://github.com/zumerlab/snapdom/issues/61)\n- Compile .js to es2015, closes #58 [`#58`](https://github.com/zumerlab/snapdom/issues/58)\n- Fix background image handling, closes #57 [`#57`](https://github.com/zumerlab/snapdom/issues/57)\n- Improve inlinePseudoElements() to handle decorative properties, closes #55 [`#55`](https://github.com/zumerlab/snapdom/issues/55)\n- Add ::first-letter detection, closes #52 [`#52`](https://github.com/zumerlab/snapdom/issues/52)\n- test: increases coverage [`7ebc871`](https://github.com/zumerlab/snapdom/commit/7ebc87143101a9e5c8573f5ae76ede2884b59eb8)\n- Increase test coverage [`0c63478`](https://github.com/zumerlab/snapdom/commit/0c634785157ca9f611973976000b2f25ba7c9549)\n- Improve split multiple backgrounds [`0e67a9b`](https://github.com/zumerlab/snapdom/commit/0e67a9b72fb1ea7ea4a625d5f6dc2eb40438d7cd)\n- chore: update contributors list [`da22404`](https://github.com/zumerlab/snapdom/commit/da2240490b46ff4a0747f7db741b822dbc6ba3c4)\n- chore: update contributors list [`ec7c275`](https://github.com/zumerlab/snapdom/commit/ec7c27590318df95e7aa903ec7cbd92112b6c2e8)\n- Add check [`bf9a888`](https://github.com/zumerlab/snapdom/commit/bf9a888525e99dd663c17455755bb1478f1cb9d7)\n- Create update-contributors.js [`453dff0`](https://github.com/zumerlab/snapdom/commit/453dff07d0fd8f333627ed22a6e8a64373dbd62d)\n- Document width and  height options [`0f7fb7a`](https://github.com/zumerlab/snapdom/commit/0f7fb7a02d9159a831a1dfc4cfbd9f6f3420bca7)\n- Create update-contributors.yml [`b48e334`](https://github.com/zumerlab/snapdom/commit/b48e334043e2a18212620df413b8742d72959468)\n- Update [`7da2892`](https://github.com/zumerlab/snapdom/commit/7da2892e69d04903111bbd24421a574e0034a83b)\n- Bumped version [`f322f51`](https://github.com/zumerlab/snapdom/commit/f322f51e2369bfdbbc1218c15e8375b0858bf73d)\n- Update README.md [`99c51a8`](https://github.com/zumerlab/snapdom/commit/99c51a89e2b486bbfc2810d94350428cbc9595f2)\n- Update update-contributors.js [`46a868b`](https://github.com/zumerlab/snapdom/commit/46a868baa45bd068be687b19bbd50cb06ceb9cf0)\n- Update update-contributors.js [`2a77e4c`](https://github.com/zumerlab/snapdom/commit/2a77e4c82dab36803b005cc6bceab06689a0e52c)\n- chore: update contributors list [`020eff8`](https://github.com/zumerlab/snapdom/commit/020eff873c18fc601c145f957bdc566403e18649)\n- Update update-contributors.js [`b4cf877`](https://github.com/zumerlab/snapdom/commit/b4cf87709f632be2e03f40b0af5661393f8f8793)\n- chore: update contributors list [`a2d28d9`](https://github.com/zumerlab/snapdom/commit/a2d28d952b18787d8e7aabf1b6d12cd8e45fa436)\n- Update doc [`7cf19de`](https://github.com/zumerlab/snapdom/commit/7cf19de5df40735b17958359251c481c1b517d8c)\n- Update update-contributors.js [`962c7c6`](https://github.com/zumerlab/snapdom/commit/962c7c6a4e23d5b8a0ef4c72111a617ffac3add4)\n- Update README.md [`183de8c`](https://github.com/zumerlab/snapdom/commit/183de8ce06c8df35fcbc3a32bb4204c14a657310)\n- Update README.md [`4352ae7`](https://github.com/zumerlab/snapdom/commit/4352ae75fb445857c14c64eb9a2ea7dbe82733c3)\n- Update update-contributors.js [`7dca4a1`](https://github.com/zumerlab/snapdom/commit/7dca4a1d3bbaacc14295f0e891095db1b39a76d0)\n- Update README.md [`ebb7f32`](https://github.com/zumerlab/snapdom/commit/ebb7f3204892d0d42713e7a2a14d261177a24d31)\n- Clean transform RootElement prop [`f293e5b`](https://github.com/zumerlab/snapdom/commit/f293e5be0e3ca6a97d43467976d80175d988916d)\n- Check if getStyle is iterable [`24dfe05`](https://github.com/zumerlab/snapdom/commit/24dfe056f6d35fe56ab39325b9c4492f84e64cd5)\n- chore: update contributors list [`8ac4aa1`](https://github.com/zumerlab/snapdom/commit/8ac4aa1f5a21373e73f86b22d0cdad8def37a8ea)\n- chore: update contributors list [`9987328`](https://github.com/zumerlab/snapdom/commit/9987328a796bb3eb70eb45cda4485dc8f5906688)\n- Update README.md [`4b52b87`](https://github.com/zumerlab/snapdom/commit/4b52b87e33b6a2f5d34460b2bff13e47ad011a73)\n\n#### [v1.7.1](https://github.com/zumerlab/snapdom/compare/v1.3.0...v1.7.1)\n\n> 19 June 2025\n\n- Improve inlineBackgroundImages to support multiple background-image values.  [`#46`](https://github.com/zumerlab/snapdom/pull/46)\n- Add @font-face / FontFace() deteccion, closes #43 [`#43`](https://github.com/zumerlab/snapdom/issues/43)\n- update [`7c5441e`](https://github.com/zumerlab/snapdom/commit/7c5441ed4b2c602bcee60b314162f10412b260c5)\n- Add benchmark against html2canvas [`f196afe`](https://github.com/zumerlab/snapdom/commit/f196afeb43b23624680a77e52a80222a476f055d)\n- Add description [`bcae4af`](https://github.com/zumerlab/snapdom/commit/bcae4af3ce9e0953fea8410303e6d77fe3e01e3e)\n- Update issue templates [`352dba3`](https://github.com/zumerlab/snapdom/commit/352dba3e53452f09fb5d056a0c5fb9216701a0f4)\n- add options.crossOrigin [`49f8ac6`](https://github.com/zumerlab/snapdom/commit/49f8ac6524e3f54e67505d048a4ad34c529ab6c9)\n- Update issue templates [`d832dbd`](https://github.com/zumerlab/snapdom/commit/d832dbd14df70f07d3f3ec9b62016dc4d19d8c9a)\n- Create CONTRIBUTING.md [`9a7be15`](https://github.com/zumerlab/snapdom/commit/9a7be151f6b36abd5a582aebbcaacfe759716c3a)\n- handle multiple background image in inlineBackgroundImages function [`95a5490`](https://github.com/zumerlab/snapdom/commit/95a5490f2de5a139f39c0286111eb4e84990fd00)\n- Update issue templates [`b69b5a4`](https://github.com/zumerlab/snapdom/commit/b69b5a4cb72e3bd0ca5f8ae5b43448c8aab95752)\n- update [`57d6b15`](https://github.com/zumerlab/snapdom/commit/57d6b1529c56e890a43cc427f817c731784f6ca0)\n- Update index.html [`f002bca`](https://github.com/zumerlab/snapdom/commit/f002bca6ee6330ae9d6f2550d36ce59414de29b0)\n- Update issue templates [`24d478f`](https://github.com/zumerlab/snapdom/commit/24d478f32795b42b13f70b4319b5e2cd0ba3fa70)\n- Bumped version [`d109fd7`](https://github.com/zumerlab/snapdom/commit/d109fd739197bbc37089f5acfe65ed10a6f48050)\n- Add files via upload [`0aecf4e`](https://github.com/zumerlab/snapdom/commit/0aecf4e46093743ca854397509a8be91e08cb666)\n- update [`e444762`](https://github.com/zumerlab/snapdom/commit/e444762ddb173d283b761e13a1e5e16c8853e325)\n- Update index.html [`997dab3`](https://github.com/zumerlab/snapdom/commit/997dab3293df81dc906116acbf7b4f388a270b39)\n- update [`0355286`](https://github.com/zumerlab/snapdom/commit/035528627f957213d35f1c63d9f73528deb972cf)\n- Prevent erasing non url background [`0d626cb`](https://github.com/zumerlab/snapdom/commit/0d626cb32b8958afd7e7fd6f96d5a71c6795113b)\n- docs: add @jhbae200 as contributor for PR #46 [`afe3094`](https://github.com/zumerlab/snapdom/commit/afe3094360f14712a55c1be134ab993c094a670b)\n- Merge pull request #44 from elliots/support-use-credentials-on-images [`005f23e`](https://github.com/zumerlab/snapdom/commit/005f23e529962d73e7550f9f20e92bdc7c8eb8ab)\n- Update index.html [`25d970f`](https://github.com/zumerlab/snapdom/commit/25d970fb1142c07bf10c8d9eba491ecdb3bf3e37)\n- update [`ea624c3`](https://github.com/zumerlab/snapdom/commit/ea624c362acb7c0f953f3c202dd78f83c84742ce)\n- update [`1cf93b7`](https://github.com/zumerlab/snapdom/commit/1cf93b7e25eaa39878f2334e9c240e98ed98f847)\n- update [`3a547df`](https://github.com/zumerlab/snapdom/commit/3a547dfccc46e835ac585057d80b03ef5b324e7b)\n- update [`2d4380b`](https://github.com/zumerlab/snapdom/commit/2d4380b4c900d3230a44a5af0380d149e55caca9)\n- Update index.html [`bedf815`](https://github.com/zumerlab/snapdom/commit/bedf815299c421e3fe810a480f32bb291aae40b1)\n- Update issue templates [`48a56fb`](https://github.com/zumerlab/snapdom/commit/48a56fb7f5006b20e64de6a592ec38c7a59b3cd8)\n- Create config.yml [`51700c4`](https://github.com/zumerlab/snapdom/commit/51700c4457abb070df34520887991508ce32ad7f)\n- update image [`0ec788c`](https://github.com/zumerlab/snapdom/commit/0ec788c562011990a008edb2b8f9b0cf18da8940)\n\n#### [v1.3.0](https://github.com/zumerlab/snapdom/compare/v1.2.5...v1.3.0)\n\n> 14 June 2025\n\n- fix: double scaled images [`#38`](https://github.com/zumerlab/snapdom/pull/38)\n- Fix: background img &  img base64 in pseudo elements, closes #36 [`#36`](https://github.com/zumerlab/snapdom/issues/36)\n- Feat: captures input values, closes #35 [`#35`](https://github.com/zumerlab/snapdom/issues/35)\n- Improve: Device Pixel Ratio handling, thanks @jswhisperer [`1a14f69`](https://github.com/zumerlab/snapdom/commit/1a14f69d340e935126b5388febe5d711c4b94e14)\n- Bumped version [`489be08`](https://github.com/zumerlab/snapdom/commit/489be081e6c7e50f1e4ba08d932d79c0ae242d45)\n- Update description [`4db784b`](https://github.com/zumerlab/snapdom/commit/4db784b4250b6eac6da8932e651872147fbc8bc1)\n\n#### [v1.2.5](https://github.com/zumerlab/snapdom/compare/v1.2.2...v1.2.5)\n\n> 9 June 2025\n\n- Fix duplicated font-icon when embedFonts is true, closes #30 [`#30`](https://github.com/zumerlab/snapdom/issues/30)\n- Fix url with encode url, closes #29 [`#29`](https://github.com/zumerlab/snapdom/issues/29)\n- Fix .toCanvas scale [`fb47284`](https://github.com/zumerlab/snapdom/commit/fb4728463a65620bd4f4f8f50cd8b2263ba7bbe7)\n- Bumped version [`75b917a`](https://github.com/zumerlab/snapdom/commit/75b917a4fefc5fa9b55da3c028c43955f0656087)\n- Update cdn [`37533a2`](https://github.com/zumerlab/snapdom/commit/37533a2c2a858000e93d8d33009241a4be5f8726)\n- add homepage [`aa85c5d`](https://github.com/zumerlab/snapdom/commit/aa85c5d9f1777c437b07e624d874f7f1a0fac6a9)\n\n#### [v1.2.2](https://github.com/zumerlab/snapdom/compare/v1.2.1...v1.2.2)\n\n> 4 June 2025\n\n- Patch: type script definitions, closes #23 [`#23`](https://github.com/zumerlab/snapdom/issues/23)\n- Bumped version [`548d7f3`](https://github.com/zumerlab/snapdom/commit/548d7f30a889d34ae4dac28a8dedc43262089325)\n\n#### [v1.2.1](https://github.com/zumerlab/snapdom/compare/v1.1.0...v1.2.1)\n\n> 31 May 2025\n\n- feat(embedFonts): also embed icon fonts when embedFonts is true [`#18`](https://github.com/zumerlab/snapdom/issues/18)\n- Fix expose snapdom and preCache on browser compilation, closes #26 [`#26`](https://github.com/zumerlab/snapdom/issues/26)\n- Improve icon-font conversion [`7bac4ee`](https://github.com/zumerlab/snapdom/commit/7bac4ee3b152d6364c218aaa6d2bed4ad9997943)\n- Fix compress mode [`652cfe9`](https://github.com/zumerlab/snapdom/commit/652cfe9a8947029e31db6b089829fe8da87c0b42)\n- Bumped version [`0cd7973`](https://github.com/zumerlab/snapdom/commit/0cd797320b92310d86df0ce6296706d1f7f0ad5d)\n- Remove some logs [`4348b39`](https://github.com/zumerlab/snapdom/commit/4348b390ab8bb88c59ba9b0d24adbe58051b277a)\n- Chore: delete old comments [`ff81a40`](https://github.com/zumerlab/snapdom/commit/ff81a40e8a1b4baa8bacca2ed2ec59124df40b6e)\n- Chore: add dry bump script [`5c421c7`](https://github.com/zumerlab/snapdom/commit/5c421c75a1775a3b8c1fbd6a688fcfe409f676af)\n\n#### [v1.1.0](https://github.com/zumerlab/snapdom/compare/v1.0.0...v1.1.0)\n\n> 28 May 2025\n\n- Add typescript declaration, closes #23 [`#23`](https://github.com/zumerlab/snapdom/issues/23)\n- Feat. support scrolling state, closes #20 [`#20`](https://github.com/zumerlab/snapdom/issues/20)\n- Fix bug by removing trim spaces, closes #21 [`#21`](https://github.com/zumerlab/snapdom/issues/21)\n- fix margin on mobile [`36297c8`](https://github.com/zumerlab/snapdom/commit/36297c89c085f605922f88ac5113f2f176c6a1a9)\n- mobile friendly [`42dada8`](https://github.com/zumerlab/snapdom/commit/42dada88bdbe886033890071e8e76499358a6b91)\n- Update index.html [`1bf3bc1`](https://github.com/zumerlab/snapdom/commit/1bf3bc1b15f4d28b50363c73410cea25ad589cda)\n- Create FUNDING.yml [`ddf914c`](https://github.com/zumerlab/snapdom/commit/ddf914c96727b3a82bbea4694d19dc0eb2b518e3)\n- Bumped version [`7a9f3d8`](https://github.com/zumerlab/snapdom/commit/7a9f3d8662099d15bcc2046ba88043eb3d3b1bfb)\n- add ga [`6d8a73f`](https://github.com/zumerlab/snapdom/commit/6d8a73fd52997e9e1a91944bd9a46d95c8c8507c)\n- Update index.html [`46e4b41`](https://github.com/zumerlab/snapdom/commit/46e4b41209c44766425e96fb9be94e1d1c08b6ae)\n- Update index.html [`1a2a04c`](https://github.com/zumerlab/snapdom/commit/1a2a04cbb3f4e81e2713d823d5b8dcdeb508591d)\n- Ignore generated screenshots tests [`cce8ead`](https://github.com/zumerlab/snapdom/commit/cce8ead47c470280761a34f7c98f9a2fd0796a34)\n- Update index.html [`5dd6749`](https://github.com/zumerlab/snapdom/commit/5dd67495df0a5cd48eda168565a81969d5639f40)\n- FIx bug that prevent scale on png format [`77a5265`](https://github.com/zumerlab/snapdom/commit/77a52651bd0ea8ccb451f199bd3d8f9e2478bf84)\n- Update README.md [`d8440f3`](https://github.com/zumerlab/snapdom/commit/d8440f3864931509f1b369d7e301d6ecccb63b14)\n- update [`ffa3a9a`](https://github.com/zumerlab/snapdom/commit/ffa3a9ad942987a5b52a7c9080914bed912db558)\n- Update README.md [`9c79e6e`](https://github.com/zumerlab/snapdom/commit/9c79e6e406ff9cb4df1539480e057b6828ef1788)\n- Update index.html [`7585674`](https://github.com/zumerlab/snapdom/commit/7585674ed21bb7009b84d1f948ceed2d5ed5ae69)\n- Update index.html [`8f4fb95`](https://github.com/zumerlab/snapdom/commit/8f4fb95a8f839159bd00c3338c7c3dc9fb23071c)\n- Update index.html [`eebc2bc`](https://github.com/zumerlab/snapdom/commit/eebc2bc01a6581f25995d5a9e946aa6bde08dfdc)\n\n### [v1.0.0](https://github.com/zumerlab/snapdom/compare/v1.0.0-pre.1747581859131...v1.0.0)\n\n> 19 May 2025\n\n- format code [`146fd95`](https://github.com/zumerlab/snapdom/commit/146fd95ec93d6b842acb28272aad43f787dc954a)\n- new demo gallery [`b8b2b6e`](https://github.com/zumerlab/snapdom/commit/b8b2b6eb4373999af5e67fc87418d6c6ab96199f)\n- Update code documentation [`6f933bc`](https://github.com/zumerlab/snapdom/commit/6f933bca3f1e9a9054f2e0e63807dfd52dda6270)\n- Add benchmarks section [`6becbb1`](https://github.com/zumerlab/snapdom/commit/6becbb12014d3cf33ec49264ca088486f08a5ce1)\n- Update documentation - add precache() [`a689566`](https://github.com/zumerlab/snapdom/commit/a6895665858f9eb574b0195dc918cef680c1651b)\n- Bumped version [`50d48c0`](https://github.com/zumerlab/snapdom/commit/50d48c05458e971a16375ca89da08eedad049e0c)\n- chore [`9f76e0c`](https://github.com/zumerlab/snapdom/commit/9f76e0cb1e7761604693588092ac8b1796cc892e)\n- update [`d84d395`](https://github.com/zumerlab/snapdom/commit/d84d39599abbd8fbd31727ff3a6650278ec0e28c)\n\n#### [v1.0.0-pre.1747581859131](https://github.com/zumerlab/snapdom/compare/v0.9.9...v1.0.0-pre.1747581859131)\n\n> 18 May 2025\n\n- Fix retina and scale bug, closes #15 [`#15`](https://github.com/zumerlab/snapdom/issues/15)\n- Improve public API, closes #16 [`#16`](https://github.com/zumerlab/snapdom/issues/16)\n- Fix bug to render canvas with precache compress mode, closes #13 [`#13`](https://github.com/zumerlab/snapdom/issues/13)\n- Update to reflect new public API [`b6024cb`](https://github.com/zumerlab/snapdom/commit/b6024cb800b848103411d4e8f4be9a7ffdb84f48)\n- Update tests and benckmarks [`f06a0f8`](https://github.com/zumerlab/snapdom/commit/f06a0f835e42036a19761152cf5bf941b53d2f27)\n- Add helper to check Safari [`6c9ee04`](https://github.com/zumerlab/snapdom/commit/6c9ee0484c598dd56d52e62f3de37499024ad5e5)\n- Remove preWarm [`d3bd582`](https://github.com/zumerlab/snapdom/commit/d3bd582c144775617fc6221c4504466eb4cd6bef)\n- Bumped version [`fb0855d`](https://github.com/zumerlab/snapdom/commit/fb0855d55eafdbcae79f537b7e1a51e2cd4d1dfc)\n\n#### [v0.9.9](https://github.com/zumerlab/snapdom/compare/v0.9.8...v0.9.9)\n\n> 14 May 2025\n\n- Bumped version [`676b00d`](https://github.com/zumerlab/snapdom/commit/676b00d71b5b51ea3a90c1aa95776a9b226378a3)\n- Fix bug on collectUsedTagNames() [`d627f18`](https://github.com/zumerlab/snapdom/commit/d627f18b6c0512545ab695bfae660cac8f64a9f0)\n- update [`c0e64d0`](https://github.com/zumerlab/snapdom/commit/c0e64d00905898660db68f054f5f5598c3fb9581)\n- Fix menu options [`8e87681`](https://github.com/zumerlab/snapdom/commit/8e876810c721fa0306c0f7d1b427ba6b111f8afe)\n\n#### [v0.9.8](https://github.com/zumerlab/snapdom/compare/v0.9.7...v0.9.8)\n\n> 14 May 2025\n\n- Add font example [`26c59c8`](https://github.com/zumerlab/snapdom/commit/26c59c864aeaae80b54c22ace32e96396cb9eae6)\n- Bumped version [`0819d89`](https://github.com/zumerlab/snapdom/commit/0819d89bc52af1417e31b54e695af8491b709969)\n- update tests [`3cd5b70`](https://github.com/zumerlab/snapdom/commit/3cd5b70427613d7d595dd15736cb545db6411d88)\n- Fix capture output format [`2afa36a`](https://github.com/zumerlab/snapdom/commit/2afa36a1c41ff798ded5b7f8ecef1632e08ab716)\n- Update index.html [`0345fb1`](https://github.com/zumerlab/snapdom/commit/0345fb1f177297db0e17141c5737f9b3b510e6ca)\n- Add demo site [`88d0faa`](https://github.com/zumerlab/snapdom/commit/88d0faa1b27db0d305e8b78c7280c8a5e83384a5)\n- Disable user zoom [`3813580`](https://github.com/zumerlab/snapdom/commit/381358028159c51b9ed0da11e25928da490170fb)\n\n#### [v0.9.7](https://github.com/zumerlab/snapdom/compare/v0.9.2...v0.9.7)\n\n> 14 May 2025\n\n- Update Dev branch [`#11`](https://github.com/zumerlab/snapdom/pull/11)\n- Delete functions [`c5040d9`](https://github.com/zumerlab/snapdom/commit/c5040d90b6276daa04e919ca4b0ecdf205f73af9)\n- improve cache handling [`27d7b19`](https://github.com/zumerlab/snapdom/commit/27d7b19cfafeed83f4b30a824638ee7edd63e10b)\n- add some examples [`3ce9dd2`](https://github.com/zumerlab/snapdom/commit/3ce9dd2807c8b84ed927c186621850a2518dfd2a)\n- Reorganice and add helpers [`c4f4182`](https://github.com/zumerlab/snapdom/commit/c4f4182a3e9ce636a2a263a05d75e64b33b25d7b)\n- Add tests [`455e7f2`](https://github.com/zumerlab/snapdom/commit/455e7f20e8a72f6a646a7d1e900f41fb22a18666)\n- Check if element to capture exists [`dfa96f2`](https://github.com/zumerlab/snapdom/commit/dfa96f2f720238fdff5df6e24b4572691ad6198f)\n- Improve capture logic [`79ab1b9`](https://github.com/zumerlab/snapdom/commit/79ab1b9e165dd08a34338fe0d837b0330be48539)\n- Update readme [`fdc2877`](https://github.com/zumerlab/snapdom/commit/fdc2877fd9e6fb73bc5d7bc9cf1f4a405f088be0)\n- Add preCache [`48bd910`](https://github.com/zumerlab/snapdom/commit/48bd910743a638ae8ce35ab7d617ad05a75d29a2)\n- Optimice [`cc638e7`](https://github.com/zumerlab/snapdom/commit/cc638e7f0f2e63a24eeee65ab4d87755e7207dec)\n- Bumped version [`3b26632`](https://github.com/zumerlab/snapdom/commit/3b266324c747c4bc139b99e4978493df79a5555c)\n- update [`111fdb4`](https://github.com/zumerlab/snapdom/commit/111fdb444b3c6d61dcb0e6bb2e21c871f5e73587)\n- Add cache Maps [`091484c`](https://github.com/zumerlab/snapdom/commit/091484c00941822684afc9148a59cb23e4b34627)\n- update [`bdbba7a`](https://github.com/zumerlab/snapdom/commit/bdbba7aeff458a60d5a83b5ead2d4f9402492fd3)\n- Update README.md [`c1756a9`](https://github.com/zumerlab/snapdom/commit/c1756a9192f8e3af90fd66da7e19c5fb883dbe0a)\n- Expose preCache [`1e96db1`](https://github.com/zumerlab/snapdom/commit/1e96db14c6c4e697361ceed2fb6f9c618801a138)\n- Chore [`38c08c0`](https://github.com/zumerlab/snapdom/commit/38c08c0c5a9eda486619855b9df47f33f490a921)\n- fix url [`bebec7f`](https://github.com/zumerlab/snapdom/commit/bebec7fd70141b3a82d41a5f6cc0849dcfb0c715)\n- Update README.md [`fb0ab3a`](https://github.com/zumerlab/snapdom/commit/fb0ab3ae528d4b37223e4eef03135e9be6a62b0b)\n- Update README.md [`1a76186`](https://github.com/zumerlab/snapdom/commit/1a76186d938a7a776a33c0e42ecc6813e86a9262)\n- Update README.md [`90d18a1`](https://github.com/zumerlab/snapdom/commit/90d18a165725ca3369fb5ebf48c281e0dd1377ae)\n\n#### [v0.9.2](https://github.com/zumerlab/snapdom/compare/v0.9.2-pre.1746130901718...v0.9.2)\n\n> 1 May 2025\n\n- chore [`2f788af`](https://github.com/zumerlab/snapdom/commit/2f788afd3b25ae6391af6a41086e0b5c3595a701)\n\n#### [v0.9.2-pre.1746130901718](https://github.com/zumerlab/snapdom/compare/v0.9.1...v0.9.2-pre.1746130901718)\n\n> 1 May 2025\n\n- This PR dramatically improves the speed and accuracy of snapDOM. It increases the result size and may produce some long tasks, but it provides a solid foundation to address these side effects in the future. [`#6`](https://github.com/zumerlab/snapdom/pull/6)\n- Add as draft new default approach - not implemented [`6f4ec41`](https://github.com/zumerlab/snapdom/commit/6f4ec41c7146525c9db5cfce103e131bb3f19616)\n- Add tests [`bdd5a7f`](https://github.com/zumerlab/snapdom/commit/bdd5a7f491561966cd04bf72ca74185dc8e5a766)\n- Feat: captures icon fonts [`7b39e5f`](https://github.com/zumerlab/snapdom/commit/7b39e5fb964bc023f6d6fad555b357de5ab113f0)\n- Omit process default styles - temporary [`2953196`](https://github.com/zumerlab/snapdom/commit/2953196e00aa6bf9d026df95089d3fc81812f24d)\n- update to v.0.9.2 [`e0179a1`](https://github.com/zumerlab/snapdom/commit/e0179a160e361a1e7d58ee5e83747f385cacb887)\n- Add options as Object and allow bgColor on jpg and webp [`e5abaa7`](https://github.com/zumerlab/snapdom/commit/e5abaa72de77f75ebe6901935c5f539cda253db2)\n- Update commented docs [`cfd2272`](https://github.com/zumerlab/snapdom/commit/cfd2272b065e8c11fff1a729c6cbec1f14000668)\n- Update README.md [`fef6751`](https://github.com/zumerlab/snapdom/commit/fef6751ffa90d379c8d829998277825daddc27b8)\n- update [`26ff7ea`](https://github.com/zumerlab/snapdom/commit/26ff7ea0528d569820bed8748520a7d02c6506cd)\n- Update README.md [`3fda999`](https://github.com/zumerlab/snapdom/commit/3fda999bbdefb5aa32186bb07c59249f9a86e7e9)\n- Update README.md [`8ee616b`](https://github.com/zumerlab/snapdom/commit/8ee616baf0059eefcf7e83e7930f5ab8f3850eb5)\n- Omit delay function - temporary [`0f04721`](https://github.com/zumerlab/snapdom/commit/0f04721c458ba921694ee38117b8e0b8231a8c1a)\n- update unpkg url [`13ce66b`](https://github.com/zumerlab/snapdom/commit/13ce66bfee83802c32edfd9019959540d260cf84)\n- Bumped version [`9e4f518`](https://github.com/zumerlab/snapdom/commit/9e4f51885bddebd5364fb4ca96647233304e0dc7)\n- Update README.md [`3733476`](https://github.com/zumerlab/snapdom/commit/373347665ca89249244038eaf48731f6d7ee37b8)\n- Update README.md [`00c74b0`](https://github.com/zumerlab/snapdom/commit/00c74b07881373275d8c0e5144696d594b031e7e)\n- Update README.md [`dd2c9c5`](https://github.com/zumerlab/snapdom/commit/dd2c9c5dd507a432e4dc75e67c5d2d311073e791)\n- Update README.md [`d271cf7`](https://github.com/zumerlab/snapdom/commit/d271cf77f5747ee69df07785fef34e8c5e63649e)\n- Update README.md [`02bf650`](https://github.com/zumerlab/snapdom/commit/02bf6506ae7e3cf03507e10d8f76983c07f39c66)\n\n#### [v0.9.1](https://github.com/zumerlab/snapdom/compare/v0.9.0...v0.9.1)\n\n> 27 April 2025\n\n- update [`d90fcb9`](https://github.com/zumerlab/snapdom/commit/d90fcb97bdeb75a2adaaa14b25bd6ebced4a70e2)\n- Bumped version [`99c286a`](https://github.com/zumerlab/snapdom/commit/99c286a8883ede66ff93aa96a62d008411e4ded0)\n- fix change files prop [`548adbe`](https://github.com/zumerlab/snapdom/commit/548adbe9490b0ed4fd7e9fb77e7d6e69a6dc28c9)\n- update [`f70a917`](https://github.com/zumerlab/snapdom/commit/f70a9173c7b11d659e6bf80c6ef60b9f71e652b7)\n\n#### v0.9.0\n\n> 27 April 2025\n\n- first public version [`aac1d99`](https://github.com/zumerlab/snapdom/commit/aac1d997836362dd008d6372173c9dd84a76197f)\n- Initial commit [`fb1c063`](https://github.com/zumerlab/snapdom/commit/fb1c06307b4b822bb898477beca46f88109ac196)"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 ZumerLab\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"http://zumerlab.github.io/snapdom\">\n    <img src=\"https://raw.githubusercontent.com/zumerlab/snapdom/main/docs/assets/newhero.png\" width=\"80%\">\n  </a>\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.npmjs.com/package/@zumer/snapdom\">\n    <img alt=\"NPM version\" src=\"https://img.shields.io/npm/v/@zumer/snapdom?style=flat-square&label=Version\">\n  </a>\n  <a href=\"https://www.npmjs.com/package/@zumer/snapdom\">\n    <img alt=\"NPM weekly downloads\" src=\"https://img.shields.io/npm/dw/@zumer/snapdom?style=flat-square&label=Downloads\">\n  </a>\n  <a href=\"https://github.com/zumerlab/snapdom/graphs/contributors\">\n    <img alt=\"GitHub contributors\" src=\"https://img.shields.io/github/contributors/zumerlab/snapdom?style=flat-square&label=Contributors\">\n  </a>\n  <a href=\"https://github.com/zumerlab/snapdom/stargazers\">\n    <img alt=\"GitHub stars\" src=\"https://img.shields.io/github/stars/zumerlab/snapdom?style=flat-square&label=Stars\">\n  </a>\n  <a href=\"https://github.com/zumerlab/snapdom/network/members\">\n    <img alt=\"GitHub forks\" src=\"https://img.shields.io/github/forks/zumerlab/snapdom?style=flat-square&label=Forks\">\n  </a>\n  <a href=\"https://github.com/sponsors/tinchox5\">\n    <img alt=\"Sponsor tinchox5\" src=\"https://img.shields.io/github/sponsors/tinchox5?style=flat-square&label=Sponsor\">\n  </a>\n\n  <a href=\"https://github.com/zumerlab/snapdom/blob/main/LICENSE\">\n    <img alt=\"License\" src=\"https://img.shields.io/github/license/zumerlab/snapdom?style=flat-square\">\n  </a>\n</p>\n\n<p align=\"center\">English | <a href=\"README_CN.md\">简体中文</a></p>\n\n# SnapDOM\n\n**SnapDOM** is a next-generation **DOM Capture Engine** — ultra-fast, modular, and extensible.  \nIt converts any DOM subtree into a self-contained representation that can be exported to SVG, PNG, JPG, WebP, Canvas, Blob, or **any custom format** through plugins.\n\n* Full DOM capture\n* Embedded styles, pseudo-elements, and fonts\n* Export to SVG, PNG, JPG, WebP, `canvas`, or Blob\n* ⚡ Ultra fast, no dependencies\n* 100% based on standard Web APIs\n* Support same-origin `ìframe`\n* Support CSS counter() and CSS counters()\n* Support `...` line-clamp\n\n## Demo\n\n[https://snapdom.dev](https://snapdom.dev)\n\n\n## Quick Start\n\n**Capture any DOM element to PNG in one line:**\n\n```js\nimport { snapdom } from '@zumer/snapdom';\n\nconst img = await snapdom.toPng(document.querySelector('#card'));\ndocument.body.appendChild(img);\n```\n\n**Reusable capture** (one clone, multiple exports):\n\n```js\nconst result = await snapdom(document.querySelector('#card'));\nawait result.toPng();      // → HTMLImageElement\nawait result.toSvg();      // → SVG as Image\nawait result.download({ format: 'jpg', filename: 'card.jpg' });\n```\n\n---\n\n## Capture Flow\n\nSnapDOM transforms your DOM element through these stages:\n\n```\nDOM Element\n    ↓\nClone\n    ↓\nStyles & Pseudo\n    ↓\nImages & Backgrounds\n    ↓\nFonts\n    ↓\nSVG foreignObject\n    ↓\ndata:image/svg+xml\n    ↓\ntoPng / toSvg / toBlob / download\n```\n\n| Stage | What happens |\n|-------|--------------|\n| **Clone** | Deep clone with styles, Shadow DOM, iframes. Exclude/filter nodes. |\n| **Styles & Pseudo** | Inline `::before`/`::after` as elements, resolve `counter()`/`counters()`. |\n| **Images & Backgrounds** | Fetch and inline external images/backgrounds as data URLs. |\n| **Fonts** | Embed `@font-face` (optional) and icon fonts. |\n| **SVG** | Wrap clone in `<foreignObject>`, serialize to `data:image/svg+xml`. |\n| **Export** | Convert SVG to PNG/JPG/WebP/Blob or trigger download. |\n\nPlugin hooks: `beforeSnap` → `beforeClone` → `afterClone` → `beforeRender` → `afterRender` → `beforeExport` → `afterExport`.\n\n\n## Table of Contents\n\n- [Quick Start](#quick-start)\n- [Capture Flow](#capture-flow)\n- [Installation](#installation)\n  - [NPM / Yarn (stable)](#npm--yarn-stable)\n  - [NPM / Yarn (dev builds)](#npm--yarn-dev-builds)\n  - [CDN (stable)](#cdn-stable)\n  - [CDN (dev builds)](#cdn-dev-builds)\n- [Build Outputs](#build-outputs--tree-shaking)\n- [Usage](#usage)\n  - [Reusable capture](#reusable-capture)\n  - [One-step shortcuts](#one-step-shortcuts)\n- [API](#api)\n  - [snapdom(el, options?)](#snapdomel-options)\n  - [Shortcut methods](#shortcut-methods)\n- [Options](#options)\n  - [debug](#debug)\n  - [Fallback image on `<img>` load failure](#fallback-image-on-img-load-failure)\n  - [Dimensions (`scale`, `width`, `height`)](#dimensions-scale-width-height)\n  - [Cross-Origin Images & Fonts (`useProxy`)](#cross-origin-images--fonts-useproxy)\n  - [Fonts](#fonts)\n    - [embedFonts](#embedfonts)\n    - [localFonts](#localfonts)\n    - [iconFonts](#iconfonts)\n    - [excludeFonts](#excludefonts)\n  - [Filtering nodes: `exclude` vs `filter`](#filtering-nodes-exclude-vs-filter)\n  - [outerTransforms](#outerTransforms)\n  - [outerShadows](#no-shadows)\n  - [Cache control](#cache-control)\n- [preCache](#precache--optional-helper)\n- [Plugins (BETA)](#plugins-beta)\n  - [Registering Plugins](#registering-plugins)\n  - [Plugin Lifecycle Hooks](#plugin-lifecycle-hooks)\n  - [Context Object](#context-object)\n  - [Custom Exports via Plugins](#custom-exports-via-plugins)\n  - [Example: Overlay Filter Plugin](#example-overlay-filter-plugin)\n  - [Full Plugin Template](#full-plugin-template)\n- [Limitations](#limitations)\n- [⚡ Performance Benchmarks (Chromium)](#performance-benchmarks)\n  - [Simple elements](#simple-elements)\n  - [Complex elements](#complex-elements)\n  - [Run the benchmarks](#run-the-benchmarks)\n- [Roadmap](#roadmap)\n- [Development](#development)\n- [Contributors 🙌](#contributors)\n- [💖 Sponsors](#sponsors)\n- [Star History](#star-history)\n- [License](#license)\n\n\n\n## Installation\n\n### NPM / Yarn (stable)\n\n```bash\nnpm i @zumer/snapdom\nyarn add @zumer/snapdom\n```\n\n### NPM / Yarn (dev builds)\n\nFor early access to new features and fixes:\n\n```bash\nnpm i @zumer/snapdom@dev\nyarn add @zumer/snapdom@dev\n```\n\n⚠️ The `@dev` tag usually includes improvements before they reach production, but may be less stable.\n\n\n### CDN (stable)\n\n```html\n<!-- Minified build -->\n<script src=\"https://unpkg.com/@zumer/snapdom/dist/snapdom.js\"></script>\n\n<!-- Minified ES Module build -->\n<script type=\"module\">\n  import { snapdom } from \"https://unpkg.com/@zumer/snapdom/dist/snapdom.mjs\";\n</script>\n```\n\n### CDN (dev builds)\n\n```html\n<!-- Minified build (dev) -->\n<script src=\"https://unpkg.com/@zumer/snapdom@dev/dist/snapdom.js\"></script>\n\n<!-- Minified ES Module build (dev) -->\n<script type=\"module\">\n  import { snapdom } from \"https://unpkg.com/@zumer/snapdom@dev/dist/snapdom.mjs\";\n</script>\n```\n\n## Build Outputs\n\n| Variant | File | Use case |\n|---------|------|----------|\n| **ESM** (tree-shakeable) | `dist/snapdom.mjs` | Bundlers (Vite, webpack), `import` |\n| **IIFE** (global) | `dist/snapdom.js` | Script tag, legacy `require` |\n\n**Bundler (npm):**\n```js\nimport { snapdom } from '@zumer/snapdom';  // → dist/snapdom.mjs\n```\n\n**Script tag (CDN):**\n```html\n<script src=\"https://unpkg.com/@zumer/snapdom/dist/snapdom.js\"></script>\n<script> snapdom.toPng(document.body).then(img => document.body.appendChild(img)); </script>\n```\n\n**Subpath imports** (lighter bundle if you only need one):\n```js\nimport { preCache } from '@zumer/snapdom/preCache';\nimport { plugins } from '@zumer/snapdom/plugins';\n```\n\n\n## Usage\n\n| Pattern | When to use |\n|---------|-------------|\n| **Reusable** `snapdom(el)` | One clone → many exports (PNG + JPG + download). |\n| **Shortcuts** `snapdom.toPng(el)` | Single export, less code. |\n\n### Reusable capture\n\nCapture once, export many times (no re-clone):\n\n```js\nconst el = document.querySelector('#target');\nconst result = await snapdom(el);\n\nconst img = await result.toPng();\ndocument.body.appendChild(img);\nawait result.download({ format: 'jpg', filename: 'my-capture.jpg' });\n```\n\n### One-step shortcuts\n\nDirect export when you need a single format:\n\n```js\nconst png = await snapdom.toPng(el);\nconst blob = await snapdom.toBlob(el);\ndocument.body.appendChild(png);\n```\n\n## API\n\n### `snapdom(el, options?)`\n\nReturns an object with reusable export methods:\n\n```js\n{\n  url: string;\n  toRaw(): string;\n  toImg(): Promise<HTMLImageElement>; // deprecated \n  toSvg(): Promise<HTMLImageElement>;\n  toCanvas(): Promise<HTMLCanvasElement>;\n  toBlob(options?): Promise<Blob>;\n  toPng(options?): Promise<HTMLImageElement>;\n  toJpg(options?): Promise<HTMLImageElement>;\n  toWebp(options?): Promise<HTMLImageElement>;\n  download(options?): Promise<void>;\n}\n```\n\n### Shortcut methods\n\n| Method                         | Description                       |\n| ------------------------------ | --------------------------------- |\n| `snapdom.toImg(el, options?)`  | Returns an SVG `HTMLImageElement` (deprecated) |\n| `snapdom.toSvg(el, options?)`  | Returns an SVG `HTMLImageElement` |\n| `snapdom.toCanvas(el, options?)` | Returns a `Canvas`               |\n| `snapdom.toBlob(el, options?)` | Returns an SVG or raster `Blob`   |\n| `snapdom.toPng(el, options?)`  | Returns a PNG image               |\n| `snapdom.toJpg(el, options?)`  | Returns a JPG image               |\n| `snapdom.toWebp(el, options?)` | Returns a WebP image              |\n| `snapdom.download(el, options?)` | Triggers a download              |\n\n### Exporter-specific options\n\nSome exporters accept a small set of **export-only options** in addition to the global capture options.\n\n#### `download()` \n\n| Option | Type | Default | Description |\n| --- | --- | --- | --- |\n| `filename` | `string` | `snapdom` | Download name. |\n| `format` | `\"png\" \\| \"jpeg\" \\| \"jpg\" \\| \"webp\" \\| \"svg\"` | `\"png\"` | Output format for the downloaded file. |\n\n**Example:**\n\n```js\nawait result.download({\n  format: 'jpg',\n  quality: 0.92,\n  filename: 'my-capture'\n});\n```\n\n#### `toBlob()`\n\n| Option | Type | Default | Description |\n| --- | --- | --- | --- |\n| `type` | `\"svg\" \\| \"png\" \\| \"jpeg\" \\| \"jpg\" \\| \"webp\"` | `\"svg\"` | Blob type to generate. |\n\n**Example:**\n\n```js\nconst blob = await result.toBlob({ type: 'jpeg', quality: 0.92 });\n```\n\n## Options\n\nAll capture methods accept an `options` object:\n\n\n| Option            | Type     | Default  | Description                                     |\n| ----------------- | -------- | -------- | ----------------------------------------------- |\n| `debug`           | boolean  | `false`  | When `true`, logs suppressed errors to `console.warn` for troubleshooting |\n| `fast`            | boolean  | `true`   | Skips small idle delays for faster results      |\n| `embedFonts`      | boolean  | `false`  | Inlines non-icon fonts (icon fonts always on)   |\n| `localFonts`      | array    | `[]`     | Local fonts `{ family, src, weight?, style? }`  |\n| `iconFonts`       | string\\|RegExp\\|Array | `[]` | Extra icon font matchers                      |\n| `excludeFonts`    | object   | `{}`     | Exclude families/domains/subsets during embedding |\n| `scale`           | number   | `1`      | Output scale multiplier                         |\n| `dpr`             | number   | `devicePixelRatio` | Device pixel ratio                     |\n| `width`           | number   | -        | Output width                                    |\n| `height`          | number   | -        | Output height                                   |\n| `backgroundColor` | string   | `\"#fff\"` | Fallback color for JPG/WebP                     |\n| `quality`         | number   | `1`      | Quality for JPG/WebP (0 to 1)                   |\n| `useProxy`        | string   | `''`     | Proxy base for CORS fallbacks                   |\n| `exclude`         | string[] | -        | CSS selectors to exclude                        |\n| `excludeMode`     | `\"hide\"`\\|`\"remove\"` | `\"hide\"` | How `exclude` is applied                  |\n| `filter`          | function | -        | Custom predicate `(el) => boolean`              |\n| `filterMode`      | `\"hide\"`\\|`\"remove\"` | `\"hide\"` | How `filter` is applied                   |\n| `cache`           | string   | `\"soft\"` | `disabled` \\| `soft` \\| `auto` \\| `full`        |\n| `placeholders`    | boolean  | `true`   | Show placeholders for images/CORS iframes       |\n| `fallbackURL`     | string \\| function  | - | Fallback image for `<img>` load failure |\n| `outerTransforms`      | boolean  | `true`  | When `false` removes `translate/rotate` but preserves `scale/skew`, producing a flat, reusable capture |\n| `outerShadows`       | boolean  | `false`  | Do not expand the root’s bounding box for shadows/blur/outline, and strip those visual effects from the cloned root |\n\n| `safariWarmupAttempts` | number   | `3`      | Safari only: iterations to prime font/decode (WebKit #219770). Use `1` if 3 causes lag |\n\n### debug\n\nWhen `debug: true`, SnapDOM logs normally suppressed errors to `console.warn` (with the `[snapdom]` prefix). Useful for troubleshooting capture issues (canvas failures, blob resolution, style stripping, etc.) without noisy output in production.\n\n```js\nawait snapdom.toPng(el, { debug: true });\n```\n\n### Fallback image on `<img>` load failure\n\nProvide a default image for failed `<img>` loads. You can pass a fixed URL or a callback that receives measured dimensions and returns a URL (handy to generate dynamic placeholders).\n\n```js\n// 1) Fixed URL fallback\nawait snapdom.toSvg(element, {\n  fallbackURL: '/images/fallback.png'\n});\n\n// 2) Dynamic placeholder via callback\nawait snapdom.toSvg(element, {\n  fallbackURL: ({ width: 300, height: 150 }) =>\n    `https://placehold.co/${width}x${height}`\n});\n\n// 3) With proxy (if your fallback host has no CORS)\nawait snapdom.toSvg(element, {\n  fallbackURL: ({ width = 300, height = 150 }) =>\n    `https://dummyimage.com/${width}x${height}/cccccc/666.png&text=img`,\n  useProxy: 'https://proxy.corsfix.com/?'\n});\n```\n\nNotes:\n- If the fallback image also fails to load, snapDOM replaces the `<img>` with a placeholder block preserving width/height.\n- Width/height used by the callback are gathered from the original element (dataset, style/attrs, etc.) when available.\n\n\n### Dimensions (`scale`, `width`, `height`)\n\n* If `scale` is provided, it **takes precedence** over `width`/`height`.\n* If only `width` is provided, height scales proportionally (and vice versa).\n* Providing both `width` and `height` forces an exact size (may distort).\n\n### Cross-Origin Images & Fonts (`useProxy`)\n\nBy default snapDOM tries `crossOrigin=\"anonymous\"` (or `use-credentials` for same-origin). If an asset is CORS-blocked, you can set `useProxy` to a prefix URL that forwards the actual `src`:\n\n```js\nawait snapdom.toPng(el, {\n  useProxy: 'https://proxy.corsfix.com/?' // Note: Any cors proxy could be used 'https://proxy.corsfix.com/?'\n});\n```\n\n\n* The proxy is only used as a **fallback**; same-origin and CORS-enabled assets skip it.\n\n### Fonts\n\n#### `embedFonts`\nWhen `true`, snapDOM embeds **non-icon** `@font-face` rules detected as used within the captured subtree. Icon fonts (Font Awesome, Material Icons, etc.) are embedded **always**.\n\n#### `localFonts`\nIf you serve fonts yourself or have data URLs, you can declare them here to avoid extra CSS discovery:\n\n```js\nawait snapdom.toPng(el, {\n  embedFonts: true,\n  localFonts: [\n    { family: 'Inter', src: '/fonts/Inter-Variable.woff2', weight: 400, style: 'normal' },\n    { family: 'Inter', src: '/fonts/Inter-Italic.woff2', style: 'italic' }\n  ]\n});\n```\n\n#### `iconFonts`\nAdd custom icon families (names or regex matchers). Useful for private icon sets:\n\n```js\nawait snapdom.toPng(el, {\n  iconFonts: ['MyIcons', /^(Remix|Feather) Icons?$/i]\n});\n```\n\n#### `excludeFonts`\nSkip specific non-icon fonts to speed up capture or avoid unnecessary downloads.\n\n```js\nawait snapdom.toPng(el, {\n  embedFonts: true,\n  excludeFonts: {\n    families: ['Noto Serif', 'SomeHeavyFont'],     // skip by family name\n    domains: ['fonts.gstatic.com', 'cdn.example'], // skip by source host\n    subsets: ['cyrillic-ext']                      // skip by unicode-range subset tag\n  }\n});\n```\n*Notes*\n- `excludeFonts` only applies to **non-icon** fonts. Icon fonts are always embedded.\n- Matching is case-insensitive for `families`. Hosts are matched by substring against the resolved URL.\n\n\n#### Filtering nodes: `exclude` vs `filter`\n\n* `exclude`: remove by **selector**.\n* `excludeMode`: `hide` applies `visibility:hidden` CSS rule on excluded nodes and the layout remains as the original. `remove` do not clone excluded nodes at all.\n* `filter`: advanced predicate per element (return `false` to drop).\n* `filterMode`: `hide` applies `visibility:hidden` CSS rule on filtered nodes and the layout remains as the original. `remove` do not clone filtered nodes at all.\n\n**Example: filter out elements with `display:none`:**\n```js\n/**\n * Example filter: skip elements with display:none\n * @param {Element} el\n * @returns {boolean} true = keep, false = exclude\n */\nfunction filterHidden(el) {\n  const cs = window.getComputedStyle(el);\n  if (cs.display === 'none') return false;\n  return true;\n}\n\nawait snapdom.toPng(document.body, { filter: filterHidden });\n```\n\n**Example with `exclude`:** remove banners or tooltips by selector\n```js\nawait snapdom.toPng(el, {\n  exclude: ['.cookie-banner', '.tooltip', '[data-test=\"debug\"]']\n});\n```\n\n### outerTransforms \n\nWhen capturing rotated or translated elements, you may want use **outerTransforms: false** option if you want to eliminate those external transforms. So, the output is **flat, upright, and ready** to use elsewhere.\n\n- **`outerTransforms: true (default)`**  \n  **Keeps the original `transforms` and `rotate`**.  \n  \n\n### outerShadows\n- **`outerShadows: false (default)`**  \n  Prevents expanding the bounding box for shadows, blur, or outline on the root, and also strips `box-shadow`, `text-shadow`, `filter: blur()/drop-shadow()`, and `outline` from the cloned root.  \n\n> 💡 **Tip:** Using both (`outerTransforms: false` + `outerShadows: false`) produces a strict, minimal bounding box with no visual bleed.\n\n**Example**\n\n```js\n// outerTransforms and remove shadow bleed\nawait snapdom.toSvg(el, { outerTransforms: true, outerShadows: true });\n```\n\n## Cache control\n\nSnapDOM maintains internal caches for images, backgrounds, resources, styles, and fonts.\nYou can control how they are cleared between captures using the `cache` option:\n\n| Mode        | Description                                                                 |\n| ----------- | --------------------------------------------------------------------------- |\n| `\"disabled\"`| No cache                   |\n| `\"soft\"`    | Clears session caches (`styleMap`, `nodeMap`, `styleCache`) _(default)_      |\n| `\"auto\"`    | Minimal cleanup: only clears transient maps                                 |\n| `\"full\"`    | Keeps all caches (nothing is cleared, maximum performance)                  |\n\n**Examples:**\n\n```js\n// Use minimal but fast cache\nawait snapdom.toPng(el, { cache: 'auto' });\n\n// Keep everything in memory between captures\nawait snapdom.toPng(el, { cache: 'full' });\n\n// Force a full cleanup on every capture\nawait snapdom.toPng(el, { cache: 'disabled' });\n```\n\n## `preCache()` – Optional helper\n\nPreloads external resources to avoid first-capture stalls (helpful for big/complex trees).\n\n```js\nimport { preCache } from '@zumer/snapdom';\n\nawait preCache({\n  root: document.body,\n  embedFonts: true,\n  localFonts: [{ family: 'Inter', src: '/fonts/Inter.woff2', weight: 400 }],\n  useProxy: 'https://proxy.corsfix.com/?'\n});\n```\n\n## Plugins (BETA)\n\nSnapDOM includes a lightweight **plugin system** that allows you to extend or override behavior at any stage of the capture and export process — without touching the core library.\n\nA plugin is a simple object with a unique `name` and one or more lifecycle **hooks**.\nHooks can be synchronous or `async`, and they receive a shared **`context`** object.\n\n### Registering Plugins\n\n**Global registration** (applies to all captures):\n\n```js\nimport { snapdom } from '@zumer/snapdom';\n\n// You can register instances, factories, or [factory, options]\nsnapdom.plugins(\n  myPluginInstance,\n  [myPluginFactory, { optionA: true }],\n  { plugin: anotherFactory, options: { level: 2 } }\n);\n```\n\n**Per-capture registration** (only for that specific call):\n\n```js\nconst out = await snapdom(element, {\n  plugins: [\n    [overlayFilterPlugin, { color: 'rgba(0,0,0,0.25)' }],\n    [myFullPlugin, { providePdf: true }]\n  ]\n});\n```\n\n* **Execution order = registration order** (first registered, first executed).\n* **Per-capture plugins** run **before** global ones.\n* Duplicates are automatically skipped by `name`; a per-capture plugin with the same `name` overrides its global version.\n\n### Plugin Lifecycle Hooks\n\nHooks run in capture order (see [Capture Flow](#capture-flow)):\n\n| Hook | Stage | Purpose |\n|------|-------|---------|\n| `beforeSnap` | Start | Adjust options before any work. |\n| `beforeClone` | Pre-clone | Before DOM clone (modify live DOM carefully). |\n| `afterClone` | Post-clone | Modify cloned tree safely (e.g. inject overlay). |\n| `beforeRender` | Pre-serialize | Right before SVG → data URL. |\n| `afterRender` | Post-serialize | Inspect `context.svgString` / `context.dataURL`. |\n| `beforeExport` | Per export | Before each `toPng`, `toSvg`, etc. |\n| `afterExport` | Per export | Transform returned result. |\n| `afterSnap` | Once | After first export; cleanup. |\n| `defineExports` | Setup | Add custom exporters (e.g. `toPdf`). |\n\n> Returned values from `afterExport` are chained to the next plugin (transform pipeline).\n\n### Context Object\n\nEvery hook receives a single `context` object that contains normalized capture state:\n\n* **Input & options:**\n  `element`, `debug`, `fast`, `scale`, `dpr`, `width`, `height`, `backgroundColor`, `quality`, `useProxy`, `cache`, `outerTransforms`, `outerShadows`, `safariWarmupAttempts`, `embedFonts`, `localFonts`, `iconFonts`, `excludeFonts`, `exclude`, `excludeMode`, `filter`, `filterMode`, `fallbackURL`.\n\n* **Intermediate values (depending on stage):**\n  `clone`, `classCSS`, `styleCache`, `fontsCSS`, `baseCSS`, `svgString`, `dataURL`.\n\n* **During export:**\n  `context.export = { type, options, url }`\n  where `type` is the exporter name (`\"png\"`, `\"jpeg\"`, `\"svg\"`, `\"blob\"`, etc.), and `url` is the serialized SVG base.\n\n> You may safely modify `context` (e.g., override `backgroundColor` or `quality`) — but do so early (`beforeSnap`) for global effects or in `beforeExport` for single-export changes.\n\n\n## Custom Exports via Plugins\n\nPlugins can add new exports using `defineExports(context)`.\nFor each export key you return (e.g., `\"pdf\"`), SnapDOM automatically exposes a helper method named **`toPdf()`** on the capture result.\n\n**Register the plugin (global or per capture):**\n\n```js\nimport { snapdom } from '@zumer/snapdom';\n\n// global\nsnapdom.plugins(pdfExportPlugin());\n\n// or per capture\nconst out = await snapdom(element, { plugins: [pdfExportPlugin()] });\n```\n\n**Call the custom export:**\n\n```js\nconst out = await snapdom(document.querySelector('#report'));\n\n// because the plugin returns { pdf: async (ctx, opts) => ... }\nconst pdfBlob = await out.toPdf({\n  // exporter-specific options (width, height, quality, filename, etc.)\n});\n```\n\n### Example: Overlay Filter Plugin\n\nAdds a translucent overlay or color filter **only** to the captured clone (not your live DOM).\nUseful for highlighting or dimming sections before export.\n\n```js\n/**\n * Ultra-simple overlay filter for SnapDOM (HTML-only).\n * Inserts a full-size <div> overlay on the cloned root.\n *\n * @param {{ color?: string; blur?: number }} [options]\n *   color: overlay color (rgba/hex/hsl). Default: 'rgba(0,0,0,0.25)'\n *   blur: optional blur in px (default: 0)\n */\nexport function overlayFilterPlugin(options = {}) {\n  const color = options.color ?? 'rgba(0,0,0,0.25)';\n  const blur = Math.max(0, options.blur ?? 0);\n\n  return {\n    name: 'overlay-filter',\n\n    /**\n     * Add a full-coverage overlay to the cloned HTML root.\n     * @param {any} context\n     */\n    async afterClone(context) {\n      const root = context.clone;\n      if (!(root instanceof HTMLElement)) return; // HTML-only\n\n      // Ensure containing block so absolute overlay anchors to the root\n      if (getComputedStyle(root).position === 'static') {\n        root.style.position = 'relative';\n      }\n\n      const overlay = document.createElement('div');\n      overlay.style.position = 'absolute';\n      overlay.style.left = '0';\n      overlay.style.top = '0';\n      overlay.style.right = '0';\n      overlay.style.bottom = '0';\n      overlay.style.background = color;\n      overlay.style.pointerEvents = 'none';\n      if (blur) overlay.style.filter = `blur(${blur}px)`;\n\n      root.appendChild(overlay);\n    }\n  };\n}\n\n```\n\n**Usage:**\n\n```js\nimport { snapdom } from '@zumer/snapdom';\n\n// Global registration\nsnapdom.plugins([overlayFilterPlugin, { color: 'rgba(0,0,0,0.3)', blur: 2 }]);\n\n// Per-capture\nconst out = await snapdom(document.querySelector('#card'), {\n  plugins: [[overlayFilterPlugin, { color: 'rgba(255,200,0,0.15)' }]]\n});\n\nconst png = await out.toPng();\ndocument.body.appendChild(png);\n```\n\n> The overlay is injected **only in the cloned tree**, never in your live DOM, ensuring perfect fidelity and zero flicker.\n\n\n### Full Plugin Template\n\nUse this as a starting point for custom logic or exporters.\n\n```js\nexport function myPlugin(options = {}) {\n  return {\n    /** Unique name used for de-duplication/overrides */\n    name: 'my-plugin',\n\n    /** Early adjustments before any clone/style work. */\n    async beforeSnap(context) {},\n\n    /** Before subtree cloning (use sparingly if touching the live DOM). */\n    async beforeClone(context) {},\n\n    /** After subtree cloning (safe to modify the cloned tree). */\n    async afterClone(context) {},\n\n    /** Right before serialization (SVG/dataURL). */\n    async beforeRender(context) {},\n\n    /** After serialization; inspect context.svgString/context.dataURL if needed. */\n    async afterRender(context) {},\n\n    /** Before EACH export call (toPng/toSvg/toBlob/...). */\n    async beforeExport(context) {},\n\n    /**\n     * After EACH export call.\n     * If you return a value, it becomes the result for the next plugin (chaining).\n     */\n    async afterExport(context, result) { return result; },\n\n    /**\n     * Define custom exporters (auto-added as helpers like out.toPdf()).\n     * Return a map { [key: string]: (ctx:any, opts:any) => Promise<any> }.\n     */\n    async defineExports(context) { return {}; },\n\n    /** Runs ONCE after the FIRST export finishes (cleanup). */\n    async afterSnap(context) {}\n  };\n}\n```\n\n**Quick recap:**\n\n* Plugins can modify capture behavior (`beforeSnap`, `afterClone`, etc.).\n* You can inject visuals or transformations safely into the cloned tree.\n* New exporters defined in `defineExports()` automatically become helpers like `out.toPdf()`.\n* All hooks can be asynchronous, run in order, and share the same `context`.\n\n\n## Limitations\n\n* External images should be CORS-accessible (use `useProxy` option for handling CORS denied)\n* When WebP format is used on Safari, it will fallback to PNG rendering.\n* `@font-face` CSS rule is well supported, but if need to use JS `FontFace()`, see this workaround [`#43`](https://github.com/zumerlab/snapdom/issues/43)\n* **Safari**: captures with `embedFonts` or background/mask images run slower due to [WebKit #219770](https://bugs.webkit.org/show_bug.cgi?id=219770) (font decode timing). SnapDOM does pre-captures + `drawImage` to prime the pipeline; configurable via `safariWarmupAttempts` (default 3).\n* **Custom scrollbar styles** (`::-webkit-scrollbar`): Applied only when the element has *not* been scrolled. When scrolled, the viewport content is captured without the scrollbar.\n\n\n## Performance Benchmarks\n\n**Setup.** Vitest benchmarks on Chromium, repo tests. Hardware may affect results.\nValues are **average capture time (ms)** → lower is better.\n\n### Simple elements\n\n| Scenario                 | SnapDOM current | SnapDOM v1.9.9 | html2canvas | html-to-image |\n| ------------------------ | --------------- | -------------- | ----------- | ------------- |\n| Small (200×100)          | **0.5 ms**      | 0.8 ms         | 67.7 ms     | 3.1 ms        |\n| Modal (400×300)          | **0.5 ms**      | 0.8 ms         | 75.5 ms     | 3.6 ms        |\n| Page View (1200×800)     | **0.5 ms**      | 0.8 ms         | 114.2 ms    | 3.3 ms        |\n| Large Scroll (2000×1500) | **0.5 ms**      | 0.8 ms         | 186.3 ms    | 3.2 ms        |\n| Very Large (4000×2000)   | **0.5 ms**      | 0.9 ms         | 425.9 ms    | 3.3 ms        |\n\n\n### Complex elements\n\n| Scenario                 | SnapDOM current | SnapDOM v1.9.9 | html2canvas | html-to-image |\n| ------------------------ | --------------- | -------------- | ----------- | ------------- |\n| Small (200×100)          | **1.6 ms**      | 3.3 ms         | 68.0 ms     | 14.3 ms       |\n| Modal (400×300)          | **2.9 ms**      | 6.8 ms         | 87.5 ms     | 34.8 ms       |\n| Page View (1200×800)     | **17.5 ms**     | 50.2 ms        | 178.0 ms    | 429.0 ms      |\n| Large Scroll (2000×1500) | **54.0 ms**     | 201.8 ms       | 735.2 ms    | 984.2 ms      |\n| Very Large (4000×2000)   | **171.4 ms**    | 453.7 ms       | 1,800.4 ms  | 2,611.9 ms    |\n\n\n### Run the benchmarks\n\n```sh\ngit clone https://github.com/zumerlab/snapdom.git\ncd snapdom\nnpm install\nnpm run test:benchmark\n```\n\n\n## Roadmap\n\nPlanned improvements for future versions of SnapDOM:\n\n* [X] **Implement plugin system**\n  SnapDOM will support external plugins to extend or override internal behavior (e.g. custom node transformers, exporters, or filters).\n\n* [ ] **Refactor to modular architecture**\n  Internal logic will be split into smaller, focused modules to improve maintainability and code reuse.\n\n* [X] **Decouple internal logic from global options**\n  Functions will be redesigned to avoid relying directly on `options`. A centralized capture context will improve clarity, autonomy, and testability. See [`next` branch](https://github.com/zumerlab/snapdom/tree/main)\n\n* [X] **Expose cache control**\n  Users will be able to manually clear image and font caches or configure their own caching strategies.\n\n* [X] **Auto font preloading**\n  Required fonts will be automatically detected and preloaded before capture, reducing the need for manual `preCache()` calls.\n\n* [X] **Document plugin development**\n  A full guide will be provided for creating and registering custom SnapDOM plugins.\n\n* [ ] **Make export utilities tree-shakeable**\n  Export functions like `toPng`, `toJpg`, `toBlob`, etc. will be restructured into independent modules to support tree shaking and minimal builds.\n\nHave ideas or feature requests?\nFeel free to share suggestions or feedback in [GitHub Discussions](https://github.com/zumerlab/snapdom/discussions).\n\n\n## Development\n\n**Source layout:**\n- `src/api/` – Public API (`snapdom`, `preCache`)\n- `src/core/` – Capture pipeline, clone, prepare, plugins\n- `src/modules/` – Images, fonts, pseudo-elements, backgrounds, SVG\n- `src/exporters/` – toPng, toSvg, toBlob, etc.\n- `dist/` – Build output (`snapdom.js`, `snapdom.mjs`, `preCache.mjs`, `plugins.mjs`)\n\n**Build:**\n```sh\ngit clone https://github.com/zumerlab/snapdom.git\ncd snapdom\ngit checkout dev\nnpm install\nnpm run compile\n```\n\n**Test:**\n```sh\nnpx playwright install   # Required for browser tests\nnpm test\nnpm run test:benchmark\n```\n\nFor detailed guidelines, see [CONTRIBUTING](https://github.com/zumerlab/snapdom/blob/main/CONTRIBUTING.md).\n\n\n## Contributors\n\n<!-- CONTRIBUTORS:START -->\n<p>\n<a href=\"https://github.com/tinchox5\" title=\"tinchox5\"><img src=\"https://avatars.githubusercontent.com/u/11557901?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"tinchox5\"/></a>\n<a href=\"https://github.com/Jarvis2018\" title=\"Jarvis2018\"><img src=\"https://avatars.githubusercontent.com/u/36788851?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"Jarvis2018\"/></a>\n<a href=\"https://github.com/tarwin\" title=\"tarwin\"><img src=\"https://avatars.githubusercontent.com/u/646149?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"tarwin\"/></a>\n<a href=\"https://github.com/Amyuan23\" title=\"Amyuan23\"><img src=\"https://avatars.githubusercontent.com/u/25892910?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"Amyuan23\"/></a>\n<a href=\"https://github.com/airamhr9\" title=\"airamhr9\"><img src=\"https://avatars.githubusercontent.com/u/57371081?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"airamhr9\"/></a>\n<a href=\"https://github.com/FlavioLimaMindera\" title=\"FlavioLimaMindera\"><img src=\"https://avatars.githubusercontent.com/u/96424442?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"FlavioLimaMindera\"/></a>\n<a href=\"https://github.com/jswhisperer\" title=\"jswhisperer\"><img src=\"https://avatars.githubusercontent.com/u/1177690?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"jswhisperer\"/></a>\n<a href=\"https://github.com/K1ender\" title=\"K1ender\"><img src=\"https://avatars.githubusercontent.com/u/146767945?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"K1ender\"/></a>\n<a href=\"https://github.com/kohaiy\" title=\"kohaiy\"><img src=\"https://avatars.githubusercontent.com/u/15622127?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"kohaiy\"/></a>\n<a href=\"https://github.com/17biubiu\" title=\"17biubiu\"><img src=\"https://avatars.githubusercontent.com/u/13295895?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"17biubiu\"/></a>\n<a href=\"https://github.com/av01d\" title=\"av01d\"><img src=\"https://avatars.githubusercontent.com/u/6247646?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"av01d\"/></a>\n<a href=\"https://github.com/CHOYSEN\" title=\"CHOYSEN\"><img src=\"https://avatars.githubusercontent.com/u/25995358?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"CHOYSEN\"/></a>\n<a href=\"https://github.com/pedrocateexte\" title=\"pedrocateexte\"><img src=\"https://avatars.githubusercontent.com/u/207524750?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"pedrocateexte\"/></a>\n<a href=\"https://github.com/domialex\" title=\"domialex\"><img src=\"https://avatars.githubusercontent.com/u/4694217?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"domialex\"/></a>\n<a href=\"https://github.com/elliots\" title=\"elliots\"><img src=\"https://avatars.githubusercontent.com/u/622455?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"elliots\"/></a>\n<a href=\"https://github.com/stypr\" title=\"stypr\"><img src=\"https://avatars.githubusercontent.com/u/6625978?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"stypr\"/></a>\n<a href=\"https://github.com/mon-jai\" title=\"mon-jai\"><img src=\"https://avatars.githubusercontent.com/u/91261297?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"mon-jai\"/></a>\n<a href=\"https://github.com/sharuzzaman\" title=\"sharuzzaman\"><img src=\"https://avatars.githubusercontent.com/u/7421941?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"sharuzzaman\"/></a>\n<a href=\"https://github.com/simon1uo\" title=\"simon1uo\"><img src=\"https://avatars.githubusercontent.com/u/60037549?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"simon1uo\"/></a>\n<a href=\"https://github.com/titoBouzout\" title=\"titoBouzout\"><img src=\"https://avatars.githubusercontent.com/u/64156?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"titoBouzout\"/></a>\n<a href=\"https://github.com/ZiuChen\" title=\"ZiuChen\"><img src=\"https://avatars.githubusercontent.com/u/64892985?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"ZiuChen\"/></a>\n<a href=\"https://github.com/harshasiddartha\" title=\"harshasiddartha\"><img src=\"https://avatars.githubusercontent.com/u/147021873?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"harshasiddartha\"/></a>\n<a href=\"https://github.com/karasHou\" title=\"karasHou\"><img src=\"https://avatars.githubusercontent.com/u/27048083?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"karasHou\"/></a>\n<a href=\"https://github.com/jhbae200\" title=\"jhbae200\"><img src=\"https://avatars.githubusercontent.com/u/20170610?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"jhbae200\"/></a>\n<a href=\"https://github.com/xiaobai-web715\" title=\"xiaobai-web715\"><img src=\"https://avatars.githubusercontent.com/u/81091224?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"xiaobai-web715\"/></a>\n<a href=\"https://github.com/miusuncle\" title=\"miusuncle\"><img src=\"https://avatars.githubusercontent.com/u/7549857?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"miusuncle\"/></a>\n<a href=\"https://github.com/rbbydotdev\" title=\"rbbydotdev\"><img src=\"https://avatars.githubusercontent.com/u/101137670?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"rbbydotdev\"/></a>\n<a href=\"https://github.com/zhanghaotian2018\" title=\"zhanghaotian2018\"><img src=\"https://avatars.githubusercontent.com/u/169218899?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"zhanghaotian2018\"/></a>\n</p>\n<!-- CONTRIBUTORS:END -->\n\n## Sponsors\n\nSpecial thanks to [@megaphonecolin](https://github.com/megaphonecolin), [@sdraper69](https://github.com/sdraper69), [@reynaldichernando](https://github.com/reynaldichernando), [@gamma-app](https://github.com/gamma-app) and [@jrjohnson](https://github.com/jrjohnson),for supporting this project!\n\nIf you'd like to support this project too, you can [become a sponsor](https://github.com/sponsors/tinchox5).\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=zumerlab/snapdom&type=Date)](https://www.star-history.com/#zumerlab/snapdom&Date)\n\n## License\n\nMIT © Zumerlab"
  },
  {
    "path": "README_CN.md",
    "content": "<p align=\"center\">\n  <a href=\"http://zumerlab.github.io/snapdom\">\n    <img src=\"https://raw.githubusercontent.com/zumerlab/snapdom/main/docs/assets/newhero.png\" width=\"80%\">\n  </a>\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.npmjs.com/package/@zumer/snapdom\">\n    <img alt=\"NPM version\" src=\"https://img.shields.io/npm/v/@zumer/snapdom?style=flat-square&label=Version\">\n  </a>\n  <a href=\"https://www.npmjs.com/package/@zumer/snapdom\">\n    <img alt=\"NPM weekly downloads\" src=\"https://img.shields.io/npm/dw/@zumer/snapdom?style=flat-square&label=Downloads\">\n  </a>\n  <a href=\"https://github.com/zumerlab/snapdom/graphs/contributors\">\n    <img alt=\"GitHub contributors\" src=\"https://img.shields.io/github/contributors/zumerlab/snapdom?style=flat-square&label=Contributors\">\n  </a>\n  <a href=\"https://github.com/zumerlab/snapdom/stargazers\">\n    <img alt=\"GitHub stars\" src=\"https://img.shields.io/github/stars/zumerlab/snapdom?style=flat-square&label=Stars\">\n  </a>\n  <a href=\"https://github.com/zumerlab/snapdom/network/members\">\n    <img alt=\"GitHub forks\" src=\"https://img.shields.io/github/forks/zumerlab/snapdom?style=flat-square&label=Forks\">\n  </a>\n  <a href=\"https://github.com/sponsors/tinchox5\">\n    <img alt=\"Sponsor tinchox5\" src=\"https://img.shields.io/github/sponsors/tinchox5?style=flat-square&label=Sponsor\">\n  </a>\n\n  <a href=\"https://github.com/zumerlab/snapdom/blob/main/LICENSE\">\n    <img alt=\"License\" src=\"https://img.shields.io/github/license/zumerlab/snapdom?style=flat-square\">\n  </a>\n</p>\n<p align=\"center\"><a href=\"README.md\">English</a> | 简体中文</p>\n\n# snapDOM\n\n**SnapDOM** 是新一代的 **DOM 捕获引擎（DOM Capture Engine）**——超高速、模块化、可扩展。  \n它可以将任意 DOM 子树转换为自包含的结构，并导出为 SVG、PNG、JPG、WebP、Canvas、Blob，或通过插件系统生成 **任何自定义格式**。\n\nSnapDOM 会保留样式、字体、背景图像、伪元素、Shadow DOM 等所有视觉特性，并通过可扩展的架构实现强大的灵活性和最高级别的捕获质量。\n\n\n* 完整的 DOM 捕获\n* 内嵌样式、伪元素和字体\n* 导出为 SVG、PNG、JPG、WebP、`canvas` 或 Blob\n* ⚡ 超快速度，无依赖\n* 100% 基于标准 Web API\n* 支持同源 `iframe`\n* 支持 CSS counter() 和 CSS counters()\n* 支持 `...` 文本截断（line-clamp）\n\n## 演示\n\n[https://snapdom.dev](https://snapdom.dev)\n\n\n## 快速开始\n\n**一行代码将任意 DOM 元素导出为 PNG：**\n\n```js\nimport { snapdom } from '@zumer/snapdom';\n\nconst img = await snapdom.toPng(document.querySelector('#card'));\ndocument.body.appendChild(img);\n```\n\n**可复用捕获**（一次克隆，多次导出）：\n\n```js\nconst result = await snapdom(document.querySelector('#card'));\nawait result.toPng();      // → HTMLImageElement\nawait result.toSvg();      // → SVG 图片\nawait result.download({ format: 'jpg', filename: 'card.jpg' });\n```\n\n---\n\n## 捕获流程\n\nSnapDOM 将 DOM 元素按以下阶段转换：\n\n```\nDOM Element\n    ↓\nClone\n    ↓\nStyles & Pseudo\n    ↓\nImages & Backgrounds\n    ↓\nFonts\n    ↓\nSVG foreignObject\n    ↓\ndata:image/svg+xml\n    ↓\ntoPng / toSvg / toBlob / download\n```\n\n| 阶段 | 说明 |\n|------|------|\n| **Clone** | 深度克隆，含样式、Shadow DOM、iframe。排除/过滤节点。 |\n| **Styles & Pseudo** | 将 `::before`/`::after` 内联为元素，解析 `counter()`/`counters()`。 |\n| **Images & Backgrounds** | 拉取并内联外部图片/背景为 data URL。 |\n| **Fonts** | 嵌入 `@font-face`（可选）及图标字体。 |\n| **SVG** | 将克隆包裹在 `<foreignObject>` 中，序列化为 `data:image/svg+xml`。 |\n| **Export** | 转换为 PNG/JPG/WebP/Blob 或触发下载。 |\n\n插件钩子顺序：`beforeSnap` → `beforeClone` → `afterClone` → `beforeRender` → `afterRender` → `beforeExport` → `afterExport`。\n\n\n## 目录\n\n- [快速开始](#快速开始)\n- [捕获流程](#捕获流程)\n- [安装](#安装)\n  - [NPM / Yarn (稳定版)](#npm--yarn-稳定版)\n  - [NPM / Yarn (开发版)](#npm--yarn-开发版)\n  - [CDN (稳定版)](#cdn-稳定版)\n  - [CDN (开发版)](#cdn-开发版)\n- [构建产物](#构建产物与摇树优化)\n- [用法](#基本用法)\n  - [可复用的捕获](#可复用的捕获)\n  - [一步式快捷方法](#一步式快捷方法)\n- [API](#api)\n  - [snapdom(el, options?)](#snapdomel-options)\n  - [快捷方法](#快捷方法)\n- [选项](#选项)\n  - [debug](#debug)\n  - [`<img>` 加载失败时的备用图片](#img-加载失败时的备用图片)\n  - [尺寸 (`scale`, `width`, `height`)](#尺寸-scale-width-height)\n  - [跨域图片和字体 (`useProxy`)](#跨域图片和字体-useproxy)\n  - [字体](#字体)\n    - [embedFonts](#embedfonts)\n    - [localFonts](#localfonts)\n    - [iconFonts](#iconfonts)\n    - [excludeFonts](#excludefonts)\n  - [节点过滤：`exclude` vs `filter`](#节点过滤-exclude-vs-filter)\n  - [outerTransforms](#outertransforms)\n  - [outerShadows](#outerShadows)\n  - [缓存控制](#缓存控制)\n- [preCache](#precache--可选辅助函数)\n- [插件（测试版）](#插件测试版)\n  - [注册插件](#注册插件)\n  - [插件生命周期钩子](#插件生命周期钩子)\n  - [上下文对象](#上下文对象)\n  - [通过插件自定义导出](#通过插件自定义导出)\n  - [示例：叠加滤镜插件](#示例叠加滤镜插件)\n  - [完整插件模板](#完整插件模板)\n- [限制](#限制)\n- [⚡ 性能基准测试（Chromium）](#性能基准测试chromium)\n  - [简单元素](#简单元素)\n  - [复杂元素](#复杂元素)\n  - [运行基准测试](#运行基准测试)\n- [路线图](#路线图)\n- [开发](#开发)\n- [贡献者 🙌](#贡献者)\n- [💖 赞助者](#赞助者)\n- [Star 历史](#star-历史)\n- [许可证](#许可证)\n\n\n## 安装\n\n### NPM / Yarn (稳定版)\n\n```bash\nnpm i @zumer/snapdom\nyarn add @zumer/snapdom\n```\n\n### NPM / Yarn (开发版)\n\n想要提前体验新功能和修复：\n\n```bash\nnpm i @zumer/snapdom@dev\nyarn add @zumer/snapdom@dev\n```\n\n⚠️ `@dev` 标签通常包含在正式发布前的改进，但可能不够稳定。\n\n### CDN (稳定版)\n\n```html\n<!-- 压缩的 构建 -->\n<script src=\"https://unpkg.com/@zumer/snapdom/dist/snapdom.js\"></script>\n\n<!-- ES 模块构建 -->\n<script type=\"module\">\n  import { snapdom } from \"https://unpkg.com/@zumer/snapdom/dist/snapdom.mjs\";\n</script>\n```\n\n### CDN (开发版)\n\n```html\n<!-- 压缩的 UMD 构建（开发版） -->\n<script src=\"https://unpkg.com/@zumer/snapdom@dev/dist/snapdom.js\"></script>\n\n<!-- ES 模块构建（开发版） -->\n<script type=\"module\">\n  import { snapdom } from \"https://unpkg.com/@zumer/snapdom@dev/dist/snapdom.mjs\";\n</script>\n```\n\n\n## 构建产物\n\n| 变体 | 文件 | 使用场景 |\n|------|------|----------|\n| **ESM**（可摇树） | `dist/snapdom.mjs` | 打包工具（Vite、webpack），`import` |\n| **IIFE**（全局） | `dist/snapdom.js` | script 标签、传统 `require` |\n\n**打包工具 (npm)：**\n```js\nimport { snapdom } from '@zumer/snapdom';  // → dist/snapdom.mjs\n```\n\n**script 标签 (CDN)：**\n```html\n<script src=\"https://unpkg.com/@zumer/snapdom/dist/snapdom.js\"></script>\n<script> snapdom.toPng(document.body).then(img => document.body.appendChild(img)); </script>\n```\n\n**子路径导入**（仅需部分功能时可减小体积）：\n```js\nimport { preCache } from '@zumer/snapdom/preCache';\nimport { plugins } from '@zumer/snapdom/plugins';\n```\n\n\n## 基本用法\n\n| 模式 | 适用场景 |\n|------|----------|\n| **可复用** `snapdom(el)` | 一次克隆 → 多次导出（PNG + JPG + 下载）。 |\n| **快捷** `snapdom.toPng(el)` | 单次导出，代码更简洁。 |\n\n### 可复用的捕获\n\n一次捕获，多次导出（不会重新克隆）：\n\n```js\nconst el = document.querySelector('#target');\nconst result = await snapdom(el);\n\nconst img = await result.toPng();\ndocument.body.appendChild(img);\nawait result.download({ format: 'jpg', filename: 'my-capture.jpg' });\n```\n\n### 一步式快捷方法\n\n直接导出单一格式：\n\n```js\nconst png = await snapdom.toPng(el);\nconst blob = await snapdom.toBlob(el);\ndocument.body.appendChild(png);\n```\n\n## API\n\n### `snapdom(el, options?)`\n\n返回一个包含可复用导出方法的对象：\n\n```js\n{\n  url: string;\n  toRaw(): string;\n  toImg(): Promise<HTMLImageElement>; // 已废弃 \n  toSvg(): Promise<HTMLImageElement>;\n  toCanvas(): Promise<HTMLCanvasElement>;\n  toBlob(options?): Promise<Blob>;\n  toPng(options?): Promise<HTMLImageElement>;\n  toJpg(options?): Promise<HTMLImageElement>;\n  toWebp(options?): Promise<HTMLImageElement>;\n  download(options?): Promise<void>;\n}\n```\n\n### 快捷方法\n\n| 方法                         | 描述                       |\n| ------------------------------ | --------------------------------- |\n| `snapdom.toImg(el, options?)`  | 返回一个 SVG `HTMLImageElement`（已废弃） |\n| `snapdom.toSvg(el, options?)`  | 返回一个 SVG `HTMLImageElement` |\n| `snapdom.toCanvas(el, options?)` | 返回一个 `Canvas`               |\n| `snapdom.toBlob(el, options?)` | 返回一个 SVG 或光栅 `Blob`   |\n| `snapdom.toPng(el, options?)`  | 返回一个 PNG 图片               |\n| `snapdom.toJpg(el, options?)`  | 返回一个 JPG 图片               |\n| `snapdom.toWebp(el, options?)` | 返回一个 WebP 图片              |\n| `snapdom.download(el, options?)` | 触发下载              |\n\n### 导出器专用选项\n\n除了全局的捕获选项之外，部分导出器还支持一小组 **仅用于导出** 的选项。\n\n#### `download()`\n\n| Option     | Type                                          | Default   | Description |\n| ---------- | --------------------------------------------- | --------- | ----------- |\n| `filename` | `string`                                      | `snapdom` | 下载文件名。      |\n| `format`   | `\"png\" \\| \"jpeg\" \\| \"jpg\" \\| \"webp\" \\| \"svg\"` | `\"png\"`   | 下载文件的输出格式。  |\n\n**示例：**\n\n```js\nawait result.download({\n  format: 'jpg',\n  quality: 0.92,\n  filename: 'my-capture'\n});\n```\n\n#### `toBlob()`\n\n| Option | Type                                          | Default | Description  |\n| ------ | --------------------------------------------- | ------- | ------------ |\n| `type` | `\"svg\" \\| \"png\" \\| \"jpeg\" \\| \"jpg\" \\| \"webp\"` | `\"svg\"` | 生成的 Blob 类型。 |\n\n**示例：**\n\n```js\nconst blob = await result.toBlob({ type: 'jpeg', quality: 0.92 });\n```\n\n\n## 选项\n\n所有捕获方法都接受一个 `options` 对象：\n\n\n| 选项            | 类型     | 默认值  | 描述                                     |\n| ----------------- | -------- | -------- | ----------------------------------------------- |\n| `debug`           | boolean  | `false`  | 设为 `true` 时，将静默处理的错误输出到 `console.warn`，便于排查问题 |\n| `fast`            | boolean  | `true`   | 跳过小的空闲延迟以获得更快的结果      |\n| `embedFonts`      | boolean  | `false`  | 内嵌非图标字体（图标字体始终内嵌）   |\n| `localFonts`      | array    | `[]`     | 本地字体 `{ family, src, weight?, style? }`  |\n| `iconFonts`       | string\\|RegExp\\|Array | `[]` | 额外的图标字体匹配器                      |\n| `excludeFonts`    | object   | `{}`     | 在嵌入时排除字体族/域名/子集 |\n| `scale`           | number   | `1`      | 输出缩放倍数                         |\n| `dpr`             | number   | `devicePixelRatio` | 设备像素比                     |\n| `width`           | number   | -        | 输出宽度                                    |\n| `height`          | number   | -        | 输出高度                                   |\n| `backgroundColor` | string   | `\"#fff\"` | JPG/WebP 的备用颜色                     |\n| `quality`         | number   | `1`      | JPG/WebP 的质量（0 到 1）                   |\n| `useProxy`        | string   | `''`     | CORS 备用代理基础 URL                   |\n| `exclude`         | string[] | -        | 要排除的 CSS 选择器                        |\n| `excludeMode`     | `\"hide\"`\\|`\"remove\"` | `\"hide\"` | `exclude` 的应用方式                  |\n| `filter`          | function | -        | 自定义谓词函数 `(el) => boolean`              |\n| `filterMode`      | `\"hide\"`\\|`\"remove\"` | `\"hide\"` | `filter` 的应用方式                   |\n| `cache`           | string   | `\"soft\"` | `disabled` \\| `soft` \\| `auto` \\| `full`        |\n| `placeholders`    | boolean  | `true`   | 为图片/CORS iframe 显示占位符       |\n| `fallbackURL`     | string \\| function  | - | `<img>` 加载失败时的备用图片 |\n| `outerTransforms`      | boolean  | `true`  | 当为 `false` 时移除 `translate/rotate` 但保留 `scale/skew`，产生扁平、可复用的捕获 |\n| `outerShadows`       | boolean  | `false`  | 不为根元素的阴影/模糊/轮廓扩展边界框，并从克隆的根元素中移除这些视觉效果 |\n| `safariWarmupAttempts` | number   | `3`      | 仅 Safari：预热的迭代次数（WebKit #219770）。若 3 次过慢可设为 `1` |\n\n### debug\n\n当 `debug: true` 时，SnapDOM 会将通常静默处理的错误输出到 `console.warn`（带 `[snapdom]` 前缀）。便于排查捕获问题（如 canvas 失败、blob 解析、样式剥离等），而无需在生产环境中产生冗余输出。\n\n```js\nawait snapdom.toPng(el, { debug: true });\n```\n\n### `<img>` 加载失败时的备用图片\n\n为失败的 `<img>` 加载提供默认图片。您可以传递一个固定 URL 或一个接收测量尺寸并返回 URL 的回调函数（便于生成动态占位符）。\n\n```js\n// 1) 固定 URL 备用\nawait snapdom.toSvg(element, {\n  fallbackURL: '/images/fallback.png'\n});\n\n// 2) 通过回调生成动态占位符\nawait snapdom.toSvg(element, {\n  fallbackURL: ({ width: 300, height: 150 }) =>\n    `https://placehold.co/${width}x${height}`\n});\n\n// 3) 使用代理（如果您的备用图片主机没有 CORS）\nawait snapdom.toSvg(element, {\n  fallbackURL: ({ width = 300, height = 150 }) =>\n    `https://dummyimage.com/${width}x${height}/cccccc/666.png&text=img`,\n  useProxy: 'https://proxy.corsfix.com/?'\n});\n```\n\n注意：\n- 如果备用图片也加载失败，snapDOM 会用保留宽度/高度的占位符块替换 `<img>`。\n- 回调使用的宽度/高度从原始元素（dataset、style/attrs 等）中收集（如果可用）。\n\n### 尺寸 (`scale`, `width`, `height`)\n\n* 如果提供了 `scale`，它将**优先于** `width`/`height`。\n* 如果只提供 `width`，高度按比例缩放（反之亦然）。\n* 同时提供 `width` 和 `height` 会强制使用精确尺寸（可能会失真）。\n\n### 跨域图片和字体 (`useProxy`)\n\n默认情况下，snapDOM 尝试使用 `crossOrigin=\"anonymous\"`（或同源时使用 `use-credentials`）。如果资源被 CORS 阻止，您可以将 `useProxy` 设置为转发实际 `src` 的前缀 URL：\n\n```js\nawait snapdom.toPng(el, {\n  useProxy: 'https://proxy.corsfix.com/?' // 注意：可以使用任何 CORS 代理 'https://proxy.corsfix.com/?'\n});\n```\n\n\n* 代理仅用作**备用**；同源和启用 CORS 的资源会跳过它。\n\n### 字体\n\n#### `embedFonts`\n当为 `true` 时，snapDOM 会嵌入在捕获子树中检测到使用的**非图标** `@font-face` 规则。图标字体（Font Awesome、Material Icons 等）**始终**被嵌入。\n\n#### `localFonts`\n如果您自己提供字体或拥有 data URL，可以在此处声明它们以避免额外的 CSS 发现：\n\n```js\nawait snapdom.toPng(el, {\n  embedFonts: true,\n  localFonts: [\n    { family: 'Inter', src: '/fonts/Inter-Variable.woff2', weight: 400, style: 'normal' },\n    { family: 'Inter', src: '/fonts/Inter-Italic.woff2', style: 'italic' }\n  ]\n});\n```\n\n#### `iconFonts`\n添加自定义图标字体族（名称或正则表达式匹配器）。对私有图标集很有用：\n\n```js\nawait snapdom.toPng(el, {\n  iconFonts: ['MyIcons', /^(Remix|Feather) Icons?$/i]\n});\n```\n\n#### `excludeFonts`\n跳过特定的非图标字体以加快捕获速度或避免不必要的下载。\n\n```js\nawait snapdom.toPng(el, {\n  embedFonts: true,\n  excludeFonts: {\n    families: ['Noto Serif', 'SomeHeavyFont'],     // 按字体族名称跳过\n    domains: ['fonts.gstatic.com', 'cdn.example'], // 按源主机跳过\n    subsets: ['cyrillic-ext']                      // 按 unicode-range 子集标签跳过\n  }\n});\n```\n*注意*\n- `excludeFonts` 仅适用于**非图标**字体。图标字体始终被嵌入。\n- `families` 的匹配不区分大小写。主机通过子字符串与解析后的 URL 进行匹配。\n\n\n#### 节点过滤：`exclude` vs `filter`\n\n* `exclude`: 通过**选择器**移除。\n* `excludeMode`: `hide` 对排除的节点应用 `visibility:hidden` CSS 规则，布局保持原样。`remove` 完全不克隆排除的节点。\n* `filter`: 每个元素的高级谓词函数（返回 `false` 以丢弃）。\n* `filterMode`: `hide` 对过滤的节点应用 `visibility:hidden` CSS 规则，布局保持原样。`remove` 完全不克隆过滤的节点。\n\n**示例：过滤掉 `display:none` 的元素：**\n```js\n/**\n * 示例过滤器：跳过 display:none 的元素\n * @param {Element} el\n * @returns {boolean} true = 保留, false = 排除\n */\nfunction filterHidden(el) {\n  const cs = window.getComputedStyle(el);\n  if (cs.display === 'none') return false;\n  return true;\n}\n\nawait snapdom.toPng(document.body, { filter: filterHidden });\n```\n\n**使用 `exclude` 的示例：** 通过选择器移除横幅或工具提示\n```js\nawait snapdom.toPng(el, {\n  exclude: ['.cookie-banner', '.tooltip', '[data-test=\"debug\"]']\n});\n```\n\n### outerTransforms \n\n捕获旋转或平移的元素时，如果您想消除这些外部变换，可以使用 **outerTransforms: false** 选项。这样，输出是**扁平、直立且可直接**在其他地方使用的。\n\n- **`outerTransforms: true (默认)`**  \n  **保留原始的 `transforms` 和 `rotate`**。  \n  \n\n### outerShadows\n- **`outerShadows: false (默认)`**  \n  防止为根元素的阴影、模糊或轮廓扩展边界框，并从克隆的根元素中移除 `box-shadow`、`text-shadow`、`filter: blur()/drop-shadow()` 和 `outline`。  \n\n> 💡 **提示：** 同时使用两者（`outerTransforms: false` + `outerShadows: false`）会产生严格、最小化的边界框，没有视觉溢出。\n\n**示例**\n\n```js\n// outerTransforms 和移除阴影溢出\nawait snapdom.toSvg(el, { outerTransforms: true, outerShadows: true });\n```\n\n## 缓存控制\n\nSnapDOM 为图片、背景、资源、样式和字体维护内部缓存。\n您可以使用 `cache` 选项控制它们在捕获之间的清除方式：\n\n| 模式        | 描述                                                                 |\n| ----------- | --------------------------------------------------------------------------- |\n| `\"disabled\"`| 无缓存                   |\n| `\"soft\"`    | 清除会话缓存（`styleMap`、`nodeMap`、`styleCache`）_(默认)_      |\n| `\"auto\"`    | 最小清理：仅清除临时映射                                 |\n| `\"full\"`    | 保留所有缓存（不清除任何内容，最大性能）                  |\n\n**示例：**\n\n```js\n// 使用最小但快速的缓存\nawait snapdom.toPng(el, { cache: 'auto' });\n\n// 在捕获之间将所有内容保留在内存中\nawait snapdom.toPng(el, { cache: 'full' });\n\n// 强制在每次捕获时完全清理\nawait snapdom.toPng(el, { cache: 'disabled' });\n```\n\n## `preCache()` – 可选辅助函数\n\n预加载外部资源以避免首次捕获时的停顿（对大型/复杂树很有帮助）。\n\n```js\nimport { preCache } from '@zumer/snapdom';\n\nawait preCache({\n  root: document.body,\n  embedFonts: true,\n  localFonts: [{ family: 'Inter', src: '/fonts/Inter.woff2', weight: 400 }],\n  useProxy: 'https://proxy.corsfix.com/?'\n});\n```\n\n## 插件（测试版）\n\nSnapDOM 包含一个轻量级**插件系统**，允许您在捕获和导出过程的任何阶段扩展或覆盖行为——无需修改核心库。\n\n插件是一个简单的对象，具有唯一的 `name` 和一个或多个生命周期**钩子**。\n钩子可以是同步的或 `async`，它们接收一个共享的 **`context`** 对象。\n\n### 注册插件\n\n**全局注册**（适用于所有捕获）：\n\n```js\nimport { snapdom } from '@zumer/snapdom';\n\n// 您可以注册实例、工厂函数或 [工厂函数, 选项]\nsnapdom.plugins(\n  myPluginInstance,\n  [myPluginFactory, { optionA: true }],\n  { plugin: anotherFactory, options: { level: 2 } }\n);\n```\n\n**单次捕获注册**（仅适用于该特定调用）：\n\n```js\nconst out = await snapdom(element, {\n  plugins: [\n    [overlayFilterPlugin, { color: 'rgba(0,0,0,0.25)' }],\n    [myFullPlugin, { providePdf: true }]\n  ]\n});\n```\n\n* **执行顺序 = 注册顺序**（先注册，先执行）。\n* **单次捕获插件**在全局插件**之前**运行。\n* 重复项通过 `name` 自动跳过；具有相同 `name` 的单次捕获插件会覆盖其全局版本。\n\n### 插件生命周期钩子\n\n钩子按捕获顺序执行（见[捕获流程](#捕获流程)）：\n\n| 钩子 | 阶段 | 目的 |\n|------|------|------|\n| `beforeSnap` | 开始 | 任何工作之前调整选项。 |\n| `beforeClone` | 克隆前 | DOM 克隆之前（谨慎修改实时 DOM）。 |\n| `afterClone` | 克隆后 | 安全修改克隆树（如注入叠加层）。 |\n| `beforeRender` | 序列化前 | SVG 转 data URL 之前。 |\n| `afterRender` | 序列化后 | 检查 `context.svgString` / `context.dataURL`。 |\n| `beforeExport` | 每次导出前 | 每次 `toPng`、`toSvg` 等之前。 |\n| `afterExport` | 每次导出后 | 转换返回结果。 |\n| `afterSnap` | 一次 | 第一次导出后；清理。 |\n| `defineExports` | 设置 | 添加自定义导出器（如 `toPdf`）。 |\n\n> `afterExport` 的返回值会链接到下一个插件（转换管道）。\n\n### 上下文对象\n\n每个钩子都接收一个包含规范化捕获状态的 `context` 对象：\n\n* **输入和选项：**\n  `element`, `debug`, `fast`, `scale`, `dpr`, `width`, `height`, `backgroundColor`, `quality`, `useProxy`, `cache`, `outerTransforms`, `outerShadows`, `safariWarmupAttempts`, `embedFonts`, `localFonts`, `iconFonts`, `excludeFonts`, `exclude`, `excludeMode`, `filter`, `filterMode`, `fallbackURL`。\n\n* **中间值（取决于阶段）：**\n  `clone`, `classCSS`, `styleCache`, `fontsCSS`, `baseCSS`, `svgString`, `dataURL`。\n\n* **导出期间：**\n  `context.export = { type, options, url }`\n  其中 `type` 是导出器名称（`\"png\"`、`\"jpeg\"`、`\"svg\"`、`\"blob\"` 等），`url` 是序列化的 SVG 基础。\n\n> 您可以安全地修改 `context`（例如，覆盖 `backgroundColor` 或 `quality`）——但要在早期（`beforeSnap`）进行以获得全局效果，或在 `beforeExport` 中进行以获得单次导出更改。\n\n## 通过插件自定义导出\n\n插件可以使用 `defineExports(context)` 添加新的导出。\n对于您返回的每个导出键（例如，`\"pdf\"`），SnapDOM 会在捕获结果上自动公开一个名为 **`toPdf()`** 的辅助方法。\n\n**注册插件（全局或单次捕获）：**\n\n```js\nimport { snapdom } from '@zumer/snapdom';\n\n// 全局\nsnapdom.plugins(pdfExportPlugin());\n\n// 或单次捕获\nconst out = await snapdom(element, { plugins: [pdfExportPlugin()] });\n```\n\n**调用自定义导出：**\n\n```js\nconst out = await snapdom(document.querySelector('#report'));\n\n// 因为插件返回 { pdf: async (ctx, opts) => ... }\nconst pdfBlob = await out.toPdf({\n  // 导出器特定选项（width, height, quality, filename 等）\n});\n```\n\n### 示例：叠加滤镜插件\n\n仅在捕获的克隆中添加半透明叠加层或颜色滤镜（不在您的实时 DOM 中）。\n在导出前用于高亮显示或变暗部分很有用。\n\n```js\n/**\n * SnapDOM 的超简单叠加滤镜（仅 HTML）。\n * 在克隆的根元素上插入全尺寸 <div> 叠加层。\n *\n * @param {{ color?: string; blur?: number }} [options]\n *   color: 叠加颜色（rgba/hex/hsl）。默认: 'rgba(0,0,0,0.25)'\n *   blur: 可选的模糊像素值（默认: 0）\n */\nexport function overlayFilterPlugin(options = {}) {\n  const color = options.color ?? 'rgba(0,0,0,0.25)';\n  const blur = Math.max(0, options.blur ?? 0);\n\n  return {\n    name: 'overlay-filter',\n\n    /**\n     * 在克隆的 HTML 根元素上添加全覆盖叠加层。\n     * @param {any} context\n     */\n    async afterClone(context) {\n      const root = context.clone;\n      if (!(root instanceof HTMLElement)) return; // 仅 HTML\n\n      // 确保包含块，以便绝对定位的叠加层锚定到根元素\n      if (getComputedStyle(root).position === 'static') {\n        root.style.position = 'relative';\n      }\n\n      const overlay = document.createElement('div');\n      overlay.style.position = 'absolute';\n      overlay.style.left = '0';\n      overlay.style.top = '0';\n      overlay.style.right = '0';\n      overlay.style.bottom = '0';\n      overlay.style.background = color;\n      overlay.style.pointerEvents = 'none';\n      if (blur) overlay.style.filter = `blur(${blur}px)`;\n\n      root.appendChild(overlay);\n    }\n  };\n}\n\n```\n\n**用法：**\n\n```js\nimport { snapdom } from '@zumer/snapdom';\n\n// 全局注册\nsnapdom.plugins([overlayFilterPlugin, { color: 'rgba(0,0,0,0.3)', blur: 2 }]);\n\n// 单次捕获\nconst out = await snapdom(document.querySelector('#card'), {\n  plugins: [[overlayFilterPlugin, { color: 'rgba(255,200,0,0.15)' }]]\n});\n\nconst png = await out.toPng();\ndocument.body.appendChild(png);\n```\n\n> 叠加层仅注入到**克隆的树中**，永远不会注入到您的实时 DOM 中，确保完美保真度和零闪烁。\n\n### 完整插件模板\n\n使用此模板作为自定义逻辑或导出器的起点。\n\n```js\nexport function myPlugin(options = {}) {\n  return {\n    /** 用于去重/覆盖的唯一名称 */\n    name: 'my-plugin',\n\n    /** 在任何克隆/样式工作之前的早期调整。 */\n    async beforeSnap(context) {},\n\n    /** 子树克隆之前（如果触及实时 DOM，请谨慎使用）。 */\n    async beforeClone(context) {},\n\n    /** 子树克隆之后（可以安全地修改克隆的树）。 */\n    async afterClone(context) {},\n\n    /** 序列化之前（SVG/dataURL）。 */\n    async beforeRender(context) {},\n\n    /** 序列化之后；如果需要，检查 context.svgString/context.dataURL。 */\n    async afterRender(context) {},\n\n    /** 每次导出调用之前（toPng/toSvg/toBlob/...）。 */\n    async beforeExport(context) {},\n\n    /**\n     * 每次导出调用之后。\n     * 如果您返回一个值，它将成为下一个插件的结果（链式）。\n     */\n    async afterExport(context, result) { return result; },\n\n    /**\n     * 定义自定义导出器（自动添加为辅助方法，如 out.toPdf()）。\n     * 返回映射 { [key: string]: (ctx:any, opts:any) => Promise<any> }。\n     */\n    async defineExports(context) { return {}; },\n\n    /** 在第一次导出完成后运行一次（清理）。 */\n    async afterSnap(context) {}\n  };\n}\n```\n\n**快速回顾：**\n\n* 插件可以修改捕获行为（`beforeSnap`、`afterClone` 等）。\n* 您可以安全地将视觉效果或转换注入到克隆的树中。\n* 在 `defineExports()` 中定义的新导出器会自动成为辅助方法，如 `out.toPdf()`。\n* 所有钩子都可以是异步的，按顺序运行，并共享相同的 `context`。\n\n\n## 限制\n\n* 外部图片应该是 CORS 可访问的（使用 `useProxy` 选项处理 CORS 拒绝）\n* 在 Safari 上使用 WebP 格式时，将回退到 PNG 渲染。\n* `@font-face` CSS 规则得到良好支持，但如果需要使用 JS `FontFace()`，请参阅此解决方案 [`#43`](https://github.com/zumerlab/snapdom/issues/43)\n* **Safari**：启用 `embedFonts` 或包含背景/蒙版图片的捕获会较慢，因 [WebKit #219770](https://bugs.webkit.org/show_bug.cgi?id=219770)（字体解码时机）。SnapDOM 通过预捕获和 `drawImage` 预热管道；可通过 `safariWarmupAttempts` 调整（默认 3）。\n* **自定义滚动条样式**（`::-webkit-scrollbar`）：仅在元素*未滚动*时生效。若已滚动，将捕获视口内容且不显示滚动条。\n\n\n## ⚡ 性能基准测试（Chromium）\n\n**设置说明。** 在 Chromium 上使用 Vitest 基准测试，仓库测试。硬件可能影响结果。\n数值为**平均捕获时间（毫秒）** → 越低越好。\n\n### 简单元素\n\n| 场景                 | SnapDOM 当前版本 | SnapDOM v1.9.9 | html2canvas | html-to-image |\n| ------------------------ | --------------- | -------------- | ----------- | ------------- |\n| 小尺寸 (200×100)          | **0.5 ms**      | 0.8 ms         | 67.7 ms     | 3.1 ms        |\n| 模态框 (400×300)          | **0.5 ms**      | 0.8 ms         | 75.5 ms     | 3.6 ms        |\n| 页面视图 (1200×800)     | **0.5 ms**      | 0.8 ms         | 114.2 ms    | 3.3 ms        |\n| 大滚动 (2000×1500) | **0.5 ms**      | 0.8 ms         | 186.3 ms    | 3.2 ms        |\n| 超大尺寸 (4000×2000)   | **0.5 ms**      | 0.9 ms         | 425.9 ms    | 3.3 ms        |\n\n\n### 复杂元素\n\n| 场景                 | SnapDOM 当前版本 | SnapDOM v1.9.9 | html2canvas | html-to-image |\n| ------------------------ | --------------- | -------------- | ----------- | ------------- |\n| 小尺寸 (200×100)          | **1.6 ms**      | 3.3 ms         | 68.0 ms     | 14.3 ms       |\n| 模态框 (400×300)          | **2.9 ms**      | 6.8 ms         | 87.5 ms     | 34.8 ms       |\n| 页面视图 (1200×800)     | **17.5 ms**     | 50.2 ms        | 178.0 ms    | 429.0 ms      |\n| 大滚动 (2000×1500) | **54.0 ms**     | 201.8 ms       | 735.2 ms    | 984.2 ms      |\n| 超大尺寸 (4000×2000)   | **171.4 ms**    | 453.7 ms       | 1,800.4 ms  | 2,611.9 ms    |\n\n\n### 运行基准测试\n\n```sh\ngit clone https://github.com/zumerlab/snapdom.git\ncd snapdom\nnpm install\nnpm run test:benchmark\n```\n\n\n## 路线图\n\nSnapDOM 未来版本的计划改进：\n\n* [X] **实现插件系统**\n  SnapDOM 将支持外部插件以扩展或覆盖内部行为（例如自定义节点转换器、导出器或过滤器）。\n\n* [ ] **重构为模块化架构**\n  内部逻辑将被拆分为更小、更专注的模块，以提高可维护性和代码复用。\n\n* [X] **将内部逻辑与全局选项解耦**\n  函数将重新设计以避免直接依赖 `options`。集中式捕获上下文将提高清晰度、自主性和可测试性。参见 [`next` 分支](https://github.com/zumerlab/snapdom/tree/main)\n\n* [X] **暴露缓存控制**\n  用户将能够手动清除图片和字体缓存或配置自己的缓存策略。\n\n* [X] **自动字体预加载**\n  所需的字体将在捕获前自动检测和预加载，减少手动调用 `preCache()` 的需要。\n\n* [X] **文档化插件开发**\n  将提供完整的指南，用于创建和注册自定义 SnapDOM 插件。\n\n* [ ] **使导出工具支持 tree-shaking**\n  `toPng`、`toJpg`、`toBlob` 等导出函数将被重构为独立模块，以支持 tree shaking 和最小化构建。\n\n有想法或功能请求？\n欢迎在 [GitHub Discussions](https://github.com/zumerlab/snapdom/discussions) 中分享建议或反馈。\n\n\n## 开发\n\n**源码结构：**\n- `src/api/` – 公共 API（`snapdom`、`preCache`）\n- `src/core/` – 捕获流程、克隆、准备、插件\n- `src/modules/` – 图片、字体、伪元素、背景、SVG\n- `src/exporters/` – toPng、toSvg、toBlob 等\n- `dist/` – 构建产物（`snapdom.js`、`snapdom.mjs`、`preCache.mjs`、`plugins.mjs`）\n\n**构建：**\n```sh\ngit clone https://github.com/zumerlab/snapdom.git\ncd snapdom\ngit checkout dev\nnpm install\nnpm run compile\n```\n\n**测试：**\n```sh\nnpx playwright install   # 浏览器测试所需\nnpm test\nnpm run test:benchmark\n```\n\n详细指南请参阅 [CONTRIBUTING](https://github.com/zumerlab/snapdom/blob/main/CONTRIBUTING.md)。\n\n\n## 贡献者 🙌\n\n<!-- CONTRIBUTORS:START -->\n<p>\n<a href=\"https://github.com/tinchox5\" title=\"tinchox5\"><img src=\"https://avatars.githubusercontent.com/u/11557901?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"tinchox5\"/></a>\n<a href=\"https://github.com/Jarvis2018\" title=\"Jarvis2018\"><img src=\"https://avatars.githubusercontent.com/u/36788851?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"Jarvis2018\"/></a>\n<a href=\"https://github.com/tarwin\" title=\"tarwin\"><img src=\"https://avatars.githubusercontent.com/u/646149?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"tarwin\"/></a>\n<a href=\"https://github.com/Amyuan23\" title=\"Amyuan23\"><img src=\"https://avatars.githubusercontent.com/u/25892910?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"Amyuan23\"/></a>\n<a href=\"https://github.com/airamhr9\" title=\"airamhr9\"><img src=\"https://avatars.githubusercontent.com/u/57371081?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"airamhr9\"/></a>\n<a href=\"https://github.com/FlavioLimaMindera\" title=\"FlavioLimaMindera\"><img src=\"https://avatars.githubusercontent.com/u/96424442?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"FlavioLimaMindera\"/></a>\n<a href=\"https://github.com/jswhisperer\" title=\"jswhisperer\"><img src=\"https://avatars.githubusercontent.com/u/1177690?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"jswhisperer\"/></a>\n<a href=\"https://github.com/K1ender\" title=\"K1ender\"><img src=\"https://avatars.githubusercontent.com/u/146767945?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"K1ender\"/></a>\n<a href=\"https://github.com/kohaiy\" title=\"kohaiy\"><img src=\"https://avatars.githubusercontent.com/u/15622127?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"kohaiy\"/></a>\n<a href=\"https://github.com/17biubiu\" title=\"17biubiu\"><img src=\"https://avatars.githubusercontent.com/u/13295895?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"17biubiu\"/></a>\n<a href=\"https://github.com/av01d\" title=\"av01d\"><img src=\"https://avatars.githubusercontent.com/u/6247646?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"av01d\"/></a>\n<a href=\"https://github.com/CHOYSEN\" title=\"CHOYSEN\"><img src=\"https://avatars.githubusercontent.com/u/25995358?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"CHOYSEN\"/></a>\n<a href=\"https://github.com/pedrocateexte\" title=\"pedrocateexte\"><img src=\"https://avatars.githubusercontent.com/u/207524750?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"pedrocateexte\"/></a>\n<a href=\"https://github.com/domialex\" title=\"domialex\"><img src=\"https://avatars.githubusercontent.com/u/4694217?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"domialex\"/></a>\n<a href=\"https://github.com/elliots\" title=\"elliots\"><img src=\"https://avatars.githubusercontent.com/u/622455?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"elliots\"/></a>\n<a href=\"https://github.com/stypr\" title=\"stypr\"><img src=\"https://avatars.githubusercontent.com/u/6625978?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"stypr\"/></a>\n<a href=\"https://github.com/mon-jai\" title=\"mon-jai\"><img src=\"https://avatars.githubusercontent.com/u/91261297?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"mon-jai\"/></a>\n<a href=\"https://github.com/sharuzzaman\" title=\"sharuzzaman\"><img src=\"https://avatars.githubusercontent.com/u/7421941?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"sharuzzaman\"/></a>\n<a href=\"https://github.com/simon1uo\" title=\"simon1uo\"><img src=\"https://avatars.githubusercontent.com/u/60037549?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"simon1uo\"/></a>\n<a href=\"https://github.com/titoBouzout\" title=\"titoBouzout\"><img src=\"https://avatars.githubusercontent.com/u/64156?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"titoBouzout\"/></a>\n<a href=\"https://github.com/ZiuChen\" title=\"ZiuChen\"><img src=\"https://avatars.githubusercontent.com/u/64892985?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"ZiuChen\"/></a>\n<a href=\"https://github.com/harshasiddartha\" title=\"harshasiddartha\"><img src=\"https://avatars.githubusercontent.com/u/147021873?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"harshasiddartha\"/></a>\n<a href=\"https://github.com/karasHou\" title=\"karasHou\"><img src=\"https://avatars.githubusercontent.com/u/27048083?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"karasHou\"/></a>\n<a href=\"https://github.com/jhbae200\" title=\"jhbae200\"><img src=\"https://avatars.githubusercontent.com/u/20170610?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"jhbae200\"/></a>\n<a href=\"https://github.com/xiaobai-web715\" title=\"xiaobai-web715\"><img src=\"https://avatars.githubusercontent.com/u/81091224?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"xiaobai-web715\"/></a>\n<a href=\"https://github.com/miusuncle\" title=\"miusuncle\"><img src=\"https://avatars.githubusercontent.com/u/7549857?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"miusuncle\"/></a>\n<a href=\"https://github.com/rbbydotdev\" title=\"rbbydotdev\"><img src=\"https://avatars.githubusercontent.com/u/101137670?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"rbbydotdev\"/></a>\n<a href=\"https://github.com/zhanghaotian2018\" title=\"zhanghaotian2018\"><img src=\"https://avatars.githubusercontent.com/u/169218899?v=4&s=100\" style=\"border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;\" alt=\"zhanghaotian2018\"/></a>\n</p>\n<!-- CONTRIBUTORS:END -->\n\n## 💖 赞助者\n\n特别感谢 [@megaphonecolin](https://github.com/megaphonecolin)、[@sdraper69](https://github.com/sdraper69)、[@reynaldichernando](https://github.com/reynaldichernando) 和 [@gamma-app](https://github.com/gamma-app)，感谢他们对本项目的支持！\n\n如果您也想支持这个项目，您可以[成为赞助者](https://github.com/sponsors/tinchox5)。\n\n## Star 历史\n\n[![Star History Chart](https://api.star-history.com/svg?repos=zumerlab/snapdom&type=Date)](https://www.star-history.com/#zumerlab/snapdom&Date)\n\n## 许可证\n\nMIT © Zumerlab\n"
  },
  {
    "path": "__tests__/api.preCache.more.test.js",
    "content": "// __tests__/api.preCache.more.test.js\nimport { describe, it, expect, vi, beforeEach } from 'vitest'\n\nvi.mock('../src/utils', async () => {\n  const actual = await vi.importActual('../src/utils')\n  return {\n    ...actual,\n    fetchImage: vi.fn(async () => 'data:image/png;base64,iVBORw0KGgo='),\n    precacheCommonTags: vi.fn(),\n    isSafari: vi.fn(() => false), // queda como vi.fn() invocable\n  }\n})\n\n// preCache importa inlineBackgroundImages desde ../modules/background.js\n// (si en tu código real lo seguís importando desde ../utils, cambiá esta ruta acá)\nvi.mock('../src/modules/background.js', () => ({\n  inlineBackgroundImages: vi.fn(async () => {}),\n}))\n\nvi.mock('../src/modules/fonts.js', () => ({\n  embedCustomFonts: vi.fn(async () => ''),\n  collectUsedFontVariants: vi.fn(() => new Set(['Mansalva__700__italic__100'])),\n  collectUsedCodepoints: vi.fn(() => new Set([65])),\n  ensureFontsReady: vi.fn(async () => {}),\n}))\n\n// ⬇️ recién ahora importamos SUT + símbolos mocked\nimport { preCache } from '../src/api/preCache.js'\nimport { cache } from '../src/core/cache.js'\nimport * as utils from '../src/utils'\nimport { inlineBackgroundImages } from '../src/modules/background.js'\nimport {\n  embedCustomFonts,\n  collectUsedFontVariants,\n  collectUsedCodepoints,\n  ensureFontsReady,\n} from '../src/modules/fonts.js'\n\ndescribe('preCache – líneas difíciles', () => {\n  beforeEach(() => {\n     vi.clearAllMocks()\n  utils.isSafari.mockReset?.()\n  utils.isSafari.mockReturnValue(false)\n    if (!cache.session) cache.session = {}\n    cache.session.styleCache = new WeakMap()\n  })\n\n  it('pasa cache.session.styleCache a inlineBackgroundImages (líneas 49–50)', async () => {\n    const root = document.createElement('div')\n    const ref = cache.session.styleCache\n\n    await expect(preCache(root, { embedFonts: false })).resolves.toBeUndefined()\n\n    expect(inlineBackgroundImages).toHaveBeenCalledTimes(1)\n    const args = inlineBackgroundImages.mock.calls[0] // [source, mirror, styleCache, options]\n    expect(args[0]).toBe(root)\n    expect(args[2]).toStrictEqual(ref)              // MISMA referencia => cubre 49–50\n    expect(args[3]).toMatchObject({ useProxy: '' })\n  })\n\n  it('si inlineBackgroundImages falla, preCache resuelve igual (catch 56–65)', async () => {\n    inlineBackgroundImages.mockRejectedValueOnce(new Error('boom'))\n    const el = document.createElement('section')\n\n    await expect(preCache(el, { embedFonts: false })).resolves.toBeUndefined()\n  })\n\n  it('Safari warmup + embed de fuentes con params correctos (84–91)', async () => {\n    utils.isSafari.mockReturnValue(true)\n\n    const root = document.createElement('div')\n    const excludeFonts = { subsets: ['latin'], domains: ['bad.example'] }\n    const localFonts = [{ family: 'Foo', src: 'data:font/woff2;base64,AA==' }]\n\n    await preCache(root, {\n      embedFonts: true,\n      useProxy: '/proxy/',\n      excludeFonts,\n      localFonts,\n    })\n\n    expect(ensureFontsReady).toHaveBeenCalledTimes(1)\n    const [families, reps] = ensureFontsReady.mock.calls[0]\n    expect(families instanceof Set).toBe(true)\n    expect(Array.from(families)).toContain('Mansalva')\n    expect(reps).toBe(3)\n\n    expect(embedCustomFonts).toHaveBeenCalledTimes(1)\n    const call = embedCustomFonts.mock.calls[0][0]\n    expect(call.required).toEqual(collectUsedFontVariants())\n    expect(call.usedCodepoints).toEqual(collectUsedCodepoints())\n    expect(call.exclude).toEqual(excludeFonts)\n    expect(call.localFonts).toEqual(localFonts)\n    expect(call.useProxy).toBe('/proxy/')\n  })\n\n  it('crea styleCache si no existe y lo inyecta a inlineBackgroundImages (49–50)', async () => {\n  // Aseguramos estado inicial sin styleCache\n  cache.session = cache.session || {}\n  delete cache.session.styleCache\n\n  const root = document.createElement('main')\n\n  await expect(preCache(root, { embedFonts: false })).resolves.toBeUndefined()\n\n  // Se llamó una vez y con el WeakMap recién creado\n  expect(inlineBackgroundImages).toHaveBeenCalledTimes(1)\n  const args = inlineBackgroundImages.mock.calls[0] // [source, mirror, styleCache, options]\n  expect(args[0]).toBe(root)\n\n  // preCache debió crear y colgar el WeakMap en cache.session.styleCache\n  expect(cache.session.styleCache).toBeInstanceOf(WeakMap)\n  expect(args[2]).toBe(cache.session.styleCache) // MISMA referencia => cubre 49–50\n})\n\nit('si inlineBackgroundImages lanza (throw sync), preCache resuelve igual (56–65)', async () => {\n  // Lanzar sincrónico para entrar al try/catch de preCache\n  inlineBackgroundImages.mockImplementationOnce(() => { throw new Error('sync-boom') })\n\n  const el = document.createElement('section')\n  await expect(preCache(el, { embedFonts: false })).resolves.toBeUndefined()\n\n  // (Opcional) el resto del flujo no debe romperse\n  expect(inlineBackgroundImages).toHaveBeenCalledTimes(1)\n})\n\n})\n"
  },
  {
    "path": "__tests__/api.preCache.test.js",
    "content": "import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { preCache } from '../src/api/preCache.js'\nimport { cache } from '../src/core/cache.js'\nimport { safeEncodeURI } from '../src/utils/helpers.js' // ajustá el path si difiere\n\nbeforeEach(() => {\n  vi.restoreAllMocks()\n  cache.image?.clear?.()\n  cache.background?.clear?.()\n  document.body.innerHTML = ''\n})\n\ndescribe('preCache – extra coverage', () => {\n  it('prefetches SVG background via proxy fallback and dedupes repeated URL', async () => {\n  const PROXY  = 'https://proxy.example.com/?u='\n  const DIRECT = 'https://cdn.example.com/icon.svg'\n  const svg    = '<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"1\" height=\"1\"/></svg>'\n\n  globalThis.fetch = vi.fn((url) => {\n    const u = String(url)\n    if (u.startsWith(PROXY)) {\n      return Promise.resolve({\n        ok: true,\n        text: () => Promise.resolve(svg),\n        blob: () => Promise.resolve(new Blob([svg], { type: 'image/svg+xml' })),\n      })\n    }\n    // En el contrato nuevo, no se debería llegar acá si useProxy está seteado\n    return Promise.reject(new Error('network fail'))\n  })\n\n  const root = document.createElement('div')\n  const a = document.createElement('div')\n  const b = document.createElement('div')\n  a.style.backgroundImage = `url(${DIRECT})`\n  b.style.backgroundImage = `url(${DIRECT})`\n  root.appendChild(a)\n  root.appendChild(b)\n  document.body.appendChild(root)\n\n  await preCache(root, { useProxy: PROXY })\n\n  const calls = vi.mocked(globalThis.fetch).mock.calls.map(([u]) => String(u))\n  const proxyCalls   = calls.filter(u => u.startsWith(PROXY))\n  const directCalls  = calls.filter(u => u === DIRECT)\n\n  // (1) EXACTAMENTE una llamada proxied (in-flight + cache dedupe)\n  expect(proxyCalls.length).toBe(1)\n\n  // (2) Con proxy activo, NO hay intentos directos en el nuevo snapFetch\n  expect(directCalls.length).toBe(0)\n\n  // (3) Dedupe en cache.background: una sola entrada para ese URL\n  const key = safeEncodeURI(DIRECT)\n  expect(cache.background.has(key)).toBe(true)\n  expect([...cache.background.keys()].filter(k => k === key).length).toBe(1)\n\n  document.body.removeChild(root)\n})\n\n  it('handles mixed background layers (gradient + url) and only processes the URL layer', async () => {\n    // No contamos fetch acá porque raster usa Image() y puede ser 0.\n    globalThis.fetch = vi.fn() // por si algo intenta fetch (no debería)\n\n    const URL = 'https://assets.test/a.svg' // usamos SVG para que sí pase por fetch en tu impl\n    // Si querés testear raster, cambiá asserts a cache.image; con SVG comprobamos background.\n    const svg = '<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>'\n    vi.mocked(globalThis.fetch).mockResolvedValue({\n      ok: true,\n      text: () => Promise.resolve(svg),\n      blob: () => Promise.resolve(new Blob([svg], { type: 'image/svg+xml' })),\n    })\n\n    const el = document.createElement('div')\n    el.style.backgroundImage = `linear-gradient(90deg, #000, #fff), url(${URL})`\n    document.body.appendChild(el)\n\n    await preCache(el)\n\n    // Verificamos que SOLO la capa url(...) fue procesada y quedó cacheada\n    const key = safeEncodeURI(URL)\n    expect(cache.background.has(key)).toBe(true)\n\n    // No exigimos conteo de fetch: puede ser 0 si fuese raster.\n    // Si mantenés SVG como arriba, opcionalmente:\n    // expect(globalThis.fetch).toHaveBeenCalledTimes(1);\n\n    document.body.removeChild(el)\n  })\n\n  it('walks the subtree and preloads child backgrounds', async () => {\n    const svg = '<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>'\n    globalThis.fetch = vi.fn().mockResolvedValue({\n      ok: true,\n      text: () => Promise.resolve(svg),\n      blob: () => Promise.resolve(new Blob([svg], { type: 'image/svg+xml' })),\n    })\n\n    const root = document.createElement('div')\n    const child = document.createElement('span')\n    const CHILD_URL = 'https://x.test/nested.svg'\n    child.style.backgroundImage = `url(${CHILD_URL})`\n    root.appendChild(child)\n    document.body.appendChild(root)\n\n    await preCache(root)\n\n    // Comprobamos que el hijo fue visto y cacheado\n    const key = safeEncodeURI(CHILD_URL)\n    expect(cache.background.has(key)).toBe(true)\n\n    document.body.removeChild(root)\n  })\n})\n"
  },
  {
    "path": "__tests__/api.snapdom.more.test.js",
    "content": "// __tests__/api.snapdom.more.test.js – snapdom.js extra coverage\nimport { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'\nimport { snapdom } from '../src/index.js'\n\nvi.mock('../src/utils/browser', { spy: true })\nimport * as browser from '../src/utils/browser'\n\nbeforeEach(() => {\n  document.body.innerHTML = ''\n  vi.restoreAllMocks()\n  vi.mocked(browser.isSafari).mockReturnValue(false)\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('snapdom – error handling', () => {\n  it('throws when element is null', async () => {\n    await expect(snapdom(null)).rejects.toThrow(/cannot be null/)\n  })\n  it('throws when element is undefined', async () => {\n    await expect(snapdom(undefined)).rejects.toThrow(/cannot be null/)\n  })\n})\n\ndescribe('snapdom – result.to()', () => {\n  it('throws for unknown export type', async () => {\n    const el = document.createElement('div')\n    el.textContent = 'x'\n    document.body.appendChild(el)\n    const result = await snapdom(el)\n    await expect(result.to('unknownType')).rejects.toThrow(/Unknown export type/)\n  })\n})\n\ndescribe('snapdom – result helpers', () => {\n  it('result has all expected export methods', async () => {\n    const el = document.createElement('div')\n    el.style.width = '50px'\n    el.style.height = '50px'\n    el.textContent = 'x'\n    document.body.appendChild(el)\n    const result = await snapdom(el)\n    expect(typeof result.toPng).toBe('function')\n    expect(typeof result.toSvg).toBe('function')\n    expect(typeof result.toCanvas).toBe('function')\n    expect(typeof result.download).toBe('function')\n  })\n})\n"
  },
  {
    "path": "__tests__/api.snapdom.test.js",
    "content": "import { describe, it, expect, vi } from 'vitest'\nimport { snapdom } from '../src/api/snapdom.js'\n\ndescribe('snapdom API (direct)', () => {\n  it('throws on null element', async () => {\n    await expect(snapdom(null)).rejects.toThrow()\n  })\n\n  it('snapdom returns export methods', async () => {\n    const el = document.createElement('div')\n    el.style.width = '100px'\n    el.style.height = '50px'\n    document.body.appendChild(el)\n    const result = await snapdom(el)\n    expect(result).toHaveProperty('toRaw')\n    expect(result).toHaveProperty('toImg')\n    expect(result).toHaveProperty('download')\n    document.body.removeChild(el)\n  })\n\n  it('snapdom.toRaw, toImg, toCanvas, toBlob, toPng, toJpg, toWebp, download', async () => {\n    const el = document.createElement('div')\n    el.style.width = '100px'\n    el.style.height = '50px'\n    document.body.appendChild(el)\n    await snapdom.toRaw(el)\n    await snapdom.toImg(el)\n    await snapdom.toCanvas(el)\n    await snapdom.toBlob(el)\n    await snapdom.toPng(el)\n    await snapdom.toJpg(el)\n    await snapdom.toWebp(el)\n    await snapdom.download(el, { format: 'png', filename: 'test' })\n    document.body.removeChild(el)\n  })\n\n  it('cubre rama Safari en toImg', async () => {\n    vi.resetModules()\n    vi.mock('../utils', async () => {\n      const actual = await vi.importActual('../utils')\n      return { ...actual, isSafari: true }\n    })\n    const { snapdom } = await import('../src/api/snapdom.js')\n    const el = document.createElement('div')\n    el.style.width = '10px'\n    el.style.height = '10px'\n    document.body.appendChild(el)\n    // Forzar un SVG dataURL simple\n    const img = new Image()\n    img.width = 10\n    img.height = 10\n    img.decode = () => Promise.resolve()\n    globalThis.Image = function() { return img }\n    const res = await snapdom(el)\n    await res.toImg()\n    document.body.removeChild(el)\n    vi.resetModules()\n  })\n\n  it('cubre rama de download SVG', async () => {\n    const el = document.createElement('div')\n    el.style.width = '10px'\n    el.style.height = '10px'\n    document.body.appendChild(el)\n    // Mock a.click y URL.createObjectURL\n    const a = document.createElement('a')\n    document.body.appendChild(a)\n    const origCreate = URL.createObjectURL\n    URL.createObjectURL = () => 'blob:url'\n    const origClick = a.click\n    a.click = () => {}\n    HTMLAnchorElement.prototype.click = () => {}\n    const { snapdom } = await import('../src/api/snapdom.js')\n    await snapdom.download(el, { format: 'svg', filename: 'testsvg' })\n    URL.createObjectURL = origCreate\n    a.click = origClick\n    document.body.removeChild(a)\n    document.body.removeChild(el)\n  })\n\n  it('snapdom.toBlob supports type options ', async () => {\n  const el = document.createElement('div')\n  el.style.width = '50px'\n  el.style.height = '30px'\n  document.body.appendChild(el)\n\n  const result = await snapdom(el)\n\n  const pngBlob = await result.toBlob({ type: 'png' })\n  expect(pngBlob).toBeInstanceOf(Blob)\n  expect(pngBlob.type).toBe('image/png')\n\n  const jpgBlob = await result.toBlob({ type: 'jpeg', quality: 0.8 })\n  expect(jpgBlob).toBeInstanceOf(Blob)\n  expect(jpgBlob.type).toBe('image/jpeg')\n\n  const webpBlob = await result.toBlob({ type: 'webp', quality: 0.9 })\n  expect(webpBlob).toBeInstanceOf(Blob)\n  expect(webpBlob.type).toBe('image/webp')\n\n  // default fallback\n  const svgBlob = await result.toBlob()\n  expect(svgBlob).toBeInstanceOf(Blob)\n  expect(svgBlob.type).toBe('image/svg+xml')\n\n  document.body.removeChild(el)\n})\n\nit('toPng, toJpg, toWebp return HTMLImageElement with  URLs', async () => {\n  const el = document.createElement('div')\n  el.style.width = '60px'\n  el.style.height = '40px'\n  document.body.appendChild(el)\n  const snap = await snapdom(el)\n\n  const pngImg = await snap.toPng()\n  expect(pngImg).toBeInstanceOf(HTMLImageElement)\n  expect(typeof pngImg.src).toBe('string')\nexpect(pngImg.src.startsWith('data:image/png')).toBe(true)\n\n  const jpgImg = await snap.toJpg()\n  expect(jpgImg).toBeInstanceOf(HTMLImageElement)\n  expect(typeof jpgImg.src).toBe('string')\nexpect(jpgImg.src.startsWith('data:image/jpeg')).toBe(true)\n\n  const webpImg = await snap.toWebp()\n  expect(webpImg).toBeInstanceOf(HTMLImageElement)\n  expect(typeof webpImg.src).toBe('string')\nexpect(webpImg.src.startsWith('data:image/webp')).toBe(true)\n  document.body.removeChild(el)\n})\n\nit('snapdom should support exclude option to filter out elements by CSS selectors', async () => {\n  const el = document.createElement('div')\n  el.innerHTML = `\n    <h1>Title</h1>\n    <div class=\"exclude-me\">Should be excluded</div>\n    <div data-private=\"true\">Private data</div>\n    <p>This should remain</p>\n  `\n  document.body.appendChild(el)\n\n  const result = await snapdom(el, { exclude: ['.exclude-me', '[data-private]'] })\n\n  const svg = result.toRaw()\n  const decoded = decodeURIComponent(svg.split(',')[1])\n  expect(decoded).not.toContain('Should be excluded')\n  expect(decoded).not.toContain('Private data')\n  expect(decoded).toContain('Title')\n  expect(decoded).toContain('This should remain')\n})\n\nit('snapdom should support filter option to exclude elements with custom logic', async () => {\n  const el = document.createElement('div')\n  el.innerHTML = `\n    <div class=\"level-1\">Level 1\n      <div class=\"level-2\">Level 2\n        <div class=\"level-3\">Level 3</div>\n      </div>\n    </div>\n  `\n  document.body.appendChild(el)\n  const result = await snapdom(el, {\n    filter: (element) => !element.classList.contains('level-3')\n  })\n\n    const svg = result.toRaw()\n  const decoded = decodeURIComponent(svg.split(',')[1])\n  expect(decoded).toContain('Level 1')\n  expect(decoded).toContain('Level 2')\n  expect(decoded).not.toContain('Level 3')\n})\n\nit('snapdom should support combining exclude and filter options', async () => {\n  const el = document.createElement('div')\n  el.innerHTML = `\n    <div class=\"exclude-by-selector\">Exclude by selector</div>\n    <div class=\"exclude-by-filter\">Exclude by filter</div>\n    <div class=\"keep-me\">Keep this content</div>\n  `\n  document.body.appendChild(el)\n\n  const result = await snapdom(el, {\n    exclude: ['.exclude-by-selector'],\n    filter: (element) => !element.classList.contains('exclude-by-filter')\n  })\n\n   const svg = result.toRaw()\n  const decoded = decodeURIComponent(svg.split(',')[1])\n  expect(decoded).not.toContain('Exclude by selector')\n  expect(decoded).not.toContain('Exclude by filter')\n  expect(decoded).toContain('Keep this content')\n})\n\n})\n"
  },
  {
    "path": "__tests__/core.cache.test.js",
    "content": "// __tests__/core.cache.test.js\nimport { describe, it, expect, beforeEach } from 'vitest'\nimport { cache, normalizeCachePolicy, applyCachePolicy } from '../src/core/cache.js'\n\n/**\n * Snapshot helpers to assert identity changes.\n */\nfunction snapshotRefs() {\n  return {\n    image: cache.image,\n    background: cache.background,\n    resource: cache.resource,\n    defaultStyle: cache.defaultStyle,\n    baseStyle: cache.baseStyle,\n    computedStyle: cache.computedStyle,\n    font: cache.font,\n    session_styleMap: cache.session.styleMap,\n    session_styleCache: cache.session.styleCache,\n    session_nodeMap: cache.session.nodeMap,\n  }\n}\n\nfunction seedSomeData() {\n  cache.image.set('k', 1)\n  cache.background.set('b', 2)\n  cache.resource.set('r', 3)\n  cache.defaultStyle.set('d', 4)\n  cache.baseStyle.set('bs', 5)\n  cache.computedStyle.set({}, { c: 6 })\n  cache.font.add('Inter__400')\n  cache.session.styleMap.set('x', 'y')\n  cache.session.styleCache.set({}, { sc: 1 })\n  cache.session.nodeMap.set({}, document.createElement('div'))\n}\n\ndescribe('normalizeCachePolicy', () => {\n  it('maps booleans and known strings, defaults to \"soft\"', () => {\n    expect(normalizeCachePolicy(true)).toBe('soft')\n    expect(normalizeCachePolicy(false)).toBe('disabled')\n    expect(normalizeCachePolicy('auto')).toBe('auto')\n    expect(normalizeCachePolicy('full')).toBe('full')\n    expect(normalizeCachePolicy('soft')).toBe('soft')\n    expect(normalizeCachePolicy('disabled')).toBe('disabled')\n    // unknown → soft (default)\n    expect(normalizeCachePolicy('weird')).toBe('soft')\n    expect(normalizeCachePolicy(undefined)).toBe('soft')\n    expect(normalizeCachePolicy(123)).toBe('soft')\n  })\n})\n\ndescribe('applyCachePolicy', () => {\n  beforeEach(() => {\n    // Re-crear contenedores para que cada test sea independiente.\n    cache.image = new Map()\n    cache.background = new Map()\n    cache.resource = new Map()\n    cache.defaultStyle = new Map()\n    cache.baseStyle = new Map()\n    cache.computedStyle = new WeakMap()\n    cache.font = new Set()\n    cache.session.styleMap = new Map()\n    cache.session.styleCache = new WeakMap()\n    cache.session.nodeMap = new Map()\n  })\n\n  it('auto: resets only session.styleMap and session.nodeMap', () => {\n    seedSomeData()\n    const before = snapshotRefs()\n\n    applyCachePolicy('auto')\n\n    const after = snapshotRefs()\n    // Reemplaza solo estos dos\n    expect(after.session_styleMap).not.toBe(before.session_styleMap)\n    expect(after.session_nodeMap).not.toBe(before.session_nodeMap)\n    // Mantiene styleCache y resto\n    expect(after.session_styleCache).toBe(before.session_styleCache)\n    expect(after.image).toBe(before.image)\n    expect(after.background).toBe(before.background)\n    expect(after.resource).toBe(before.resource)\n    expect(after.defaultStyle).toBe(before.defaultStyle)\n    expect(after.baseStyle).toBe(before.baseStyle)\n    expect(after.computedStyle).toBe(before.computedStyle)\n    expect(after.font).toBe(before.font)\n\n    // Nuevos maps están vacíos\n    expect(cache.session.styleMap.size).toBe(0)\n    expect(cache.session.nodeMap.size).toBe(0)\n  })\n\n  it('soft: resets toda la sesión (styleMap, nodeMap, styleCache) y deja globales', () => {\n    seedSomeData()\n    const before = snapshotRefs()\n\n    applyCachePolicy('soft')\n\n    const after = snapshotRefs()\n    // Reemplaza los tres de sesión\n    expect(after.session_styleMap).not.toBe(before.session_styleMap)\n    expect(after.session_nodeMap).not.toBe(before.session_nodeMap)\n    expect(after.session_styleCache).not.toBe(before.session_styleCache)\n\n    // Globales se mantienen (misma identidad)\n    expect(after.image).toBe(before.image)\n    expect(after.background).toBe(before.background)\n    expect(after.resource).toBe(before.resource)\n    expect(after.defaultStyle).toBe(before.defaultStyle)\n    expect(after.baseStyle).toBe(before.baseStyle)\n    expect(after.computedStyle).toBe(before.computedStyle)\n    expect(after.font).toBe(before.font)\n\n    // Sesión está vacía\n    expect(cache.session.styleMap.size).toBe(0)\n    expect(cache.session.nodeMap.size).toBe(0)\n  })\n\n  it('full: no limpia nada (mantiene identidades y contenidos)', () => {\n    seedSomeData()\n    const before = snapshotRefs()\n\n    applyCachePolicy('full')\n\n    const after = snapshotRefs()\n    // Todo igual\n    expect(after.image).toBe(before.image)\n    expect(after.background).toBe(before.background)\n    expect(after.resource).toBe(before.resource)\n    expect(after.defaultStyle).toBe(before.defaultStyle)\n    expect(after.baseStyle).toBe(before.baseStyle)\n    expect(after.computedStyle).toBe(before.computedStyle)\n    expect(after.font).toBe(before.font)\n    expect(after.session_styleMap).toBe(before.session_styleMap)\n    expect(after.session_styleCache).toBe(before.session_styleCache)\n    expect(after.session_nodeMap).toBe(before.session_nodeMap)\n\n    // Y siguen con datos\n    expect(cache.image.size).toBeGreaterThan(0)\n    expect(cache.session.styleMap.size).toBeGreaterThan(0)\n  })\n\n  it('disabled: reinstancia TODO (global + sesión) y deja todo vacío', () => {\n    seedSomeData()\n    const before = snapshotRefs()\n\n    applyCachePolicy('disabled')\n\n    const after = snapshotRefs()\n    // Todo debe ser nuevo\n    expect(after.image).not.toBe(before.image)\n    expect(after.background).not.toBe(before.background)\n    expect(after.resource).not.toBe(before.resource)\n    expect(after.defaultStyle).not.toBe(before.defaultStyle)\n    expect(after.baseStyle).not.toBe(before.baseStyle)\n    expect(after.computedStyle).not.toBe(before.computedStyle)\n    expect(after.font).not.toBe(before.font)\n    expect(after.session_styleMap).not.toBe(before.session_styleMap)\n    expect(after.session_styleCache).not.toBe(before.session_styleCache)\n    expect(after.session_nodeMap).not.toBe(before.session_nodeMap)\n\n    // Vacíos\n    expect(cache.image.size).toBe(0)\n    expect(cache.background.size).toBe(0)\n    expect(cache.resource.size).toBe(0)\n    expect(cache.defaultStyle.size).toBe(0)\n    expect(cache.baseStyle.size).toBe(0)\n    expect(cache.font.size).toBe(0)\n    expect(cache.session.styleMap.size).toBe(0)\n    expect(cache.session.nodeMap.size).toBe(0)\n  })\n\n  it('default (input desconocido): cae en soft', () => {\n    seedSomeData()\n    const before = snapshotRefs()\n\n    // Política inexistente provoca rama default → soft\n    applyCachePolicy('unknown-policy')\n\n    const after = snapshotRefs()\n    // Reemplaza los de sesión\n    expect(after.session_styleMap).not.toBe(before.session_styleMap)\n    expect(after.session_nodeMap).not.toBe(before.session_nodeMap)\n    expect(after.session_styleCache).not.toBe(before.session_styleCache)\n    // Mantiene globales\n    expect(after.image).toBe(before.image)\n    expect(after.baseStyle).toBe(before.baseStyle)\n    expect(after.defaultStyle).toBe(before.defaultStyle)\n    expect(after.computedStyle).toBe(before.computedStyle)\n    expect(after.font).toBe(before.font)\n  })\n})\n"
  },
  {
    "path": "__tests__/core.capture.more.test.js",
    "content": "import { describe, it, expect, vi, afterEach } from 'vitest'\n\n/**\n * Decode the SVG XML text from a data URL returned by captureDOM.\n * @param {string} dataUrl\n * @returns {string}\n */\nfunction decodeSvg(dataUrl) {\n  const [, encoded] = dataUrl.split(',', 2)\n  return decodeURIComponent(encoded)\n}\n\n/**\n * Creates a stable DOMRect for BCR stubs.\n * @param {number} x\n * @param {number} y\n * @param {number} w\n * @param {number} h\n * @returns {DOMRect}\n */\nfunction rect(x, y, w, h) {\n  return new DOMRect(x, y, w, h)\n}\n\nafterEach(() => {\n  vi.restoreAllMocks()\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// Edge cases (los que ya tenías, sin cambios)\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM edge cases', () => {\n  it('throws for unsupported element (unknown nodeType)', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    const fakeNode = { nodeType: 999 }\n    await expect(captureDOM(fakeNode)).rejects.toThrow()\n  })\n\n  it('throws if element is null', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    await expect(captureDOM(null)).rejects.toThrow()\n  })\n\n  it('throws error if getBoundingClientRect fails', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockImplementation(() => { throw new Error('fail') })\n\n    const el = document.createElement('div')\n    await expect(captureDOM(el, { fast: true })).rejects.toThrow(/fail/)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// Functional & overflow rules\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM functional', () => {\n  it('returns a data:image/svg+xml and includes overflow visible rules', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(\n      rect(0, 0, 80, 40)\n    )\n\n    const el = document.createElement('div')\n    el.textContent = 'test'\n    const url = await captureDOM(el, { fast: true, embedFonts: false })\n    expect(url.startsWith('data:image/svg+xml')).toBe(true)\n\n    const svg = decodeSvg(url)\n    expect(svg).toMatch(/svg\\{overflow:visible;?\\}/)\n    expect(svg).toMatch(/foreignObject\\{overflow:visible;?\\}/)\n  })\n\n  it('supports scale and width/height options (wrapper sizing behavior)', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(\n      rect(10, 20, 100, 50) // aspect = 2\n    )\n\n    const el = document.createElement('div')\n\n    // scale → mantiene tamaño natural en <svg>, usa viewBox y no agrega transform: scale(...)\n    const svg1 = decodeSvg(await captureDOM(el, { fast: true, scale: 2, embedFonts: false }))\n    expect(svg1).toContain('width=\"100\"')\n    expect(svg1).toContain('height=\"50\"')\n    expect(svg1).toContain('viewBox=\"0 0 100 50\"')\n    expect(svg1).toMatch(/<div[^>]*style=\"[^\"]*width:\\s*100px/)\n    expect(svg1).toMatch(/<div[^>]*style=\"[^\"]*height:\\s*50px/)\n    expect(/transform:[^\"]*scale\\(/.test(svg1)).toBe(false)\n\n    // width only → el <svg> adopta 200x100; el wrapper interno permanece 100x50 (natural)\n    const svg2 = decodeSvg(await captureDOM(el, { fast: true, width: 200, embedFonts: false }))\n    expect(svg2).toContain('width=\"200\"')\n    expect(svg2).toContain('height=\"100\"')\n    expect(svg2).toContain('viewBox=\"0 0 100 50\"')\n    expect(svg2).toMatch(/<div[^>]*style=\"[^\"]*width:\\s*100px/)\n    expect(svg2).toMatch(/<div[^>]*style=\"[^\"]*height:\\s*50px/)\n\n    // height only → el <svg> adopta 200x100; el wrapper permanece 100x50 (natural)\n    const svg3 = decodeSvg(await captureDOM(el, { fast: true, height: 100, embedFonts: false }))\n    expect(svg3).toContain('width=\"200\"')\n    expect(svg3).toContain('height=\"100\"')\n    expect(svg3).toContain('viewBox=\"0 0 100 50\"')\n    expect(svg3).toMatch(/<div[^>]*style=\"[^\"]*width:\\s*100px/)\n    expect(svg3).toMatch(/<div[^>]*style=\"[^\"]*height:\\s*50px/)\n  })\n\n  it('supports fast=false (idle scheduling path)', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 10, 10))\n    const el = document.createElement('div')\n    const url = await captureDOM(el, { fast: false, embedFonts: false })\n    expect(url.startsWith('data:image/svg+xml')).toBe(true)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// BaseCSS presence (sin espiar ESM): solo verificamos reglas base\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – baseCSS presence (no ESM spies)', () => {\n  it('includes base overflow rules on repeated calls', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 120, 60))\n\n    const el1 = document.createElement('div')\n    const el2 = document.createElement('div')\n\n    const s1 = decodeSvg(await captureDOM(el1, { fast: true, embedFonts: false }))\n    const s2 = decodeSvg(await captureDOM(el2, { fast: true, embedFonts: false }))\n\n    expect(s1).toMatch(/svg\\{overflow:visible;?\\}/)\n    expect(s1).toMatch(/foreignObject\\{overflow:visible;?\\}/)\n    expect(s2).toMatch(/svg\\{overflow:visible;?\\}/)\n    expect(s2).toMatch(/foreignObject\\{overflow:visible;?\\}/)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// Width/Height/Scale branches (precise, alineado a tu implementación actual)\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – width/height/scale branches (precise)', () => {\n  it('natural rect used when no width/height/scale given', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 50))\n    const el = document.createElement('div')\n    const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n    expect(svg).toContain('width=\"100\"')\n    expect(svg).toContain('height=\"50\"')\n  })\n\n  it('width only → SVG 200x100; wrapper queda 100x50; viewBox natural', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 50))\n    const el = document.createElement('div')\n    const svg = decodeSvg(await captureDOM(el, { fast: true, width: 200, embedFonts: false }))\n    expect(svg).toContain('width=\"200\"')\n    expect(svg).toContain('height=\"100\"')\n    expect(svg).toContain('viewBox=\"0 0 100 50\"')\n    expect(svg).toMatch(/<div[^>]*style=\"[^\"]*width:\\s*100px/)\n    expect(svg).toMatch(/<div[^>]*style=\"[^\"]*height:\\s*50px/)\n  })\n\n  it('height only → SVG 200x100; wrapper queda 100x50; viewBox natural', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 50))\n    const el = document.createElement('div')\n    const svg = decodeSvg(await captureDOM(el, { fast: true, height: 100, embedFonts: false }))\n    expect(svg).toContain('width=\"200\"')\n    expect(svg).toContain('height=\"100\"')\n    expect(svg).toContain('viewBox=\"0 0 100 50\"')\n    expect(svg).toMatch(/<div[^>]*style=\"[^\"]*width:\\s*100px/)\n    expect(svg).toMatch(/<div[^>]*style=\"[^\"]*height:\\s*50px/)\n  })\n\n  it('scale only → keeps natural width/height; uses viewBox; no transform', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 50))\n    const el = document.createElement('div')\n    const svg = decodeSvg(await captureDOM(el, { fast: true, scale: 2, embedFonts: false }))\n    expect(svg).toContain('width=\"100\"')\n    expect(svg).toContain('height=\"50\"')\n    expect(svg).toContain('viewBox=\"0 0 100 50\"')\n    expect(/transform:[^\"]*scale\\(/.test(svg)).toBe(false)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// Viewport path (sin tocar utils): solo afirmamos x/y presentes (0 o valores)\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – viewport path sanity', () => {\n  it('foreignObject has x/y attributes (tx/ty), even if 0', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(5, 7, 80, 30))\n    const el = document.createElement('div')\n    const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n\n    // No imponemos un cálculo exacto; validamos la presencia de x=\"\" y y=\"\" numéricos.\n    expect(svg).toMatch(/<foreignObject[^>]*\\sx=\"[-\\d]+\"/)\n    expect(svg).toMatch(/<foreignObject[^>]*\\sy=\"[-\\d]+\"/)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// #348: CSS vars excluded from snapshot – fidelity preserved (var() resolved)\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – #348 CSS vars fidelity', () => {\n  it('color: var(--x) resolves to computed value in output', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    const wrap = document.createElement('div')\n    wrap.innerHTML = `\n      <style>:root { --snapdom-test-color: rgb(255, 0, 0); } .t348 { color: var(--snapdom-test-color); }</style>\n      <div class=\"t348\">red text</div>\n    `\n    document.body.appendChild(wrap)\n    const el = wrap.querySelector('.t348')\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 80, 20))\n\n    const url = await captureDOM(el, { fast: true, embedFonts: false })\n    document.body.removeChild(wrap)\n\n    const svg = decodeSvg(url)\n    expect(svg).toMatch(/rgb\\(255,\\s*0,\\s*0\\)|#[fF]{2}0000/)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// #372: iframe CSS isolation – wrapper div must not inherit iframe cascade\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – #372 iframe CSS isolation', () => {\n  it('wrapper div has all:initial to block iframe cascade (e.g. div { border: 10px solid red })', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    const iframe = document.createElement('iframe')\n    iframe.srcdoc = `\n      <!DOCTYPE html>\n      <html><head><style>div { border: 10px solid red; }</style></head>\n      <body><div>content</div></body></html>\n    `\n    iframe.style.width = '100px'\n    iframe.style.height = '80px'\n    document.body.appendChild(iframe)\n\n    await new Promise((resolve) => { iframe.onload = resolve })\n\n    const doc = iframe.contentDocument\n    const root = doc.documentElement\n    const url = await captureDOM(root, { fast: true, embedFonts: false })\n    document.body.removeChild(iframe)\n\n    const svg = decodeSvg(url)\n    // Wrapper div (container) inside foreignObject must be isolated from iframe CSS (#372).\n    // Browser expands all:initial to individual props (border: initial, position: initial, etc.)\n    expect(svg).toContain('box-sizing: border-box')\n    expect(svg).toMatch(/border:\\s*initial|position:\\s*initial/)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// #362: Tailwind * { border: 0 solid } – normalize to border: none in capture\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – #362 canvas Tailwind border', () => {\n  it('elements with border-width 0 get border:none in output (not border: 0 solid)', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    const wrap = document.createElement('div')\n    wrap.innerHTML = `\n      <style>* { border: 0 solid; }</style>\n      <canvas id=\"c362\" width=\"80\" height=\"40\"></canvas>\n    `\n    document.body.appendChild(wrap)\n    const canvas = wrap.querySelector('#c362')\n    const ctx = canvas.getContext('2d')\n    ctx.fillStyle = 'red'\n    ctx.fillRect(0, 0, 80, 40)\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(\n      new DOMRect(0, 0, 80, 40)\n    )\n\n    const url = await captureDOM(wrap, { fast: true, embedFonts: false })\n    document.body.removeChild(wrap)\n\n    const svg = decodeSvg(url)\n    // Canvas becomes img; snapshot should normalize border: 0 solid → border: none\n    expect(svg).toMatch(/\\bborder:\\s*none\\b/)\n  })\n})\n\n// ──────────────────────────────────────────────────────────────────────────────\n// Transform handling (lenient, effect-only)\n// ──────────────────────────────────────────────────────────────────────────────\ndescribe('captureDOM – transform handling (lenient heuristic)', () => {\n  it('when element has a rotate transform, output stays valid and exposes transform-related styles', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    const el = document.createElement('div')\n    el.style.transform = 'rotate(30deg)'\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockReturnValue(new DOMRect(0, 0, 120, 60))\n\n    const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n\n    // 1) SVG válido\n    expect(svg.startsWith('<svg')).toBe(true)\n\n    // 2) El wrapper suele incluir transform-origin aunque no tenga shorthand transform\n    expect(svg).toMatch(/transform-origin:\\s*[\\d.]+px\\s+[\\d.]+px/)\n\n    // 3) Aceptamos props individuales inline O resets en el CSS base (rotate/scale/translate:none)\n    const hasInlineProps =\n      /style=\"[^\"]*(?:rotate:\\s*[^;\"]+|scale:\\s*[^;\"]+|translate:\\s*[^;\"]+)[^\"]*\"/.test(svg)\n    const hasBaseResets = /\\b(rotate|scale|translate):\\s*none\\b/.test(svg)\n    expect(hasInlineProps || hasBaseResets).toBe(true)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// Base transform + individual rotate/scale/translate (sin forzar valores exactos)\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – baseTransform & individual props on clone (lenient)', () => {\n  it('inline style in output includes individual transform properties', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    const el = document.createElement('div')\n    el.style.transform = 'rotate(10deg)'\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 100))\n\n    const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n\n    // Aceptamos presencia inline o, si la implementación normaliza, resets en CSS base.\n    const hasInlineAny = /style=\"[^\"]*(?:rotate|scale|translate):/.test(svg)\n    const hasBaseResets = /\\b(rotate|scale|translate):\\s*none\\b/.test(svg)\n    expect(hasInlineAny || hasBaseResets).toBe(true)\n\n    // transform-origin suele estar presente\n    expect(svg).toMatch(/transform-origin:\\s*[\\d.]+px\\s+[\\d.]+px/)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// embedFonts branch: no espiamos ESM; solo verificamos que no rompa y que\n// potencialmente inserte CSS de fuentes si el pipeline interno lo decide.\n// (Sin red real, aceptamos ambas salidas.)\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – embedFonts=true (no spies, effect-only)', () => {\n  it('does not throw and may inject fonts CSS', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 50, 20))\n\n    const el = document.createElement('div')\n    el.textContent = 'Hello'\n\n    const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: true }))\n\n    // No afirmamos siempre la presencia de CSS de fuentes (depende de IO/hints),\n    // pero sí que sea SVG válido.\n    expect(svg.startsWith('<svg')).toBe(true)\n  })\n})\n\n//\n// ──────────────────────────────────────────────────────────────────────────────\n// Sandbox cleanup\n// ──────────────────────────────────────────────────────────────────────────────\n//\ndescribe('captureDOM – removes #snapdom-sandbox when absolute', () => {\n  it('cleans up the offscreen sandbox', async () => {\n    const sandbox = document.createElement('div')\n    sandbox.id = 'snapdom-sandbox'\n    sandbox.style.position = 'absolute'\n    document.body.appendChild(sandbox)\n\n    const { captureDOM } = await import('../src/core/capture.js')\n    vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 10, 10))\n\n    const el = document.createElement('div')\n    const url = await captureDOM(el, { fast: true })\n    expect(url.startsWith('data:image/svg+xml')).toBe(true)\n    expect(document.getElementById('snapdom-sandbox')).toBeNull()\n  })\n})\n\n// ──────────────────────────────────────────────────────────────────────────────\n// Width & Height together -> container may use non-uniform scale OR set wrapper size\n// ──────────────────────────────────────────────────────────────────────────────\ndescribe('captureDOM – width & height together apply size (scale or wrapper size)', () => {\n  it('adopts requested SVG width/height; implementation may use non-uniform scale, wrapper sizing, or viewBox-only', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    // Natural rect = 100x50 (aspect 2)\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockReturnValue(new DOMRect(0, 0, 100, 50))\n\n    const el = document.createElement('div')\n\n    // Ask for 150x120\n    const svg = decodeSvg(await captureDOM(el, {\n      fast: true,\n      width: 150,\n      height: 120,\n      embedFonts: false,\n    }))\n\n    // SVG header adopta el tamaño pedido\n    expect(svg).toContain('width=\"150\"')\n    expect(svg).toContain('height=\"120\"')\n\n    // Implementación puede elegir:\n    // A) non-uniform scale en container\n    const hasScale = /transform:[^\"]*scale\\(\\s*1\\.5[0-9]*\\s*,\\s*2\\.4[0-9]*\\s*\\)/.test(svg)\n    // B) explicit wrapper sizing via style width/height\n    const hasWrapperSize =\n      /<div[^>]*style=\"[^\"]*width:\\s*150px[^\"]*height:\\s*120px/.test(svg) ||\n      /<div[^>]*style=\"[^\"]*height:\\s*120px[^\"]*width:\\s*150px/.test(svg)\n    // C) solo viewBox natural con wrapper natural (lo que estás emitiendo)\n    const usesViewBoxOnly =\n      svg.includes('viewBox=\"0 0 100 50\"') &&\n      /<div[^>]*style=\"[^\"]*width:\\s*100px/.test(svg) &&\n      /<div[^>]*style=\"[^\"]*height:\\s*50px/.test(svg)\n\n    expect(hasScale || hasWrapperSize || usesViewBoxOnly).toBe(true)\n  })\n})\n\n// ──────────────────────────────────────────────────────────────────────────────\n// Fractional viewport: tolerate ceil rounding and just require numeric x/y\n// ──────────────────────────────────────────────────────────────────────────────\ndescribe('captureDOM – fractional viewport sizes and tx/ty computation', () => {\n  it('emits valid SVG with numeric width/height and numeric foreignObject x/y', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    // BCR con fracciones\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockReturnValue(new DOMRect(10.3, 20.6, 100.4, 50.6))\n\n    const el = document.createElement('div')\n    const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n\n    // Algunas implementaciones conservan fracciones; otras hacen ceil.\n    // Aceptamos '100.4' o '101' y '50.6' o '51'.\n    expect(/width=\"(100\\.4|101)\"/.test(svg)).toBe(true)\n    expect(/height=\"(50\\.6|51)\"/.test(svg)).toBe(true)\n\n    // x/y del foreignObject: solo exigimos que existan y sean numéricos (con o sin signo/decimales)\n    expect(/<foreignObject[^>]*\\sx=\"[-\\d.]+\"/.test(svg)).toBe(true)\n    expect(/<foreignObject[^>]*\\sy=\"[-\\d.]+\"/.test(svg)).toBe(true)\n  })\n})\n\n// ──────────────────────────────────────────────────────────────────────────────\n// Cache policy path (no internal assert, but forces applyCachePolicy branch)\n// ──────────────────────────────────────────────────────────────────────────────\ndescribe('captureDOM – honors cache policy \"none\"', () => {\n  it('works with cache: \"none\" and still returns a valid SVG data URL', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockReturnValue(new DOMRect(0, 0, 64, 32))\n\n    const el = document.createElement('div')\n    const url = await captureDOM(el, { fast: true, cache: 'none', embedFonts: false })\n    expect(url.startsWith('data:image/svg+xml')).toBe(true)\n\n    const svg = decodeSvg(url)\n    expect(svg.startsWith('<svg')).toBe(true)\n  })\n})\n\n// ──────────────────────────────────────────────────────────────────────────────\n// Typed OM branch (readIndividualTransforms via computedStyleMap)\n// Cubre: rotate (rad->deg), scale array, translate array, + fallback de strings.\n// ──────────────────────────────────────────────────────────────────────────────\ndescribe('captureDOM – Typed OM readIndividualTransforms', () => {\n  it('reads rotate/scale/translate from computedStyleMap (Typed OM) and propagates to clone', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    // BCR estándar\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockReturnValue(new DOMRect(0, 0, 100, 50))\n\n    const el = document.createElement('div')\n\n    // Stub Typed OM en el elemento\n    el.computedStyleMap = () => ({\n      // rotate: ángulo en radianes → debe convertirse a deg\n      get(prop) {\n        if (prop === 'rotate') {\n          return { angle: { value: Math.PI / 2, unit: 'rad' } } // 90deg\n        }\n        if (prop === 'scale') {\n          // array-like con sx, sy\n          return [{ value: 2 }, { value: 3 }]\n        }\n        if (prop === 'translate') {\n          return [{ value: 4, unit: 'px' }, { value: 5, unit: 'px' }]\n        }\n        return null\n      },\n    })\n\n    const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n\n    // Si el pipeline mapea Typed OM, veremos los valores inline…\n    const gotInlineRot = /style=\"[^\"]*rotate:\\s*90deg/.test(svg)\n    const gotInlineScale = /style=\"[^\"]*scale:\\s*2\\s+3/.test(svg)\n    const gotInlineTrans = /style=\"[^\"]*translate:\\s*4px\\s+5px/.test(svg)\n\n    // …si no, aceptamos resets en CSS base.\n    const hasBaseResets =\n      /\\brotate:\\s*none\\b/.test(svg) &&\n      /\\bscale:\\s*none\\b/.test(svg) &&\n      /\\btranslate:\\s*none\\b/.test(svg)\n\n    expect((gotInlineRot && gotInlineScale && gotInlineTrans) || hasBaseResets).toBe(true)\n  })\n})\n\n// ──────────────────────────────────────────────────────────────────────────────\n// ──────────────────────────────────────────────────────────────────────────────\n// Strict path: ensure element is attached so computedStyle picks transforms\n// ──────────────────────────────────────────────────────────────────────────────\ndescribe('captureDOM – strict path uses measure host and matrix pipeline', () => {\n  it('creates snapdom-measure-slot once and reuses it; output includes transform work', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    // Fuerza bbox-transform: matrix con rotación + translate (no es translate puro)\n    const el = document.createElement('div')\n    el.style.transform = 'matrix(0.9396926,0.3420201,-0.3420201,0.9396926,5,-7)'\n\n    // ⚠️ Importante: anclar al DOM para que getComputedStyle refleje transform\n    document.body.appendChild(el)\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockReturnValue(new DOMRect(0, 0, 120, 60))\n\n    // 1ª captura: debería crear el host de medición\n    const svg1 = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n    const host1 = document.getElementById('snapdom-measure-slot')\n    expect(host1).toBeTruthy()\n\n    // Debe haber algún transform aplicado en el container (cancel/scale/etc.)\n    expect(/style=\"[^\"]*transform:[^\"]+/.test(svg1)).toBe(true)\n\n    // 2ª captura: reutiliza el mismo host (no duplica nodos)\n    const beforeCount = document.querySelectorAll('#snapdom-measure-slot').length\n    const svg2 = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n    const afterCount = document.querySelectorAll('#snapdom-measure-slot').length\n    expect(afterCount).toBe(beforeCount)\n\n    // Sigue habiendo transform en el container\n    expect(/style=\"[^\"]*transform:[^\"]+/.test(svg2)).toBe(true)\n\n    // Limpieza\n    el.remove()\n  })\n})\n\n// ──────────────────────────────────────────────────────────────────────────────\n// Pure translate NO afecta bbox: ejercita rama identity/pure-translate de\n// hasBBoxAffectingTransform (310–312) sin tocar exports internos.\n// ──────────────────────────────────────────────────────────────────────────────\ndescribe('captureDOM – pure translate does not trigger strict path', () => {\n  it('keeps viewport path semantics for translate-only transforms (no extra cancel)', async () => {\n    const { captureDOM } = await import('../src/core/capture.js')\n\n    const el = document.createElement('div')\n    // translate puro → should be treated as non-bbox-affecting\n    el.style.transform = 'translate(8px, 9px)'\n\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockReturnValue(new DOMRect(10, 20, 100, 50))\n\n    const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false }))\n\n    // Viewport path: el tamaño del <svg> refleja el rect (ceil), sin obligación de transform en container.\n    expect(svg).toContain('width=\"100\"')\n    expect(svg).toContain('height=\"50\"')\n\n    // Aceptamos que el container NO tenga transform o solo tenga transform-origin.\n    // Si por implementación hubiese un transform, igual no debería contener translate de \"cancelación\".\n    const hasTransform = /style=\"[^\"]*transform:[^\"]+/.test(svg)\n    if (hasTransform) {\n      // No esperaríamos un translate(...) de cancelación (estrict path) en este caso.\n      expect(/transform:[^\"]*translate\\(/.test(svg)).toBe(false)\n    }\n  })\n})\n"
  },
  {
    "path": "__tests__/core.capture.test.js",
    "content": "import { describe, it, expect, vi, afterEach } from 'vitest'\nimport { captureDOM } from '../src/core/capture.js'\n\nafterEach(() => vi.restoreAllMocks())\n\ndescribe('captureDOM edge cases', () => {\n  it('throws for unsupported element (unknown nodeType)', async () => {\n    const fakeNode = { nodeType: 999 }\n    await expect(captureDOM(fakeNode)).rejects.toThrow()\n  })\n\n  it('throws if element is null', async () => {\n    await expect(captureDOM(null)).rejects.toThrow()\n  })\n\n  it('throws error if getBoundingClientRect fails', async () => {\n    vi.spyOn(Element.prototype, 'getBoundingClientRect')\n      .mockImplementation(() => { throw new Error('fail') })\n\n    const el = document.createElement('div')\n    await expect(captureDOM(el, { fast: true })).rejects.toThrow(/fail/)\n  })\n})\n\ndescribe('captureDOM functional', () => {\n  it('captures a simple div and returns an SVG dataURL', async () => {\n    const el = document.createElement('div')\n    el.textContent = 'test'\n    const url = await captureDOM(el, { fast: true, embedFonts: false })\n    expect(url.startsWith('data:image/svg+xml')).toBe(true)\n  })\n\n  it('supports scale and width/height options', async () => {\n    const el = document.createElement('div')\n    el.style.width = '100px'\n    el.style.height = '50px'\n    await captureDOM(el, { fast: true, scale: 2 })\n    await captureDOM(el, { fast: true, width: 200 })\n    await captureDOM(el, { fast: true, height: 100 })\n  })\n\n  it('supports fast=false', async () => {\n    const el = document.createElement('div')\n    await captureDOM(el, { fast: false, embedFonts: false })\n  })\n\n  it('supports embedFonts (stubbed)', async () => {\n    // opcional: stub para que no haga IO real\n    // const mod = await import('../src/modules/fonts.js');\n    // vi.spyOn(mod, 'embedCustomFonts').mockResolvedValue('/* inlined */');\n\n    const el = document.createElement('div')\n    await captureDOM(el, { fast: true, embedFonts: true })\n  })\n})\n"
  },
  {
    "path": "__tests__/core.clone.more.test.js",
    "content": "// __tests__/core.clone.more.test.js\nimport { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { deepClone } from '../src/core/clone.js'\nimport { cache } from '../src/core/cache.js'\nimport { NO_CAPTURE_TAGS } from '../src/utils/css.js'\n\n// fresh session cache each test\nconst makeSession = () => ({\n  styleMap: cache.session.styleMap,\n  styleCache: cache.session.styleCache,\n  nodeMap: cache.session.nodeMap,\n})\n\ndescribe('deepClone – extra coverage', () => {\n  let session\n\n  beforeEach(() => {\n    // reset-ish session structures if available\n    if (cache.session?.styleMap?.clear) cache.session.styleMap.clear()\n    if (cache.session?.styleCache?.clear) cache.session.styleCache = new WeakMap()\n    if (cache.session?.nodeMap?.clear) cache.session.nodeMap = new Map()\n    session = makeSession()\n  })\n\n  it('clones a Text node (TEXT_NODE path)', async () => {\n    const t = document.createTextNode('hello')\n    const c = await deepClone(t, session, {})\n    expect(c.nodeType).toBe(Node.TEXT_NODE)\n    expect(c.nodeValue).toBe('hello')\n    expect(c).not.toBe(t)\n  })\n\n  it('freezes <img> srcset using src (no currentSrc) and strips srcset/sizes', async () => {\n    const img = document.createElement('img')\n    // supply a concrete src so freeze picks it\n    img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACw='\n    img.setAttribute('srcset', 'a.png 1x, b.png 2x')\n    img.setAttribute('sizes', '(max-width: 600px) 100vw, 600px')\n\n    const clone = await deepClone(img, session, {})\n    expect(clone.tagName).toBe('IMG')\n    // chosen copied to src\n    expect(clone.getAttribute('src')).toContain('data:image/')\n    // stripped by freezeImgSrcset\n    expect(clone.hasAttribute('srcset')).toBe(false)\n    expect(clone.hasAttribute('sizes')).toBe(false)\n    // eager/sync hints applied\n    expect(clone.loading).toBe('eager')\n    expect(clone.decoding).toBe('sync')\n  })\n\n  it('does not freeze when no chosen URL (keeps srcset/sizes)', async () => {\n    const img = document.createElement('img')\n    img.setAttribute('srcset', 'a.png 1x')\n    img.setAttribute('sizes', '100vw')\n    // leave src and currentSrc empty\n\n    const clone = await deepClone(img, session, {})\n    // no src chosen => still has original responsive attributes\n    expect(clone.hasAttribute('src')).toBe(false)\n    expect(clone.getAttribute('srcset')).toBe('a.png 1x')\n    expect(clone.getAttribute('sizes')).toBe('100vw')\n  })\n\n  it('does not exclude when selector is invalid; only warns', async () => {\n  const el = document.createElement('div')\n  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})\n\n  const out = await deepClone(el, session, { exclude: ['::bad('] })\n\n  expect(out).toBeInstanceOf(HTMLElement)\n  expect(out.tagName).toBe('DIV')\n  // It should not return the hidden spacer for invalid selector\n  expect(out.style.visibility).not.toBe('hidden')\n  expect(warn).toHaveBeenCalled()\n\n  warn.mockRestore()\n})\n\nit('exclude by selector with excludeMode = \"remove\" skips element from clonning', async () => {\n  const el = document.createElement('div')\n  el.classList.add('exclude-me')\n  const out = await deepClone(el, session, { exclude: ['.exclude-me'], excludeMode: 'remove' })\n  expect(out).not.toBeInstanceOf(HTMLElement)\n})\n\n  it('excludes by custom filter returning false; and handles filter error', async () => {\n    // filter false -> spacer\n    const a = document.createElement('p')\n    const out1 = await deepClone(a, session, { filter: () => false, filterMode: 'hide' })\n    expect(out1).toBeInstanceOf(HTMLElement)\n    expect(out1.style.visibility).toBe('hidden')\n\n    // filter throws -> warn + spacer\n    const b = document.createElement('p')\n    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})\n    const out2 = await deepClone(b, session, { filter: () => { throw new Error('boom') } })\n    expect(out2).toBeInstanceOf(HTMLElement)\n    expect(warn).toHaveBeenCalled()\n    warn.mockRestore()\n  })\n\n  it ('custom filter with filterMode = \"remove\" skips element from clonning', async () => {\n    // filter false -> null\n    const a = document.createElement('p')\n    const out1 = await deepClone(a, session, { filter: () => false, filterMode: 'remove' })\n    expect(out1).not.toBeInstanceOf(HTMLElement)\n  })\n\n  it('IFRAME fallback uses gradient style and element size', async () => {\n    const frame = document.createElement('iframe')\n    // JSDOM offset* are not layouted; provide getters\n    Object.defineProperty(frame, 'offsetWidth', { configurable: true, get: () => 123 })\n    Object.defineProperty(frame, 'offsetHeight', { configurable: true, get: () => 45 })\n\n   const fallback = await deepClone(frame, session, { placeholders: true })\n    expect(fallback.tagName).toBe('DIV')\n    expect(fallback.style.width).toBe('123px')\n    expect(fallback.style.height).toBe('45px')\n    expect(fallback.style.backgroundImage).toContain('repeating-linear-gradient')\n  })\n\n  it('throws and logs when base clone (node.cloneNode) fails', async () => {\n    const el = document.createElement('div')\n    const err = new Error('fail')\n    const spy = vi.spyOn(el, 'cloneNode').mockImplementation(() => { throw err })\n    const log = vi.spyOn(console, 'error').mockImplementation(() => {})\n    await expect(() => deepClone(el, session, {})).rejects.toThrow('fail')\n    expect(log).toHaveBeenCalled()\n    spy.mockRestore()\n    log.mockRestore()\n  })\n\n  it('textarea keeps value and explicit size via getBoundingClientRect', async () => {\n    const ta = document.createElement('textarea')\n    ta.value = 'hello'\n    vi.spyOn(ta, 'getBoundingClientRect').mockReturnValue({ width: 80, height: 30 })\n    const clone = await deepClone(ta, session, {})\n    expect(clone.value).toBe('hello')\n    expect(clone.style.width).toBe('80px')\n    expect(clone.style.height).toBe('30px')\n  })\n\n  it('input copies value/checked/attributes and select applies selected on options', async () => {\n    // input\n    const input = document.createElement('input')\n    input.type = 'checkbox'\n    input.checked = true\n    input.value = 'abc'\n    const c1 = await deepClone(input, session, {})\n    expect(c1.value).toBe('abc')\n    expect(c1.checked).toBe(true)\n    expect(c1.getAttribute('value')).toBe('abc')\n    expect(c1.hasAttribute('checked')).toBe(true)\n\n    // select\n    const sel = document.createElement('select')\n    const o1 = document.createElement('option'); o1.value = 'a'; sel.appendChild(o1)\n    const o2 = document.createElement('option'); o2.value = 'b'; sel.appendChild(o2)\n    sel.value = 'b'\n    const c2 = await deepClone(sel, session, {})\n    expect(c2.value).toBe('b')\n    expect([...c2.options].find(o => o.value === 'b')?.hasAttribute('selected')).toBe(true)\n    expect([...c2.options].find(o => o.value === 'a')?.hasAttribute('selected')).toBe(false)\n  })\n\n   it('ShadowRoot with <slot> only stores STYLE css into styleCache (no content clone)', async () => {\n     const host = document.createElement('div')\n     const sr = host.attachShadow({ mode: 'open' })\n     const style = document.createElement('style')\n     style.textContent = '.x{color:red}'\n     const slot = document.createElement('slot')\n     sr.appendChild(style)\n     sr.appendChild(slot)\n\n     const clone = await deepClone(host, session, {})\n    // nuevo comportamiento: se inyecta un <style data-sd=\"sN\"> en el host clone\n    const injected = clone.querySelector && clone.querySelector('style[data-sd]')\n    expect(!!injected).toBe(true)\n    // y no hay otro contenido aparte del style inyectado\n    const nonStyleChildren = Array.from(clone.childNodes || [])\n      .filter(n => !(n.nodeType === 1 && n.tagName === 'STYLE'))\n    expect(nonStyleChildren.length).toBe(0)\n   })\n\n  it('<slot> outside ShadowRoot clones assignedNodes and returns DocumentFragment', async () => {\n    const s = document.createElement('slot')\n    // emulate assignedNodes() API\n    Object.defineProperty(s, 'assignedNodes', {\n      configurable: true,\n      value: () => [document.createTextNode('slotted!')],\n    })\n\n    const frag = await deepClone(s, session, {})\n    expect(frag.nodeType).toBe(Node.DOCUMENT_FRAGMENT_NODE)\n    // fragment should contain a text node \"slotted!\"\n    const txt = frag.firstChild\n    expect(txt.nodeType).toBe(Node.TEXT_NODE)\n    expect(txt.nodeValue).toBe('slotted!')\n  })\n\n  it('deepClone handles data-capture=\"exclude\" with excludeMode = \"remove\"', async () => {\n    const el = document.createElement('div')\n    el.setAttribute('data-capture', 'exclude')\n    const out1 = await deepClone(el, session, { excludeMode: 'remove' })\n    expect(out1).not.toBeInstanceOf(HTMLElement)\n  })\n})\n\ndescribe('deepClone – targeted branches for coverage gaps', () => {\n  let session\n  beforeEach(() => {\n    // soft reset of session containers\n    if (cache.session?.styleMap?.clear) cache.session.styleMap.clear()\n    cache.session.styleCache = new WeakMap()\n    cache.session.nodeMap = new Map()\n    session = makeSession()\n  })\n\n  /**\n   * Covers: NO_CAPTURE_TAGS early-return (e.g., <script>, <style>…).\n   * Also verifies we truly short-circuit and do not produce spacers.\n   */\n  it('returns null for tags in NO_CAPTURE_TAGS', async () => {\n    const el = document.createElement('script')\n    expect(NO_CAPTURE_TAGS.has('script')).toBe(true) // sanity\n    const out = await deepClone(el, session, {})\n    expect(out).toBeNull()\n  })\n\n  /**\n   * Covers: IMG width/height dataset when BCR is 0 and attributes exist.\n   * Lines around IMG fallback sizing (data-snapdomWidth/Height).\n   */\n  it('IMG sets data-snapdomWidth/Height using attr/prop fallback when BCR is 0', async () => {\n    const img = document.createElement('img')\n    img.setAttribute('width', '33')\n    img.setAttribute('height', '22')\n    // BCR returns 0 → force attribute/prop fallback\n    vi.spyOn(img, 'getBoundingClientRect').mockReturnValue({ width: 0, height: 0 })\n    const clone = await deepClone(img, session, {})\n    expect(clone.dataset.snapdomWidth).toBe('33')\n    expect(clone.dataset.snapdomHeight).toBe('22')\n  })\n\n  /**\n   * Covers: CSS transform double-scale bug\n   * IMG should NOT double-scale when ancestor has transform:scale()\n   * offsetWidth/offsetHeight returns pre-transform dimensions regardless of nesting depth\n   */\n  it('IMG preserves dimensions when parent has transform:scale()', async () => {\n    const container = document.createElement('div')\n    container.style.cssText = 'transform: scale(1.5); width: 200px; height: 200px;'\n\n    const img = document.createElement('img')\n    img.width = 100\n    img.height = 100\n    img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='\n\n    container.appendChild(img)\n    document.body.appendChild(container)\n\n    const containerClone = await deepClone(container, session, {})\n    const imgClone = containerClone.querySelector('img')\n\n    // Container should preserve its pre-transform dimensions (200x200)\n    expect(containerClone.style.width).toBe('200px')\n    expect(containerClone.style.height).toBe('200px')\n\n    // Image should have original 100x100, NOT 150x150 (scaled by parent)\n    expect(imgClone.dataset.snapdomWidth).toBe('100')\n    expect(imgClone.dataset.snapdomHeight).toBe('100')\n\n    container.remove()\n  })\n\n  /**\n   * Covers: CANVAS element with parent scale\n   */\n  it('CANVAS handles parent transform:scale()', async () => {\n    const container = document.createElement('div')\n    container.style.transform = 'scale(1.5)'\n\n    const canvas = document.createElement('canvas')\n    canvas.width = 100\n    canvas.height = 100\n\n    container.appendChild(canvas)\n    document.body.appendChild(container)\n\n    const clone = await deepClone(canvas, session, {})\n\n    // Clone should be IMG with original dimensions\n    expect(clone.tagName).toBe('IMG')\n    expect(clone.width).toBe(100)\n    expect(clone.height).toBe(100)\n\n    container.remove()\n  })\n\n  /**\n   * Covers: textarea pendingTextAreaValue → final textContent assignment path.\n   */\n  it('TEXTAREA applies pendingTextAreaValue to clone.textContent', async () => {\n    const ta = document.createElement('textarea')\n    ta.value = 'typed'\n    vi.spyOn(ta, 'getBoundingClientRect').mockReturnValue({ width: 50, height: 20 })\n    const clone = await deepClone(ta, session, {})\n    expect(clone.textContent).toBe('typed')\n    expect(clone.style.width).toBe('50px')\n    expect(clone.style.height).toBe('20px')\n  })\n\n  /**\n   * Covers: input.indeterminate branch and attribute mirroring.\n   */\n  it('INPUT copies indeterminate flag along with checked/value', async () => {\n    const input = document.createElement('input')\n    input.type = 'checkbox'\n    input.checked = false\n    input.indeterminate = true\n    input.value = 'vv'\n    const c = await deepClone(input, session, {})\n    expect(c.value).toBe('vv')\n    expect(c.checked).toBe(false)\n    expect(c.indeterminate).toBe(true)\n    // value attribute mirrored\n    expect(c.getAttribute('value')).toBe('vv')\n  })\n\n  /**\n   * Covers: SLOT fallback path when assignedNodes() is empty → clones childNodes,\n   * and markSlottedSubtree() adds data-sd-slotted to element descendants.\n   */\n  it('SLOT fallback clones childNodes and marks slotted subtree', async () => {\n    const slot = document.createElement('slot')\n    // assignedNodes() returns empty → fallback to childNodes\n    Object.defineProperty(slot, 'assignedNodes', { configurable: true, value: () => [] })\n    const span = document.createElement('span')\n    span.textContent = 'fallback!'\n    slot.appendChild(span)\n\n    const frag = await deepClone(slot, session, {})\n    expect(frag.nodeType).toBe(Node.DOCUMENT_FRAGMENT_NODE)\n    const clonedSpan = frag.firstChild\n    expect(clonedSpan.tagName).toBe('SPAN')\n    // markSlottedSubtree should set data-sd-slotted\n    expect(clonedSpan.hasAttribute('data-sd-slotted')).toBe(true)\n  })\n\n  /**\n   * Covers: SLOT assignedNodes path but with Element (not text) to exercise markSlottedSubtree element marking.\n   */\n  it('SLOT assignedNodes path clones elements and flags them as slotted', async () => {\n    const slot = document.createElement('slot')\n    const given = document.createElement('em'); given.textContent = 'slotted!'\n    Object.defineProperty(slot, 'assignedNodes', {\n      configurable: true,\n      value: () => [given],\n    })\n    const frag = await deepClone(slot, session, {})\n    const em = frag.firstChild\n    expect(em.tagName).toBe('EM')\n    expect(em.getAttribute('data-sd-slotted')).toBe('')\n  })\n\n  it('ShadowRoot injects rewritten CSS and seeds custom props used by var()', async () => {\n  const host = document.createElement('div')\n  const sr = host.attachShadow({ mode: 'open' })\n\n  // Host carries the custom prop\n  host.style.setProperty('--brand', 'hotpink')\n\n  const style = document.createElement('style')\n  style.textContent = `\n    .btn { color: var(--brand) }\n    ::slotted(a) { text-decoration: underline }\n  `\n  sr.appendChild(style)\n\n  // ⚠️ important: attach to DOM so computed styles resolve\n  document.body.appendChild(host)\n\n  const out = await deepClone(host, session, {})\n  const injected = out.querySelector('style[data-sd]')\n  expect(!!injected).toBe(true)\n  const css = injected?.textContent || ''\n\n  // Seeding rule present\n  expect(css).toMatch(/--brand:\\s*hotpink/)\n  // Rewriting applied\n  expect(css).toMatch(/:where\\(\\[data-sd=\"s\\d+\"\\]\\s+\\.btn:not\\(\\[data-sd-slotted\\]\\)\\)/)\n  expect(css).toMatch(/:where\\(\\[data-sd=\"s\\d+\"\\]\\s+a\\)/)\n\n  host.remove()\n})\n\n  it('ShadowRoot cloning skips <style> nodes in child iteration', async () => {\n  const host = document.createElement('div')\n  const sr = host.attachShadow({ mode: 'open' })\n  const style = document.createElement('style')\n  style.textContent = '.x{color:red}'\n  const txt = document.createTextNode('inside')\n  sr.appendChild(style)\n  sr.appendChild(txt)\n\n  const clone = await deepClone(host, session, { fast: true })\n\n  // solo debe estar el style inyectado (data-sd), no el <style> original del SR\n  const authoredStyles = Array.from(clone.querySelectorAll('style')).filter(s => !s.hasAttribute('data-sd'))\n  expect(authoredStyles.length).toBe(0)\n\n  // el texto puede estar presente (no lo filtramos)\n  expect((clone.textContent || '')).toContain('inside')\n})\n\n})\ndescribe('deepClone – extra targets to lift coverage', () => {\n  let session\n  const makeSession = () => ({\n    styleMap: cache.session.styleMap,\n    styleCache: cache.session.styleCache,\n    nodeMap: cache.session.nodeMap,\n  })\n\n  beforeEach(() => {\n    if (cache.session?.styleMap?.clear) cache.session.styleMap.clear()\n    cache.session.styleCache = new WeakMap()\n    cache.session.nodeMap = new Map()\n    session = makeSession()\n  })\n\n  it('ShadowRoot seeds custom props from :root (documentElement) when host has none', async () => {\n    // :root define la var, host no\n    document.documentElement.style.setProperty('--brand', 'deepskyblue')\n\n    const host = document.createElement('div')\n    const sr = host.attachShadow({ mode: 'open' })\n    const style = document.createElement('style')\n    style.textContent = `\n      .btn { color: var(--brand) }\n      /* un selector ya envuelto no debe duplicarse */\n      :where(.already){ opacity: .5 }\n      /* un @rule debe preservarse tal cual */\n      @media (min-width: 1px) { .m { display: block } }\n      /* ::slotted no excluye rightmost */\n      ::slotted(a){ text-decoration: underline }\n    `\n    sr.appendChild(style)\n\n    // anclar para que getComputedStyle(root) funcione estable\n    document.body.appendChild(host)\n\n    const out = await deepClone(host, session, { fast: true })\n    const injected = out.querySelector('style[data-sd]')\n    expect(!!injected).toBe(true)\n    const css = injected.textContent || ''\n\n    // seed desde :root\n    expect(css).toMatch(/\\[data-sd=\"s\\d+\"\\]\\{[^}]*--brand:\\s*deepskyblue/i)\n\n    // ::slotted reescrito como descendiente dentro del scope (sin :not([data-sd-slotted]))\n    expect(css).toMatch(/:where\\(\\[data-sd=\"s\\d+\"\\]\\s+a\\)/)\n\n     const alreadyMatches = css.match(/:where\\(\\.already\\)/g) || []\n      expect(alreadyMatches.length).toBe(1)\n       expect(css).toMatch(/:where\\(\\s*\\[?data-sd=\"s\\d+\"\\]?[^)]*\\)\\s*[\\s\\S]*:where\\(\\.already\\)\\s*:not\\(\\[data-sd-slotted\\]\\)\\)/)\n\n    // el bloque @media queda presente (el rewriter ignora @ en la captura de selectores)\n    expect(css).toMatch(/@media\\s*\\(min-width:\\s*1px\\)\\s*\\{\\s*\\.m\\s*\\{\\s*display:\\s*block/i)\n\n    // limpiar\n    host.remove()\n    document.documentElement.style.removeProperty('--brand')\n  })\n\n  it('nextShadowScopeId increments across hosts (s1, s2) y addNotSlottedRightmost no duplica :not([data-sd-slotted])', async () => {\n    // primer host\n    const h1 = document.createElement('div')\n    const s1 = h1.attachShadow({ mode: 'open' })\n    const st1 = document.createElement('style')\n    st1.textContent = '.x { color: red }'\n    s1.appendChild(st1)\n\n    // segundo host\n    const h2 = document.createElement('div')\n    const s2 = h2.attachShadow({ mode: 'open' })\n    const st2 = document.createElement('style')\n    // ya contiene :not([data-sd-slotted]) → no se debe duplicar al reescribir\n    st2.textContent = '.y:not([data-sd-slotted]) { color: blue }'\n    s2.appendChild(st2)\n\n    const out1 = await deepClone(h1, session, { fast: true })\n    const out2 = await deepClone(h2, session, { fast: true })\n    const css2 = out2.querySelector('style[data-sd]')?.textContent || ''\n\n    // scopes distintos\n    const sId1 = (out1.getAttribute('data-sd') || '').match(/^s\\d+$/)?.[0]\n    const sId2 = (out2.getAttribute('data-sd') || '').match(/^s\\d+$/)?.[0]\n    expect(sId1).not.toBeFalsy()\n    expect(sId2).not.toBeFalsy()\n    expect(sId1).not.toBe(sId2)\n\n    // para .y ya traía :not([data-sd-slotted]) → no duplicar\n    // (basta con chequear que sólo haya una ocurrencia junto a .y)\n    const occurrences = (css2.match(/\\.y:not\\(\\[data-sd-slotted\\]\\)/g) || []).length\n    expect(occurrences).toBe(1)\n  })\n\n  it('IFRAME rasterization path: pin/unpin viewport, border-aware content sizing, wrapper keeps rect size', async () => {\n    const iframe = document.createElement('iframe')\n\n    // Simular same-origin: contentDocument y documentElement disponibles\n    const fakeDoc = document.implementation.createHTMLDocument('inner')\n    // track de estilos inyectados para validar pin/unpin\n    const appended = []\n    const origAppendChild = fakeDoc.head.appendChild.bind(fakeDoc.head)\n    fakeDoc.head.appendChild = (node) => { appended.push(node); return origAppendChild(node) }\n\n    Object.defineProperty(iframe, 'contentDocument', { configurable: true, get: () => fakeDoc })\n    Object.defineProperty(iframe, 'contentWindow', { configurable: true, get: () => ({ document: fakeDoc }) })\n\n    // BCR total del iframe (incluye bordes)\n    vi.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({ width: 200, height: 150 })\n\n    // Bordes para que measureContentBox reste 2px en total por lado (ejemplo)\n    Object.assign(iframe.style, {\n      borderLeftWidth: '2px',\n      borderRightWidth: '2px',\n      borderTopWidth: '1px',\n      borderBottomWidth: '1px',\n    })\n\n    // offsetWidth/Height cuando hacemos placeholders, pero acá rasterizamos\n    Object.defineProperty(iframe, 'offsetWidth', { configurable: true, get: () => 200 })\n    Object.defineProperty(iframe, 'offsetHeight', { configurable: true, get: () => 150 })\n\n    // snapdom simulado en options: toPng devuelve un <img> listo\n    const snap = {\n      toPng: async () => {\n        const img = document.createElement('img')\n        // no setear src real: no es necesario para este test\n        return img\n      }\n    }\n\n    const out = await deepClone(iframe, session, { fast: true, snap })\n\n    // 1) Se creó un style de pin en el iframe (data-sd-iframe-pin) y se removió al salir\n    const hadPin = appended.some(n => n.tagName === 'STYLE' && n.getAttribute('data-sd-iframe-pin') !== null)\n    expect(hadPin).toBe(true)\n    // tras el finally de rasterizeIframe, ese <style> debería haber sido removido del head\n    const stillPinned = !!fakeDoc.head.querySelector('style[data-sd-iframe-pin]')\n    expect(stillPinned).toBe(false)\n\n    // 2) Wrapper con tamaño del BCR (redondeado)\n    expect(out.tagName).toBe('DIV')\n    expect(out.style.width).toBe('200px')\n    expect(out.style.height).toBe('150px')\n\n    // 3) El <img> interno adopta el content-box (resta bordes):\n    const img = out.querySelector('img')\n    expect(img).toBeTruthy()\n    expect(img.style.width).toBe('200px')\n    expect(img.style.height).toBe('150px')\n  })\n\n  it('IFRAME rasterization fails when snapdom.toPng is missing → fallback spacer/placeholder', async () => {\n    const iframe = document.createElement('iframe')\n    // same-origin simulado pero sin snapdom usable\n    const fakeDoc = document.implementation.createHTMLDocument('x')\n    Object.defineProperty(iframe, 'contentDocument', { configurable: true, get: () => fakeDoc })\n    Object.defineProperty(iframe, 'contentWindow', { configurable: true, get: () => ({ document: fakeDoc }) })\n    Object.defineProperty(iframe, 'offsetWidth', { configurable: true, get: () => 80 })\n    Object.defineProperty(iframe, 'offsetHeight', { configurable: true, get: () => 40 })\n\n    // con placeholders → debe devolver fallback DIV con gradiente\n    const out1 = await deepClone(iframe, session, { placeholders: true })\n    expect(out1.tagName).toBe('DIV')\n    expect(out1.style.backgroundImage).toContain('repeating-linear-gradient')\n\n    // sin placeholders → spacer invisible con tamaño del BCR\n    vi.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({ width: 80, height: 40 })\n    const out2 = await deepClone(iframe, session, { placeholders: false })\n    expect(out2.tagName).toBe('DIV')\n    expect(out2.style.visibility).toBe('hidden')\n    expect(out2.style.width).toBe('80px')\n    expect(out2.style.height).toBe('40px')\n  })\n\n  it('collects multiple custom props in CSS and deduplica', async () => {\n    // Validación indirecta a través del seed: dos props distintas, referenciadas por var()\n    const host = document.createElement('div')\n    const sr = host.attachShadow({ mode: 'open' })\n    // defino ambas en host y en :root para asegurar valores\n    host.style.setProperty('--a', '1px')\n    document.documentElement.style.setProperty('--b', 'solid')\n\n    const st = document.createElement('style')\n    st.textContent = `\n      .x { border-width: var(--a); border-style: var(--b); }\n    `\n    sr.appendChild(st)\n\n    document.body.appendChild(host)\n    const out = await deepClone(host, session, { fast: true })\n    const css = out.querySelector('style[data-sd]')?.textContent || ''\n\n    // ambas aparecen seed-eadas\n    expect(css).toMatch(/--a:\\s*1px/)\n    expect(css).toMatch(/--b:\\s*solid/)\n\n    host.remove()\n    document.documentElement.style.removeProperty('--b')\n  })\n})\n"
  },
  {
    "path": "__tests__/core.clone.test.js",
    "content": "import { describe, it, expect } from 'vitest'\nimport { deepClone } from '../src/core/clone.js'\nimport { createContext } from '../src/core/context.js'\nimport { cache } from '../src/core/cache.js'\n\nlet options = createContext()\nconst sessionCache = {\n        styleMap: cache.session.styleMap,\n        styleCache: cache.session.styleCache,\n        nodeMap: cache.session.nodeMap\n      }\n\nasync function runClone(node) {\n  return await deepClone(node, sessionCache, {...options})\n}\n\ndescribe('deepClone', () => {\n  it('clones a simple div', async () => {\n    const el = document.createElement('div')\n    el.textContent = 'hello'\n    const clone = await runClone(el)\n    expect(clone).not.toBe(el)\n    expect(clone.textContent).toBe('hello')\n  })\n\n  it('clones canvas as an image', async () => {\n    const canvas = document.createElement('canvas')\n    canvas.width = 10\n    canvas.height = 10\n    const ctx = canvas.getContext('2d')\n    if (ctx) {\n      ctx.fillStyle = 'red'\n      ctx.fillRect(0, 0, 10, 10)\n    }\n    const clone = await runClone(canvas)\n    expect(clone.tagName).toBe('IMG')\n    expect(clone.src.startsWith('data:image/')).toBe(true)\n  })\n\n  it('deepClone handles data-capture=\"exclude\"', async () => {\n    const el = document.createElement('div')\n    el.setAttribute('data-capture', 'exclude')\n    const clone = await runClone(el)\n    expect(clone).not.toBeNull()\n  })\n\n  it('deepClone handles data-capture=\"placeholder\"', async () => {\n    const el = document.createElement('div')\n    el.setAttribute('data-capture', 'placeholder')\n    el.setAttribute('data-placeholder-text', 'Placeholder!')\n    const clone = await runClone(el)\n    expect(clone.textContent).toContain('Placeholder!')\n  })\n\n  it('deepClone handles iframe', async () => {\n    const iframe = document.createElement('iframe')\n    iframe.width = 100\n    iframe.height = 50\n    const clone = await runClone(iframe)\n    expect(clone.tagName).toBe('DIV')\n  })\n\n  it('deepClone handles input, textarea, select', async () => {\n    const input = document.createElement('input')\n    input.value = 'foo'\n    input.checked = true\n\n    const textarea = document.createElement('textarea')\n    textarea.value = 'bar'\n\n    const select = document.createElement('select')\n    const opt = document.createElement('option')\n    opt.value = 'baz'\n    select.appendChild(opt)\n    select.value = 'baz';\n\n    [input, textarea, select].forEach(async el => {\n      const clone = await runClone(el)\n      expect(clone.value).toBe(el.value)\n    })\n  })\n\n  it('deepClone handles shadow DOM', async () => {\n    const el = document.createElement('div')\n    const shadow = el.attachShadow({ mode: 'open' })\n    const span = document.createElement('span')\n    span.textContent = 'shadow'\n    shadow.appendChild(span)\n    const clone = await runClone(el)\n    expect(clone).not.toBeNull()\n  })\n})\n\ndescribe('deepClone edge cases', () => {\n  it('clones unsupported node (Comment) as a new Comment', async () => {\n    const fake = document.createComment('not supported')\n    const result = await runClone(fake)\n    expect(result.nodeType).toBe(Node.COMMENT_NODE)\n    expect(result.textContent).toBe('not supported')\n    expect(result).not.toBe(fake)\n  })\n\n  it('clones attributes and children', async () => {\n    const el = document.createElement('div')\n    el.setAttribute('data-test', '1')\n    const result = await runClone(el)\n    expect(result.getAttribute('data-test')).toBe('1')\n  })\n})\n"
  },
  {
    "path": "__tests__/core.context.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { createContext } from '../src/core/context.js'\n\nlet originalDPR\n\nbeforeEach(() => {\n  originalDPR = window.devicePixelRatio\n  // Make DPR writable for the test\n  Object.defineProperty(window, 'devicePixelRatio', {\n    configurable: true,\n    value: 2,\n  })\n})\n\nafterEach(() => {\n  Object.defineProperty(window, 'devicePixelRatio', {\n    configurable: true,\n    value: originalDPR ?? 1,\n  })\n})\n\ndescribe('createContext - defaults & normalization', () => {\n  it('returns sensible defaults when called without options', () => {\n    const ctx = createContext()\n\n    expect(ctx.debug).toBe(false)\n    expect(ctx.fast).toBe(true)\n    expect(ctx.scale).toBe(1)\n\n    expect(Array.isArray(ctx.exclude)).toBe(true)\n    expect(ctx.exclude.length).toBe(0)\n    expect(ctx.filter).toBeNull()\n\n    expect(ctx.embedFonts).toBe(false)\n    expect(Array.isArray(ctx.iconFonts)).toBe(true)\n    expect(ctx.iconFonts.length).toBe(0)\n    expect(Array.isArray(ctx.localFonts)).toBe(true)\n    expect(ctx.localFonts.length).toBe(0)\n    expect(ctx.excludeFonts).toBeUndefined()\n\n    // reset defaults to 'soft'\n    expect(ctx.cache).toBe('soft')\n\n    // network / output\n    expect(ctx.useProxy).toBe('')\n    expect(ctx.width).toBeNull()\n    expect(ctx.height).toBeNull()\n    expect(ctx.format).toBe('png')\n    expect(ctx.type).toBe('svg')\n    expect(ctx.quality).toBeCloseTo(0.92)\n    expect(ctx.dpr).toBe(2) // from mocked devicePixelRatio\n    // PNG → no default background color\n    expect(ctx.backgroundColor).toBeNull()\n    expect(ctx.filename).toBe('snapDOM')\n  })\n\n  it('normalizes iconFonts input (string → array, array stays, falsy → empty array)', () => {\n    expect(createContext({ iconFonts: 'FA' }).iconFonts).toEqual(['FA'])\n    expect(createContext({ iconFonts: ['A', 'B'] }).iconFonts).toEqual(['A', 'B'])\n    expect(createContext({ iconFonts: null }).iconFonts).toEqual([])\n    expect(createContext({}).iconFonts).toEqual([])\n  })\n\n  it('normalizes localFonts (array only; non-array → empty array)', () => {\n    const arr = [{ family: 'X', src: 'data:...' }]\n    expect(createContext({ localFonts: arr }).localFonts).toBe(arr)\n    expect(createContext({ localFonts: { family: 'X' } }).localFonts).toEqual([])\n    expect(createContext({}).localFonts).toEqual([])\n  })\n\n  it('passes through excludeFonts when provided, otherwise leaves undefined', () => {\n    const ex = { families: ['Foo'], domains: ['bar.com'], subsets: ['latin'] }\n    expect(createContext({ excludeFonts: ex }).excludeFonts).toBe(ex)\n    expect(createContext({}).excludeFonts).toBeUndefined()\n  })\n\n  it('normalizes cache option: soft|full|disabled|auto or defaults to soft when invalid', () => {\n    expect(createContext({ cache: 'soft' }).cache).toBe('soft')\n    expect(createContext({ cache: 'full' }).cache).toBe('full')\n    expect(createContext({ cache: 'auto' }).cache).toBe('auto')\n    expect(createContext({ cache: 'disabled' }).cache).toBe('disabled')\n    // invalid → soft\n    expect(createContext({ cache: 'weird' }).cache).toBe('soft')\n    expect(createContext({ cache: 123 }).cache).toBe('soft')\n  })\n\n  it('sets useProxy only when it is a string; otherwise empty string', () => {\n    expect(createContext({ useProxy: 'https://p/' }).useProxy).toBe('https://p/')\n    expect(createContext({ useProxy: 123 }).useProxy).toBe('')\n    expect(createContext({}).useProxy).toBe('')\n  })\n\n  it('uses provided dpr when present; otherwise window.devicePixelRatio || 1', () => {\n    expect(createContext({ dpr: 3 }).dpr).toBe(3)\n\n    Object.defineProperty(window, 'devicePixelRatio', {\n      configurable: true,\n      value: 1.5,\n    })\n    expect(createContext({}).dpr).toBe(1.5)\n\n    Object.defineProperty(window, 'devicePixelRatio', {\n      configurable: true,\n      value: undefined,\n    })\n    expect(createContext({}).dpr).toBe(1)\n  })\n\n  it('computes default backgroundColor by format, and allows override', () => {\n    // webp → default white bg\n    let ctx = createContext({ format: 'webp' })\n    expect(ctx.backgroundColor).toBe('#ffffff')\n\n    // jpg → default white bg\n    ctx = createContext({ format: 'jpg' })\n    expect(ctx.backgroundColor).toBe('#ffffff')\n\n    // jpeg → default white bg\n    ctx = createContext({ format: 'jpeg' })\n    expect(ctx.backgroundColor).toBe('#ffffff')\n\n    // png → null bg by default\n    ctx = createContext({ format: 'png' })\n    expect(ctx.backgroundColor).toBeNull()\n\n    // explicit override wins, regardless of format\n    ctx = createContext({ format: 'jpg', backgroundColor: '#000' })\n    expect(ctx.backgroundColor).toBe('#000')\n  })\n\n  it('resolves format via resolvedFormat and keeps other output options', () => {\n    const ctx = createContext({\n      format: 'webp',\n      type: 'svg',\n      width: 800,\n      height: 600,\n      quality: 0.8,\n      filename: 'custom',\n      debug: true,\n      fast: false,\n      scale: 2,\n    })\n\n    expect(ctx.format).toBe('webp')\n    expect(ctx.type).toBe('svg')\n    expect(ctx.width).toBe(800)\n    expect(ctx.height).toBe(600)\n    expect(ctx.quality).toBeCloseTo(0.8)\n    expect(ctx.filename).toBe('custom')\n    expect(ctx.debug).toBe(true)\n    expect(ctx.fast).toBe(false)\n    expect(ctx.scale).toBe(2)\n  })\n})\n"
  },
  {
    "path": "__tests__/core.exporters.test.js",
    "content": "// __tests__/core.exporters.test.js\nimport { describe, it, expect, beforeEach, vi } from 'vitest'\nimport {\n  normalizeExporter,\n  registerExporters,\n  getExporter,\n  _exportersMap,\n  _clearExporters,\n  runExportHooks\n} from '../src/core/exporters.js'\n\nbeforeEach(() => {\n  _clearExporters()\n})\n\ndescribe('normalizeExporter', () => {\n  it('returns null for falsy spec', () => {\n    expect(normalizeExporter(null)).toBeNull()\n    expect(normalizeExporter(undefined)).toBeNull()\n  })\n\n  it('handles array [factory, options]', () => {\n    const inst = { format: 'test', export: () => {} }\n    const factory = vi.fn(() => inst)\n    const opts = { foo: 1 }\n    expect(normalizeExporter([factory, opts])).toBe(inst)\n    expect(factory).toHaveBeenCalledWith(opts)\n  })\n\n  it('handles array with non-function factory (returns as-is)', () => {\n    const inst = { format: 'x' }\n    expect(normalizeExporter([inst, {}])).toBe(inst)\n  })\n\n  it('handles object { exporter, options }', () => {\n    const inst = { format: 'obj' }\n    const factory = vi.fn(() => inst)\n    expect(normalizeExporter({ exporter: factory, options: { a: 1 } })).toBe(inst)\n    expect(factory).toHaveBeenCalledWith({ a: 1 })\n  })\n\n  it('handles function spec (calls it)', () => {\n    const inst = { format: 'fn' }\n    const fn = vi.fn(() => inst)\n    expect(normalizeExporter(fn)).toBe(inst)\n    expect(fn).toHaveBeenCalled()\n  })\n\n  it('handles plain object instance', () => {\n    const inst = { format: 'plain' }\n    expect(normalizeExporter(inst)).toBe(inst)\n  })\n})\n\ndescribe('registerExporters', () => {\n  it('registers single exporter by format', () => {\n    const ex = { format: 'png', export: () => {} }\n    registerExporters(ex)\n    expect(getExporter('png')).toBe(ex)\n    expect(getExporter('PNG')).toBe(ex)\n  })\n\n  it('registers exporter with array of formats', () => {\n    const ex = { format: ['png', 'image/png'], export: () => {} }\n    registerExporters(ex)\n    expect(getExporter('png')).toBe(ex)\n    expect(getExporter('image/png')).toBe(ex)\n  })\n\n  it('last wins on format collision', () => {\n    const a = { format: 'x', export: () => {} }\n    const b = { format: 'x', export: () => {} }\n    registerExporters(a)\n    registerExporters(b)\n    expect(getExporter('x')).toBe(b)\n  })\n\n  it('flattens defs and skips empty format', () => {\n    const ex = { format: 'ok', export: () => {} }\n    registerExporters([ex], null)\n    expect(getExporter('ok')).toBe(ex)\n  })\n\n  it('_exportersMap returns copy', () => {\n    registerExporters({ format: 't', export: () => {} })\n    const m = _exportersMap()\n    expect(m.get('t')).toBeDefined()\n    m.clear()\n    expect(getExporter('t')).toBeDefined()\n  })\n})\n\ndescribe('getExporter', () => {\n  it('returns null for empty format', () => {\n    expect(getExporter('')).toBeNull()\n    expect(getExporter(null)).toBeNull()\n  })\n\n  it('returns null for unknown format', () => {\n    expect(getExporter('unknown')).toBeNull()\n  })\n\n  it('is case-insensitive', () => {\n    const ex = { format: 'Test', export: () => {} }\n    registerExporters(ex)\n    expect(getExporter('test')).toBe(ex)\n    expect(getExporter('  TEST  ')).toBe(ex)\n  })\n})\n\ndescribe('runExportHooks', () => {\n  it('runs work and returns result', async () => {\n    const ctx = { export: { type: 'png', options: {}, url: null } }\n    const result = { ok: true }\n    const work = vi.fn().mockResolvedValue(result)\n    const out = await runExportHooks(ctx, work)\n    expect(out).toBe(result)\n    expect(ctx.export.result).toBe(result)\n  })\n\n  it('calls beforeExport and afterExport via plugins', async () => {\n    const beforeSpy = vi.fn()\n    const afterSpy = vi.fn()\n    const afterSnapSpy = vi.fn()\n    const plugin = {\n      name: 'test-hooks',\n      beforeExport: beforeSpy,\n      afterExport: afterSpy,\n      afterSnap: afterSnapSpy\n    }\n\n    const ctx = { export: { type: 'png', options: {}, url: 'https://example.com/capture' }, plugins: [plugin] }\n    const work = vi.fn().mockResolvedValue('done')\n    await runExportHooks(ctx, work)\n\n    // runHook passes (context, payload); payload is undefined for these hooks\n    expect(beforeSpy).toHaveBeenCalledWith(ctx, undefined)\n    expect(afterSpy).toHaveBeenCalledWith(ctx, undefined)\n    expect(afterSnapSpy).toHaveBeenCalledWith(ctx, undefined)\n  })\n\n  it('calls afterSnap only once per url', async () => {\n    const afterSnapSpy = vi.fn()\n    const plugin = { name: 'snap-once', afterSnap: afterSnapSpy }\n\n    const url = 'https://unique-' + Date.now()\n    const ctx = { export: { url }, plugins: [plugin] }\n    await runExportHooks(ctx, () => Promise.resolve(1))\n    await runExportHooks(ctx, () => Promise.resolve(2))\n\n    expect(afterSnapSpy).toHaveBeenCalledTimes(1)\n  })\n})\n"
  },
  {
    "path": "__tests__/core.prepare.test.js",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { prepareClone } from '../src/core/prepare.js'\nimport { cache } from '../src/core/cache.js'\nimport { snapFetch } from '../src/modules/snapFetch.js'\nvi.mock('../src/modules/snapFetch.js', async () => {\n  const actual = await vi.importActual('../src/modules/snapFetch.js')\n  return {\n    ...actual,\n    // queda espiable y con la impl. real por defecto\n    snapFetch: vi.fn(actual.snapFetch),\n  }\n})\n\n// Wrap ESM exports once so we can override per-test with mockImplementationOnce.\n// By default they call through to the actual implementations.\nvi.mock('../src/modules/svgDefs.js', async () => {\n  const actual = await vi.importActual('../src/modules/svgDefs.js')\n  return {\n    ...actual,\n    inlineExternalDefsAndSymbols: vi.fn(actual.inlineExternalDefsAndSymbols),\n  }\n})\n\nvi.mock('../src/modules/pseudo.js', async () => {\n  const actual = await vi.importActual('../src/modules/pseudo.js')\n  return {\n    ...actual,\n    inlinePseudoElements: vi.fn(actual.inlinePseudoElements),\n  }\n})\n\nvi.mock('../src/utils/index.js', async () => {\n  const actual = await vi.importActual('../src/utils/index.js')\n  return {\n    ...actual,\n    stripTranslate: vi.fn(actual.stripTranslate),\n    // everything else passes through as-is\n  }\n})\n\n// (Optional) allow deepClone error branch without permanent stubbing\nvi.mock('../src/core/clone.js', async () => {\n  const actual = await vi.importActual('../src/core/clone.js')\n  return {\n    ...actual,\n    deepClone: vi.fn(actual.deepClone),\n  }\n})\n\n//import { prepareClone } from '../src/core/prepare.js'\nimport * as svgDefs from '../src/modules/svgDefs.js'\nimport * as pseudo from '../src/modules/pseudo.js'\nimport * as utils from '../src/utils/index.js'\nimport * as cloneMod from '../src/core/clone.js'\n\ndescribe('prepareClone deep coverage (Browser Mode)', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  afterEach(() => {\n    // Restore any globals we might have touched\n    if ('fetch' in globalThis) {\n      // If we replaced it with a mock in a test, restore\n      try { vi.restoreAllMocks() } catch {}\n    }\n    // Always restore getComputedStyle if mocked\n    if (window.getComputedStyle && 'mock' in window.getComputedStyle) {\n      window.getComputedStyle.mockRestore()\n    }\n  })\n\n  it('prepares a basic clone and returns classCSS', async () => {\n    const el = document.createElement('div')\n    el.textContent = 'test'\n    const { clone, classCSS } = await prepareClone(el)\n    expect(clone).toBeTruthy()\n    expect(typeof classCSS).toBe('string')\n  })\n\n  it('throws for null node', async () => {\n    // @ts-ignore - intentionally wrong\n    await expect(prepareClone(null)).rejects.toThrow()\n  })\n\n  it('applies stabilizeLayout when outline is visible and no border present', async () => {\n  const el = document.createElement('div')\n  el.style.outline = '2px solid red'\n  vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({\n    outlineStyle: 'solid',\n    outlineWidth: '2px',   // 👈 con unidad\n    borderStyle: 'none',   // 👈 sin borde\n    borderWidth: '0px',    // 👈 con unidad\n    getPropertyValue: () => '', // requerido por inlineAllStyles\n  }))\n  await prepareClone(el)\n  expect(el.style.border).toContain('transparent') // se setea en stabilizeLayout\n  window.getComputedStyle.mockRestore()\n})\n\n  it('handles error in inlineExternalDefsAndSymbols (logs and continues)', async () => {\n    const el = document.createElement('div')\n    vi.mocked(svgDefs.inlineExternalDefsAndSymbols).mockImplementationOnce(() => {\n      throw new Error('fail')\n    })\n    await expect(prepareClone(el)).resolves.toBeTruthy()\n  })\n\n  it('handles error in inlinePseudoElements (logs and continues)', async () => {\n    const el = document.createElement('div')\n    vi.mocked(pseudo.inlinePseudoElements).mockImplementationOnce(() => {\n      throw new Error('fail')\n    })\n    await expect(prepareClone(el)).resolves.toBeTruthy()\n  })\n\n  it('applies scroll wrapper when original has scroll offsets', async () => {\n  const el = document.createElement('div')\n  const child = document.createElement('div')\n  child.textContent = 'content'\n  el.appendChild(child)\n\n  // Fuerza valores de scroll sin depender de layout\n  Object.defineProperty(el, 'scrollLeft', { configurable: true, get: () => 10 })\n  Object.defineProperty(el, 'scrollTop',  { configurable: true, get: () => 20 })\n\n  const { clone } = await prepareClone(el)\n  const wrapper = clone.firstChild\n  expect(wrapper).toBeTruthy()\n  expect(wrapper instanceof HTMLElement).toBe(true)\n  expect(wrapper.style.transform).toContain('translate(-10px, -20px)')\n})\n\n  it('applies inline styles (no class) for nodes inside ShadowRoot', async () => {\n    const host = document.createElement('div')\n    const shadow = host.attachShadow({ mode: 'open' })\n    const inner = document.createElement('span')\n    inner.style.background = 'red'\n    shadow.appendChild(inner)\n\n    const { clone } = await prepareClone(inner)\n    // In shadow DOM branch, it sets inline style string\n    expect(clone.getAttribute('style') || '').toMatch(/background/)\n  })\n\n  it('uses stripTranslate result for the root transform', async () => {\n    const el = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockReturnValue({\n      transform: 'translate(10px, 20px) rotate(5deg)',\n      getPropertyValue: () => '',\n    })\n    vi.mocked(utils.stripTranslate).mockReturnValueOnce('scale(1)')\n    const { clone } = await prepareClone(el)\n    expect(clone.style.transform).toBe('scale(1)')\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('normalizes margins for <pre> elements', async () => {\n    const el = document.createElement('pre')\n    el.textContent = 'x'\n    const { clone } = await prepareClone(el)\n    // CSSOM normalizes to '0px'\n    expect(clone.style.marginTop).toBe('0px')\n    expect(clone.style.marginBlockStart).toBe('0px')\n  })\n\n  it('converts <img src=\"blob:...\"> to data URL', async () => {\n  const wrap = document.createElement('div')\n  const el = document.createElement('img')\n  el.src = 'blob:12345'\n  wrap.appendChild(el)\n\n  const originalFetch = globalThis.fetch\n  globalThis.fetch = vi.fn().mockResolvedValue({\n    ok: true,\n    blob: () => new Blob(['abc'], { type: 'text/plain' }),\n  })\n\n  const { clone } = await prepareClone(wrap)\n  const outImg = clone.querySelector('img')\n  expect(outImg.getAttribute('src') || '').toMatch(/^data:/)\n\n  globalThis.fetch = originalFetch\n})\n\n it('converts <img srcset> entries with blob: to data URLs', async () => {\n  const wrap = document.createElement('div')\n  const el = document.createElement('img')\n  el.setAttribute('srcset', 'blob:123 1x, blob:456 2x')\n  wrap.appendChild(el)\n\n  const originalFetch = globalThis.fetch\n  globalThis.fetch = vi.fn().mockResolvedValue({\n    ok: true,\n    blob: () => new Blob(['abc'], { type: 'text/plain' }),\n  })\n\n  const { clone } = await prepareClone(wrap)\n   const outImg = clone.querySelector('img')\n   const outSrcset = outImg?.getAttribute('srcset') || ''\n   const outSrc = outImg?.getAttribute('src') || ''\n   // Nunca debe quedar blob:\n   expect(outSrcset).not.toContain('blob:')\n   expect(outSrc).not.toContain('blob:')\n   // Aceptamos ambos flujos: (a) srcset con data: o (b) src con data: y srcset vacío\n   expect(\n     (outSrcset.includes('data:')) || (outSrc.startsWith('data:'))\n   ).toBe(true)\n  globalThis.fetch = originalFetch\n})\n\n  it('converts <svg><image href=\"blob:...\"> to data URL', async () => {\n    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')\n    const img = document.createElementNS('http://www.w3.org/2000/svg', 'image')\n    img.setAttribute('href', 'blob:123')\n    svg.appendChild(img)\n\n    const originalFetch = globalThis.fetch\n    globalThis.fetch = vi.fn().mockResolvedValue({\n      ok: true,\n      blob: () => new Blob(['abc'], { type: 'text/plain' }),\n    })\n\n    const { clone } = await prepareClone(svg)\n    const outHref = clone.querySelector('image')?.getAttribute('href') || ''\n    expect(outHref).toMatch(/^data:/)\n\n    globalThis.fetch = originalFetch\n  })\n\n  it('replaces blob: URLs inside inline style attributes', async () => {\n    const el = document.createElement('div')\n    const styled = document.createElement('div')\n    styled.setAttribute('style', 'background-image:url(blob:123)')\n    el.appendChild(styled)\n\n    const originalFetch = globalThis.fetch\n    globalThis.fetch = vi.fn().mockResolvedValue({\n      ok: true,\n      blob: () => new Blob(['abc'], { type: 'text/plain' }),\n    })\n\n    const { clone } = await prepareClone(el)\n    const outStyle = clone.querySelector('[style]')?.getAttribute('style') || ''\n    expect(outStyle).toContain('data:')\n\n    globalThis.fetch = originalFetch\n  })\n\n  it('replaces blob: URLs inside <style> tags', async () => {\n    const el = document.createElement('div')\n    const style = document.createElement('style')\n    style.textContent = '.a{background:url(blob:123)}'\n    el.appendChild(style)\n\n    const originalFetch = globalThis.fetch\n    globalThis.fetch = vi.fn().mockResolvedValue({\n      ok: true,\n      blob: () => new Blob(['abc'], { type: 'text/plain' }),\n    })\n\n    const { clone } = await prepareClone(el)\n    const out = clone.querySelector('style')?.textContent || ''\n    expect(out).toContain('data:')\n\n    globalThis.fetch = originalFetch\n  })\n\n  it('converts \"poster\" attribute when it starts with blob:', async () => {\n  const wrap = document.createElement('div')\n  const el = document.createElement('video')\n  el.setAttribute('poster', 'blob:123')\n  wrap.appendChild(el)\n\n  const originalFetch = globalThis.fetch\n  globalThis.fetch = vi.fn().mockResolvedValue({\n    ok: true,\n    blob: () => new Blob(['abc'], { type: 'text/plain' }),\n  })\n\n  const { clone } = await prepareClone(wrap)\n  const outPoster = clone.querySelector('video')?.getAttribute('poster') || ''\n  expect(outPoster).toMatch(/^data:/)\n\n  globalThis.fetch = originalFetch\n})\n\n  it('propagates deepClone error (internal logic throws)', async () => {\n    const el = document.createElement('div')\n    vi.mocked(cloneMod.deepClone).mockImplementationOnce(() => {\n      throw new Error('fail')\n    })\n    await expect(prepareClone(el)).rejects.toThrow('fail')\n  })\n})\n\n// 1) No hay blob: en CSS → early return de replaceBlobUrlsInCssText\nit('does nothing when no blob: appears in style/style attribute', async () => {\n  const root = document.createElement('div')\n\n  const styled = document.createElement('div')\n  styled.setAttribute('style', 'background:red') // sin blob:\n  root.appendChild(styled)\n\n  const style = document.createElement('style')\n  style.textContent = '.x{color:blue}' // sin blob:\n  root.appendChild(style)\n\n  const { clone } = await prepareClone(root)\n  const outInline = clone.querySelector('[style]')?.getAttribute('style') || ''\n  const outStyle  = clone.querySelector('style')?.textContent || ''\n  expect(outInline).toBe('background:red') // normalizado\n  expect(outStyle).toContain('color:blue')\n})\n\n// 2) blobUrlToDataUrl: hit en cache.resource evita fetch/snapFetch\nit('uses cache.resource for blob: → data: without calling fetch', async () => {\n  const wrap = document.createElement('div')\n  const img = document.createElement('img')\n  img.src = 'blob:abc123'\n  wrap.appendChild(img)\n\n  // Pre-cargar cache global\n  cache.resource.set('blob:abc123', 'data:text/plain;base64,Zm9v')\n\n  const spyFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { throw new Error('should not be called') })\n\n  const { clone } = await prepareClone(wrap)\n  const src = clone.querySelector('img')?.getAttribute('src') || ''\n  expect(src).toBe('data:text/plain;base64,Zm9v')\n\n  spyFetch.mockRestore()\n})\n\n// 3) blobUrlToDataUrl: fallo limpia memo y permite reintento\nit('on fetch failure, memo is cleared and a later call can succeed', async () => {\n  const wrap = document.createElement('div')\n  const a = document.createElement('img')\n  const b = document.createElement('img')\n  a.src = 'blob:fail-then-ok'\n  b.src = 'blob:fail-then-ok'\n  wrap.appendChild(a)\n  wrap.appendChild(b)\n\n//  import { snapFetch } from '../src/modules/snapFetch.js'\n\n// Primer intento: falla → se mantienen los blob:\nvi.mocked(snapFetch).mockResolvedValueOnce({ ok: false, data: null })\n\nconst first = await prepareClone(wrap)\nconst srcs1 = [...first.clone.querySelectorAll('img')]\n  .map(n => n.getAttribute('src') || '')\nexpect(srcs1.every(s => s.startsWith('blob:'))).toBe(true)\n\n// Segundo intento: éxito → convierte a data:\nvi.mocked(snapFetch).mockResolvedValueOnce({\n  ok: true,\n  data: 'data:text/plain;base64,AAA=',\n})\n\nconst { clone } = await prepareClone(wrap)\nconst srcs = [...clone.querySelectorAll('img')].map(n => n.getAttribute('src') || '')\nexpect(srcs.every(s => s.startsWith('data:'))).toBe(true)\n\n})\n\n// 4) <image xlink:href=\"blob:...\"> → set href y removeAttributeNS\nit('moves xlink:href to href and removes namespaced attribute', async () => {\n  const XLINK = 'http://www.w3.org/1999/xlink'\n  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')\n  const img = document.createElementNS('http://www.w3.org/2000/svg', 'image')\n  img.setAttributeNS(XLINK, 'xlink:href', 'blob:777')\n  svg.appendChild(img)\n\n  const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({\n    ok: true,\n    blob: () => new Blob(['abc'], { type: 'text/plain' }),\n  })\n\n  const { clone } = await prepareClone(svg)\n  const out = clone.querySelector('image')\n  expect(out?.getAttribute('href') || '').toMatch(/^data:/)\n  // namespaced removido (si removeAttributeNS está disponible en el ambiente)\n  if (out?.getAttributeNS) {\n    expect(out.getAttributeNS(XLINK, 'href')).toBeNull()\n  }\n  fetchSpy.mockRestore()\n})\n\n// 5) Extrae style[data-sd] del clone y lo concatena en classCSS\nit('pulls <style data-sd> out of clone and prepends into classCSS', async () => {\n  const host = document.createElement('div')\n  const sr = host.attachShadow({ mode: 'open' })\n  const style = document.createElement('style')\n  style.textContent = '.inside{color:rebeccapurple}'\n  sr.appendChild(style)\n  document.body.appendChild(host)\n\n  const { clone, classCSS } = await prepareClone(host)\n  const injected = clone.querySelector('style[data-sd]')\n  expect(injected).toBeNull()          // removido del DOM\n  expect(classCSS).toContain('.inside') // concatenado en classCSS\n\n  host.remove()\n})\n\n// 6) stabilizeLayout: NO parchea si ya hay borde (rama negativa)\nit('does not set transparent border when element already has border', async () => {\n  const el = document.createElement('div')\n  el.style.border = '1px solid black'\n  el.style.outline = '2px solid red'\n\n  const csMock = vi.spyOn(window, 'getComputedStyle').mockReturnValue({\n    outlineStyle: 'solid',\n    outlineWidth: '2px',\n    borderStyle: 'solid',\n    borderWidth: '1px',\n    getPropertyValue: () => '',\n  })\n\n  await prepareClone(el)\n  expect(el.style.border).toBe('1px solid black') // no se pisa\n\n  csMock.mockRestore()\n})\n\n// 7) stripTranslate devuelve ''/none → transform queda vacío\nit('root transform becomes empty string when stripTranslate returns falsy', async () => {\n  const el = document.createElement('div')\n\n  // getComputedStyle con transform none\n  const csMock = vi.spyOn(window, 'getComputedStyle').mockReturnValue({\n    transform: 'none',\n    getPropertyValue: () => '',\n  })\n\n  // mock puntual de stripTranslate para devolver ''\n  const mod = await vi.importMock('../src/utils/index.js')\n  mod.stripTranslate.mockReturnValueOnce('')\n\n  const { clone } = await prepareClone(el)\n  expect(clone.style.transform).toBe('')\n\n  csMock.mockRestore()\n})\n\n// 8) resolveBlobUrlsInTree early paths: img sin src/srcset, style sin blob\nit('skips nodes with no actionable URLs (early paths)', async () => {\n  const root = document.createElement('div')\n\n  const img = document.createElement('img') // sin src/srcset\n  root.appendChild(img)\n\n  const style = document.createElement('style')\n  style.textContent = '.a{color:red}' // sin blob\n  root.appendChild(style)\n\n  const { clone } = await prepareClone(root)\n  const outImg = clone.querySelector('img')\n  const outStyle = clone.querySelector('style')?.textContent || ''\n  expect(outImg?.hasAttribute('src')).toBe(false)\n  expect(outStyle).toContain('color:red')\n})\nit('replaces blob: URLs inside <img srcset> and preserves non-blob candidates/descriptor', async () => {\n  const wrap = document.createElement('div')\n  const img = document.createElement('img')\n  img.setAttribute('srcset', 'blob:aa 1x, https://x/y.png 2x, blob:bb 3x')\n  wrap.appendChild(img)\n\n  // ⬇️ Evitar que freezeImgSrcset borre srcset\n  Object.defineProperty(img, 'currentSrc', { configurable: true, get: () => '' })\n  Object.defineProperty(img, 'src',        { configurable: true, get: () => '' })\n\n  vi.mocked(snapFetch)\n    .mockResolvedValueOnce({ ok: true, data: 'data:image/png;base64,AAA' }) // blob:aa\n    .mockResolvedValueOnce({ ok: true, data: 'data:image/png;base64,BBB' }) // blob:bb\n\n  const { clone } = await prepareClone(wrap)\n  const out = clone.querySelector('img')?.getAttribute('srcset') || ''\n\n  expect(out.includes('blob:')).toBe(false)\n  expect(out).toContain('data:image/png;base64,AAA 1x')\n  expect(out).toContain('https://x/y.png 2x')\n  expect(out).toContain('data:image/png;base64,BBB 3x')\n})\n\nit('keeps original srcset when blob→data conversion fails (changed=false)', async () => {\n  const wrap = document.createElement('div')\n  const img = document.createElement('img')\n  img.setAttribute('srcset', 'blob:fail 1x, blob:alsofail 2x')\n  wrap.appendChild(img)\n\n  // ⬇️ Evitar que freezeImgSrcset borre srcset\n  Object.defineProperty(img, 'currentSrc', { configurable: true, get: () => '' })\n  Object.defineProperty(img, 'src',        { configurable: true, get: () => '' })\n\n  vi.mocked(snapFetch)\n    .mockResolvedValueOnce({ ok: false, data: null })\n    .mockResolvedValueOnce({ ok: false, data: null })\n\n  const { clone } = await prepareClone(wrap)\n  const out = clone.querySelector('img')?.getAttribute('srcset') || ''\n\n  // Como todas fallaron, changed=false → no se toca el atributo\n  expect(out).toBe('blob:fail 1x, blob:alsofail 2x')\n})\n"
  },
  {
    "path": "__tests__/cssTools.utils.test.js",
    "content": "import { describe, it, expect } from 'vitest'\nimport { getStyleKey, collectUsedTagNames, getDefaultStyleForTag } from '../src/utils'\n\ndescribe('getStyleKey', () => {\n\n  it('getStyleKey works with compress true default', () => {\n    const snapshot = { color: 'red', 'font-size': '12px' }\n    const key = getStyleKey(snapshot, 'div')\n    expect(typeof key).toBe('string')\n  })\n})\n\ndescribe('collectUsedTagNames', () => {\n  it('returns unique tag names', () => {\n    const root = document.createElement('div')\n    root.innerHTML = '<span></span><p></p><span></span>'\n    const tags = collectUsedTagNames(root)\n    expect(tags).toContain('div')\n    expect(tags).toContain('span')\n    expect(tags).toContain('p')\n  })\n})\n\ndescribe('getDefaultStyleForTag', () => {\n  it('returns a default style object', () => {\n    const defaults = getDefaultStyleForTag('div')\n    expect(typeof defaults).toBe('object')\n  })\n\n  it('getDefaultStyleForTag skips special tags', () => {\n    expect(getDefaultStyleForTag('script')).toEqual({})\n  })\n})\n"
  },
  {
    "path": "__tests__/exporter.download.test.js",
    "content": "// __tests__/exporter.download.test.js – download.js coverage\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\n\nvi.mock('../src/utils/browser', { spy: true })\nimport * as browser from '../src/utils/browser'\nimport { download } from '../src/exporters/download.js'\n\nconst DATA_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBCd4/7mEAAAAASUVORK5CYII='\nconst DATA_SVG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg xmlns=\"http://www.w3.org/2000/svg\"/>')\n\nbeforeEach(() => {\n  document.body.innerHTML = ''\n  vi.mocked(browser.isIOS).mockReturnValue(false)\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('download', () => {\n  it('triggers download for PNG format', async () => {\n    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})\n    await download(DATA_PNG, { format: 'png', filename: 'test.png' })\n    expect(clickSpy).toHaveBeenCalled()\n    clickSpy.mockRestore()\n  })\n\n  it('normalizes jpg to jpeg for filename', async () => {\n    const appended = []\n    const appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((el) => {\n      appended.push(el)\n      return el\n    })\n    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})\n    await download(DATA_PNG, { format: 'jpg', filename: 'out.jpg' })\n    const a = appended.find(el => el.tagName === 'A')\n    expect(a?.download).toMatch(/\\.jpe?g$/i)\n    appendSpy.mockRestore()\n    clickSpy.mockRestore()\n  })\n\n  it('downloads SVG format', async () => {\n    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})\n    await download(DATA_SVG, { format: 'svg', filename: 'out.svg' })\n    expect(clickSpy).toHaveBeenCalled()\n    clickSpy.mockRestore()\n  })\n\n  it('uses default filename when not provided', async () => {\n    const appended = []\n    const appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((el) => {\n      appended.push(el)\n      return el\n    })\n    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})\n    await download(DATA_PNG, { format: 'png' })\n    const a = appended.find(el => el.tagName === 'A')\n    expect(a?.download).toMatch(/snapdom\\.png/)\n    appendSpy.mockRestore()\n    clickSpy.mockRestore()\n  })\n\n  it('uses Web Share API on iOS when share is available', async () => {\n    const shareFn = vi.fn().mockResolvedValue()\n    const canShareFn = vi.fn(() => true)\n    Object.defineProperty(navigator, 'share', { value: shareFn, configurable: true })\n    Object.defineProperty(navigator, 'canShare', { value: canShareFn, configurable: true })\n    vi.mocked(browser.isIOS).mockReturnValue(true)\n    await download(DATA_SVG, { format: 'svg', filename: 'share.svg' })\n    expect(shareFn).toHaveBeenCalledWith(expect.objectContaining({ title: 'share.svg' }))\n  })\n\n  it('uses Web Share for raster on iOS', async () => {\n    const shareFn = vi.fn().mockResolvedValue()\n    const canShareFn = vi.fn(() => true)\n    Object.defineProperty(navigator, 'share', { value: shareFn, configurable: true })\n    Object.defineProperty(navigator, 'canShare', { value: canShareFn, configurable: true })\n    vi.mocked(browser.isIOS).mockReturnValue(true)\n    await download(DATA_PNG, { format: 'png', filename: 'share.png' })\n    expect(shareFn).toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "__tests__/exporter.toCanvas.more.test.js",
    "content": "// __tests__/exporter.toCanvas.more.test.js – extra toCanvas coverage\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\n\nvi.mock('../src/utils/browser', { spy: true })\nimport * as browser from '../src/utils/browser'\nimport { toCanvas } from '../src/exporters/toCanvas.js'\n\nconst ONE_BY_ONE_PNG =\n  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBCd4/7mEAAAAASUVORK5CYII='\n\nbeforeEach(() => {\n  document.body.innerHTML = ''\n  vi.restoreAllMocks()\n  vi.mocked(browser.isSafari).mockReturnValue(false)\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('toCanvas – width/height branches', () => {\n  it('uses explicit width and height', async () => {\n    const canvas = await toCanvas(ONE_BY_ONE_PNG, { width: 100, height: 50 })\n    expect(canvas.width).toBe(100)\n    expect(canvas.height).toBe(50)\n  })\n\n  it('uses width only (scales height proportionally)', async () => {\n    const canvas = await toCanvas(ONE_BY_ONE_PNG, { width: 50 })\n    expect(canvas.style.width).toBe('50px')\n  })\n\n  it('uses height only (scales width proportionally)', async () => {\n    const canvas = await toCanvas(ONE_BY_ONE_PNG, { height: 40 })\n    expect(canvas.style.height).toBe('40px')\n  })\n})\n\ndescribe('toCanvas – backgroundColor', () => {\n  it('fills background when backgroundColor is set', async () => {\n    const canvas = await toCanvas(ONE_BY_ONE_PNG, { scale: 1, backgroundColor: '#ff0000' })\n    expect(canvas).toBeInstanceOf(HTMLCanvasElement)\n    expect(canvas.width).toBeGreaterThan(0)\n    expect(canvas.height).toBeGreaterThan(0)\n  })\n})\n\ndescribe('toCanvas – Safari SVG box-shadow path', () => {\n  it('converts box-shadow to drop-shadow for Safari on SVG URLs', async () => {\n    vi.mocked(browser.isSafari).mockReturnValue(true)\n    const svgWithBoxShadow = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(\n      '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\">' +\n      '<style>rect{box-shadow:2px 2px 4px black}</style><rect fill=\"blue\"/></svg>'\n    )\n    const canvas = await toCanvas(svgWithBoxShadow, {})\n    expect(canvas).toBeInstanceOf(HTMLCanvasElement)\n  })\n})\n"
  },
  {
    "path": "__tests__/exporter.toCanvas.test.js",
    "content": "// __tests__/exporters.toCanvas.more.test.js\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\n\n// IMPORTANT: in Browser Mode we cannot spy on ESM exports directly.\n// Use { spy: true } so we can override implementations safely.\nvi.mock('../src/utils/browser', { spy: true })\nimport * as browser from '../src/utils/browser'\n\nimport { toCanvas } from '../src/exporters/toCanvas.js'\n\nconst ONE_BY_ONE_PNG =\n  'data:image/png;base64,' +\n  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBCd4/7mEAAAAASUVORK5CYII='\n\nbeforeEach(() => {\n  // clean up DOM between tests\n  document.body.innerHTML = ''\n  vi.restoreAllMocks()\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('toCanvas (Browser Mode)', () => {\n  it('renders to canvas (non-Safari path) without appending the <img>', async () => {\n    // Non-Safari path\n    vi.mocked(browser.isSafari).mockReturnValue(false)\n\n    // Make sure no IMG remains in the DOM after execution (should never append)\n    const beforeImgs = document.querySelectorAll('img').length\n\n    const canvas = await toCanvas(ONE_BY_ONE_PNG, { scale: 2, dpr: 1.5 })\n    expect(canvas).toBeInstanceOf(HTMLCanvasElement)\n\n    // For a 1x1 image with scale=2 and dpr=1.5:\n    // CSS size: 2x2, backing store: ceil(2 * 1.5) = 3\n    expect(canvas.style.width).toBe('2px')\n    expect(canvas.style.height).toBe('2px')\n    expect(canvas.width).toBe(3)\n    expect(canvas.height).toBe(3)\n\n    const afterImgs = document.querySelectorAll('img').length\n    expect(afterImgs - beforeImgs).toBe(0) // nothing appended\n  })\n\n  it('appends and removes <img> and waits 100ms on Safari path', async () => {\n    vi.mocked(browser.isSafari).mockReturnValue(true)\n\n    // Spy setTimeout so the promise resolves immediately and we can assert the delay\n    const origSetTimeout = globalThis.setTimeout\n    const calls = []\n    const stoSpy = vi\n      .spyOn(globalThis, 'setTimeout')\n      .mockImplementation((cb, ms, ...args) => {\n        calls.push(ms)\n        // Trigger callback ASAP so the awaited promise resolves\n        return origSetTimeout(cb, 0, ...args)\n      })\n\n    // Spy on Element.prototype.remove to ensure the appended <img> is removed\n    const rmSpy = vi.spyOn(Element.prototype, 'remove')\n\n    const imgCountBefore = document.querySelectorAll('img').length\n    const canvas = await toCanvas(ONE_BY_ONE_PNG, { scale: 1, dpr: 2 })\n    expect(canvas).toBeInstanceOf(HTMLCanvasElement)\n\n    const imgCountAfter = document.querySelectorAll('img').length\n    expect(imgCountAfter).toBe(imgCountBefore) // no stray <img> left in the DOM\n\n    stoSpy.mockRestore()\n    rmSpy.mockRestore()\n  })\n})\n"
  },
  {
    "path": "__tests__/exporter.toImg.test.js",
    "content": "// __tests__/exporter.toImg.test.js – toImg.js coverage\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\n\nvi.mock('../src/utils', async () => {\n  const actual = await vi.importActual('../src/utils')\n  return { ...actual, isSafari: vi.fn() }\n})\nimport { isSafari } from '../src/utils'\nimport { toImg } from '../src/exporters/toImg.js'\n\nconst DATA_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBCd4/7mEAAAAASUVORK5CYII='\nconst DATA_SVG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"10\"/>')\n\nbeforeEach(() => {\n  vi.mocked(isSafari).mockReturnValue(false)\n})\n\nafterEach(() => {\n  vi.restoreAllMocks()\n})\n\ndescribe('toImg', () => {\n  it('uses width and height when both provided', async () => {\n    const img = await toImg(DATA_PNG, { width: 100, height: 50 })\n    expect(img.style.width).toBe('100px')\n    expect(img.style.height).toBe('50px')\n  })\n\n  it('uses width only (scales height)', async () => {\n    const img = await toImg(DATA_PNG, { width: 80 })\n    expect(img.style.width).toBe('80px')\n    expect(img.style.height).toBeDefined()\n  })\n\n  it('uses height only (scales width)', async () => {\n    const img = await toImg(DATA_PNG, { height: 60 })\n    expect(img.style.height).toBe('60px')\n    expect(img.style.width).toBeDefined()\n  })\n\n  it('uses meta.w0/meta.h0 when provided', async () => {\n    const img = await toImg(DATA_PNG, { width: 50, meta: { w0: 10, h0: 5 } })\n    expect(img.style.width).toBe('50px')\n    expect(img.style.height).toBe('25px')\n  })\n\n  it('uses scale for non-SVG', async () => {\n    const img = await toImg(DATA_PNG, { scale: 2 })\n    expect(img.style.width).toBe('2px')\n    expect(img.style.height).toBe('2px')\n  })\n\n  it('patches SVG dimensions when scale !== 1', async () => {\n    const img = await toImg(DATA_SVG, { scale: 2 })\n    expect(img.style.width).toBe('40px')\n    expect(img.style.height).toBe('20px')\n    expect(decodeURIComponent(img.src)).toContain('width=\"40\"')\n  })\n\n  it('uses rasterize path on Safari when wantsScale', async () => {\n    vi.mocked(isSafari).mockReturnValue(true)\n    const img = await toImg(DATA_PNG, { scale: 2 })\n    expect(img).toBeDefined()\n  })\n})\n"
  },
  {
    "path": "__tests__/exporters.jpg-png-svg-webp.test.js",
    "content": "// __tests__/exporters.jpg-png-svg-webp.test.js – direct exporter calls (0% → covered)\nimport { describe, it, expect } from 'vitest'\n\nconst DATA_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBCd4/7mEAAAAASUVORK5CYII='\n\ndescribe('toJpg (direct)', () => {\n  it('returns rasterized output when given data URL (string path)', async () => {\n    const { toJpg } = await import('../src/exporters/toJpg.js')\n    const out = await toJpg(DATA_PNG, { scale: 1 })\n    expect(out).toBeDefined()\n    expect(out instanceof HTMLImageElement || out instanceof HTMLCanvasElement || typeof out === 'string' || out instanceof Blob).toBe(true)\n  })\n})\n\ndescribe('toPng (direct)', () => {\n  it('returns rasterized output when given data URL', async () => {\n    const { toPng } = await import('../src/exporters/toPng.js')\n    const out = await toPng(DATA_PNG)\n    expect(out).toBeDefined()\n  })\n})\n\ndescribe('toSvg (direct)', () => {\n  const DATA_SVG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"/>')\n\n  it('re-exports toImg as toSvg and returns Image when given SVG data URL', async () => {\n    const { toSvg } = await import('../src/exporters/toSvg.js')\n    const out = await toSvg(DATA_SVG, { scale: 1 })\n    expect(out).toBeInstanceOf(HTMLImageElement)\n    expect(out.src).toMatch(/^data:image\\/svg\\+xml/)\n  })\n})\n\ndescribe('toWebp (direct)', () => {\n  it('returns rasterized output when given data URL', async () => {\n    const { toWebp } = await import('../src/exporters/toWebp.js')\n    const out = await toWebp(DATA_PNG)\n    expect(out).toBeDefined()\n  })\n})\n"
  },
  {
    "path": "__tests__/index.browser.test.js",
    "content": "import { it, expect } from 'vitest'\nimport * as snapdom from '../src/index.browser.js'\n\nit('should import the browser bundle without errors', () => {\n  expect(snapdom).toBeDefined()\n})\n"
  },
  {
    "path": "__tests__/module.background.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { inlineBackgroundImages } from '../src/modules/background.js'\n\ndescribe('inlineBackgroundImages', () => {\n  let source, clone\n  beforeEach(() => {\n    source = document.createElement('div')\n    clone = document.createElement('div')\n    document.body.appendChild(source)\n    document.body.appendChild(clone)\n  })\n  afterEach(() => {\n    document.body.removeChild(source)\n    document.body.removeChild(clone)\n  })\n\n  it('does not fail if there is no background-image', async () => {\n    source.style.background = 'none'\n    await expect(inlineBackgroundImages(source, clone, new WeakMap())).resolves.toBeUndefined()\n  })\n\n  it('processes a valid background-image', async () => {\n    source.style.backgroundImage = 'url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAn8B9p6Q2wAAAABJRU5ErkJggg==\")'\n    await expect(inlineBackgroundImages(source, clone, new WeakMap())).resolves.toBeUndefined()\n  })\n})\n"
  },
  {
    "path": "__tests__/module.changeCSS.test.js",
    "content": "// __tests__/module.changeCSS.test.js – freezeSticky coverage\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { freezeSticky } from '../src/modules/changeCSS.js'\n\nbeforeEach(() => {\n  document.body.innerHTML = ''\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('freezeSticky', () => {\n  it('returns early for null roots', () => {\n    const clone = document.createElement('div')\n    expect(() => freezeSticky(null, clone)).not.toThrow()\n    expect(() => freezeSticky(document.createElement('div'), null)).not.toThrow()\n  })\n\n  it('returns early when scrollTop is 0', () => {\n    const orig = document.createElement('div')\n    orig.style.height = '200px'\n    orig.style.overflow = 'auto'\n    const sticky = document.createElement('div')\n    sticky.style.position = 'sticky'\n    sticky.style.top = '0'\n    orig.appendChild(sticky)\n    document.body.appendChild(orig)\n    const clone = orig.cloneNode(true)\n    freezeSticky(orig, clone)\n    expect(clone.querySelector('[data-snap-ph]')).toBeNull()\n  })\n\n  it('freezes sticky elements when scrollTop > 0', async () => {\n    const orig = document.createElement('div')\n    orig.style.cssText = 'height:100px; overflow:auto; position:relative;'\n    const sticky = document.createElement('div')\n    sticky.style.cssText = 'position:sticky; top:0; height:24px; min-height:24px;'\n    sticky.textContent = 'sticky'\n    orig.appendChild(sticky)\n    const filler = document.createElement('div')\n    filler.style.height = '200px'\n    filler.textContent = 'filler'\n    orig.appendChild(filler)\n    document.body.appendChild(orig)\n    orig.scrollTop = 50\n    await new Promise(r => requestAnimationFrame(r))\n    const clone = orig.cloneNode(true)\n    document.body.appendChild(clone)\n    freezeSticky(orig, clone)\n    const cloneSticky = Array.from(clone.children).find(c => !c.hasAttribute('data-snap-ph'))\n    if (cloneSticky) {\n      expect(cloneSticky.style.position).toBe('absolute')\n    }\n    expect(clone.querySelector('[data-snap-ph=\"1\"]')).toBeTruthy()\n  })\n})\n"
  },
  {
    "path": "__tests__/module.counter.test.js",
    "content": "// __tests__/module.counter.test.js – counter.js coverage\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport {\n  hasCounters,\n  unquoteDoubleStrings,\n  buildCounterContext,\n  resolveCountersInContent,\n  deriveCounterCtxForPseudo,\n  resolvePseudoContent\n} from '../src/modules/counter.js'\n\nbeforeEach(() => {\n  document.body.innerHTML = ''\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('hasCounters', () => {\n  it('detects counter()', () => {\n    expect(hasCounters('content: counter(x)')).toBe(true)\n    expect(hasCounters('counter(x)')).toBe(true)\n  })\n  it('detects counters()', () => {\n    expect(hasCounters('content: counters(x, \".\")')).toBe(true)\n    expect(hasCounters('counters(name, sep)')).toBe(true)\n  })\n  it('returns false for non-counter content', () => {\n    expect(hasCounters('content: \"foo\"')).toBe(false)\n    expect(hasCounters('')).toBe(false)\n    expect(hasCounters(null)).toBe(false)\n  })\n})\n\ndescribe('unquoteDoubleStrings', () => {\n  it('removes double quotes from strings', () => {\n    expect(unquoteDoubleStrings('\"hello\"')).toBe('hello')\n    expect(unquoteDoubleStrings('before \"mid\" after')).toBe('before mid after')\n  })\n  it('handles null/empty', () => {\n    expect(unquoteDoubleStrings(null)).toBe('')\n    expect(unquoteDoubleStrings('')).toBe('')\n  })\n})\n\ndescribe('buildCounterContext', () => {\n  it('returns get and getStack for a node', () => {\n    const root = document.createElement('div')\n    root.innerHTML = '<span></span>'\n    document.body.appendChild(root)\n    const ctx = buildCounterContext(root)\n    expect(typeof ctx.get).toBe('function')\n    expect(typeof ctx.getStack).toBe('function')\n    expect(ctx.get(root.querySelector('span'), 'x')).toBe(0)\n    expect(ctx.getStack(root.querySelector('span'), 'x')).toEqual([])\n  })\n\n  it('applies counter-reset and counter-increment', () => {\n    const root = document.createElement('div')\n    const child = document.createElement('span')\n    child.style.counterReset = 'section 1'\n    child.style.counterIncrement = 'section'\n    root.appendChild(child)\n    document.body.appendChild(root)\n    const ctx = buildCounterContext(root)\n    expect(ctx.get(child, 'section')).toBe(2)\n  })\n\n  it('handles LI with value attribute via counter-reset', () => {\n    const ol = document.createElement('ol')\n    const li = document.createElement('li')\n    li.setAttribute('value', '10')\n    li.style.counterReset = 'list-item 9'\n    li.style.counterIncrement = 'list-item'\n    li.textContent = 'x'\n    ol.appendChild(li)\n    document.body.appendChild(ol)\n    const ctx = buildCounterContext(ol)\n    expect(ctx.get(li, 'list-item')).toBe(10)\n  })\n\n  it('accepts Document as root', () => {\n    const ctx = buildCounterContext(document)\n    expect(ctx.get(document.documentElement, 'x')).toBe(0)\n  })\n})\n\ndescribe('resolveCountersInContent', () => {\n  it('resolves counter(name) with decimal style', () => {\n    const root = document.createElement('div')\n    const span = document.createElement('span')\n    span.style.counterIncrement = 'step'\n    root.appendChild(span)\n    document.body.appendChild(root)\n    const ctx = buildCounterContext(root)\n    expect(resolveCountersInContent('counter(step)', span, ctx)).toBe('1')\n  })\n  it('resolves counter with upper-alpha', () => {\n    const root = document.createElement('div')\n    const span = document.createElement('span')\n    span.style.counterReset = 'a 3'\n    span.style.counterIncrement = 'a'\n    root.appendChild(span)\n    document.body.appendChild(root)\n    const ctx = buildCounterContext(root)\n    expect(resolveCountersInContent('counter(a, upper-alpha)', span, ctx)).toBe('D')\n  })\n  it('returns raw for none', () => {\n    expect(resolveCountersInContent('none', null, null)).toBe('none')\n  })\n  it('returns empty for empty', () => {\n    expect(resolveCountersInContent('', null, null)).toBe('')\n  })\n  it('resolves counters(name, sep)', () => {\n    const root = document.createElement('div')\n    const inner = document.createElement('span')\n    inner.style.counterReset = 'x 1'\n    inner.style.counterIncrement = 'x'\n    root.appendChild(inner)\n    document.body.appendChild(root)\n    const ctx = buildCounterContext(root)\n    expect(resolveCountersInContent('counters(x, \". \")', inner, ctx)).toBe('2')\n  })\n})\n\ndescribe('deriveCounterCtxForPseudo', () => {\n  it('applies pseudo counter-reset/increment', () => {\n    const span = document.createElement('span')\n    span.style.counterReset = 'item 0'\n    document.body.appendChild(span)\n    const baseCtx = buildCounterContext(span)\n    const pseudoStyle = {\n      counterReset: 'item 5',\n      counterIncrement: 'item'\n    }\n    const derived = deriveCounterCtxForPseudo(span, pseudoStyle, baseCtx)\n    expect(derived.get(span, 'item')).toBe(6)\n  })\n})\n\ndescribe('resolvePseudoContent', () => {\n  it('returns empty for none/normal', () => {\n    const span = document.createElement('span')\n    document.body.appendChild(span)\n    const ctx = buildCounterContext(span)\n    expect(resolvePseudoContent(span, '::before', ctx)).toBe('')\n  })\n})\n"
  },
  {
    "path": "__tests__/module.fonts.katex.test.js",
    "content": "// __tests__/module.fonts.katex.test.js\nimport { describe, it, expect, vi, beforeEach } from 'vitest'\n\n/**\n * Test for issue #344: KaTeX font embedding with dynamically injected stylesheets\n * Validates that the isLikelyFontStylesheet function recognizes KaTeX CDN URLs\n */\n\nvi.mock('../src/utils/helpers', async () => {\n  const actual = await vi.importActual('../src/utils/helpers')\n  return {\n    ...actual,\n    extractURL: actual.extractURL,\n    fetchResource: vi.fn(actual.fetchResource)\n  }\n})\n\nvi.mock('../src/modules/iconFonts.js', () => ({\n  isIconFont: vi.fn(() => false)\n}))\n\nvi.mock('../src/modules/snapFetch.js', () => ({\n  snapFetch: vi.fn(async (url, opts = {}) => {\n    if (opts.as === 'text') {\n      // Return minimal KaTeX CSS with @font-face\n      return {\n        ok: true,\n        data: `\n          @font-face {\n            font-family: 'KaTeX_Main';\n            font-style: normal;\n            font-weight: 400;\n            src: url(fonts/KaTeX_Main-Regular.woff2) format('woff2');\n          }\n        `,\n        status: 200,\n        url,\n        fromCache: false\n      }\n    }\n    return {\n      ok: true,\n      data: 'data:font/woff2;base64,AA==',\n      status: 200,\n      url,\n      fromCache: false,\n      mime: 'font/woff2'\n    }\n  })\n}))\n\nimport { embedCustomFonts } from '../src/modules/fonts.js'\nimport { cache } from '../src/core/cache.js'\nimport { snapFetch } from '../src/modules/snapFetch.js'\n\nfunction addLink(href) {\n  const link = document.createElement('link')\n  link.rel = 'stylesheet'\n  link.href = href\n  document.head.appendChild(link)\n  return link\n}\n\nconst req = (...keys) => new Set(keys)\nconst cps = (t) => new Set([...t].map((ch) => ch.codePointAt(0)))\n\nbeforeEach(() => {\n  if (typeof cache.reset === 'function') cache.reset()\n  if (typeof cache.resetCache === 'function') cache.resetCache()\n  cache.font?.clear?.()\n  cache.resource?.clear?.()\n  vi.clearAllMocks()\n  document.querySelectorAll('style,link[rel=\"stylesheet\"]').forEach((n) => n.remove())\n})\n\ndescribe('embedCustomFonts - KaTeX CDN support (issue #344)', () => {\n  it('recognizes and processes KaTeX CSS from registry.npmmirror.com', async () => {\n    const href = 'https://registry.npmmirror.com/katex/0.16.25/files/dist/katex.min.css'\n    addLink(href)\n\n    const required = req('KaTeX_Main__400__normal__100')\n    const usedCodepoints = cps('abc123')\n\n    const result = await embedCustomFonts({\n      required,\n      usedCodepoints\n    })\n\n    // Should have called snapFetch to fetch the stylesheet\n    expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' }))\n\n    // Should include the font-face in the result\n    expect(result).toContain('@font-face')\n    expect(result).toContain('KaTeX_Main')\n  })\n\n  it('recognizes KaTeX CSS from unpkg.com', async () => {\n    const href = 'https://unpkg.com/katex@0.16.8/dist/katex.min.css'\n    addLink(href)\n\n    const required = req('KaTeX_Main__400__normal__100')\n    const usedCodepoints = cps('abc123')\n\n    const result = await embedCustomFonts({\n      required,\n      usedCodepoints\n    })\n\n    expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' }))\n    expect(result).toContain('@font-face')\n  })\n\n  it('recognizes KaTeX CSS from cdn.jsdelivr.net', async () => {\n    const href = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css'\n    addLink(href)\n\n    const required = req('KaTeX_Main__400__normal__100')\n    const usedCodepoints = cps('abc123')\n\n    const result = await embedCustomFonts({\n      required,\n      usedCodepoints\n    })\n\n    expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' }))\n    expect(result).toContain('@font-face')\n  })\n\n  it('recognizes cross-origin CSS when fontStylesheetDomains allows custom CDN (#309)', async () => {\n    const href = 'https://cdn.example.com/styles.css'\n    addLink(href)\n\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: `\n        @font-face {\n          font-family: 'CustomFont';\n          src: url(./CustomFont.woff2) format('woff2');\n        }\n      `,\n      status: 200,\n      url: href,\n      fromCache: false\n    })\n\n    const required = req('CustomFont__400__normal__100')\n    const usedCodepoints = cps('abc')\n\n    const result = await embedCustomFonts({\n      required,\n      usedCodepoints,\n      fontStylesheetDomains: ['cdn.example.com']\n    })\n\n    expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' }))\n    expect(result).toContain('@font-face')\n    expect(result).toContain('CustomFont')\n  })\n\n  it('recognizes MathJax CSS from CDN', async () => {\n    const href = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/output/chtml/fonts/woff-v2/mathjax.css'\n    addLink(href)\n\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: `\n        @font-face {\n          font-family: 'MJX';\n          src: url(MathJax_Main.woff2) format('woff2');\n        }\n      `,\n      status: 200,\n      url: href,\n      fromCache: false\n    })\n\n    const required = req('MJX__400__normal__100')\n    const usedCodepoints = cps('abc123')\n\n    const result = await embedCustomFonts({\n      required,\n      usedCodepoints\n    })\n\n    expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' }))\n    expect(result).toContain('@font-face')\n  })\n})\n"
  },
  {
    "path": "__tests__/module.fonts.more.more.test.js",
    "content": "// __tests__/module.fonts.more.more.test.js\nimport { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'\n\n// ====== Mocks HOISTED-SAFE ======\n// helpers: dejamos extractURL real si querés, pero acá no dependemos de fetchResource.\nvi.mock('../src/utils/helpers', async () => {\n  const actual = await vi.importActual('../src/utils/helpers')\n  return {\n    ...actual,\n    extractURL: actual.extractURL ?? ((cssUrlFn) => {\n      const m = String(cssUrlFn).match(/url\\(([\"']?)([^\"')]+)\\1\\)/i)\n      return m ? m[2] : ''\n    }),\n    fetchResource: vi.fn(async () => ({\n      async blob () { return new Blob([new Uint8Array([0x77, 0x6F, 0x32])], { type: 'font/woff2' }) },\n      async text () { return '' }\n    })),\n  }\n})\n\n// iconFonts siempre falso para no excluir\nvi.mock('../src/modules/iconFonts.js', () => ({\n  isIconFont: vi.fn(() => false),\n}))\n\n// 🔴 Nuevo: mock de snapFetch (API no-throw)\nvi.mock('../src/modules/snapFetch.js', () => ({\n  snapFetch: vi.fn(async (url, opts = {}) => {\n    if (opts.as === 'text') {\n      return { ok: true, data: '', status: 200, url, fromCache: false }\n    }\n    // default: devolvemos una dataURL mínima de woff2\n    return {\n      ok: true,\n      data: 'data:font/woff2;base64,AA==',\n      status: 200,\n      url,\n      fromCache: false,\n      mime: 'font/woff2',\n    }\n  }),\n}))\n\n// ====== SUT + deps ======\nimport { embedCustomFonts } from '../src/modules/fonts.js'\nimport { cache } from '../src/core/cache.js'\nimport * as helpers from '../src/utils/helpers'\nimport { snapFetch } from '../src/modules/snapFetch.js'\n\n// ====== utilidades locales ======\nfunction addStyle (css) {\n  const s = document.createElement('style')\n  s.setAttribute('data-test', 'fonts-extra')\n  s.textContent = css\n  document.head.appendChild(s)\n  return s\n}\nfunction cleanInjectedStuff () {\n  document.querySelectorAll('link[rel=\"stylesheet\"]').forEach(n => n.remove())\n  document.querySelectorAll('link[data-snapdom=\"injected-import\"]').forEach(n => n.remove())\n  document.querySelectorAll('style[data-test=\"fonts-extra\"]').forEach(n => n.remove())\n  // Limpia styles con @import colgados de otras suites\n  document.querySelectorAll('style').forEach(n => {\n    if ((n.textContent || '').includes('@import')) n.remove()\n  })\n}\n\n/** Mock mínimo de document.fonts */\nfunction setDocumentFonts (fontsArray = []) {\n  const items = [...fontsArray]\n  const iter = function * () { yield * items }\n  const fakeSet = {\n    [Symbol.iterator]: iter,\n    values: iter,\n    entries: function * () { for (const it of items) yield [it.family, it] },\n    forEach (cb, thisArg) { for (const it of items) cb.call(thisArg, it, it, fakeSet) },\n    has (ff) { return items.includes(ff) },\n    add (ff) { items.push(ff) },\n    delete (ff) { const i = items.indexOf(ff); if (i >= 0) items.splice(i, 1) },\n    clear () { items.length = 0 },\n    ready: Promise.resolve(),\n    size: items.length\n  }\n  Object.defineProperty(document, 'fonts', {\n    configurable: true,\n    get () { return fakeSet },\n    set () {}\n  })\n  return () => { delete document.fonts }\n}\n\nlet restoreFonts = () => {}\n\n// ====== setup/teardown ======\nbeforeEach(() => {\n  vi.restoreAllMocks()\n\n  // helpers mocks ya están instalados; limpiamos counters\n  helpers.fetchResource?.mockClear?.()\n\n  // limpiar caches y DOM\n  try { cache.resource?.clear?.() } catch {}\n  try { cache.font?.clear?.() } catch {}\n  cleanInjectedStuff()\n\n  // document.fonts vacío por default\n  restoreFonts?.()\n  restoreFonts = setDocumentFonts([])\n\n  // reset de snapFetch mock por si algún test setea respuestas específicas\n  vi.mocked(snapFetch).mockImplementation(async (url, opts = {}) => {\n    if (opts.as === 'text') {\n      return { ok: true, data: '', status: 200, url, fromCache: false }\n    }\n    return {\n      ok: true,\n      data: 'data:font/woff2;base64,AA==',\n      status: 200,\n      url,\n      fromCache: false,\n      mime: 'font/woff2',\n    }\n  })\n})\n\nafterEach(() => {\n  restoreFonts?.()\n  cleanInjectedStuff()\n})\n\n/* ----------------- unicode-range & helpers ------------------ */\ndescribe('embedCustomFonts – unicode-range & helpers', () => {\n  it('incluye la face cuando usedCodepoints intersecta el unicode-range', async () => {\n    const url = 'https://cdn.example.com/cyr.woff2'\n    addStyle(`\n      @font-face {\n        font-family: 'CyrillicOnly';\n        font-style: normal;\n        font-weight: 400;\n        font-stretch: 100%;\n        unicode-range: U+0400-04FF;\n        src: url(${url}) format('woff2');\n      }`)\n\n    // asegurar que el fetch de la fuente devuelva una dataURL conocida\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true, data: 'data:font/woff2;base64,CCC=', status: 200, url, fromCache: false, mime: 'font/woff2'\n    })\n\n    const css = await embedCustomFonts({\n      required: new Set(['CyrillicOnly__400__normal__100']),\n      usedCodepoints: new Set([0x0410]) // 'А' cirílica ⇒ intersecta\n    })\n\n    expect(css).toMatch(/font-family:\\s*['\"]?CyrillicOnly['\"]?/)\n    expect(css).toMatch(/url\\([\"']?data:/)\n  })\n})\n\n/* ----------------- cache.resource short-circuit ------------------ */\ndescribe('embedCustomFonts – cache.resource short-circuit', () => {\n  it('usa cache.resource para inlining y evita snapFetch', async () => {\n    const fontUrl = 'https://cdn.example.com/foo.woff2'\n    const b64 = 'data:font/woff2;base64,AAA'\n    cache.resource.set(fontUrl, b64)\n\n    addStyle(`\n      @font-face {\n        font-family: 'Foo';\n        font-style: normal;\n        font-weight: 400;\n        font-stretch: 100%;\n        src: url(${fontUrl}) format('woff2');\n      }`)\n\n    const css = await embedCustomFonts({\n      required: new Set(['Foo__400__normal__100']),\n      usedCodepoints: new Set([0x41])\n    })\n\n    expect(css).toMatch(/font-family:\\s*['\"]?Foo['\"]?/)\n    expect(css).toMatch(/url\\([\"']?data:font\\/woff2;base64,AAA/)\n    expect(snapFetch).not.toHaveBeenCalled()\n  })\n})\n\n/* ----------------- document.fonts con _snapdomSrc ------------------ */\ndescribe('document.fonts con _snapdomSrc', () => {\n  it('descarga _snapdomSrc (no data:) y lo inyecta como @font-face', async () => {\n    // simular un font cargado dinámico\n    restoreFonts?.()\n    restoreFonts = setDocumentFonts([\n      { family: 'DynFont', status: 'loaded', style: 'normal', weight: '400', _snapdomSrc: 'https://cdn.example.com/dyn.woff2' }\n    ])\n\n    // se espera 1 fetch → dataURL\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true, data: 'data:font/woff2;base64,DYN=', status: 200,\n      url: 'https://cdn.example.com/dyn.woff2', fromCache: false, mime: 'font/woff2'\n    })\n\n    const css = await embedCustomFonts({\n      required: new Set(['DynFont__400__normal__100']),\n      usedCodepoints: new Set([0x41])\n    })\n\n    expect(css).toMatch(/font-family:\\s*['\"]?DynFont['\"]?/)\n    expect(css).toMatch(/url\\([\"']?data:/)\n    expect(snapFetch).toHaveBeenCalledTimes(1)\n  })\n\n  it('si _snapdomSrc ya es data:, no hace fetch', async () => {\n    restoreFonts?.()\n    restoreFonts = setDocumentFonts([\n      { family: 'DynData', status: 'loaded', style: 'normal', weight: '400', _snapdomSrc: 'data:font/woff2;base64,ZZ==' }\n    ])\n\n    const css = await embedCustomFonts({\n      required: new Set(['DynData__400__normal__100']),\n      usedCodepoints: new Set([0x41])\n    })\n\n    expect(css).toMatch(/DynData/)\n    expect(css).toMatch(/url\\(['\"]?data:/)\n    expect(snapFetch).not.toHaveBeenCalled()\n  })\n\n  it('si snapFetch devuelve ok:false, continúa sin romper', async () => {\n    restoreFonts?.()\n    restoreFonts = setDocumentFonts([\n      { family: 'DynFail', status: 'loaded', style: 'normal', weight: '400', _snapdomSrc: 'https://cdn.example.com/fail.woff2' }\n    ])\n\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: false, data: null, status: 0, url: 'https://cdn.example.com/fail.woff2', fromCache: false, reason: 'network'\n    })\n\n    const css = await embedCustomFonts({\n      required: new Set(['DynFail__400__normal__100']),\n      usedCodepoints: new Set([0x41])\n    })\n\n    // Puede no incluir DynFail si no pudo inlinear — lo importante es que no explote y sea string.\n    expect(typeof css).toBe('string')\n  })\n})\n"
  },
  {
    "path": "__tests__/module.fonts.more.test.js",
    "content": "// __tests__/module.fonts.more.test.js\nimport { describe, it, expect, vi, beforeEach } from 'vitest'\n\n/**\n * Mocks\n * - helpers: mantenemos extractURL real; fetchResource queda spied pero ya no lo usa fonts.js\n * - iconFonts: forzado a false (no excluir)\n * - snapFetch: API nueva (no-throw) → devolvemos {ok,data,...}\n */\nvi.mock('../src/utils/helpers', async () => {\n  const actual = await vi.importActual('../src/utils/helpers')\n  return {\n    ...actual,\n    extractURL: actual.extractURL,\n    fetchResource: vi.fn(actual.fetchResource),\n  }\n})\n\nvi.mock('../src/modules/iconFonts.js', () => ({\n  isIconFont: vi.fn(() => false),\n}))\n\nvi.mock('../src/modules/snapFetch.js', () => ({\n  snapFetch: vi.fn(async (url, opts = {}) => {\n    if (opts.as === 'text') {\n      return { ok: true, data: '', status: 200, url, fromCache: false }\n    }\n    return {\n      ok: true,\n      data: 'data:font/woff2;base64,AA==',\n      status: 200,\n      url,\n      fromCache: false,\n      mime: 'font/woff2',\n    }\n  }),\n}))\n\nimport { embedCustomFonts, ensureFontsReady } from '../src/modules/fonts.js'\nimport { cache } from '../src/core/cache.js'\nimport { snapFetch } from '../src/modules/snapFetch.js'\n\n/* ----------------- helpers dom & utils ------------------ */\nfunction addLink(href) {\n  const link = document.createElement('link')\n  link.rel = 'stylesheet'\n  link.href = href\n  document.head.appendChild(link)\n  return link\n}\nfunction addStyle(css) {\n  const s = document.createElement('style')\n  s.textContent = css\n  document.head.appendChild(s)\n  return s\n}\nconst req = (...keys) => new Set(keys)\nconst cps = (t) => new Set([...t].map(ch => ch.codePointAt(0)))\n\nbeforeEach(() => {\n  if (typeof cache.reset === 'function') cache.reset()\n  if (typeof cache.resetCache === 'function') cache.resetCache()\n  cache.font?.clear?.()\n  cache.resource?.clear?.()\n  vi.clearAllMocks()\n  document.querySelectorAll('style,link[rel=\"stylesheet\"]').forEach(n => n.remove())\n})\n\n/* ----------------- External links & heuristics ------------------ */\ndescribe('embedCustomFonts - external links & heuristics', () => {\n  it('fetches cross-origin Google Fonts link and inlines @font-face', async () => {\n    const href = 'https://fonts.googleapis.com/css2?family=Unbounded:wght@400'\n    addLink(href)\n\n    // 1) CSS del <link>\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: `\n        @font-face {\n          font-family: 'Unbounded';\n          font-style: normal;\n          font-weight: 400;\n          font-stretch: 100%;\n          unicode-range: U+000-5FF;\n          src: url(https://fonts.gstatic.com/s/unbounded/v1/a.woff2) format('woff2');\n        }\n      `,\n      status: 200,\n      url: href,\n      fromCache: false,\n    })\n    // 2) Blob → DataURL\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: 'data:font/woff2;base64,ABC=',\n      status: 200,\n      url: 'https://fonts.gstatic.com/s/unbounded/v1/a.woff2',\n      fromCache: false,\n      mime: 'font/woff2',\n    })\n\n    const css = await embedCustomFonts({\n      required: req('Unbounded__400__normal__100'),\n      usedCodepoints: cps('A'),\n    })\n\n    expect(css).toMatch(/font-family:\\s*['\"]?Unbounded['\"]?/)\n    expect(css).toMatch(/url\\([\"']?data:/)\n    expect(snapFetch).toHaveBeenCalledTimes(2)\n  })\n\n  it('allows cross-origin by family token in URL (e.g., family=<name>) and inlines font blob', async () => {\n    const href = 'https://cdn.example.com/css?family=My+Fancy+Font'\n    addLink(href)\n\n    addStyle(`@font-face{\n      font-family:'My Fancy Font';\n      font-style:normal;font-weight:400;font-stretch:100%;\n      src:url(https://cdn.example.com/mff.woff2) format('woff2');\n    }`)\n\n    // 1) CSS del <link>\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: '@font-face{font-family:\\'My Fancy Font\\';font-style:normal;font-weight:400;font-stretch:100%;src:url(https://cdn.example.com/mff.woff2) format(\\'woff2\\');}',\n      status: 200,\n      url: href,\n      fromCache: false,\n    })\n    // 2) Fuente → DataURL\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: 'data:font/woff2;base64,ABC=',\n      status: 200,\n      url: 'https://cdn.example.com/mff.woff2',\n      fromCache: false,\n      mime: 'font/woff2',\n    })\n\n    const css = await embedCustomFonts({\n      required: new Set(['My Fancy Font__400__normal__100']),\n      usedCodepoints: new Set(['B'.codePointAt(0)]),\n    })\n\n    expect(css).toMatch(/My Fancy Font/)\n    expect(css).toMatch(/url\\([\"']?data:/)\n    expect(snapFetch).toHaveBeenCalledTimes(2)\n  })\n})\n\n/* ----------------- Cache hits & fetch errors (CSSOM path, deterministic) ------------------ */\ndescribe('embedCustomFonts - cache hits & fetch errors', () => {\n  it('uses cache.resource for URL already in cache (no refetch)', async () => {\n    addStyle(`@font-face{\n      font-family:'Foo';\n      font-style:normal;font-weight:400;font-stretch:100%;\n      src:url(https://fonts.gstatic.com/foo.woff2) format('woff2');\n    }`)\n\n    cache.resource.set('https://fonts.gstatic.com/foo.woff2', 'data:font/woff2;base64,AAA')\n\n    const css = await embedCustomFonts({\n      required: req('Foo__400__normal__100'),\n      usedCodepoints: cps('A'),\n    })\n\n    expect(css).toMatch(/url\\([\"']?data:font\\/woff2;base64,AAA/)\n    expect(snapFetch).not.toHaveBeenCalled()\n  })\n\n  it('continues when font fetch fails (ok:false)', async () => {\n    addStyle(`@font-face{\n      font-family:'Bar';\n      font-style:normal;font-weight:400;font-stretch:100%;\n      src:url(https://fonts.gstatic.com/bar.woff2) format('woff2');\n    }`)\n\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: false, data: null, status: 0,\n      url: 'https://fonts.gstatic.com/bar.woff2',\n      fromCache: false, reason: 'network'\n    })\n\n    const css = await embedCustomFonts({\n      required: new Set(['Bar__400__normal__100']),\n      usedCodepoints: new Set(['C'.codePointAt(0)]),\n    })\n\n    expect(typeof css).toBe('string')\n    expect(snapFetch).toHaveBeenCalled()\n  })\n})\n\n/* ----------------- @import injection & dedupe ------------------ */\ndescribe('embedCustomFonts - @import injection & dedupe', () => {\n  it('injects <link rel=\"stylesheet\"> for @import urls and does not duplicate', async () => {\n    const imported = 'https://fonts.googleapis.com/css2?family=Inter:wght@400'\n    addStyle(`@import url(\"${imported}\");`)\n\n    // 1) CSS importado\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: '@font-face{font-family:\\'Inter\\';font-style:normal;font-weight:400;font-stretch:100%;src:url(https://fonts.gstatic.com/s/inter/v1/a.woff2) format(\\'woff2\\');}',\n      status: 200,\n      url: imported,\n      fromCache: false,\n    })\n    // 2) Fuente → DataURL\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: 'data:font/woff2;base64,QQ==',\n      status: 200,\n      url: 'https://fonts.gstatic.com/s/inter/v1/a.woff2',\n      fromCache: false,\n      mime: 'font/woff2',\n    })\n\n    const css = await embedCustomFonts({\n      required: req('Inter__400__normal__100'),\n      usedCodepoints: cps('A'),\n    })\n\n    const links = [...document.querySelectorAll(`link[rel=\"stylesheet\"][href=\"${imported}\"]`)]\n    expect(links.length).toBe(1)\n    expect(links[0].getAttribute('data-snapdom')).toBe('injected-import')\n\n    expect(css).toMatch(/font-family:\\s*['\"]?Inter['\"]?/)\n    expect(css).toMatch(/url\\([\"']?data:/)\n\n    // dedupe: segunda llamada no duplica faces\n    const css2 = await embedCustomFonts({\n      required: req('Inter__400__normal__100'),\n      usedCodepoints: cps('A'),\n    })\n    const facesCount = (css2.match(/@font-face/g) || []).length\n    expect(facesCount).toBe(1)\n  })\n})\n\n/* ----------------- Relative URL inlining ------------------ */\ndescribe('embedCustomFonts - relative URL inlining', () => {\n  it('inlines relative url(...) using base from location.href', async () => {\n    addStyle(`@font-face{\n      font-family:'RelFace';\n      font-style:normal;font-weight:400;font-stretch:100%;\n      src:url(./rel.woff2) format('woff2');\n    }`)\n\n    let seenUrl = ''\n    vi.mocked(snapFetch).mockImplementation(async (url, opts = {}) => {\n      if (opts.as === 'text') {\n        return { ok: true, data: '', status: 200, url, fromCache: false }\n      }\n      seenUrl = String(url)\n      return { ok: true, data: 'data:font/woff2;base64,QQ==', status: 200, url, fromCache: false, mime: 'font/woff2' }\n    })\n\n    const css = await embedCustomFonts({\n      required: req('RelFace__400__normal__100'),\n      usedCodepoints: cps('B'),\n    })\n\n    expect(seenUrl).toContain(new URL('./rel.woff2', location.href).href)\n    expect(css).toMatch(/RelFace/)\n    expect(css).toMatch(/url\\([\"']?data:/)\n  })\n})\n\n/* ----------------- Weight fallback & ranges ------------------ */\ndescribe('embedCustomFonts - nearest weight fallback & ranges', () => {\n  it('selects face 400 when required 700 (nearest fallback)', async () => {\n    addStyle(`@font-face{\n      font-family:'Nearest';\n      font-style:normal;font-weight:400;font-stretch:100%;\n      src:url(data:font/woff2;base64,AA==) format('woff2');\n    }`)\n\n    const css = await embedCustomFonts({\n      required: req('Nearest__700__normal__100'),\n      usedCodepoints: cps('A'),\n    })\n    expect(css).toMatch(/font-family:\\s*['\"]?Nearest['\"]?/)\n    expect(css).toMatch(/font-weight:\\s*400/)\n  })\n\n  it('accepts faces that declare weight and stretch ranges', async () => {\n    addStyle(`@font-face{\n      font-family:'Ranges';\n      font-style:normal;font-weight:100 700;font-stretch:75% 125%;\n      src:url(data:font/woff2;base64,QQ==) format('woff2');\n      unicode-range: U+000-5FF;\n    }`)\n    const css = await embedCustomFonts({\n      required: req('Ranges__500__normal__100'),\n      usedCodepoints: cps('Z'),\n    })\n    expect(css).toMatch(/font-family:\\s*['\"]?Ranges['\"]?/)\n    expect(css).toMatch(/font-weight:\\s*100 700|font-weight:\\s*100\\s+700/i)\n  })\n})\n\n/* ----------------- Exclude knobs ------------------ */\ndescribe('embedCustomFonts - exclude by families/domains/subsets', () => {\n  it('excludes by family name', async () => {\n    addStyle(`@font-face{\n      font-family:'Excluded';\n      font-weight:400;font-style:normal;font-stretch:100%;\n      src:url(data:font/woff2;base64,AA==);\n    }`)\n    const css = await embedCustomFonts({\n      required: req('Excluded__400__normal__100'),\n      usedCodepoints: cps('A'),\n      exclude: { families: ['excluded'] },\n    })\n    expect(css).not.toMatch(/Excluded/)\n  })\n\n  it('excludes by domain host', async () => {\n    addStyle(`@font-face{\n      font-family:'DomainFace';\n      font-weight:400;font-style:normal;font-stretch:100%;\n      src:url(https://blocked.example.org/font.woff2) format('woff2');\n    }`)\n    const css = await embedCustomFonts({\n      required: req('DomainFace__400__normal__100'),\n      usedCodepoints: cps('B'),\n      exclude: { domains: ['blocked.example.org'] },\n    })\n    expect(css).not.toMatch(/DomainFace/)\n  })\n\n  it('excludes by subset detection from unicode-range (e.g., cyrillic)', async () => {\n    addStyle(`@font-face{\n      font-family:'Subsets';\n      font-weight:400;font-style:normal;font-stretch:100%;\n      unicode-range: U+0400-04FF;\n      src:url(data:font/woff2;base64,AA==);\n    }`)\n    const css = await embedCustomFonts({\n      required: req('Subsets__400__normal__100'),\n      usedCodepoints: new Set([0x0410]), // 'А'\n      exclude: { subsets: ['cyrillic'] },\n    })\n    expect(css).not.toMatch(/Subsets/)\n  })\n})\n\n/* ----------------- Cache key hit ------------------ */\ndescribe('embedCustomFonts - cache key hit', () => {\n  it('returns cached CSS on second identical call (no extra fetch)', async () => {\n    addStyle(`@font-face{\n      font-family:'CacheFace';\n      font-weight:400;font-style:normal;font-stretch:100%;\n      src:url(https://cdn.example.com/cache.woff2) format('woff2');\n    }`)\n\n    // Primera llamada: 1 fetch (dataURL)\n    vi.mocked(snapFetch).mockResolvedValueOnce({\n      ok: true,\n      data: 'data:font/woff2;base64,ABC=',\n      status: 200,\n      url: 'https://cdn.example.com/cache.woff2',\n      fromCache: false,\n      mime: 'font/woff2',\n    })\n\n    const opts = {\n      required: req('CacheFace__400__normal__100'),\n      usedCodepoints: cps('A'),\n    }\n\n    const css1 = await embedCustomFonts(opts)\n    expect(css1).toMatch(/url\\([\"']?data:/)\n    expect(snapFetch).toHaveBeenCalledTimes(1)\n\n    // Segunda llamada idéntica → sale del cache.resource por cacheKey\n    vi.mocked(snapFetch).mockClear()\n    const css2 = await embedCustomFonts(opts)\n    expect(css2).toBe(css1)\n    expect(snapFetch).not.toHaveBeenCalled()\n  })\n})\n\n/* ----------------- ensureFontsReady (smoke) ------------------ */\ndescribe('ensureFontsReady (smoke)', () => {\n  it('awaits fonts.ready and cleans up warmup container', async () => {\n    const items = []\n    const fakeSet = {\n      [Symbol.iterator]: function* () { yield* items },\n      ready: Promise.resolve(),\n      add(ff) { items.push(ff) }\n    }\n    Object.defineProperty(document, 'fonts', { configurable: true, get: () => fakeSet })\n\n    const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {\n      cb(performance.now())\n      return 1\n    })\n\n    await ensureFontsReady(['WarmupFam'], 1)\n    expect(document.querySelector('[data-warmup]')).toBeFalsy()\n\n    raf.mockRestore()\n    delete document.fonts\n  })\n})\n"
  },
  {
    "path": "__tests__/module.fonts.test.js",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { iconToImage, embedCustomFonts } from '../src/modules/fonts.js'\nimport { cache } from '../src/core/cache.js'\n\n// === helpers locales ===\nfunction cleanFontEnvironment() {\n  document\n    .querySelectorAll('style[data-test-font], link[data-test-font]')\n    .forEach(el => el.remove())\n}\n\nfunction addStyleTag(css) {\n  const style = document.createElement('style')\n  style.setAttribute('data-test-font', 'true')\n  style.textContent = css\n  document.head.appendChild(style)\n  return style\n}\n\n// Helpers nuevos para la API smart\nfunction makeRequired(family, weight='400', style='normal', stretchPct=100) {\n  const key = `${family}__${weight}__${style}__${stretchPct}`\n  return new Set([key])\n}\n\nfunction makeUsedCodepoints(text='A') {\n  const s = new Set()\n  for (const ch of text) s.add(ch.codePointAt(0))\n  return s\n}\n\n/**\n * Mock seguro de document.fonts (FontFaceSet \"mínimo pero compatible\")\n * @param {Array<{ family:string, status:string, weight?:string, style?:string, _snapdomSrc?:string }>} fontsArray\n */\nfunction setDocumentFonts(fontsArray = []) {\n  const items = [...fontsArray]\n\n  // iterables y helpers típicos\n  const iter = function* () { yield* items }\n  const fakeSet = {\n    // iterator por defecto\n    [Symbol.iterator]: iter,\n    // API parecida a FontFaceSet\n    values: iter,\n    entries: function* () { for (const it of items) yield [it.family, it] },\n    forEach(cb, thisArg) { for (const it of items) cb.call(thisArg, it, it, fakeSet) },\n    has(ff) { return items.includes(ff) },\n    add(ff) { items.push(ff) },\n    delete(ff) { const i = items.indexOf(ff); if (i >= 0) items.splice(i, 1) },\n    clear() { items.length = 0 },\n    ready: Promise.resolve(),\n    // extra mínimo\n    size: items.length\n  }\n\n  Object.defineProperty(document, 'fonts', {\n    configurable: true,\n    get() { return fakeSet },\n    set() {}\n  })\n\n  return () => { delete document.fonts }\n}\n\nlet restoreFonts = () => {}\n\nbeforeEach(() => {\n  // cache.reset() o resetCache() según exista\n  if (typeof cache.reset === 'function') cache.reset()\n  if (typeof cache.resetCache === 'function') cache.resetCache()\n  if (cache.font?.clear) cache.font.clear?.()\n  if (cache.resource?.clear) cache.resource.clear?.()\n\n  cleanFontEnvironment()\n  vi.restoreAllMocks()\n  restoreFonts = setDocumentFonts([]) // mock vacío por defecto\n})\n\nafterEach(() => {\n  restoreFonts?.()\n})\n\n// ========== iconToImage ==========\ndescribe('iconToImage', () => {\n  let ctxSpy, toDataURLSpy\n\n  afterEach(() => {\n    ctxSpy?.mockRestore?.()\n    toDataURLSpy?.mockRestore?.()\n  })\n\n  it('devuelve un data URL válido con dimensiones > 0', async () => {\n    ctxSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => ({\n      scale: vi.fn(),\n      font: '',\n      textBaseline: '',\n      fillStyle: '',\n      fillText: vi.fn(),\n    }))\n    toDataURLSpy = vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation(() => 'data:image/png;base64,TEST')\n\n    const { dataUrl, width, height } = await iconToImage('A', 'Arial', '400', 16, '#000')\n    expect(dataUrl).toMatch(/^data:image\\/png;base64,/)\n    expect(width).toBeGreaterThan(0)\n    expect(height).toBeGreaterThan(0)\n  })\n})\n\n// ========== embedCustomFonts ==========\ndescribe('embedCustomFonts', () => {\n  it('conserva @font-face con solo local() en src', async () => {\n    const style = addStyleTag(`\n      @font-face {\n        font-family: 'OnlyLocal';\n        src: local(\"Arial\");\n        font-style: normal;\n        font-weight: 400;\n      }\n    `)\n\n    const css = await embedCustomFonts({\n      required: makeRequired('OnlyLocal', '400', 'normal', 100),\n      usedCodepoints: makeUsedCodepoints('abc')\n    })\n    expect(css).toMatch(/font-family:\\s*['\"]?OnlyLocal['\"]?/)\n    expect(css).toMatch(/src:\\s*local\\([\"']Arial[\"']\\)/)\n    document.head.removeChild(style)\n  })\n\n  it('filtra @font-face no utilizados segun document.fonts', async () => {\n    restoreFonts?.() // reemplazamos con fuentes usadas\n    restoreFonts = setDocumentFonts([\n      { family: 'UsedFont', status: 'loaded', weight: 'normal', style: 'normal' },\n    ])\n\n    addStyleTag(`\n      @font-face { font-family: 'UsedFont'; src: url(data:font/woff;base64,AA==); }\n      @font-face { font-family: 'UnusedFont'; src: url(data:font/woff;base64,BB==); }\n    `)\n\n    const css = await embedCustomFonts({\n      required: makeRequired('UsedFont', '400', 'normal', 100),\n      usedCodepoints: makeUsedCodepoints('A')\n    })\n    expect(css).toMatch(/UsedFont/)\n    expect(css).not.toMatch(/UnusedFont/)\n  })\n\n  it('embebe fuentes locales provistas', async () => {\n    const css = await embedCustomFonts({\n      required: makeRequired('MyLocal', '400', 'normal', 100),\n      usedCodepoints: makeUsedCodepoints('A'),\n      localFonts: [{ family: 'MyLocal', src: 'data:font/woff;base64,AA==' }],\n    })\n    expect(css).toMatch(/font-family:\\s*['\"]?MyLocal['\"]?/)\n    expect(css).toMatch(/AA==/)\n  })\n\n  it('usa _snapdomSrc para nuevas fuentes', async () => {\n    restoreFonts?.()\n    restoreFonts = setDocumentFonts([\n      { family: 'DynFont', status: 'loaded', weight: 'normal', style: 'normal', _snapdomSrc: 'data:font/woff;base64,CC==' },\n    ])\n\n    const css = await embedCustomFonts({\n      required: makeRequired('DynFont', '400', 'normal', 100),\n      usedCodepoints: makeUsedCodepoints('A'),\n    })\n    expect(css).toMatch(/font-family:\\s*['\"]?DynFont['\"]?/)\n    expect(css).toMatch(/CC==/)\n  })\n\n  it('conserva @font-face con local() y sin url()', async () => {\n    const style = addStyleTag(`\n      @font-face {\n        font-family: LocalFont;\n        src: local('MyFont'), local('FallbackFont');\n        font-style: italic;\n        font-weight: bold;\n      }\n    `)\n\n    const css = await embedCustomFonts({\n      required: makeRequired('LocalFont', '700', 'italic', 100),\n      usedCodepoints: makeUsedCodepoints('Z')\n    })\n    expect(css).toMatch(/font-family:\\s*['\"]?LocalFont['\"]?/)\n    expect(css).toMatch(/src:\\s*local\\(['\"]MyFont['\"]\\),\\s*local\\(['\"]FallbackFont['\"]\\)/)\n    expect(css).toMatch(/font-style:\\s*italic/)\n    document.head.removeChild(style)\n  })\n})\n\n/**\n * Ensures that when a family only publishes a single weight (e.g., 400),\n * and the required variant asks for 700, we still embed the available 400 face\n * (browser will synthesize bold). This covers families like \"Mansalva\".\n */\ndescribe('embedCustomFonts - single weight fallback', () => {\n  it('embeds the 400 @font-face when 700 is required (fallback)', async () => {\n    // Prepare a minimal @font-face for \"Mansalva\" with only weight 400\n    const style = document.createElement('style')\n    style.textContent = `\n      @font-face {\n        font-family: 'Mansalva';\n        font-style: normal;\n        font-weight: 400;\n        font-stretch: 100%;\n        unicode-range: U+000-5FF;\n        src: local('Mansalva'),\n             url(data:font/woff2;base64,AA==) format('woff2');\n      }\n    `\n    document.head.appendChild(style)\n\n    // Required variants:\n    // ask for 700 normal stretch=100% (our faceMatchesRequired must accept nearest=400)\n    const required = new Set(['Mansalva__700__normal__100'])\n\n    // Used codepoints: any latin char; we keep within declared unicode-range\n    const usedCodepoints = new Set([65]) // 'A'\n\n    const css = await embedCustomFonts({\n      required,\n      usedCodepoints,\n      // no excludes, no proxy\n    })\n\n    // Expectations:\n    expect(css).toMatch(/font-family:\\s*['\"]?Mansalva['\"]?/)\n    // It must embed the available 400 face (we accept nearest)\n    expect(css).toMatch(/font-weight:\\s*400/)\n    // And keep the inlined data URL we provided\n    expect(css).toMatch(/data:font\\/woff2;base64,AA==/)\n\n    document.head.removeChild(style)\n  })\n})\n"
  },
  {
    "path": "__tests__/module.iconFonts.more.test.js",
    "content": "// __tests__/module.iconFonts.more.test.js – extend iconFonts coverage (34% → higher)\nimport { describe, it, expect, beforeEach, vi } from 'vitest'\n\nlet mod\n\nbeforeEach(async () => {\n  vi.restoreAllMocks()\n  vi.resetModules()\n  mod = await import('../src/modules/iconFonts.js')\n})\n\ndescribe('isMaterialFamily', () => {\n  it('returns true for \"Material Icons\"', () => {\n    expect(mod.isMaterialFamily('Material Icons')).toBe(true)\n  })\n  it('returns true for \"Material Symbols\"', () => {\n    expect(mod.isMaterialFamily('Material Symbols')).toBe(true)\n  })\n  it('returns false for non-Material fonts', () => {\n    expect(mod.isMaterialFamily('Arial')).toBe(false)\n    expect(mod.isMaterialFamily('Font Awesome')).toBe(false)\n  })\n  it('is case-insensitive', () => {\n    expect(mod.isMaterialFamily('material icons')).toBe(true)\n    expect(mod.isMaterialFamily('MATERIAL SYMBOLS')).toBe(true)\n  })\n})\n\ndescribe('isIconFont heuristics', () => {\n  it('matches icon, glyph, symbols, feather, fontawesome (fallback heuristics)', () => {\n    const { isIconFont } = mod\n    expect(isIconFont('My Icon Pack')).toBe(true)\n    expect(isIconFont('Glyph Set')).toBe(true)\n    expect(isIconFont('Symbols font')).toBe(true)\n    expect(isIconFont('Feather icons')).toBe(true)\n    expect(isIconFont('FontAwesome free')).toBe(true)\n  })\n})\n\ndescribe('materialIconToImage', () => {\n  it('returns object with dataUrl, width, height', async () => {\n    const out = await mod.materialIconToImage('home', { fontSize: 24 })\n    expect(out).toBeDefined()\n    expect(out.dataUrl).toMatch(/^data:image\\//)\n    expect(typeof out.width).toBe('number')\n    expect(typeof out.height).toBe('number')\n  })\n})\n"
  },
  {
    "path": "__tests__/module.iconFonts.test.js",
    "content": "// __tests__/module.iconFonts.test.js\nimport { describe, it, expect, beforeEach, vi } from 'vitest'\n\nlet mod // se setea en beforeEach para resetear estado del módulo\n\nbeforeEach(async () => {\n  vi.restoreAllMocks()\n  vi.resetModules() // importante para resetear userIconFonts\n  mod = await import('../src/modules/iconFonts.js') // ESM dynamic import\n})\n\ndescribe('extendIconFonts', () => {\n  it('acepta string y lo convierte a RegExp (case-insensitive)', () => {\n    const { extendIconFonts, isIconFont } = mod\n    // string -> RegExp\n    extendIconFonts('acme-brand')\n    expect(isIconFont('ACME-BRAND pack')).toBe(true)\n    // control: no matchea algo que no contenga el patrón y tampoco cae en la heurística\n    expect(isIconFont('qwerty')).toBe(false)\n  })\n\n  it('acepta RegExp y lo agrega a la lista de usuarios', () => {\n    const { extendIconFonts, isIconFont } = mod\n    extendIconFonts(/brandx/i)\n    expect(isIconFont('This is BrAnDx kit')).toBe(true)\n    expect(isIconFont('no-match-here')).toBe(false)\n  })\n\n  it('acepta arrays mezclando strings y RegExp', () => {\n    const { extendIconFonts, isIconFont } = mod\n    extendIconFonts(['foo-lib', /bar-pkg/i])\n    expect(isIconFont('FOO-LIB icons')).toBe(true)\n    expect(isIconFont('BAR-PKG family')).toBe(true)\n    expect(isIconFont('none')).toBe(false)\n  })\n\n  it('ignora valores inválidos y hace console.warn', () => {\n    const { extendIconFonts, isIconFont } = mod\n    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})\n    extendIconFonts(123)           // inválido\n    extendIconFonts({ nope: true }) // inválido\n    expect(warn).toHaveBeenCalled() // cubre rama del console.warn\n    // No debe haber agregado nada que haga matchear \"qwerty\"\n    expect(isIconFont('qwerty')).toBe(false)\n    warn.mockRestore()\n  })\n})\n\ndescribe('isIconFont (defaults)', () => {\n  it('reconoce patrones por default (e.g., Font Awesome)', () => {\n    const { isIconFont } = mod\n    expect(isIconFont('Font Awesome 6 Pro')).toBe(true) // match por defaultIconFonts\n  })\n})\n"
  },
  {
    "path": "__tests__/module.lineClamp.test.js",
    "content": "// __tests__/module.lineClamp.test.js – lineClamp coverage\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { lineClamp, lineClampTree } from '../src/modules/lineClamp.js'\n\nbeforeEach(() => {\n  document.body.innerHTML = ''\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('lineClamp', () => {\n  it('returns no-op for null element', () => {\n    const undo = lineClamp(null)\n    expect(typeof undo).toBe('function')\n    undo()\n  })\n\n  it('returns no-op when no -webkit-line-clamp', () => {\n    const div = document.createElement('div')\n    div.textContent = 'Hello world'\n    document.body.appendChild(div)\n    lineClamp(div)\n    expect(div.textContent).toBe('Hello world')\n  })\n\n  it('returns no-op when not plain text container (has child elements)', () => {\n    const div = document.createElement('div')\n    div.style.webkitLineClamp = '2'\n    div.appendChild(document.createElement('span'))\n    div.appendChild(document.createTextNode('text'))\n    document.body.appendChild(div)\n    lineClamp(div)\n    expect(div.childNodes.length).toBe(2)\n  })\n\n  it('clamps text and returns undo when content overflows', () => {\n    const div = document.createElement('div')\n    div.style.webkitLineClamp = '2'\n    div.style.lineHeight = '20px'\n    div.style.fontSize = '16px'\n    div.style.padding = '0'\n    div.style.width = '200px'\n    div.style.overflow = 'hidden'\n    const longText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'\n    div.textContent = longText\n    document.body.appendChild(div)\n    const undo = lineClamp(div)\n    expect(div.textContent).toContain('…')\n    undo()\n    expect(div.textContent).toBe(longText)\n  })\n\n  it('returns no-op when content fits in N lines', () => {\n    const div = document.createElement('div')\n    div.style.webkitLineClamp = '5'\n    div.style.lineHeight = '20px'\n    div.style.fontSize = '16px'\n    div.textContent = 'Short'\n    document.body.appendChild(div)\n    lineClamp(div)\n    expect(div.textContent).toBe('Short')\n  })\n})\n\ndescribe('lineClampTree (#386)', () => {\n  it('clamps nested element with -webkit-line-clamp', () => {\n    const outer = document.createElement('div')\n    outer.style.width = '200px'\n    const inner = document.createElement('div')\n    inner.style.webkitLineClamp = '2'\n    inner.style.lineHeight = '20px'\n    inner.style.fontSize = '16px'\n    inner.style.padding = '0'\n    inner.style.overflow = 'hidden'\n    const longText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore.'\n    inner.textContent = longText\n    outer.appendChild(inner)\n    document.body.appendChild(outer)\n\n    const undo = lineClampTree(outer)\n    expect(inner.textContent).toContain('…')\n    undo()\n    expect(inner.textContent).toBe(longText)\n  })\n\n  it('returns no-op for null', () => {\n    const undo = lineClampTree(null)\n    expect(typeof undo).toBe('function')\n    undo()\n  })\n})\n"
  },
  {
    "path": "__tests__/module.pseudo.test.js",
    "content": "// __tests__/modules.pseudo.test.js\nimport { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { inlinePseudoElements } from '../src/modules/pseudo.js'\n\n// Mock de utils y fonts con importActual para que Vitest Browser no rompa\nvi.mock('../src/utils', async () => {\n  const actual = await vi.importActual('../src/utils')\n  return {\n    ...actual,\n    fetchImage: vi.fn(),\n    inlineSingleBackgroundEntry: vi.fn(), // agregado para cubrir casos extra\n  }\n})\n\nvi.mock('../src/modules/fonts.js', async () => {\n  const actual = await vi.importActual('../src/modules/fonts.js')\n  return {\n    ...actual,\n    iconToImage: vi.fn(),\n  }\n})\n\nimport * as helpers from '../src/utils/index.js'\nimport * as fonts from '../src/modules/fonts.js'\n\nconst sessionCache = {\n  styleMap: new Map(),\n  styleCache: new WeakMap()\n}\n\ndescribe('inlinePseudoElements', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('does not fail with simple elements', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    await expect(inlinePseudoElements(el, clone, sessionCache, {})).resolves.toBeUndefined()\n  })\n\n  it('handles ::before with text content', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) =>\n          prop === 'content' ? '\"★\"'\n            : prop === 'font-family' ? 'Arial'\n            : prop === 'font-size' ? '32'\n            : prop === 'font-weight' ? '400'\n            : prop === 'color' ? '#000'\n            : prop === 'background-image' ? 'none'\n            : prop === 'background-color' ? 'transparent'\n            : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('handles ::before with icon font', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) =>\n          prop === 'content' ? '\"★\"'\n            : prop === 'font-family' ? 'Font Awesome'\n            : prop === 'font-size' ? '32'\n            : prop === 'font-weight' ? '400'\n            : prop === 'color' ? '#000'\n            : prop === 'background-image' ? 'none'\n            : prop === 'background-color' ? 'transparent'\n            : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Font Awesome'\n      }\n      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }\n    })\n    fonts.iconToImage.mockResolvedValue('data:image/png;base64,icon')\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('handles ::before with url content', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) =>\n          prop === 'content' ? 'url(\"https://test.com/img.png\")'\n            : prop === 'font-family' ? 'Arial'\n            : prop === 'font-size' ? '32'\n            : prop === 'font-weight' ? '400'\n            : prop === 'color' ? '#000'\n            : prop === 'background-image' ? 'none'\n            : prop === 'background-color' ? 'transparent'\n            : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }\n    })\n    helpers.fetchImage.mockResolvedValue('data:image/png;base64,img')\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('handles ::before with background-image (data url)', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) =>\n          prop === 'content' ? 'none'\n            : prop === 'font-family' ? 'Arial'\n            : prop === 'font-size' ? '32'\n            : prop === 'font-weight' ? '400'\n            : prop === 'color' ? '#000'\n            : prop === 'background-image' ? 'url(\"data:image/png;base64,abc\")'\n            : prop === 'background-color' ? 'transparent'\n            : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('handles ::before with background-image (fetch ok)', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) =>\n          prop === 'content' ? 'none'\n            : prop === 'font-family' ? 'Arial'\n            : prop === 'font-size' ? '32'\n            : prop === 'font-weight' ? '400'\n            : prop === 'color' ? '#000'\n            : prop === 'background-image' ? 'url(\"https://test.com/img.png\")'\n            : prop === 'background-color' ? 'transparent'\n            : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }\n    })\n    helpers.fetchImage.mockResolvedValue('data:image/png;base64,img')\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('handles ::before with background-image (fetch error)', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) =>\n          prop === 'content' ? 'none'\n            : prop === 'font-family' ? 'Arial'\n            : prop === 'font-size' ? '32'\n            : prop === 'font-weight' ? '400'\n            : prop === 'color' ? '#000'\n            : prop === 'background-image' ? 'url(\"https://test.com/img.png\")'\n            : prop === 'background-color' ? 'transparent'\n            : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }\n    })\n    helpers.fetchImage.mockRejectedValue(new Error('fail'))\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('cubre el catch de error en inlineSingleBackgroundEntry', async () => {\n    helpers.inlineSingleBackgroundEntry.mockRejectedValue(new Error('fail'))\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) => prop === 'background-image' ? 'url(\"data:image/png;base64,abc\")' : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('cubre el inlining exitoso con inlineSingleBackgroundEntry', async () => {\n    helpers.inlineSingleBackgroundEntry.mockResolvedValue('url(\"data:image/png;base64,abc\")')\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) => prop === 'background-image' ? 'url(\"https://test.com/img.png\")' : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('handles ::before with no visible box', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return { getPropertyValue: () => 'none' }\n      return { getPropertyValue: () => '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('handles ::first-letter with no textNode', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ getPropertyValue: () => '' }))\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('handles error in pseudo processing', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation(() => { throw new Error('fail') })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('ignores if source no es Element', async () => {\n    const notElement = {}\n    const clone = document.createElement('div')\n    await expect(inlinePseudoElements(notElement, clone, sessionCache, {})).resolves.toBeUndefined()\n  })\n\n  it('ignores if clone no es Element', async () => {\n    const el = document.createElement('div')\n    const notElement = {}\n    await expect(inlinePseudoElements(el, notElement, {})).resolves.toBeUndefined()\n  })\n\n  it('inserta pseudoEl como ::after', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::after') return {\n        getPropertyValue: (prop) => prop === 'content' ? '\"after\"' : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('inserta pseudoEl como ::before', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return {\n        getPropertyValue: (prop) => prop === 'content' ? '\"before\"' : '',\n        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'\n      }\n      return { getPropertyValue: () => '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('maneja ::first-letter meaningful', async () => {\n    const el = document.createElement('div')\n    el.textContent = 'Test'\n    const clone = document.createElement('div')\n    clone.textContent = 'Test'\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::first-letter') return {\n        getPropertyValue: (prop) => prop === 'color' ? '#f00' : '', color: '#f00', fontSize: '32px'\n      }\n      return { getPropertyValue: () => '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('inserta ambos pseudoEl ::before y ::after', async () => {\n    const el = document.createElement('div')\n    const clone = document.createElement('div')\n    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {\n      if (pseudo === '::before') return { getPropertyValue: () => '\"before\"' }\n      if (pseudo === '::after') return { getPropertyValue: () => '\"after\"' }\n      return { getPropertyValue: () => '' }\n    })\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    window.getComputedStyle.mockRestore()\n  })\n\n  it('should inline ::first-letter when style is meaningful', async () => {\n    const el = document.createElement('p')\n    el.textContent = '¡Hola mundo!'\n    el.style.setProperty('color', 'black')\n    document.body.appendChild(el)\n    const clone = el.cloneNode(true)\n    const style = document.createElement('style')\n    style.textContent = `\n      p::first-letter {\n        color: red;\n        font-size: 200%;\n      }\n    `\n    document.head.appendChild(style)\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    const firstLetterEl = clone.querySelector('[data-snapdom-pseudo=\"::first-letter\"]')\n    expect(firstLetterEl).toBeTruthy()\n    expect(firstLetterEl.textContent.length).toBeGreaterThan(0)\n  })\n\n  it('should inline background-image entries for pseudo-element', async () => {\n    const el = document.createElement('div')\n    document.body.appendChild(el)\n    const style = document.createElement('style')\n    style.textContent = `\n      div::after {\n        content: \" \";\n        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\");\n        display: inline-block;\n        width: 10px;\n        height: 10px;\n      }\n    `\n    document.head.appendChild(style)\n    const clone = el.cloneNode(true)\n    await inlinePseudoElements(el, clone, sessionCache, {})\n    const pseudoAfter = clone.querySelector('[data-snapdom-pseudo=\"::after\"]')\n    expect(pseudoAfter).toBeTruthy()\n    expect(pseudoAfter.style.backgroundImage.startsWith('url(\"data:image/')).toBeTruthy()\n  })\n})\n"
  },
  {
    "path": "__tests__/module.snapFetch.test.js",
    "content": "// __tests__/module.snapFetch.test.js\n\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\n\n/**\n * Re-import the module with a clean state so internal singletons\n * like _inflight / _errorCache start fresh.\n * @returns {Promise<{snapFetch: (url: string, opts?: any)=>Promise<any>}>}\n */\nasync function importFresh() {\n  vi.resetModules()\n  return import('../src/modules/snapFetch.js')\n}\n\n/** Stable origins/URLs for tests (no need to redefine window.location). */\nconst ORIGIN = globalThis.location?.origin || 'http://localhost'\nconst SAME = `${ORIGIN}/assets/a.css`\nconst CROSS = 'https://cdn.example/x.png'\n\n/** Mock `fetch` with a static Response. */\nfunction mockFetchOnce(status = 200, body = 'ok', headers = {}) {\n  globalThis.fetch = vi.fn(async (_input, _init) => new Response(body, { status, headers }))\n}\n\n/** Mock `fetch` that rejects like a network error. */\nfunction mockFetchNetworkError() {\n  globalThis.fetch = vi.fn(async () => {\n    throw new TypeError('Failed to fetch')\n  })\n}\n\n/** Mock `fetch` that rejects with AbortError when the signal aborts (timeout). */\nfunction mockFetchTimeoutAware() {\n  globalThis.fetch = vi.fn((input, init) => {\n    return new Promise((_, reject) => {\n      const err = Object.assign(new Error('timeout'), { name: 'AbortError' })\n      const signal = init?.signal\n      if (signal?.aborted) return reject(err)\n      signal?.addEventListener('abort', () => reject(err), { once: true })\n    })\n  })\n}\n\n/** Create a deferred promise for fine-grained inflight control. */\nfunction deferred() {\n  const d = {}\n  d.promise = new Promise((res, rej) => {\n    d.resolve = res\n    d.reject = rej\n  })\n  // @ts-ignore\n  return d\n}\n\nbeforeEach(() => {\n  vi.restoreAllMocks()\n  vi.useRealTimers()\n})\n\nafterEach(() => {\n  vi.restoreAllMocks()\n  vi.useRealTimers()\n})\n\ndescribe('snapFetch (happy paths)', () => {\n  it('returns text when as:\"text\"', async () => {\n    const { snapFetch } = await importFresh()\n    mockFetchOnce(200, 'hello', { 'content-type': 'text/plain' })\n\n    const res = await snapFetch(`${ORIGIN}/hello.txt`, { as: 'text' })\n    expect(res.ok).toBe(true)\n    expect(res.status).toBe(200)\n    expect(res.data).toBe('hello')\n  })\n\n  it('returns Blob when as:\"blob\"', async () => {\n    const { snapFetch } = await importFresh()\n    mockFetchOnce(200, 'BLOB!', { 'content-type': 'image/png' })\n\n    const res = await snapFetch(`${ORIGIN}/p.png`, { as: 'blob' })\n    expect(res.ok).toBe(true)\n    expect(res.data).toBeInstanceOf(Blob)\n    expect(res.mime).toMatch(/image\\/png/i)\n  })\n\n  it('returns DataURL when as:\"dataURL\"', async () => {\n    const { snapFetch } = await importFresh()\n    mockFetchOnce(200, 'PNGDATA', { 'content-type': 'image/png' })\n\n    const res = await snapFetch(`${ORIGIN}/p.png`, { as: 'dataURL' })\n    expect(res.ok).toBe(true)\n    expect(typeof res.data).toBe('string')\n    expect(res.data.startsWith('data:')).toBe(true)\n  })\n})\n\ndescribe('snapFetch (errors are non-throwing)', () => {\n  it('maps HTTP error to { ok:false, reason:\"http_error\" }', async () => {\n    const { snapFetch } = await importFresh()\n    mockFetchOnce(404, '', {})\n\n    const res = await snapFetch(`${ORIGIN}/miss.css`, { as: 'text' })\n    expect(res.ok).toBe(false)\n    expect(res.status).toBe(404)\n    expect(res.reason).toBe('http_error')\n  })\n\n  it('maps network error to { ok:false, reason:\"network\" }', async () => {\n    const { snapFetch } = await importFresh()\n    mockFetchNetworkError()\n\n    const res = await snapFetch(CROSS, { as: 'blob' })\n    expect(res.ok).toBe(false)\n    expect(res.status).toBe(0)\n    expect(res.reason).toBe('network')\n  })\n\n  it('maps timeout to { ok:false, reason:\"timeout\" }', async () => {\n    vi.useFakeTimers()\n    const { snapFetch } = await importFresh()\n    mockFetchTimeoutAware()\n\n    const p = snapFetch(CROSS, { as: 'blob', timeout: 123 })\n    vi.advanceTimersByTime(123)\n\n    const res = await p\n    expect(res.ok).toBe(false)\n    expect(res.reason).toBe('timeout')\n    expect(res.status).toBe(0)\n  })\n})\n\ndescribe('snapFetch (inflight dedup + error TTL)', () => {\n  it('deduplicates inflight requests to same key', async () => {\n    const { snapFetch } = await importFresh()\n\n    // Controlled fetch that resolves later\n    const d = deferred()\n    globalThis.fetch = vi.fn((_input, _init) => d.promise)\n\n    const reqA = snapFetch(`${ORIGIN}/a.png`, { as: 'blob', timeout: 999 })\n    const reqB = snapFetch(`${ORIGIN}/a.png`, { as: 'blob', timeout: 999 })\n\n    // Only one network call\n    expect(globalThis.fetch).toHaveBeenCalledTimes(1)\n\n    // Fulfill with a real Response\n    d.resolve(new Response('IMG', { status: 200, headers: { 'content-type': 'image/png' } }))\n\n    const [resA, resB] = await Promise.all([reqA, reqB])\n    expect(resA.ok && resB.ok).toBe(true)\n    expect(resA.status).toBe(200)\n    expect(resB.status).toBe(200)\n  })\n\n   it('caches errors for errorTTL and does not re-fetch within TTL', async () => {\n  vi.useFakeTimers()\n  vi.setSystemTime(new Date('2020-01-01T00:00:00Z'))\n\n  const { snapFetch } = await importFresh()\n\n  // Un solo spy con dos respuestas\n  const spy = vi.fn()\n    .mockResolvedValueOnce(new Response('', { status: 500 }))\n    .mockResolvedValueOnce(new Response('OK', { status: 200, headers: { 'content-type': 'image/png' } }))\n  globalThis.fetch = spy\n\n  // 1) Falla inicial → cachea error\n  const r1 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 })\n  expect(r1.ok).toBe(false)\n  expect(spy).toHaveBeenCalledTimes(1)\n\n  // 2) Dentro del TTL → no re-fetch (desde cache)\n  const r2 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 })\n  expect(r2.ok).toBe(false)\n  expect(r2.fromCache).toBe(true)\n  expect(spy).toHaveBeenCalledTimes(1)\n\n  // 3) Pasado el TTL → re-fetch (segunda llamada real)\n  vi.advanceTimersByTime(8001)\n  vi.setSystemTime(new Date('2020-01-01T00:00:08.001Z'))\n\n  const r3 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 })\n  expect(r3.ok).toBe(true)\n  expect(spy).toHaveBeenCalledTimes(2)\n})\n\n})\n\ndescribe('snapFetch (proxy & credentials)', () => {\n  it('same-origin → credentials: include, no proxy applied', async () => {\n    const { snapFetch } = await importFresh()\n    const spy = vi.fn(async (_input, _init) => new Response('ok', { status: 200 }))\n    globalThis.fetch = spy\n\n    const url = SAME\n    await snapFetch(url, { as: 'text', useProxy: 'https://proxy.example/p?url={url}' })\n\n    // No proxy used\n    expect(spy.mock.calls[0][0]).toBe(url)\n    // Credentials should be 'include' for same-origin\n    expect(spy.mock.calls[0][1]?.credentials).toBe('include')\n  })\n\n  it('cross-origin + proxy template replaces {url}', async () => {\n    const { snapFetch } = await importFresh()\n    mockFetchOnce(200, 'ok', {})\n    const res = await snapFetch(CROSS, {\n      as: 'blob',\n      useProxy: 'https://proxy.example/p?url={url}'\n    })\n    expect(res.ok).toBe(true)\n    expect(res.url).toBe('https://proxy.example/p?url=' + encodeURIComponent(CROSS))\n  })\n\n  it('cross-origin + base proxy appends ?url=', async () => {\n    const { snapFetch } = await importFresh()\n    mockFetchOnce(200, 'ok', {})\n    const res = await snapFetch(CROSS, {\n      as: 'text',\n      useProxy: 'https://proxy.example/p?'\n    })\n    expect(res.ok).toBe(true)\n    expect(res.url).toBe('https://proxy.example/p?url=' + encodeURIComponent(CROSS))\n  })\n})\n\n describe('snapFetch (logging & silent mode)', () => {\n   it('dedupes console messages; silent:true suppresses logs', async () => {\n     const { snapFetch } = await importFresh()\n     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})\n     const errSpy  = vi.spyOn(console, 'error').mockImplementation(() => {})\n\n     // First: HTTP error (warn once)\n     mockFetchOnce(404, '', {})\n     const r1 = await snapFetch(`${ORIGIN}/missing.png`, { as: 'blob' })\n     expect(r1.ok).toBe(false)\n\n     const warnCountAfter1 = warnSpy.mock.calls.length\n\n     // Second same error within logger TTL: no new warn\n     const r2 = await snapFetch(`${ORIGIN}/missing.png`, { as: 'blob' })\n     expect(r2.ok).toBe(false)\n     expect(warnSpy.mock.calls.length).toBe(warnCountAfter1)\n\n     const totalBefore = errSpy.mock.calls.length + warnSpy.mock.calls.length\n mockFetchNetworkError()\n const r3 = await snapFetch(CROSS, { as: 'blob' })\n expect(r3.ok).toBe(false)\n const totalAfter = errSpy.mock.calls.length + warnSpy.mock.calls.length\n // Aceptamos 0 o 1 log nuevo (dedupe-friendly):\n expect([0, 1]).toContain(totalAfter - totalBefore)\n\n     // Silent suppresses\n      mockFetchNetworkError()\n  const r4 = await snapFetch('https://cdn.example/b.png', { as: 'blob', silent: true })\n  expect(r4.ok).toBe(false)\n  const afterSilent = errSpy.mock.calls.length + warnSpy.mock.calls.length\n  expect(afterSilent).toBe(totalAfter)\n\n     warnSpy.mockRestore()\n     errSpy.mockRestore()\n   })\n })\n"
  },
  {
    "path": "__tests__/module.styles.test.js",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest'\n\n// Carga fresca del módulo para que __wired se reinicie en cada test que lo pida\nasync function loadInlineAllStylesFresh() {\n  await vi.resetModules()\n  const mod = await import('../src/modules/styles.js')\n  return mod.inlineAllStyles\n}\n\n// Stub minimal de MutationObserver para contar instancias\nclass MOStub {\n  static count = 0\n  constructor(/* cb */) {\n    MOStub.count++\n  }\n  observe() {}\n  disconnect() {}\n  takeRecords() { return [] }\n}\n\nfunction freshSession() {\n  return {\n    styleMap: new Map(),\n    styleCache: new WeakMap(),\n    nodeMap: new Map(),\n  }\n}\n\ndescribe('inlineAllStyles – branches y firmas', () => {\n  beforeEach(() => {\n    MOStub.count = 0\n    globalThis.MutationObserver = MOStub\n  })\n\n  it('early-return para <style> (no escribe en styleMap)', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n\n    const src = document.createElement('style')\n    src.textContent = '.x{color:red}'\n    const clone = document.createElement('style')\n    const session = freshSession()\n\n    await inlineAllStyles(src, clone, session, { cache: 'auto' })\n\n    expect(session.styleMap.size).toBe(0)\n    expect(MOStub.count).toBe(0)\n  })\n\n  it('engancha invalidación una vez cuando cache !== \"disabled\"', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n\n    const s1 = document.createElement('div')\n    const c1 = document.createElement('div')\n    const s2 = document.createElement('span')\n    const c2 = document.createElement('span')\n    const session = freshSession()\n\n    await inlineAllStyles(s1, c1, session, { cache: 'auto' })\n    await inlineAllStyles(s2, c2, session, { cache: 'soft' })\n\n    // styles.js engancha 2 observers (documentElement + head) una sola vez\n    expect(MOStub.count).toBe(2)\n    expect(session.styleMap.has(c1)).toBe(true)\n    expect(session.styleMap.has(c2)).toBe(true)\n  })\n\n  it('NO engancha invalidación cuando cache === \"disabled\"', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n\n    const src = document.createElement('div')\n    const clone = document.createElement('div')\n    const session = freshSession()\n\n    await inlineAllStyles(src, clone, session, { cache: 'disabled' })\n\n    expect(MOStub.count).toBe(0)\n    expect(session.styleMap.has(clone)).toBe(true)\n  })\n\n  it('firma #1: (source, clone, sessionCache, options)', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n\n    const src = document.createElement('p')\n    const clone = document.createElement('p')\n    const session = freshSession()\n\n    await inlineAllStyles(src, clone, session, { cache: 'auto' })\n    expect(session.styleMap.has(clone)).toBe(true)\n  })\n\n  it('firma #2: (source, clone, ctx) pasando ctx completo', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n    const { cache } = await import('../src/core/cache.js')\n\n    const src = document.createElement('em')\n    const clone = document.createElement('em')\n    const session = freshSession()\n\n    const ctx = {\n      session,\n      persist: {\n        snapshotKeyCache: new Map(),\n        defaultStyle: cache.defaultStyle,\n        baseStyle: cache.baseStyle,\n        image: cache.image,\n        resource: cache.resource,\n        background: cache.background,\n        font: cache.font,\n      },\n      options: { cache: 'auto' },\n    }\n\n    await inlineAllStyles(src, clone, ctx)\n    expect(session.styleMap.has(clone)).toBe(true)\n  })\n\n  it('firma #3: (source, clone, options) usando sólo options', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n    const { cache } = await import('../src/core/cache.js')\n\n    const src = document.createElement('strong')\n    const clone = document.createElement('strong')\n\n    await inlineAllStyles(src, clone, { cache: 'soft' })\n\n    expect(cache.session.styleMap.has(clone)).toBe(true)\n  })\n\n  it('#348: excludeStyleProps regex excludes matching props from snapshot', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n\n    const src = document.createElement('div')\n    src.style.color = 'red'\n    src.style.fontSize = '13.37px'  // highly non-default so it appears in key\n    document.body.appendChild(src)\n\n    const clone = document.createElement('div')\n    const session = freshSession()\n\n    await inlineAllStyles(src, clone, session, {\n      cache: 'auto',\n      excludeStyleProps: /^color$/\n    })\n\n    document.body.removeChild(src)\n\n    const key = session.styleMap.get(clone)\n    expect(key).toBeDefined()\n    // Only the exact \"color\" prop was excluded; other *-color props remain\n    expect(key).not.toMatch(/(?:^|;)color:/)\n    expect(key).toMatch(/font-size:/)\n  })\n\n  it('#348: excludeStyleProps function excludes matching props', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n\n    const src = document.createElement('span')\n    src.style.fontSize = '14px'\n    const clone = document.createElement('span')\n    const session = freshSession()\n\n    await inlineAllStyles(src, clone, session, {\n      cache: 'auto',\n      excludeStyleProps: (prop) => prop === 'font-size'\n    })\n\n    const key = session.styleMap.get(clone)\n    expect(key).toBeDefined()\n    expect(key).not.toMatch(/font-size:/)\n  })\n\n  it('#362: border: 0 solid normalizes to border: none in snapshot', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n\n    const style = document.createElement('style')\n    style.textContent = '* { border: 0 solid; }'\n    document.head.appendChild(style)\n\n    const src = document.createElement('div')\n    document.body.appendChild(src)\n\n    const clone = document.createElement('div')\n    const session = freshSession()\n\n    await inlineAllStyles(src, clone, session, { cache: 'auto' })\n\n    document.body.removeChild(src)\n    document.head.removeChild(style)\n\n    const key = session.styleMap.get(clone)\n    expect(key).toBeDefined()\n    // Tailwind * { border: 0 solid } must become border: none in output (#362)\n    expect(key).toMatch(/\\bborder:\\s*none\\b/)\n  })\n\n  it('cachea getComputedStyle en session.styleCache (una sola lectura por source)', async () => {\n    const inlineAllStyles = await loadInlineAllStylesFresh()\n\n    const src = document.createElement('div')\n    const clone1 = document.createElement('div')\n    const clone2 = document.createElement('div')\n    const session = freshSession()\n\n    const spy = vi.spyOn(window, 'getComputedStyle')\n\n    await inlineAllStyles(src, clone1, session, { cache: 'auto' })\n    await inlineAllStyles(src, clone2, session, { cache: 'auto' })\n\n    expect(spy).toHaveBeenCalledTimes(1)\n    expect(session.styleMap.has(clone1)).toBe(true)\n    expect(session.styleMap.has(clone2)).toBe(true)\n\n    spy.mockRestore()\n  })\n})\n"
  },
  {
    "path": "__tests__/module.svg.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { inlineExternalDefsAndSymbols } from '../src/modules/svgDefs.js'\n\nconst SVG_NS = 'http://www.w3.org/2000/svg'\n\n/** Utility to create a global top-level <svg> holder for defs/symbols */\nfunction createGlobalSvg() {\n  const svg = document.createElementNS(SVG_NS, 'svg')\n  document.body.appendChild(svg)\n  return svg\n}\n\nfunction createRootContainer() {\n  const root = document.createElement('div')\n  document.body.appendChild(root)\n  return root\n}\n\nfunction createLocalUse(hrefValue) {\n  const svg = document.createElementNS(SVG_NS, 'svg')\n  const use = document.createElementNS(SVG_NS, 'use')\n  use.setAttribute('href', hrefValue)\n  svg.appendChild(use)\n  return { svg, use }\n}\n\nbeforeEach(() => {\n  // Clean slate between tests\n  document.body.innerHTML = ''\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('inlineExternalDefsAndSymbols', () => {\n  it('does nothing for null/undefined root', () => {\n    // Should not throw\n    inlineExternalDefsAndSymbols(null)\n    inlineExternalDefsAndSymbols(undefined)\n    // No container created\n    expect(document.querySelector('svg.inline-defs-container')).toBeFalsy()\n  })\n\n  it('does nothing if there are no <use> references inside root', () => {\n    const root = createRootContainer()\n    // No <use> elements\n    inlineExternalDefsAndSymbols(root)\n    // Container must not be created (early return on empty usedIds)\n    expect(root.querySelector('svg.inline-defs-container')).toBeFalsy()\n  })\n\n  it('inlines an external <symbol> referenced by <use href=\"#id\">', () => {\n    // Global symbol outside root\n    const gsvg = createGlobalSvg()\n    const sym = document.createElementNS(SVG_NS, 'symbol')\n    sym.setAttribute('id', 'icon-star')\n    const path = document.createElementNS(SVG_NS, 'path')\n    path.setAttribute('d', 'M0 0 L10 0 L5 10 Z')\n    sym.appendChild(path)\n    gsvg.appendChild(sym)\n\n    // Root with a local <use>\n    const root = createRootContainer()\n    const { svg } = createLocalUse('#icon-star')\n    root.appendChild(svg)\n\n    inlineExternalDefsAndSymbols(root)\n\n    const container = root.querySelector('svg.inline-defs-container')\n    expect(container).toBeTruthy()\n    // Container visibility/positioning attributes\n    expect(container.getAttribute('aria-hidden')).toBe('true')\n    expect(container.getAttribute('style')).toMatch(/width:\\s*0/)\n    expect(container.getAttribute('style')).toMatch(/height:\\s*0/)\n\n    const cloned = container.querySelector('symbol#icon-star')\n    expect(cloned).toBeTruthy()\n    // Ensure it is a clone (not the same node)\n    expect(cloned).not.toBe(sym)\n    // And child path is present\n    expect(cloned.querySelector('path')).toBeTruthy()\n  })\n\n  it('inlines from global <defs> (e.g., linearGradient) when referenced', () => {\n    // Global defs with a gradient\n    const gsvg = createGlobalSvg()\n    const defs = document.createElementNS(SVG_NS, 'defs')\n    const grad = document.createElementNS(SVG_NS, 'linearGradient')\n    grad.setAttribute('id', 'grad1')\n    defs.appendChild(grad)\n    gsvg.appendChild(defs)\n\n    // Root referencing that id\n    const root = createRootContainer()\n    const { svg } = createLocalUse('#grad1')\n    root.appendChild(svg)\n\n    inlineExternalDefsAndSymbols(root)\n\n    const container = root.querySelector('svg.inline-defs-container')\n    expect(container).toBeTruthy()\n\n    // A <defs> container should be created inside the hidden svg with the cloned element\n    const localDefs = container.querySelector('defs')\n    expect(localDefs).toBeTruthy()\n    expect(localDefs.querySelector('#grad1')).toBeTruthy()\n    expect(localDefs.querySelector('#grad1')).not.toBe(grad)\n  })\n\n  it('supports xlink:href on <use> (legacy attribute)', () => {\n    // Global symbol\n    const gsvg = createGlobalSvg()\n    const sym = document.createElementNS(SVG_NS, 'symbol')\n    sym.setAttribute('id', 'legacy-icon')\n    gsvg.appendChild(sym)\n\n    // Root with <use xlink:href>\n    const root = createRootContainer()\n    const svg = document.createElementNS(SVG_NS, 'svg')\n    const use = document.createElementNS(SVG_NS, 'use')\n    use.setAttribute('xlink:href', '#legacy-icon')\n    svg.appendChild(use)\n    root.appendChild(svg)\n\n    inlineExternalDefsAndSymbols(root)\n\n    const container = root.querySelector('svg.inline-defs-container')\n    expect(container).toBeTruthy()\n    expect(container.querySelector('symbol#legacy-icon')).toBeTruthy()\n  })\n\n  it('does not duplicate if a symbol/defs with the same id already exists in root', () => {\n    // Global symbol\n    const gsvg = createGlobalSvg()\n    const sym = document.createElementNS(SVG_NS, 'symbol')\n    sym.setAttribute('id', 'dup')\n    gsvg.appendChild(sym)\n\n    // Root already contains the same id\n    const root = createRootContainer()\n    const container = document.createElementNS(SVG_NS, 'svg')\n    container.classList.add('inline-defs-container')\n    const localSym = document.createElementNS(SVG_NS, 'symbol')\n    localSym.setAttribute('id', 'dup')\n    container.appendChild(localSym)\n    root.appendChild(container)\n\n    // And it references the same id\n    const { svg } = createLocalUse('#dup')\n    root.appendChild(svg)\n\n    inlineExternalDefsAndSymbols(root)\n\n    // Still only one symbol#dup in root\n    const all = root.querySelectorAll('symbol#dup')\n    expect(all.length).toBe(1)\n  })\n\n  it('clones elements with special characters in id (CSS.escape path)', () => {\n    // Global symbol with special chars\n    const gsvg = createGlobalSvg()\n    const specialId = 'icon:weird.*'\n    const sym = document.createElementNS(SVG_NS, 'symbol')\n    sym.setAttribute('id', specialId)\n    gsvg.appendChild(sym)\n\n    // Root referencing it\n    const root = createRootContainer()\n    const { svg } = createLocalUse('#' + specialId)\n    root.appendChild(svg)\n\n    inlineExternalDefsAndSymbols(root)\n\n    const container = root.querySelector('svg.inline-defs-container')\n    expect(container).toBeTruthy()\n    // querySelector with literal should still work because we test the existence of the cloned node\n    const cloned = Array.from(container.querySelectorAll('symbol')).find(s => s.id === specialId)\n    expect(cloned).toBeTruthy()\n  })\n\n  it('creates the hidden container even if no matches are found for used ids', () => {\n    // No matching global defs/symbols, but there IS a <use>\n    const root = createRootContainer()\n    const { svg } = createLocalUse('#nope')\n    root.appendChild(svg)\n\n    inlineExternalDefsAndSymbols(root)\n\n    const container = root.querySelector('svg.inline-defs-container')\n    expect(container).toBeTruthy()\n    // Empty container (no symbol/defs children)\n    expect(container.querySelector('symbol, defs')).toBeFalsy()\n  })\n})\n"
  },
  {
    "path": "__tests__/modules.images.test.js",
    "content": "// __tests__/modules.images.more.test.js\nimport { describe, it, expect, beforeEach, vi, afterEach} from 'vitest'\nimport { inlineImages } from '../src/modules/images.js'\n\n// mock del módulo que usa images.js\nvi.mock('../src/modules/snapFetch.js', async () => {\n  // devolvemos una función reemplazable por-test\n  return {\n    snapFetch: vi.fn(async () => ({ ok: true, data: 'data:image/png;base64,AAA' })),\n  }\n})\nimport { snapFetch } from '../src/modules/snapFetch.js'\n\ndescribe('inlineImages', () => {\n  let container\n\nif (typeof window !== 'undefined') {\n  window.addEventListener('unhandledrejection', (e) => {\n    const msg = (e.reason && e.reason.message) || ''\n    if (\n      msg.includes('[SnapDOM - fetchImage] Fetch failed and no proxy provided') ||\n      msg.includes('Image load timed out') ||\n      msg.includes('[SnapDOM - fetchImage] Recently failed (cooldown).')\n    ) {\n      e.preventDefault() // evita el banner de Vitest\n    }\n  })\n}\n\n  beforeEach(() => {\n    container = document.createElement('div')\n    document.body.appendChild(container)\n\n    vi.restoreAllMocks()\n    // Mock OK por defecto para fetch (evita red real en otros tests)\n    globalThis.fetch = vi.fn(() =>\n      Promise.resolve({\n        ok: true,\n        status: 200,\n        blob: () =>\n          Promise.resolve(\n            new Blob(\n              [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], // header PNG\n              { type: 'image/png' }\n            )\n          ),\n        text: () => Promise.resolve('<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>'),\n      })\n    )\n  })\n\n  afterEach(() => {\n    document.body.removeChild(container)\n  })\n\n  it('converts <img> to dataURL if the image loads', async () => {\n    const img = document.createElement('img')\n    img.src =\n      'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAn8B9p6Q2wAAAABJRU5ErkJggg=='\n    container.appendChild(img)\n\n    await inlineImages(container)\n\n    expect(img.src.startsWith('data:image/')).toBe(true)\n  })\n\n it('replaces <img> with a fallback if the image fails', async () => {\n  const img = document.createElement('img')\n  img.src = 'https://x/fail.png'\n  container.appendChild(img)\n\n  // fuerza fallo explícito de snapFetch para este caso\n  vi.mocked(snapFetch).mockResolvedValueOnce({ ok: false, data: null })\n\n  await inlineImages(container)\n\n  expect(container.querySelector('div')).not.toBeNull() // fallback\n  expect(container.querySelector('img')).toBeNull()\n})\n\n})\n\ndescribe('inlineImages – extra coverage', () => {\n  let wrap\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n    wrap = document.createElement('div')\n    document.body.appendChild(wrap)\n  })\n\n  it('normaliza src desde currentSrc y elimina srcset/sizes', async () => {\n  const wrap = document.createElement('div')\n  const img = document.createElement('img')\n\n  Object.defineProperty(img, 'currentSrc', { configurable: true, get: () => 'https://ex.com/a.png' })\n  img.setAttribute('srcset', 'a.png 1x, b.png 2x')\n  img.setAttribute('sizes', '100vw')\n  wrap.appendChild(img)\n\n  // asegurá que vemos la llamada y luego devolvemos OK\n  vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:image/png;base64,AAA' })\n\n  await inlineImages(wrap)\n\n  // se llamó con la URL normalizada desde currentSrc\n  expect(vi.mocked(snapFetch).mock.calls[0][0]).toBe('https://ex.com/a.png')\n\n  // los atributos responsivos fueron removidos\n  expect(img.hasAttribute('srcset')).toBe(false)\n  expect(img.hasAttribute('sizes')).toBe(false)\n\n  // y el resultado final es data:\n  expect(img.src.startsWith('data:')).toBe(true)\n})\n\n  it('convierte a dataURL cuando snapFetch ok y garantiza dimensiones', async () => {\n    const img = document.createElement('img')\n    img.src = 'https://ex.com/ok.png'\n    // natural sizes presentes, width/height 0 → deben fijarse\n    Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 123 })\n    Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 45 })\n    wrap.appendChild(img)\n\n    vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:image/png;base64,ZZZ' })\n    await inlineImages(wrap)\n\n    expect(img.src.startsWith('data:')).toBe(true)\n    expect(img.width).toBe(123)\n    expect(img.height).toBe(45)\n  })\n\n  it('cuando primer fetch falla usa fallbackURL STRING como fallback y conserva tamaño estimado', async () => {\n    const img = document.createElement('img')\n    img.src = 'https://ex.com/fails.png'\n    // datos de tamaño en dataset/attrs/estilo → se usan por prioridad\n    img.dataset.snapdomWidth = '200'\n    img.dataset.snapdomHeight = '100'\n    wrap.appendChild(img)\n\n    // 1ª llamada falla, 2ª (fallback) ok\n    vi.mocked(snapFetch)\n      .mockResolvedValueOnce({ ok: false, data: null }) // original\n      .mockResolvedValueOnce({ ok: true, data: 'data:image/png;base64,FALLBACK' }) // fallback\n\n    await inlineImages(wrap, { fallbackURL: 'https://ex.com/fallback.png' })\n\n    expect(img.src).toBe('data:image/png;base64,FALLBACK')\n    expect(img.width).toBe(200)\n    expect(img.height).toBe(100)\n  })\n\n  it('fallbackURL CALLBACK async recibe dimensiones inferidas y se aplica', async () => {\n    const img = document.createElement('img')\n    img.src = 'https://ex.com/fails2.png'\n    // esta vez sin dataset/attr; que tome style → 150x60\n    img.style.width = '150px'\n    img.style.height = '60px'\n    wrap.appendChild(img)\n\n    vi.mocked(snapFetch)\n      .mockResolvedValueOnce({ ok: false, data: null }) // original falla\n      .mockResolvedValueOnce({ ok: true, data: 'data:image/png;base64,CB' }) // callback URL ok\n\n    const cb = vi.fn(async ({ width, height, src }) => {\n      expect(src).toBe('https://ex.com/fails2.png')\n      expect(width).toBe(150)\n      expect(height).toBe(60)\n      return 'https://ex.com/fb.png'\n    })\n\n    await inlineImages(wrap, { fallbackURL: cb })\n\n    expect(cb).toHaveBeenCalled()\n    expect(img.src).toBe('data:image/png;base64,CB')\n    expect(img.width).toBe(150)\n    expect(img.height).toBe(60)\n  })\n\n  it('placeholders:false genera un spacer invisible (no \"img\" visible)', async () => {\n    const img = document.createElement('img')\n    img.src = 'https://ex.com/down.png'\n    wrap.appendChild(img)\n\n    // falla → sin fallbackURL → spacer\n    vi.mocked(snapFetch).mockResolvedValueOnce({ ok: false, data: null })\n    await inlineImages(wrap, { placeholders: false })\n\n    const fallback = wrap.firstElementChild\n    expect(fallback.tagName).toBe('DIV')\n    expect(fallback.style.visibility).toBe('hidden')\n    expect(fallback.textContent || '').not.toContain('img')\n  })\n\n  it('procesa en lotes de 4 (5 imágenes) y aplica placeholder por falla', async () => {\n    const img = document.createElement('img')\n    img.src = 'https://ex.com/down.png'\n    wrap.appendChild(img)\n    const img1 = document.createElement('img')\n    img1.src = 'https://ex.com/down.png'\n    wrap.appendChild(img1)\n    const img2 = document.createElement('img')\n    img2.src = 'https://ex.com/down.png'\n    wrap.appendChild(img2)\n    const img3 = document.createElement('img')\n    img3.src = 'https://ex.com/down.png'\n    wrap.appendChild(img3)\n    const img4 = document.createElement('img')\n    img4.src = 'https://ex.com/down.png'\n    wrap.appendChild(img4)\n    // todas fallan\n    vi.mocked(snapFetch).mockResolvedValue({ ok: false, data: null })\n\n    await inlineImages(wrap)\n\n    // todas reemplazadas por <div> de placeholder\n    const divs = wrap.querySelectorAll('div')\n    expect(divs.length).toBe(5)\n  })\n\n  it('si fallbackURL arroja error, cae en placeholder por defecto', async () => {\n    const img = document.createElement('img')\n    img.src = 'https://ex.com/bad.png'\n    wrap.appendChild(img)\n\n    // fetch del original falla\n    vi.mocked(snapFetch).mockResolvedValueOnce({ ok: false, data: null })\n\n    const badCb = vi.fn(async () => { throw new Error('boom') })\n    await inlineImages(wrap, { fallbackURL: badCb })\n\n    const div = wrap.querySelector('div')\n    expect(div).toBeTruthy()\n    expect((div?.textContent || '')).toBe('img')\n  })\n\n  it('#341: inlines SVG <image href=\"https://...\"> to data URL', async () => {\n    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')\n    const img = document.createElementNS('http://www.w3.org/2000/svg', 'image')\n    img.setAttribute('href', 'https://placehold.co/150x100')\n    svg.appendChild(img)\n    wrap.appendChild(svg)\n\n    vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:image/png;base64,SVGIMG' })\n\n    await inlineImages(wrap)\n\n    const out = wrap.querySelector('image')\n    expect(out?.getAttribute('href')).toBe('data:image/png;base64,SVGIMG')\n  })\n})\n"
  },
  {
    "path": "__tests__/snapdom.attributes.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { snapdom } from  '../src/index'\n\ndescribe('snapdom capture attributes', () => {\n  let container\n\n  beforeEach(() => {\n    container = document.createElement('div')\n    container.style.width = '300px'\n    container.style.height = '150px'\n    document.body.appendChild(container)\n  })\n\n  afterEach(() => {\n    document.body.removeChild(container)\n  })\n\n  it('should exclude elements with data-capture=\"exclude\"', async () => {\n    const excluded = document.createElement('div')\n    excluded.setAttribute('data-capture', 'exclude')\n    excluded.textContent = 'Should be excluded'\n    container.appendChild(excluded)\n\n    const svgDataUrl = await snapdom.toRaw(container)\n    const svgText = decodeURIComponent(svgDataUrl.split(',')[1])\n\n    expect(svgText).not.toContain('Should be excluded')\n  })\n\n  it('should replace elements with data-capture=\"placeholder\" and show placeholder text', async () => {\n    const placeholder = document.createElement('div')\n    placeholder.setAttribute('data-capture', 'placeholder')\n    placeholder.setAttribute('data-placeholder-text', 'Placeholder here')\n    placeholder.textContent = 'Original text'\n    container.appendChild(placeholder)\n\n    const svgDataUrl = await snapdom.toRaw(container)\n    const svgText = decodeURIComponent(svgDataUrl.split(',')[1])\n\n    expect(svgText).toContain('Placeholder here')\n    expect(svgText).not.toContain('Original text')\n  })\n})\n"
  },
  {
    "path": "__tests__/snapdom.backgroundColor.test.js",
    "content": "import { describe, it, expect, beforeEach } from 'vitest'\nimport { snapdom } from  '../src/index'\n\ndescribe('snapdom.toJpg backgroundColor option', () => {\n  let container\n\n  beforeEach(() => {\n    container = document.createElement('div')\n    container.style.width = '100px'\n    container.style.height = '100px'\n    container.style.background = 'transparent'\n    document.body.appendChild(container)\n  })\n\n  it('applies white background by default', async () => {\n    const img = await snapdom.toJpg(container )\n    const canvas = document.createElement('canvas')\n    canvas.width = img.width\n    canvas.height = img.height\n    const ctx = canvas.getContext('2d')\n    ctx.drawImage(img, 0, 0)\n    const pixel = ctx.getImageData(0, 0, 1, 1).data\n    // JPEG compresses, but for a solid color it should be near white\n    expect(pixel[0]).toBeGreaterThan(240)\n    expect(pixel[1]).toBeGreaterThan(240)\n    expect(pixel[2]).toBeGreaterThan(240)\n  })\n\n  it('applies custom background color', async () => {\n    const img = await snapdom.toJpg(container, { backgroundColor: '#00ff00'  })\n    const canvas = document.createElement('canvas')\n    canvas.width = img.width\n    canvas.height = img.height\n    const ctx = canvas.getContext('2d')\n    ctx.drawImage(img, 0, 0)\n    const pixel = ctx.getImageData(0, 0, 1, 1).data\n    // Green check (JPEG lossy, so check near values)\n    expect(pixel[0]).toBeLessThan(30)    // red\n    expect(pixel[1]).toBeGreaterThan(200) // green\n    expect(pixel[2]).toBeLessThan(30)    // blue\n  })\n})\n"
  },
  {
    "path": "__tests__/snapdom.benchmark.js",
    "content": "import { bench, describe, afterEach } from 'vitest'\nimport { domToDataUrl } from 'https://unpkg.com/modern-screenshot'\nimport * as htmlToImage from 'https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/+esm'\n//import { toPng, toJpeg, toBlob, toPixelData, toSvg } from 'https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js';\nimport { snapdom as sd } from 'https://cdn.jsdelivr.net/npm/@zumer/snapdom@1.9.9/dist/snapdom.mjs'\nimport { snapdom } from '../src/index'\n\nlet html2canvasLoaded = false\n\nasync function loadHtml2Canvas() {\n  if (html2canvasLoaded) return\n  await new Promise((resolve, reject) => {\n    const script = document.createElement('script')\n    script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'\n    script.onload = () => resolve()\n    script.onerror = reject\n    document.head.appendChild(script)\n  })\n  html2canvasLoaded = true\n}\n\nawait loadHtml2Canvas()\n\nconst sizes = [\n  { width: 200, height: 100, label: 'Small element (200x100)' },\n  { width: 400, height: 300, label: 'Modal size (400x300)' },\n  { width: 1200, height: 800, label: 'Page view (1200x800)' },\n  { width: 2000, height: 1500, label: 'Large scroll area (2000x1500)' },\n  { width: 4000, height: 2000, label: 'Very large element (4000x2000)' },\n]\n\nfor (const size of sizes) {\n  describe(`Benchmark simple node at ${size.label}`, () => {\n    let container\n\n    async function setupContainer() {\n      if (container && document.body.contains(container)) {\n        return\n      }\n      container = document.createElement('div')\n      container.style.width = `${size.width}px`\n      container.style.height = `${size.height}px`\n      container.style.background = 'linear-gradient(to right, red, blue)'\n      container.style.fontFamily = 'Arial, sans-serif'\n      container.style.display = 'flex'\n      container.style.alignItems = 'center'\n      container.style.justifyContent = 'center'\n      container.style.fontSize = '24px'\n      container.innerHTML = `<h1>${size.label}</h1>`\n      document.body.appendChild(container)\n    }\n\n    /*   async function setupContainer() {\n        if (container && document.body.contains(container)) return;\n\n        container = document.createElement('div');\n        container.style.width = `${size.width}px`;\n        container.style.height = `${size.height}px`;\n        container.style.padding = '20px';\n        container.style.overflow = 'auto';\n        container.style.background = 'white';\n        container.style.border = '2px solid black';\n        container.style.fontFamily = 'Arial, sans-serif';\n        container.style.color = '#333';\n        container.style.position = 'relative';\n\n        const grid = document.createElement('div');\n        grid.style.display = 'grid';\n        grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(120px, 1fr))';\n        grid.style.gap = '10px';\n\n        for (let i = 0; i < Math.floor((size.width * size.height) / 20000); i++) {\n          const card = document.createElement('div');\n          card.style.padding = '10px';\n          card.style.borderRadius = '8px';\n          card.style.background = i % 2 === 0 ? '#f0f0f0' : '#e0eaff';\n          card.style.boxShadow = '0 2px 5px rgba(0,0,0,0.1)';\n          card.style.display = 'flex';\n          card.style.flexDirection = 'column';\n          card.style.alignItems = 'center';\n\n          const title = document.createElement('h3');\n          title.textContent = `Card ${i + 1}`;\n          title.style.margin = '0 0 10px 0';\n          title.style.fontSize = '14px';\n\n          const icon = document.createElement('div');\n          icon.style.width = '30px';\n          icon.style.height = '30px';\n          icon.style.borderRadius = '50%';\n          icon.style.background = i % 2 === 0 ? 'red' : 'blue';\n          icon.style.marginBottom = '10px';\n\n          const text = document.createElement('p');\n          text.textContent = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';\n          text.style.fontSize = '12px';\n          text.style.textAlign = 'center';\n\n          card.appendChild(icon);\n          card.appendChild(title);\n          card.appendChild(text);\n          grid.appendChild(card);\n        }\n\n        container.appendChild(grid);\n        document.body.appendChild(container);\n      }\n       */\n    afterEach(() => {\n      if (container) {\n        container.remove()\n        container = null\n      }\n    })\n\n    bench('snapDOM current version', async () => {\n      await setupContainer()\n      await snapdom.toRaw(container)\n    })\n\n     bench('snapDOM V1.9.9', async () => {\n      await setupContainer()\n      await sd.toRaw(container)\n    })\n\n    bench('html2canvas capture', async () => {\n      await setupContainer()\n      const canvas = await window.html2canvas(container, { logging: false, scale: 1 })\n      await canvas.toDataURL()\n    })\n\n    bench('modern-screenshot capture', async () => {\n      await setupContainer()\n      await domToDataUrl(container)\n    })\n\n    bench('html-to-image capture', async () => {\n      await setupContainer()\n      await htmlToImage.toSvg(container)\n    })\n  })\n}\n"
  },
  {
    "path": "__tests__/snapdom.complex.benchmark.js",
    "content": "import { bench, describe, afterEach } from 'vitest'\nimport { domToDataUrl } from 'https://unpkg.com/modern-screenshot'\nimport * as htmlToImage from 'https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/+esm'\nimport { snapdom as sd } from 'https://cdn.jsdelivr.net/npm/@zumer/snapdom@1.9.9/dist/snapdom.mjs'\nimport { snapdom } from '../src/index'\n\nlet html2canvasLoaded = false\n\nasync function loadHtml2Canvas() {\n  if (html2canvasLoaded) return\n  await new Promise((resolve, reject) => {\n    const script = document.createElement('script')\n    script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'\n    script.onload = () => resolve()\n    script.onerror = reject\n    document.head.appendChild(script)\n  })\n  html2canvasLoaded = true\n}\n\nawait loadHtml2Canvas()\n\nconst sizes = [\n  { width: 200, height: 100, label: 'Small element (200x100)' },\n  { width: 400, height: 300, label: 'Modal size (400x300)' },\n  { width: 1200, height: 800, label: 'Page view (1200x800)' },\n  { width: 2000, height: 1500, label: 'Large scroll area (2000x1500)' },\n  { width: 4000, height: 2000, label: 'Very large element (4000x2000)' },\n]\n\nfor (const size of sizes) {\n  describe(`Benchmark complex node at ${size.label}`, () => {\n    let container\n\n  async function setupContainer() {\n        if (container && document.body.contains(container)) return\n\n        container = document.createElement('div')\n        container.style.width = `${size.width}px`\n        container.style.height = `${size.height}px`\n        container.style.padding = '20px'\n        container.style.overflow = 'auto'\n        container.style.background = 'white'\n        container.style.border = '2px solid black'\n        container.style.fontFamily = 'Arial, sans-serif'\n        container.style.color = '#333'\n        container.style.position = 'relative'\n\n        const grid = document.createElement('div')\n        grid.style.display = 'grid'\n        grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(120px, 1fr))'\n        grid.style.gap = '10px'\n\n        for (let i = 0; i < Math.floor((size.width * size.height) / 20000); i++) {\n          const card = document.createElement('div')\n          card.style.padding = '10px'\n          card.style.borderRadius = '8px'\n          card.style.background = i % 2 === 0 ? '#f0f0f0' : '#e0eaff'\n          card.style.boxShadow = '0 2px 5px rgba(0,0,0,0.1)'\n          card.style.display = 'flex'\n          card.style.flexDirection = 'column'\n          card.style.alignItems = 'center'\n\n          const title = document.createElement('h3')\n          title.textContent = `Card ${i + 1}`\n          title.style.margin = '0 0 10px 0'\n          title.style.fontSize = '14px'\n\n          const icon = document.createElement('div')\n          icon.style.width = '30px'\n          icon.style.height = '30px'\n          icon.style.borderRadius = '50%'\n          icon.style.background = i % 2 === 0 ? 'red' : 'blue'\n          icon.style.marginBottom = '10px'\n\n          const text = document.createElement('p')\n          text.textContent = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'\n          text.style.fontSize = '12px'\n          text.style.textAlign = 'center'\n\n          card.appendChild(icon)\n          card.appendChild(title)\n          card.appendChild(text)\n          grid.appendChild(card)\n        }\n\n        container.appendChild(grid)\n        document.body.appendChild(container)\n\n      }\n\n    afterEach (async () => {\n      if (container) {\n        container.remove()\n        container = null\n      }\n\n       document.body.innerHTML = ''\n    })\n\n    bench('snapDOM current version', async () => {\n      await setupContainer()\n      await snapdom.toRaw(container)\n    })\n\n     bench('snapDOM V1.9.9', async () => {\n      await setupContainer()\n      await sd.toRaw(container)\n    })\n\n    bench('html2canvas capture', async () => {\n      await setupContainer()\n      const canvas = await window.html2canvas(container, { logging: false, scale: 1 })\n      await canvas.toDataURL()\n    })\n\n    bench('modern-screenshot capture', async () => {\n      await setupContainer()\n      await domToDataUrl(container)\n    })\n    bench('html-to-image capture', async () => {\n      await setupContainer()\n      await htmlToImage.toSvg(container)\n    })\n  })\n}\n"
  },
  {
    "path": "__tests__/snapdom.delete.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { snapdom } from  '../src/index'\n\ndescribe('snapdom advanced tests', () => {\n  let testElement\n\n  beforeEach(() => {\n    testElement = document.createElement('div')\n    testElement.style.width = '100px'\n    testElement.style.height = '50px'\n    testElement.innerHTML = '<h1>Hello World</h1>'\n    document.body.appendChild(testElement)\n  })\n\n  afterEach(() => {\n    document.body.removeChild(testElement)\n  })\n\n  it('should generate different SVGs for different scales', async () => {\n    const svg1 = await snapdom.toImg(testElement, { scale: 1 })\n    const svg2 = await snapdom.toImg(testElement, { scale: 2 })\n    expect(svg1).not.toBe(svg2)\n  })\n\n  it('captured SVG should contain inner text content', async () => {\n    const svgDataUrl = await snapdom.toRaw(testElement)\n    const svgText = decodeURIComponent(svgDataUrl.split(',')[1])\n    expect(svgText).toContain('Hello World')\n  })\n\n  it('should throw an error if element is null', async () => {\n    await expect(() => snapdom.toRaw(null)).rejects.toThrow()\n  })\n\n  it('should generate SVG with correct attributes', async () => {\n    const svgDataUrl = await snapdom.toRaw(testElement)\n    const svgText = decodeURIComponent(svgDataUrl.split(',')[1])\n    const parser = new DOMParser()\n    const doc = parser.parseFromString(svgText, 'image/svg+xml')\n    const svg = doc.querySelector('svg')\n\n    expect(svg).not.toBeNull()\n    expect(svg.getAttribute('width')).toBe('100')\n    expect(svg.getAttribute('height')).toBe('50')\n    expect(svg.getAttribute('viewBox')).toBe('0 0 100 50')\n  })\n\n  it('snapdom.toBlob should contain valid SVG content', async () => {\n    const blob = await snapdom.toBlob(testElement)\n    const text = await blob.text()\n    expect(text).toContain('<svg')\n    expect(text).toContain('</svg>')\n  })\n\n})\n"
  },
  {
    "path": "__tests__/snapdom.precache.perf.test.js",
    "content": "import { describe, test, expect, afterEach, afterAll, beforeEach } from 'vitest'\nimport { snapdom, preCache } from '../src/index'\nimport { cache } from '../src/core/cache'\n\nconst sizes = [\n  { width: 200, height: 100, label: 'Small element (200x100)' },\n  { width: 400, height: 300, label: 'Modal size (400x300)' },\n  { width: 1200, height: 800, label: 'Page view (1200x800)' },\n]\nlet results = []\nfunction createContainer(size) {\n  const container = document.createElement('div')\n  container.style.width = `${size.width}px`\n  container.style.height = `${size.height}px`\n  container.style.padding = '20px'\n  container.style.overflow = 'auto'\n  container.style.background = 'white'\n  container.style.border = '2px solid black'\n  container.style.fontFamily = 'Arial, sans-serif'\n  container.style.color = '#333'\n  container.style.position = 'relative'\n\n  const grid = document.createElement('div')\n  grid.style.display = 'grid'\n  grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(120px, 1fr))'\n  grid.style.gap = '10px'\n\n  const cardCount = Math.floor((size.width * size.height) / 20000)\n  for (let i = 0; i < cardCount; i++) {\n    const card = document.createElement('div')\n    card.style.padding = '10px'\n    card.style.borderRadius = '8px'\n    card.style.background = i % 2 === 0 ? '#f0f0f0' : '#e0eaff'\n    card.style.boxShadow = '0 2px 5px rgba(0,0,0,0.1)'\n    card.style.display = 'flex'\n    card.style.flexDirection = 'column'\n    card.style.alignItems = 'center'\n\n    const title = document.createElement('h3')\n    title.textContent = `Card ${i + 1}`\n    title.style.margin = '0 0 10px 0'\n    title.style.fontSize = '14px'\n\n    const icon = document.createElement('div')\n    icon.style.width = '30px'\n    icon.style.height = '30px'\n    icon.style.borderRadius = '50%'\n    icon.style.background = i % 2 === 0 ? 'red' : 'blue'\n    icon.style.marginBottom = '10px'\n\n    const text = document.createElement('p')\n    text.textContent = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'\n    text.style.fontSize = '12px'\n    text.style.textAlign = 'center'\n\n    card.appendChild(icon)\n    card.appendChild(title)\n    card.appendChild(text)\n    grid.appendChild(card)\n  }\n\n  container.appendChild(grid)\n  return container\n}\n\nfunction waitForNextFrame() {\n  return new Promise((resolve) => {\n    requestAnimationFrame(() => setTimeout(resolve, 0))\n  })\n}\nbeforeEach(() => {\n  cache.image.clear()\n  cache.background.clear()\n  cache.resource.clear()\n})\nafterAll(() => {\n  for (const r of results) {\n    console.log(r.log)\n  }\n  results = []\n\n      document.body.innerHTML = ''\n})\nfor (const size of sizes) {\n  describe(`snapDOM performance preCache test (may not be accurate) - ${size.label}`, () => {\n    let container\n\n    afterEach( () => {\n      container?.remove()\n      container = null\n      document.body.innerHTML = ''\n\n    })\n\n    test('without preCache', async () => {\n      container = createContainer(size)\n      document.body.appendChild(container)\n      await waitForNextFrame()\n\n      const start = performance.now()\n      await snapdom.toRaw(container)\n      const end = performance.now()\n\n       let log = `[${size.label}] WITHOUT preCache: capture ${(end - start).toFixed(2)}ms`\n       results.push({ log })\n      expect(true).toBe(true)\n\n    })\n\n    test('with preCache', async () => {\n      container = createContainer(size)\n      document.body.appendChild(container)\n      await waitForNextFrame()\n\n      const startPre = performance.now()\n      await preCache()\n      const endPre = performance.now()\n\n      const startCap = performance.now()\n      await snapdom.toRaw(container)\n      const endCap = performance.now()\n\n      const precacheTime = (endPre - startPre).toFixed(2)\n      const captureTime = (endCap - startCap).toFixed(2)\n\n     let log = `[${size.label}] WITH preCache:  capture ${captureTime}ms  (preCache ${precacheTime}ms)  `\n\n      results.push({ log })\n\n      expect(true).toBe(true)\n    })\n\n  })\n}\n"
  },
  {
    "path": "__tests__/snapdom.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { snapdom } from '../src/index'\n\ndescribe('snapdom API', () => {\n  let testElement\n\n  beforeEach(() => {\n    testElement = document.createElement('div')\n    testElement.style.width = '100px'\n    testElement.style.height = '50px'\n    document.body.appendChild(testElement )\n  })\n\n  afterEach(() => {\n    document.body.removeChild(testElement )\n  })\n\n  describe('snapdom.toRaw', () => {\n    it('should return a SVG data URL', async () => {\n      const result = await snapdom.toRaw(testElement )\n      expect(result).toMatch(/^data:image\\/svg\\+xml/)\n    })\n  })\n\n  describe('snapdom', () => {\n    it('toImg should return an HTMLImageElement', async () => {\n\n      const img = await snapdom.toImg(testElement )\n      expect(img).toBeInstanceOf(HTMLImageElement)\n      expect(img.src).toMatch(/^data:image\\/svg\\+xml/)\n\n    })\n\n    it('toCanvas should return a HTMLCanvasElement', async () => {\n     // const decodeMock = vi.spyOn(window.Image.prototype, 'decode').mockResolvedValue();\n      const canvas = await snapdom.toCanvas(testElement )\n      expect(canvas).toBeInstanceOf(HTMLCanvasElement)\n    //  decodeMock.mockRestore();\n    })\n\n    it('toPng should return an HTMLImageElement with PNG data URL', async () => {\n    //  const decodeMock = vi.spyOn(window.Image.prototype, 'decode').mockResolvedValue();\n      const img = await snapdom.toPng(testElement )\n      expect(img).toBeInstanceOf(HTMLImageElement)\n      expect(img.src).toMatch(/^data:image\\/png/)\n    //  decodeMock.mockRestore();\n    })\n\n    it('toJpg should return an HTMLImageElement with JPEG data URL', async () => {\n     // const decodeMock = vi.spyOn(window.Image.prototype, 'decode').mockResolvedValue();\n      const img = await snapdom.toJpg(testElement )\n      expect(img).toBeInstanceOf(HTMLImageElement)\n      expect(img.src).toMatch(/^data:image\\/jpeg/)\n     // decodeMock.mockRestore();\n    })\n\n    it('toWebp should return an HTMLImageElement with WebP data URL', async () => {\n    //  const decodeMock = vi.spyOn(window.Image.prototype, 'decode').mockResolvedValue();\n      const img = await snapdom.toWebp(testElement )\n      expect(img).toBeInstanceOf(HTMLImageElement)\n      expect(img.src).toMatch(/^data:image\\/webp/)\n    //  decodeMock.mockRestore();\n    })\n\n    it('toBlob should return a Blob of type image/svg+xml', async () => {\n      const blob = await snapdom.toBlob(testElement )\n      expect(blob).toBeInstanceOf(Blob)\n      expect(blob.type).toBe('image/svg+xml')\n    })\n  })\n})\n"
  },
  {
    "path": "__tests__/snapdom.vs.htm2canvas.outputfilesize.test.js",
    "content": "import { describe, it, beforeEach, afterEach, afterAll, expect } from 'vitest'\n// ✅ variante ESM en jsDelivr (también podés usar unpkg con ?module)\nimport html2canvas from 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/+esm'\nimport { snapdom } from '../src/index.js'\n\nfunction dataUrlBytes(dataUrl) {\n  const b64 = dataUrl.split(',')[1] || ''\n  const padding = (b64.endsWith('==') ? 2 : (b64.endsWith('=') ? 1 : 0))\n  return Math.floor(b64.length * 0.75) - padding\n}\n\ndescribe('Output file size snapdom vs html2canvas (cdn, averaged)', () => {\n  let container\n  let report\n  const RUNS = 3\n\n  beforeEach(() => {\n    container = document.createElement('div')\n    container.style.width = '400px'\n    container.style.height = '300px'\n    container.style.background = 'linear-gradient(to right, red, blue)'\n    container.innerHTML = '<h1>Hello Benchmark</h1><p>Testing multiple runs...</p>'\n    document.body.appendChild(container)\n  })\n\n  afterEach(() => {\n    container?.remove()\n    container = null\n  })\n\n  afterAll(() => {\n\n    console.log(report)\n  })\n\n  it('snapdom output file size should be smaller than html2canvas', async () => {\n    let snapSum = 0\n    let h2cSum = 0\n\n    for (let i = 0; i < RUNS; i++) {\n      // SnapDOM (SVG dataURL)\n      const snapUrl = await snapdom.toRaw(container)\n      snapSum += dataUrlBytes(snapUrl)\n\n      // html2canvas → PNG dataURL\n      const canvas = await html2canvas(container, { backgroundColor: null })\n      const h2cUrl = canvas.toDataURL('image/png')\n      h2cSum += dataUrlBytes(h2cUrl)\n\n      await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))\n    }\n\n    const snapKB = snapSum / RUNS / 1024\n    const h2cKB  = h2cSum / RUNS / 1024\n    const diffPct = ((h2cKB - snapKB) / h2cKB) * 100\n\n    report = `snapdom captured file size is ${diffPct.toFixed(2)}% smaller compared to html2canvas (${snapKB.toFixed(2)} KB vs. ${h2cKB.toFixed(2)} KB)`\n\n    expect(snapKB).toBeLessThan(h2cKB)\n  })\n})\n"
  },
  {
    "path": "__tests__/snapdom.vs.modernscreenshot.outputfilesize.test.js",
    "content": "import { describe, it, beforeEach, afterEach, afterAll } from 'vitest'\nimport { domToDataUrl} from 'https://unpkg.com/modern-screenshot'\nimport { snapdom }  from '../src/index'\n//ok\n\ndescribe('Output file size snapdom vs modern-screeenshot (cdn with averaging)', () => {\n  let container\n\n  let report\n\n  beforeEach(async () => {\n    container = document.createElement('div')\n    container.style.width = '400px'\n    container.style.height = '300px'\n    container.style.background = 'linear-gradient(to right, red, blue)'\n    container.innerHTML = '<h1>Hello Benchmark</h1><p>Testing multiple runs...</p>'\n    document.body.appendChild(container)\n  })\n\n  afterEach(() => {\n    document.body.removeChild(container)\n  })\n\n  afterAll(() => {\n    console.log(report)\n  })\n\n  it('snapdom output file size should be smaller than modern-screenshot', async () => {\n\n    // SnapDOM capture\n    const snapdomDataURL = await snapdom.toRaw(container)\n    const snapdomSizeKB = (snapdomDataURL.length * 3 / 4) / 1024 // Base64 to bytes approx\n\n    // domToDataUrl capture\n    const domToDataUrlDataURL = await domToDataUrl(container)\n    const domToDataUrlSizeKB = (domToDataUrlDataURL.length * 3 / 4) / 1024 // Base64 to bytes approx\n\n    const differencePercent = ((domToDataUrlSizeKB - snapdomSizeKB) / domToDataUrlSizeKB) * 100\n\n    report =`snapdom captured file size is ${differencePercent.toFixed(2)}% smaller compared to modern-screenshot (${snapdomSizeKB.toFixed(2)} KB vs. ${domToDataUrlSizeKB.toFixed(2)} KB)`\n\n})\n\n})\n"
  },
  {
    "path": "__tests__/three-shake.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport { snapdom } from '../src/index'\n\ndescribe('snapdom API (lazy exporters + plugins)', () => {\n  let el\n\n  beforeEach(() => {\n    el = document.createElement('div')\n    el.style.width = '120px'\n    el.style.height = '60px'\n    el.style.background = 'linear-gradient(90deg, red, blue)'\n    el.textContent = 'snapdom'\n    document.body.appendChild(el)\n  })\n\n  afterEach(() => {\n    document.body.removeChild(el)\n    el = null\n  })\n\n  // -------- Core: básicos --------\n\n  it('toRaw devuelve un data URL SVG', async () => {\n    const url = await snapdom.toRaw(el)\n    expect(url).toMatch(/^data:image\\/svg\\+xml/)\n  })\n\n  it('toImg devuelve HTMLImageElement con data:image/svg+xml', async () => {\n    const img = await snapdom.toImg(el)\n    expect(img).toBeInstanceOf(HTMLImageElement)\n    expect(img.src).toMatch(/^data:image\\/svg\\+xml/)\n  })\n\n  it('toCanvas devuelve HTMLCanvasElement', async () => {\n    const canvas = await snapdom.toCanvas(el)\n    expect(canvas).toBeInstanceOf(HTMLCanvasElement)\n    expect(canvas.width).toBeGreaterThan(0)\n    expect(canvas.height).toBeGreaterThan(0)\n  })\n\n  // -------- Exportadores raster (lazy) --------\n\n  it('toPng devuelve HTMLImageElement con data:image/png', async () => {\n    const img = await snapdom.toPng(el)\n    expect(img).toBeInstanceOf(HTMLImageElement)\n    expect(img.src).toMatch(/^data:image\\/png/)\n  })\n\n  it('toJpg devuelve HTMLImageElement con data:image/jpeg', async () => {\n    const img = await snapdom.toJpg(el)\n    expect(img).toBeInstanceOf(HTMLImageElement)\n    expect(img.src).toMatch(/^data:image\\/jpeg/)\n  })\n\n  it('toWebp devuelve HTMLImageElement con data:image/webp', async () => {\n    const img = await snapdom.toWebp(el)\n    expect(img).toBeInstanceOf(HTMLImageElement)\n    expect(img.src).toMatch(/^data:image\\/webp/)\n  })\n\n  it('toBlob con { type: \"svg\" } devuelve Blob image/svg+xml', async () => {\n    const blob = await snapdom.toBlob(el, { type: 'svg' })\n    expect(blob).toBeInstanceOf(Blob)\n    expect(blob.type).toBe('image/svg+xml')\n  })\n\n  // -------- Runner .to(type) y helpers dinámicos --------\n\n  it('result.to(\"png\") funciona y sólo expone helpers para exports existentes', async () => {\n    const result = await snapdom(el)\n    const png = await result.to('png')\n    expect(png).toBeInstanceOf(HTMLImageElement)\n\n    // pedir un tipo desconocido debe fallar con mensaje claro\n    await expect(result.to('doesNotExist')).rejects.toThrow(/Unknown export type/i)\n  })\n\n  // -------- Plugins: defineExports() agrega helpers dinámicos --------\n\n  it('plugin via defineExports agrega un helper dinámico (p.ej., toAscii)', async () => {\n    // Plugin mínimo que define un exportador \"ascii\"\n    const asciiPlugin = {\n      name: 'ascii-export',\n      async defineExports(ctx) {\n        return {\n          // demo: retorna un string simple; en tu real devolverías Blob/URL/etc.\n          ascii: async () => {\n            // acceso a ctx.export.url si te sirve (SVG data URL)\n            const url = ctx?.export?.url\n            expect(url).toMatch(/^data:image\\/svg\\+xml/)\n            return 'ASCII_OK'\n          }\n        }\n      }\n    }\n\n    // Registro local-first (sólo para esta captura)\n    const result = await snapdom(el, { plugins: [asciiPlugin] })\n\n    // El helper se crea dinámicamente: toAscii()\n    expect(typeof result.toAscii).toBe('function')\n    const asciiOut = await result.toAscii()\n    expect(asciiOut).toBe('ASCII_OK')\n  })\n})\n"
  },
  {
    "path": "__tests__/utils.browser.more.test.js",
    "content": "// __tests__/utils.browser.more.test.js – browser.js extra coverage\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { isIOS, isSafari } from '../src/utils/browser.js'\n\nlet origUserAgent\nlet origUserAgentData\n\nbeforeEach(() => {\n  vi.restoreAllMocks()\n  origUserAgent = navigator.userAgent\n  origUserAgentData = navigator.userAgentData\n})\n\nafterEach(() => {\n  Object.defineProperty(navigator, 'userAgent', { value: origUserAgent, configurable: true })\n  if (origUserAgentData !== undefined) {\n    Object.defineProperty(navigator, 'userAgentData', { value: origUserAgentData, configurable: true })\n  } else {\n    try { delete navigator.userAgentData } catch { /* some envs */ }\n  }\n})\n\ndescribe('isIOS', () => {\n  it('returns true when userAgentData.platform is iOS', () => {\n    Object.defineProperty(navigator, 'userAgentData', {\n      value: { platform: 'iOS', getHighEntropyValues: () => Promise.resolve({}) },\n      configurable: true\n    })\n    expect(isIOS()).toBe(true)\n  })\n\n  it('returns false when userAgentData.platform is not iOS', () => {\n    Object.defineProperty(navigator, 'userAgentData', {\n      value: { platform: 'macOS', getHighEntropyValues: () => Promise.resolve({}) },\n      configurable: true\n    })\n    expect(isIOS()).toBe(false)\n  })\n\n})\n\ndescribe('isSafari', () => {\n  it('returns false when UA contains android', () => {\n    Object.defineProperty(navigator, 'userAgent', {\n      value: 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/91.0 Safari/537.36',\n      configurable: true\n    })\n    expect(isSafari()).toBe(false)\n  })\n})\n"
  },
  {
    "path": "__tests__/utils.browser.test.js",
    "content": "import { describe, it, expect} from 'vitest'\nimport { isSafari, idle } from '../src/utils'\n\ndescribe('isSafari', () => {\n  it('returns a boolean', () => {\n    expect(typeof isSafari()).toBe('boolean')\n  })\n})\n\ndescribe('idle', () => {\n  it('calls fn immediately if fast is true', () => {\n    let called = false\n    idle(() => { called = true }, { fast: true })\n    expect(called).toBe(true)\n  })\n  it('uses requestIdleCallback if available', () => {\n    const orig = window.requestIdleCallback\n    let called = false\n    window.requestIdleCallback = (fn) => { called = true; fn() }\n    idle(() => { called = true })\n    expect(called).toBe(true)\n    window.requestIdleCallback = orig\n  })\n  it('falls back to setTimeout if requestIdleCallback not available', async () => {\n    const orig = window.requestIdleCallback\n    delete window.requestIdleCallback\n    let called = false\n    idle(() => { called = true })\n    await new Promise(r => setTimeout(r, 10))\n    expect(called).toBe(true)\n    window.requestIdleCallback = orig\n  })\n})\n"
  },
  {
    "path": "__tests__/utils.capture.helpers.test.js",
    "content": "// __tests__/utils.capture.helpers.test.js – 51% → higher coverage\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport {\n  stripRootShadows,\n  removeAllComments,\n  sanitizeAttributesForXHTML,\n  sanitizeCloneForXHTML,\n  shrinkAutoSizeBoxes,\n  estimateKeptHeight,\n  limitDecimals,\n  collectScrollbarCSS\n} from '../src/utils/capture.helpers.js'\n\nbeforeEach(() => {\n  document.body.innerHTML = ''\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('stripRootShadows', () => {\n  it('strips box-shadow, text-shadow, outline, blur/drop-shadow from clone root', () => {\n    const orig = document.createElement('div')\n    orig.style.boxShadow = '0 0 10px red'\n    document.body.appendChild(orig)\n    const clone = document.createElement('div')\n    clone.style.boxShadow = 'inherit'\n    stripRootShadows(orig, clone)\n    expect(clone.style.boxShadow).toBe('none')\n  })\n\n  it('handles null/undefined gracefully', () => {\n    expect(() => stripRootShadows(null, document.createElement('div'))).not.toThrow()\n    expect(() => stripRootShadows(document.createElement('div'), null)).not.toThrow()\n  })\n})\n\ndescribe('removeAllComments', () => {\n  it('removes all HTML comments from root', () => {\n    const root = document.createElement('div')\n    root.appendChild(document.createComment('test comment'))\n    root.appendChild(document.createTextNode('text'))\n    root.appendChild(document.createComment('another'))\n    removeAllComments(root)\n    expect(root.childNodes.length).toBe(1)\n    expect(root.childNodes[0].nodeType).toBe(Node.TEXT_NODE)\n  })\n})\n\ndescribe('sanitizeAttributesForXHTML', () => {\n  // Note: TreeWalker.nextNode() traverses descendants; root is starting point.\n  // Put attributes on a child so the walker visits it.\n  it('removes attributes with unknown : prefix (keeps xml, xlink)', () => {\n    const root = document.createElement('div')\n    const child = document.createElement('span')\n    child.setAttribute('v-bind:foo', '1')\n    child.setAttribute('valid', '2')\n    root.appendChild(child)\n    sanitizeAttributesForXHTML(root)\n    expect(child.hasAttribute('v-bind:foo')).toBe(false)\n    expect(child.getAttribute('valid')).toBe('2')\n  })\n\n  it('removes framework directives (x-, v-, :, on:, bind:, let:, class:) when stripFrameworkDirectives', () => {\n    const root = document.createElement('div')\n    const child = document.createElement('span')\n    child.setAttribute('x-show', '1')\n    child.setAttribute('v-if', '2')\n    child.setAttribute(':class', '3')\n    child.setAttribute('data-ok', '4')\n    root.appendChild(child)\n    sanitizeAttributesForXHTML(root, { stripFrameworkDirectives: true })\n    expect(child.hasAttribute('x-show')).toBe(false)\n    expect(child.hasAttribute('v-if')).toBe(false)\n    expect(child.hasAttribute(':class')).toBe(false)\n    expect(child.getAttribute('data-ok')).toBe('4')\n  })\n\n  it('keeps framework directives when stripFrameworkDirectives is false', () => {\n    const root = document.createElement('div')\n    const child = document.createElement('span')\n    child.setAttribute('x-show', '1')\n    root.appendChild(child)\n    sanitizeAttributesForXHTML(root, { stripFrameworkDirectives: false })\n    expect(child.getAttribute('x-show')).toBe('1')\n  })\n})\n\ndescribe('sanitizeCloneForXHTML', () => {\n  it('sanitizes attributes and removes comments', () => {\n    const root = document.createElement('div')\n    const child = document.createElement('span')\n    child.setAttribute('x-foo', '1')\n    root.appendChild(child)\n    root.appendChild(document.createComment('c'))\n    sanitizeCloneForXHTML(root)\n    expect(child.hasAttribute('x-foo')).toBe(false)\n    const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT)\n    let commentCount = 0\n    while (walker.nextNode()) commentCount++\n    expect(commentCount).toBe(0)\n  })\n})\n\ndescribe('shrinkAutoSizeBoxes', () => {\n  it('shrinks boxes that lost children (excludeMode:remove)', () => {\n    const src = document.createElement('div')\n    src.appendChild(document.createElement('span'))\n    src.appendChild(document.createElement('span'))\n    const cln = document.createElement('div')\n    cln.appendChild(document.createElement('span'))\n    document.body.appendChild(src)\n    document.body.appendChild(cln)\n    shrinkAutoSizeBoxes(src, cln)\n    expect(cln.style.height).toBe('auto')\n    expect(cln.style.width).toBe('auto')\n  })\n\n  it('handles null root', () => {\n    expect(() => shrinkAutoSizeBoxes(null, document.createElement('div'))).not.toThrow()\n  })\n})\n\ndescribe('estimateKeptHeight', () => {\n  it('estimates height from children', () => {\n    const container = document.createElement('div')\n    const child = document.createElement('div')\n    child.style.height = '50px'\n    child.style.display = 'block'\n    container.appendChild(child)\n    document.body.appendChild(container)\n    const h = estimateKeptHeight(container, {})\n    expect(h).toBeGreaterThanOrEqual(0)\n  })\n\n  it('skips excluded elements (data-capture=exclude, excludeMode:remove)', () => {\n    const container = document.createElement('div')\n    const ex = document.createElement('div')\n    ex.setAttribute('data-capture', 'exclude')\n    ex.style.height = '100px'\n    container.appendChild(ex)\n    document.body.appendChild(container)\n    const h = estimateKeptHeight(container, { excludeMode: 'remove' })\n    expect(h).toBeLessThan(120)\n  })\n})\n\ndescribe('limitDecimals', () => {\n  it('rounds to n decimals', () => {\n    expect(limitDecimals(1.23456, 2)).toBe(1.23)\n    expect(limitDecimals(1.23456, 3)).toBe(1.235)\n  })\n  it('returns v unchanged for non-finite', () => {\n    expect(limitDecimals(NaN)).toBeNaN()\n    expect(limitDecimals(Infinity)).toBe(Infinity)\n  })\n})\n\ndescribe('collectScrollbarCSS (#334)', () => {\n  it('extracts ::-webkit-scrollbar rules from document stylesheets', () => {\n    const style = document.createElement('style')\n    style.textContent = `\n      .scroll-area::-webkit-scrollbar { width: 10px; }\n      .scroll-area::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.6); }\n      .other { color: red; }\n    `\n    document.head.appendChild(style)\n\n    const out = collectScrollbarCSS(document)\n    document.head.removeChild(style)\n\n    expect(out).toContain('::-webkit-scrollbar')\n    expect(out).toContain('::-webkit-scrollbar-thumb')\n    expect(out).toContain('width: 10px')\n    expect(out).not.toContain('.other')\n  })\n\n  it('returns empty string for null document', () => {\n    expect(collectScrollbarCSS(null)).toBe('')\n  })\n})\n"
  },
  {
    "path": "__tests__/utils.css.test.js",
    "content": "import { describe, it, expect } from 'vitest'\nimport { getStyle, parseContent, snapshotComputedStyle, stripTranslate, shouldIgnoreProp } from '../src/utils'\n\ndescribe('getStyle', () => {\n  it('returns a CSSStyleDeclaration', () => {\n    const el = document.createElement('div')\n    document.body.appendChild(el)\n    const style = getStyle(el)\n    expect(style).toBeInstanceOf(CSSStyleDeclaration)\n    document.body.removeChild(el)\n  })\n\n  it('never returns undefined for elements', () => {\n    const el = document.createElement('div')\n    document.body.appendChild(el)\n    const style = getStyle(el)\n    expect(style).not.toBeUndefined()\n    document.body.removeChild(el)\n  })\n\n  it('never returns undefined for pseudos', () => {\n    const el = document.createElement('div')\n    document.body.appendChild(el)\n    const pseudoStyle = getStyle(el, '::before')\n    expect(pseudoStyle).not.toBeUndefined()\n    document.body.removeChild(el)\n  })\n})\n\ndescribe('parseContent', () => {\n  it('parses CSS content correctly', () => {\n    expect(parseContent('\"★\"')).toBe('★')\n    expect(parseContent('\\\\2605')).toBe('★')\n  })\n})\n\ndescribe('parseContent edge cases', () => {\n  it('returns \\u0000 if parseInt fails (not hex)', () => {\n    expect(parseContent('\\\\nothex')).toBe('\\u0000')\n  })\n  it('returns clean if String.fromCharCode throws', () => {\n    const orig = String.fromCharCode\n    String.fromCharCode = () => { throw new Error('fail') }\n    expect(parseContent('\\\\2605')).toBe('\\\\2605')\n    String.fromCharCode = orig\n  })\n})\n\ndescribe('snapshotComputedStyle', () => {\n  it('returns a style snapshot', () => {\n    const el = document.createElement('div')\n    document.body.appendChild(el)\n    const style = getComputedStyle(el)\n    const snap = snapshotComputedStyle(style)\n    expect(typeof snap).toBe('object')\n    document.body.removeChild(el)\n  })\n})\n\ndescribe('stripTranslate', () => {\n  it('removes translate transforms', () => {\n    expect(stripTranslate('translateX(10px) scale(2)')).toContain('scale(2)')\n  })\n  it('stripTranslate removes matrix and matrix3d', () => {\n    expect(stripTranslate('matrix(1,0,0,1,10,20)')).not.toContain('10,20')\n    expect(stripTranslate('matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,10,20,30,1)')).not.toContain('10,20,30')\n  })\n})\n\ndescribe('shouldIgnoreProp (#348)', () => {\n  it('ignores CSS custom properties (--*)', () => {\n    expect(shouldIgnoreProp('--my-var')).toBe(true)\n    expect(shouldIgnoreProp('--theme-color')).toBe(true)\n  })\n  it('does not ignore paint-affecting props', () => {\n    expect(shouldIgnoreProp('color')).toBe(false)\n    expect(shouldIgnoreProp('background-color')).toBe(false)\n  })\n})\n\ndescribe('stripTranslate edge cases', () => {\n  it('returns empty string for empty or none', () => {\n    expect(stripTranslate('')).toBe('')\n    expect(stripTranslate('none')).toBe('')\n  })\n  it('returns original for malformed matrix', () => {\n    expect(stripTranslate('matrix(1,2,3)')).toBe('matrix(1,2,3)')\n    expect(stripTranslate('matrix3d(1,2,3)')).toBe('matrix3d(1,2,3)')\n  })\n})\n"
  },
  {
    "path": "__tests__/utils.helpers.test.js",
    "content": "import { describe, it, expect } from 'vitest'\nimport { extractURL, isIconFont, stripTranslate, safeEncodeURI, resolveURL } from '../src/utils'\n\ndescribe('resolveURL', () => {\n  it('resolves relative URL against base', () => {\n    expect(resolveURL('bg_body.png', 'https://example.com/page')).toBe('https://example.com/bg_body.png')\n    expect(resolveURL('img/a.png', 'https://example.com/page/')).toBe('https://example.com/page/img/a.png')\n  })\n  it('returns data/blob/about URLs unchanged', () => {\n    expect(resolveURL('data:image/png;base64,abc')).toBe('data:image/png;base64,abc')\n    expect(resolveURL('blob:https://x/123')).toBe('blob:https://x/123')\n  })\n})\n\ndescribe('extractURL', () => {\n  it('extracts the URL from background-image', () => {\n    expect(extractURL('url(\"https://test.com/img.png\")')).toBe('https://test.com/img.png')\n    expect(extractURL('none')).toBeNull()\n  })\n})\n\ndescribe('isIconFont', () => {\n  it('detects icon fonts', () => {\n    expect(isIconFont('Font Awesome')).toBe(true)\n    expect(isIconFont('Arial')).toBe(false)\n  })\n})\n\ndescribe('stripTranslate', () => {\n  it('removes translate transforms', () => {\n    expect(stripTranslate('translateX(10px) scale(2)')).toContain('scale(2)')\n  })\n  it('stripTranslate removes matrix and matrix3d', () => {\n    expect(stripTranslate('matrix(1,0,0,1,10,20)')).not.toContain('10,20')\n    expect(stripTranslate('matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,10,20,30,1)')).not.toContain('10,20,30')\n  })\n})\n\ndescribe('safeEncodeURI', () => {\n  it('returns an encoded string', () => {\n    expect(typeof safeEncodeURI('https://test.com/á')).toBe('string')\n  })\n  it('safeEncodeURI handles invalid URIs gracefully', () => {\n    expect(typeof safeEncodeURI('%E0%A4%A')).toBe('string')\n  })\n})\n\ndescribe('stripTranslate edge cases', () => {\n  it('returns empty string for empty or none', () => {\n    expect(stripTranslate('')).toBe('')\n    expect(stripTranslate('none')).toBe('')\n  })\n  it('returns original for malformed matrix', () => {\n    expect(stripTranslate('matrix(1,2,3)')).toBe('matrix(1,2,3)')\n    expect(stripTranslate('matrix3d(1,2,3)')).toBe('matrix3d(1,2,3)')\n  })\n})\n"
  },
  {
    "path": "__tests__/utils.image.test.js",
    "content": "// __tests__/utils.image.more.test.js\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { inlineSingleBackgroundEntry } from '../src/utils/image.js'\nimport { snapFetch } from '../src/modules/snapFetch.js'\nimport { cache } from '../src/core/cache.js'\n\n// Silence our intentional rejections so Vitest doesn't flag them as unhandled\nif (typeof window !== 'undefined') {\n  window.addEventListener('unhandledrejection', (e) => {\n    const msg = String(e?.reason?.message || '')\n    if (\n      msg.includes('[SnapDOM - snapFetch] Fetch failed and no proxy provided') ||\n      msg.includes('[SnapDOM - snapFetch] Recently failed (cooldown).') ||\n      msg.includes('Image load timed out')\n    ) {\n      e.preventDefault()\n    }\n  })\n}\n\nfunction clearCaches() {\n  cache.image?.clear?.()\n  cache.background?.clear?.()\n  cache.resource?.clear?.()\n  cache.font?.clear?.()\n}\n\nlet OrigImage\nlet OrigFetch\n\nbeforeEach(() => {\n  vi.restoreAllMocks()\n  clearCaches()\n\n  OrigImage = globalThis.Image\n  OrigFetch = globalThis.fetch\n\n  // Default fetch: OK for both blob() and text() cases\n  globalThis.fetch = vi.fn(async () => ({\n    ok: true,\n    status: 200,\n    blob: async () =>\n      new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], { type: 'image/png' }), // valid PNG header\n    text: async () => '<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"1\" height=\"1\"/></svg>',\n  }))\n})\n\nafterEach(() => {\n  globalThis.Image = OrigImage\n  globalThis.fetch = OrigFetch\n})\n\n// -----------------------------\n// inlineSingleBackgroundEntry\n// -----------------------------\ndescribe('inlineSingleBackgroundEntry', () => {\n  it('returns gradients and \"none\" unchanged', async () => {\n    await expect(inlineSingleBackgroundEntry('linear-gradient(white, black)')).resolves.toBe('linear-gradient(white, black)')\n    await expect(inlineSingleBackgroundEntry('none')).resolves.toBe('none')\n  })\n\n  it('returns non-url entries unchanged', async () => {\n    await expect(inlineSingleBackgroundEntry('foo bar baz')).resolves.toBe('foo bar baz')\n  })\n\n  it('returns cached data URL when present in cache.background', async () => {\n    const url = 'https://example.com/img.png'\n    const data = 'data:image/png;base64,AAA'\n    cache.background.set(url, data)\n    const out = await inlineSingleBackgroundEntry(`url(\"${url}\")`)\n    expect(out).toBe(`url(\"${data}\")`)\n  })\n\n  it('inlines via snapFetch on success (raster path → Image onload)', async () => {\n    // Simulate an <img> that loads immediately with non-zero size\n    globalThis.Image = class {\n      constructor() { setTimeout(() => this.onload && this.onload(), 0) }\n      set src(_) {}\n      decode() { return Promise.resolve() }\n      get naturalWidth() { return 2 }\n      get naturalHeight() { return 2 }\n      get width() { return 2 }\n      get height() { return 2 }\n      set crossOrigin(_) {}\n      set onload(_) {}\n      set onerror(_) {}\n    }\n\n    const out = await inlineSingleBackgroundEntry('url(\"https://assets.example.com/a.png\")')\n    expect(out).toMatch(/^url\\(\"data:image\\/png;base64,/)\n  })\n\n  it('degrades to \"none\" if inlining fails (no proxy)', async () => {\n    // Force <img> error and make fetch fallback fail (no proxy)\n    globalThis.Image = class {\n      constructor() { setTimeout(() => this.onerror && this.onerror(), 0) }\n      set src(_) {}\n      set crossOrigin(_) {}\n      set onload(_) {}\n      set onerror(_) {}\n    }\n    const mockFetch = /** @type {any} */ (globalThis.fetch)\n    mockFetch.mockResolvedValueOnce({ ok: false, status: 500, blob: async () => new Blob([], { type: 'image/png' }) })\n\n    const out = await inlineSingleBackgroundEntry('url(\"https://bad.example.com/x.png\")')\n    expect(out).toBe('none')\n  })\n})\n\n// -----------------------------\n// snapFetch\n// -----------------------------\n// -----------------------------\n// snapFetch (helpers)\n// -----------------------------\n/**\n * @param {import('../src/modules/snapFetch.js').SnapFetchResult} r\n */\nfunction expectOkDataURL(r) {\n  expect(r.ok).toBe(true)\n  expect(typeof r.data).toBe('string')\n  expect(r.data).toMatch(/^data:/)\n}\n\n/**\n * @param {import('../src/modules/snapFetch.js').SnapFetchResult} r\n */\nfunction expectOkText(r) {\n  expect(r.ok).toBe(true)\n  expect(typeof r.data).toBe('string')\n}\n\n// -----------------------------\n// snapFetch (raster path)\n// -----------------------------\ndescribe('snapFetch (raster path)', () => {\n  it('resolves a DataURL when the image loads and decode succeeds', async () => {\n    globalThis.Image = class {\n      constructor() { setTimeout(() => this.onload && this.onload(), 0) }\n      set src(_) {}\n      decode() { return Promise.resolve() }\n      get naturalWidth() { return 3 }\n      get naturalHeight() { return 4 }\n      set crossOrigin(_) {}\n      set onload(_) {}\n      set onerror(_) {}\n    }\n\n    const r = await snapFetch('https://cdn.example.com/photo.jpg', { timeout: 100, as: 'dataURL' })\n    expectOkDataURL(r)\n    expect(r.mime).toMatch(/image\\/png|image\\/jpeg|image\\/jpg/i)\n  })\n\n  it('uses credentials: \"include\" for same-origin URLs', async () => {\n    const spy = vi.spyOn(globalThis, 'fetch')\n    // first call uses our default globalThis.fetch mock; we only care about the opts it receives\n    await snapFetch('/local.png', { timeout: 100, as: 'blob' })\n\n    expect(spy).toHaveBeenCalledTimes(1)\n    const [, opts] = spy.mock.calls[0]\n    expect(opts.credentials).toBe('include')\n\n    spy.mockRestore()\n  })\n})\n\n// -----------------------------\n// snapFetch (svg path)\n// -----------------------------\ndescribe('snapFetch (svg path)', () => {\n  it('inlines SVG via direct text fetch when as:\"text\"', async () => {\n    const r = await snapFetch('https://example.com/icon.svg', { as: 'text' })\n    expectOkText(r)\n    expect(String(r.data)).toMatch(/^<svg[\\s>]/)\n  })\n\n  it('deduplicates in-flight fetches (single network call for concurrent requests)', async () => {\n    const fetchSpy = vi.spyOn(globalThis, 'fetch')\n\n    // Slow down first fetch so both calls overlap\n    fetchSpy.mockImplementationOnce(async () => {\n      await new Promise(r => setTimeout(r, 50))\n      return {\n        ok: true,\n        status: 200,\n        text: async () => '<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>',\n        blob: async () => new Blob([new Uint8Array([137,80,78,71])], { type: 'image/png' }),\n        headers: new Headers({ 'content-type': 'image/svg+xml' }),\n      }\n    })\n\n    const p1 = snapFetch('https://slow.example.com/a.svg', { as: 'text' })\n    const p2 = snapFetch('https://slow.example.com/a.svg', { as: 'text' })\n    const [a, b] = await Promise.all([p1, p2])\n\n    expectOkText(a)\n    expectOkText(b)\n    expect(fetchSpy).toHaveBeenCalledTimes(1)\n\n    fetchSpy.mockRestore()\n  })\n\n it('sets cooldown after failure and resolves ok:false; subsequent calls hit fromCache quickly', async () => {\n  const fetchSpy = vi.spyOn(globalThis, 'fetch')\n\n  // Una sola falla de red es suficiente para poblar el error cache\n  fetchSpy.mockRejectedValueOnce(new Error('boom'))\n\n  const opts = { errorTTL: 5000, as: 'text' }\n\n  // 1) Primer intento: falla y entra al error cache\n  const r1 = await snapFetch('https://fail.example.com/x.svg', opts)\n  expect(r1.ok).toBe(false)\n  expect(['network', 'timeout', 'abort', 'http_error']).toContain(r1.reason)\n\n  // 2) Retry inmediato con las MISMAS opciones → debe salir de cache\n  fetchSpy.mockClear()\n  const r2 = await snapFetch('https://fail.example.com/x.svg', opts)\n  expect(r2.ok).toBe(false)\n  expect(r2.fromCache).toBe(true)\n  expect(fetchSpy).not.toHaveBeenCalled()\n\n  fetchSpy.mockRestore()\n})\n\n  it('uses proxy for cross-origin when provided and returns DataURL if requested', async () => {\n    const f = /** @type {any} */ (globalThis.fetch)\n    // La primera llamada en este test no falla; simplemente queremos chequear que se aplique el proxy y DataURL\n    f.mockResolvedValueOnce({\n      ok: true,\n      status: 200,\n      // devolvemos un PNG mínimo como blob\n      blob: async () => new Blob([new Uint8Array([137,80,78,71,13,10,26,10])], { type: 'image/png' }),\n      text: async () => '<svg/>',\n      headers: new Headers({ 'content-type': 'image/png' }),\n    })\n\n    const proxy = 'https://proxy.test/?'\n    const url = 'https://blocked.example.com/asset.svg'\n    const r = await snapFetch(url, { useProxy: proxy, as: 'dataURL' })\n\n    expectOkDataURL(r)\n    expect(r.url.startsWith(proxy)).toBe(true)\n  })\n})\n"
  },
  {
    "path": "__tests__/utils.transforms.helpers.test.js",
    "content": "// __tests__/utils.transforms.helpers.test.js – transforms.helpers.js coverage\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest'\nimport {\n  parseBoxShadow,\n  parseFilterBlur,\n  parseOutline,\n  parseFilterDropShadows,\n  normalizeRootTransforms,\n  parseTransformOriginPx,\n  readIndividualTransforms,\n  readTotalTransformMatrix,\n  hasBBoxAffectingTransform,\n  matrixFromComputed,\n  bboxWithOriginFull\n} from '../src/utils/transforms.helpers.js'\n\nbeforeEach(() => {\n  document.body.innerHTML = ''\n})\n\nafterEach(() => {\n  document.body.innerHTML = ''\n})\n\ndescribe('parseBoxShadow', () => {\n  it('returns zeros for none', () => {\n    const div = document.createElement('div')\n    document.body.appendChild(div)\n    const cs = getComputedStyle(div)\n    const res = parseBoxShadow(cs)\n    expect(res).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })\n  })\n\n  it('calculates bleed for box-shadow', () => {\n    const div = document.createElement('div')\n    div.style.boxShadow = '10px 5px 3px 2px rgba(0,0,0,0.5)'\n    document.body.appendChild(div)\n    const cs = getComputedStyle(div)\n    const res = parseBoxShadow(cs)\n    expect(res.top).toBeGreaterThanOrEqual(0)\n    expect(res.right).toBeGreaterThanOrEqual(0)\n  })\n})\n\ndescribe('parseFilterBlur', () => {\n  it('returns zeros for no blur', () => {\n    const div = document.createElement('div')\n    document.body.appendChild(div)\n    const cs = getComputedStyle(div)\n    expect(parseFilterBlur(cs)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })\n  })\n\n  it('parses blur() value', () => {\n    const div = document.createElement('div')\n    div.style.filter = 'blur(5px)'\n    document.body.appendChild(div)\n    const cs = getComputedStyle(div)\n    const res = parseFilterBlur(cs)\n    expect(res.top).toBe(5)\n  })\n})\n\ndescribe('parseOutline', () => {\n  it('returns zeros for none', () => {\n    const div = document.createElement('div')\n    document.body.appendChild(div)\n    const cs = getComputedStyle(div)\n    expect(parseOutline(cs)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })\n  })\n})\n\ndescribe('parseFilterDropShadows', () => {\n  it('returns has:false for empty', () => {\n    const div = document.createElement('div')\n    document.body.appendChild(div)\n    const cs = getComputedStyle(div)\n    expect(parseFilterDropShadows(cs).has).toBe(false)\n  })\n\n  it('parses drop-shadow', () => {\n    const div = document.createElement('div')\n    div.style.filter = 'drop-shadow(2px 3px 4px black)'\n    document.body.appendChild(div)\n    const cs = getComputedStyle(div)\n    const res = parseFilterDropShadows(cs)\n    expect(res.has).toBe(true)\n    expect(res.bleed.top).toBeGreaterThanOrEqual(0)\n  })\n})\n\ndescribe('parseTransformOriginPx', () => {\n  it('parses left/top as 0', () => {\n    const div = document.createElement('div')\n    div.style.transformOrigin = 'left top'\n    document.body.appendChild(div)\n    const cs = getComputedStyle(div)\n    const res = parseTransformOriginPx(cs, 100, 50)\n    expect(res.ox).toBe(0)\n    expect(res.oy).toBe(0)\n  })\n\n  it('parses center as half size (mock cs with keywords)', () => {\n    const cs = { transformOrigin: 'center center' }\n    const res = parseTransformOriginPx(cs, 100, 50)\n    expect(res.ox).toBe(50)\n    expect(res.oy).toBe(25)\n  })\n\n  it('parses right/bottom as full size (mock cs)', () => {\n    const cs = { transformOrigin: 'right bottom' }\n    const res = parseTransformOriginPx(cs, 100, 50)\n    expect(res.ox).toBe(100)\n    expect(res.oy).toBe(50)\n  })\n\n  it('parses percentage (mock cs)', () => {\n    const cs = { transformOrigin: '50% 25%' }\n    const res = parseTransformOriginPx(cs, 100, 100)\n    expect(res.ox).toBe(50)\n    expect(res.oy).toBe(25)\n  })\n})\n\ndescribe('readIndividualTransforms', () => {\n  it('returns legacy path when no Typed OM', () => {\n    const div = document.createElement('div')\n    document.body.appendChild(div)\n    const res = readIndividualTransforms(div)\n    expect(res.rotate).toBeDefined()\n    expect(res).toHaveProperty('scale')\n    expect(res).toHaveProperty('translate')\n  })\n})\n\ndescribe('readTotalTransformMatrix', () => {\n  it('returns identity for empty transform', () => {\n    const M = readTotalTransformMatrix({})\n    expect(M.a).toBe(1)\n    expect(M.d).toBe(1)\n  })\n\n  it('applies baseTransform when provided', () => {\n    const M = readTotalTransformMatrix({ baseTransform: 'translate(10px, 0)' })\n    expect(M.e).toBe(10)\n  })\n})\n\ndescribe('hasBBoxAffectingTransform', () => {\n  it('returns false for no transform', () => {\n    const div = document.createElement('div')\n    document.body.appendChild(div)\n    expect(hasBBoxAffectingTransform(div)).toBe(false)\n  })\n\n  it('returns true for translate', () => {\n    const div = document.createElement('div')\n    div.style.translate = '10px 0'\n    document.body.appendChild(div)\n    expect(hasBBoxAffectingTransform(div)).toBe(true)\n  })\n})\n\ndescribe('matrixFromComputed', () => {\n  it('returns identity for none', () => {\n    const div = document.createElement('div')\n    document.body.appendChild(div)\n    const M = matrixFromComputed(div)\n    expect(M.a).toBe(1)\n    expect(M.d).toBe(1)\n  })\n})\n\ndescribe('bboxWithOriginFull', () => {\n  it('computes bbox with transform', () => {\n    const M = new DOMMatrix()\n    const res = bboxWithOriginFull(100, 50, M, 0, 0)\n    expect(res.width).toBe(100)\n    expect(res.height).toBe(50)\n  })\n})\n\ndescribe('normalizeRootTransforms', () => {\n  it('returns null for null roots', () => {\n    const clone = document.createElement('div')\n    expect(normalizeRootTransforms(null, clone)).toBeNull()\n    expect(normalizeRootTransforms(document.createElement('div'), null)).toBeNull()\n  })\n\n  it('handles transform:none identity', () => {\n    const orig = document.createElement('div')\n    const clone = document.createElement('div')\n    document.body.appendChild(orig)\n    const res = normalizeRootTransforms(orig, clone)\n    expect(res).toEqual({ a: 1, b: 0, c: 0, d: 1 })\n  })\n\n  it('handles 2D matrix', () => {\n    const orig = document.createElement('div')\n    orig.style.transform = 'matrix(2, 0, 0, 2, 10, 20)'\n    const clone = document.createElement('div')\n    document.body.appendChild(orig)\n    const res = normalizeRootTransforms(orig, clone)\n    expect(res).not.toBeNull()\n    expect(res.a).toBe(2)\n  })\n})\n"
  },
  {
    "path": "docs/CNAME",
    "content": "snapdom.dev"
  },
  {
    "path": "docs/assets/favicon/site.webmanifest",
    "content": "{\n  \"name\": \"MyWebSite\",\n  \"short_name\": \"MySite\",\n  \"icons\": [\n    {\n      \"src\": \"/web-app-manifest-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"/web-app-manifest-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"theme_color\": \"#ffffff\",\n  \"background_color\": \"#ffffff\",\n  \"display\": \"standalone\"\n}"
  },
  {
    "path": "docs/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <!-- === Meta básicos === -->\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>SnapDOM: Next-Generation DOM Capture Engine for fast and accurate HTML Conversion</title>\n\n  <meta name=\"description\" content=\"Snapdom is a powerful alternative to html2canvas and similar tools, offering extreme precision when capturing DOM elements, including pseudo-elements, web fonts, and shadow DOM.\">\n  <meta name=\"keywords\" content=\"snapdom, html2canvas, DOM capture, screenshot library, SVG export, canvas image, frontend tools, JavaScript screenshot, shadow DOM, pseudo-elements, high fidelity, plugins\">\n  <meta name=\"author\" content=\"zumerlab\">\n  <meta name=\"robots\" content=\"index, follow\">\n  <link rel=\"canonical\" href=\"https://snapdom.dev/\">\n\n  <!-- === Open Graph / Twitter === -->\n  <meta property=\"og:type\" content=\"website\">\n  <meta property=\"og:title\" content=\"snapDOM – serious alternative to html2canvas\">\n  <meta property=\"og:description\" content=\"Capture any web UI with high accuracy and speed, including details that html2canvas misses. Try the demo and see the difference.\">\n  <meta property=\"og:url\" content=\"https://snapdom.dev/\">\n  <meta property=\"og:image\" content=\"https://snapdom.dev/assets/hero.png\">\n\n  <meta name=\"twitter:card\" content=\"summary_large_image\">\n  <meta name=\"twitter:title\" content=\"snapdom – HTML capture reinvented\">\n  <meta name=\"twitter:description\" content=\"A high-fidelity alternative to html2canvas. Export DOM to SVG, PNG, JPG with full CSS and pseudo support.\">\n  <meta name=\"twitter:image\" content=\"https://snapdom.dev/assets/hero.png\">\n\n  <!-- === Favicons === -->\n  <link rel=\"icon\" type=\"image/png\" href=\"./assets/favicon/favicon-96x96.png\" sizes=\"96x96\">\n  <link rel=\"icon\" type=\"image/svg+xml\" href=\"./assets/favicon/favicon.svg\">\n  <link rel=\"shortcut icon\" href=\"./assets/favicon/favicon.ico\">\n  <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"./assets/favicon/apple-touch-icon.png\">\n  <meta name=\"apple-mobile-web-app-title\" content=\"SnapDOM\">\n  <link rel=\"manifest\" href=\"./assets/favicon/site.webmanifest\">\n  <meta name=\"theme-color\" content=\"#0b1220\">\n\n  <!-- === JSON-LD Structured Data === -->\n  <script type=\"application/ld+json\">\n  {\n    \"@context\": \"https://schema.org\",\n    \"@type\": \"SoftwareApplication\",\n    \"name\": \"SnapDOM\",\n    \"applicationCategory\": \"DeveloperApplication\",\n    \"description\": \"Next-generation DOM capture engine. Convert any DOM subtree to SVG, PNG, JPG, WebP. High-fidelity alternative to html2canvas.\",\n    \"url\": \"https://snapdom.dev\",\n    \"operatingSystem\": \"Web\",\n    \"offers\": { \"@type\": \"Offer\", \"price\": \"0\", \"priceCurrency\": \"USD\" }\n  }\n  </script>\n\n  <!-- Preconnect to critical origins only (reduces DNS/TLS overhead) -->\n  <link rel=\"preconnect\" href=\"https://cdnjs.cloudflare.com\" crossorigin>\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n  <link rel=\"preconnect\" href=\"https://api.github.com\">\n  \n  <!-- === Stylesheets === -->\n  <link rel=\"stylesheet\" href=\"https://unpkg.com/@zumer/orbit/dist/orbit.min.css\" crossorigin=\"anonymous\">\n\n  <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css\" crossorigin=\"anonymous\">\n  <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700&family=Mansalva&display=swap\">\n\n  <!-- Analytics: loaded after interactive to avoid LCP penalty -->\n  <script defer src=\"https://cloud.umami.is/script.js\" data-website-id=\"7ec718e8-f0c5-4abb-8f4f-144f57f61937\"></script>\n  <script>\n    window.addEventListener('load',function(){var s=document.createElement('script');s.async=1;s.src='https://www.googletagmanager.com/gtag/js?id=G-4G88ZHG7S1';document.head.appendChild(s);s.onload=function(){window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-4G88ZHG7S1',{transport_type:'beacon'});};});\n  </script>\n\n  <style>\n    :root {\n      --sd-primary: #0f172a;\n      --sd-primary-light: #1e293b;\n      --sd-accent: #6366f1;\n      --sd-accent-hover: #818cf8;\n      --sd-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n      --sd-gradient-alt: linear-gradient(90deg, #f97316 0%, #ec4899 100%);\n      --sd-surface: rgba(255, 255, 255, 0.92);\n      --sd-surface-elevated: #ffffff;\n      --sd-text: #0f172a;\n      --sd-text-muted: #475569;\n      --sd-border: rgba(99, 102, 241, 0.2);\n      --sd-shadow: 0 4px 24px rgba(15, 23, 42, 0.08);\n      --sd-shadow-hover: 0 8px 32px rgba(99, 102, 241, 0.15);\n      --sd-radius: 14px;\n      --sd-radius-sm: 10px;\n      --sd-star: #fbbf24;\n    }\n\n    body {\n      font-family: 'Segoe UI', 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;\n      padding: 1.5rem;\n      background: linear-gradient(160deg, #f0f4ff 0%, #e8ecff 50%, #eef2ff 100%);\n      min-height: 100vh;\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      color: var(--sd-text);\n      line-height: 1.6;\n      -webkit-font-smoothing: antialiased;\n    }\n\n    h1 {\n      color: var(--sd-primary);\n      font-size: clamp(1.75rem, 4vw, 2.5rem);\n      margin-bottom: 0.75rem;\n      text-align: center;\n      letter-spacing: -0.02em;\n      font-weight: 800;\n    }\n\n    h2 {\n      font-size: clamp(1.15rem, 2.5vw, 1.35rem);\n      color: var(--sd-primary);\n      margin-top: 0;\n      margin-bottom: 1rem;\n      font-weight: 700;\n    }\n\n\n    button, button#capture, .run-benchmark-button {\n      background: var(--sd-gradient-alt);\n      color: white !important;\n      border: none;\n      border-radius: 12px;\n      padding: 0.55rem 1.25rem;\n      font-size: 0.95rem;\n      font-weight: 700;\n      box-shadow: 0 2px 12px rgba(249, 115, 22, 0.35);\n      cursor: pointer;\n      transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;\n      margin: 0 0.5em 0.5em 0;\n    }\n\n    button:hover:not(:disabled), button#capture:hover, .run-benchmark-button:hover {\n      transform: translateY(-2px);\n      box-shadow: 0 4px 20px rgba(249, 115, 22, 0.45);\n    }\n\n    button:disabled {\n      opacity: 0.7;\n      cursor: not-allowed;\n    }\n\n    /* === Hero: título + badge + menú === */\n    .hero-block {\n      width: 100%;\n      max-width: 720px;\n      text-align: center;\n      padding: 2rem 1.25rem 1.5rem;\n      margin-bottom: 0.5rem;\n    }\n\n    .hero-block h1 {\n      margin-bottom: 0.35rem;\n      font-size: clamp(2rem, 6vw, 3rem);\n    }\n\n    .hero-tagline {\n      font-size: 1.1rem;\n      color: var(--sd-text-muted);\n      margin: 0 auto 1.25rem;\n      max-width: 520px;\n      line-height: 1.5;\n    }\n\n    .nav-section {\n      margin-top: 1.5rem;\n      padding-top: 1.25rem;\n      border-top: 1px solid var(--sd-border);\n    }\n\n    .nav-section .nav-toggle {\n      margin-bottom: 0.5rem;\n      font-size: 0.9rem;\n    }\n\n    .nav-label {\n      font-size: 0.85rem;\n      font-weight: 600;\n      color: var(--sd-text-muted);\n      margin: 0 0 0.6rem;\n      letter-spacing: 0.03em;\n    }\n\n    .nav-bar {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      gap: 1rem;\n      margin-bottom: 0;\n    }\n\n    .nav-toggle {\n      display: none;\n      border: 0;\n      background: var(--sd-primary);\n      color: #fff;\n      border-radius: var(--sd-radius-sm);\n      padding: 0.6rem 1rem;\n      font-size: 0.9rem;\n      font-weight: 700;\n      cursor: pointer;\n      box-shadow: var(--sd-shadow);\n      transition: background 0.2s;\n    }\n\n    .nav-toggle:hover {\n      background: var(--sd-primary-light);\n    }\n\n    .nav-toggle:focus-visible {\n      outline: 3px solid var(--sd-accent);\n      outline-offset: 2px;\n    }\n\n    .nav-icon {\n      display: inline-block;\n      width: 1.15rem;\n      height: 2px;\n      margin-right: 0.4rem;\n      vertical-align: middle;\n      background: currentColor;\n      border-radius: 2px;\n      position: relative;\n    }\n\n    .nav-icon::before,\n    .nav-icon::after {\n      content: \"\";\n      position: absolute;\n      left: 0;\n      right: 0;\n      height: 2px;\n      background: currentColor;\n      border-radius: 2px;\n    }\n\n    .nav-icon::before { top: -6px; }\n    .nav-icon::after { top: 6px; }\n\n    .nav-collapsible {\n      overflow: hidden;\n      max-height: 0;\n      transition: max-height 0.3s ease;\n      border-radius: var(--sd-radius-sm);\n    }\n\n    .nav-collapsible.open {\n      max-height: 75vh;\n      overflow-y: auto;\n      -webkit-overflow-scrolling: touch;\n      padding: 1rem 0 0.5rem;\n      margin-top: 0.75rem;\n      border-top: 1px solid var(--sd-border);\n      scrollbar-width: thin;\n      scrollbar-color: rgba(99, 102, 241, 0.3) transparent;\n    }\n\n    .nav-collapsible.open::-webkit-scrollbar { width: 8px; }\n    .nav-collapsible.open::-webkit-scrollbar-thumb {\n      background: rgba(99, 102, 241, 0.25);\n      border-radius: 999px;\n    }\n\n    nav.demo-menu {\n      display: grid;\n      grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));\n      gap: 0.5rem 1rem;\n      padding: 0;\n    }\n\n    nav.demo-menu a {\n      color: var(--sd-primary);\n      text-decoration: none;\n      font-weight: 600;\n      font-size: 0.9rem;\n      padding: 0.45rem 0.75rem;\n      border-radius: var(--sd-radius-sm);\n      transition: background 0.2s, color 0.2s;\n    }\n\n    nav.demo-menu a:hover {\n      background: rgba(99, 102, 241, 0.1);\n      color: var(--sd-accent);\n    }\n\n    nav.demo-menu a[href^=\"./\"] {\n      background: linear-gradient(135deg, rgba(99, 102, 241, 0.15), rgba(236, 72, 153, 0.1));\n    }\n\n    section a {\n      color: var(--sd-accent);\n      text-decoration: none;\n      font-weight: 600;\n      border-bottom: 1px solid transparent;\n      transition: border-color 0.2s;\n    }\n\n    section a:hover {\n      border-bottom-color: var(--sd-accent);\n    }\n\n    main {\n      width: 100%;\n      max-width: 720px;\n    }\n\n    section {\n      margin-bottom: 2.5rem;\n      padding: 1.5rem;\n      border-radius: var(--sd-radius);\n      background: var(--sd-surface);\n      box-shadow: var(--sd-shadow);\n      border: 1px solid var(--sd-border);\n    }\n\n    section:target {\n      box-shadow: var(--sd-shadow-hover);\n      border-color: rgba(99, 102, 241, 0.3);\n    }\n\n    .demo-explanation {\n      font-size: 0.95rem;\n      color: var(--sd-text-muted);\n      margin: -0.5rem 0 1rem;\n      line-height: 1.5;\n    }\n\n    .demo-explanation code {\n      background: rgba(99, 102, 241, 0.12);\n      padding: 0.15rem 0.4rem;\n      border-radius: 6px;\n      font-size: 0.9em;\n    }\n\n    @media (min-width: 601px) {\n      nav.demo-menu {\n        display: flex;\n        flex-wrap: wrap;\n        justify-content: center;\n        gap: 0.4rem;\n      }\n\n      nav.demo-menu a {\n        padding: 0.4rem 0.85rem;\n      }\n    }\n\n    /* GitHub badge - destacado, invitación a dar estrellas */\n    .github-badge {\n      display: inline-flex;\n      align-items: center;\n      gap: 0.6rem;\n      text-decoration: none;\n      color: var(--sd-primary);\n      font-weight: 700;\n      font-size: 0.95rem;\n      background: var(--sd-surface-elevated);\n      padding: 0.5rem 0.9rem;\n      border-radius: 999px;\n      box-shadow: var(--sd-shadow);\n      border: 1px solid var(--sd-border);\n      transition: transform 0.2s, box-shadow 0.2s;\n    }\n\n    .github-badge:hover {\n      transform: translateY(-2px);\n      box-shadow: var(--sd-shadow-hover);\n    }\n\n    .github-badge .fab {\n      font-size: 1.25rem;\n      color: var(--sd-primary);\n    }\n\n    #github-stars {\n      display: inline-flex;\n      align-items: center;\n      gap: 0.35rem;\n      background: linear-gradient(135deg, #fef3c7, #fde68a);\n      padding: 0.2rem 0.5rem;\n      border-radius: 999px;\n      font-weight: 800;\n      color: var(--sd-primary);\n      font-size: 0.9rem;\n    }\n\n    #github-stars .fas {\n      color: var(--sd-star);\n    }\n\n    .card {\n      background: linear-gradient(145deg, #ffffff, #f8fafc);\n      padding: 1.5em;\n      border-left: 4px solid var(--sd-accent);\n      border-radius: var(--sd-radius);\n      margin-bottom: 1.5em;\n      transition: transform 0.2s, box-shadow 0.2s;\n      box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);\n    }\n\n    .card.black {\n      background: #0f172a;\n      padding: 1.5em;\n      border-left: 4px solid var(--sd-accent);\n      border-radius: var(--sd-radius);\n      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);\n      margin-bottom: 1.5em;\n    }\n\n    .card:hover {\n      transform: translateY(-2px);\n      box-shadow: var(--sd-shadow-hover);\n    }\n\n    .transition-box {\n      background: linear-gradient(to right, #43cea2, #185a9d);\n      color: rgb(30, 27, 27);\n      text-align: center;\n      font-size: 1.2em;\n      padding: 2em;\n      border-radius: 16px;\n      animation: bounceColor 5s infinite ease-in-out;\n      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);\n    }\n\n    @keyframes bounceColor {\n      0% {\n        transform: rotate(0deg) scale(1) translateY(0);\n        background: radial-gradient(circle at center, #4facfe, #00f2fe);\n        filter: brightness(1);\n      }\n\n      20% {\n        transform: rotate(-1deg) scale(1.08) translateY(4px);\n        background: radial-gradient(circle at 40% 60%, #fa709a, #fee140);\n        filter: brightness(1.12) hue-rotate(20deg);\n      }\n\n      50% {\n        transform: rotate(2deg) scale(0.9) translateY(-6px);\n        background: radial-gradient(circle at 70% 30%, #30cfd0, #330867);\n        filter: brightness(1.1) hue-rotate(-20deg);\n      }\n\n      70% {\n        transform: rotate(-3deg) scale(1) translateY(6px);\n        background: radial-gradient(circle at 30% 70%, #ffe259, #ffa751);\n        filter: brightness(1.15) saturate(1.2);\n      }\n\n      100% {\n        transform: rotate(0deg) scale(0.95) translateY(0);\n        background: radial-gradient(circle at center, #4facfe, #00f2fe);\n        filter: brightness(1);\n      }\n    }\n\n    .export-text {\n      font-size: 1.1rem;\n      color: #2d3a4b;\n      text-align: center;\n    }\n\n    .export-format {\n      font-weight: bold;\n      padding: 0.2rem 0.4rem;\n      border-radius: 8px;\n      background: rgba(255, 255, 255, 0.7);\n      margin: 0 0.2rem;\n    }\n\n    .pseudo-box::before {\n      content: '★ ';\n      color: gold;\n      font-size: 1.2em;\n    }\n\n    .pseudo-box::after {\n      content: ' ✨';\n      color: violet;\n      font-size: 1.2em;\n    }\n\n    .clip-card {\n      clip-path: polygon(0 0, 100% 0, 80% 100%, 20% 100%);\n      background: linear-gradient(120deg, #b6eaff 0%, #6dd5ed 100%);\n      color: #1a2233;\n      font-weight: bold;\n      text-align: center;\n    }\n\n    .blend-card {\n      /* Gradient + color overlay with background-blend-mode: multiply */\n      background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Cdefs%3E%3ClinearGradient id='a' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23e0f2fe'/%3E%3Cstop offset='50%25' stop-color='%23bae6fd'/%3E%3Cstop offset='100%25' stop-color='%237dd3fc'/%3E%3C/linearGradient%3E%3ClinearGradient id='b' x1='0' y1='1' x2='0' y2='0'%3E%3Cstop offset='0%25' stop-color='%2322c55e'/%3E%3Cstop offset='60%25' stop-color='%234ade80'/%3E%3Cstop offset='100%25' stop-color='%23bbf7d0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23a)' width='400' height='180'/%3E%3Crect fill='url(%23b)' y='140' width='400' height='160'/%3E%3C/svg%3E\");\n      background-color: rgba(30, 58, 138, 0.65);\n      background-blend-mode: multiply;\n      background-size: cover;\n      background-position: center;\n      background-repeat: no-repeat;\n      color: white;\n      padding: 2.5em 2em;\n      font-weight: bold;\n      border-radius: 16px;\n      text-align: center;\n      text-shadow: 0 1px 2px rgba(0,0,0,0.3);\n    }\n\n    .dancers {\n      display: inline-block;\n      font-size: 2.2rem;\n      animation: dance-move 1.2s infinite cubic-bezier(0.68, -0.55, 0.27, 1.55);\n      will-change: transform, filter;\n    }\n\n    @keyframes dance-move {\n      0% {\n        transform: rotate(-10deg) scale(1) translateY(0);\n        filter: brightness(1) drop-shadow(0 0 0 #fff0);\n      }\n\n      10% {\n        transform: rotate(8deg) scale(1.08, 0.95) translateY(-6px) skewY(-6deg);\n        filter: brightness(1.1) drop-shadow(0 2px 4px #ffe25988);\n      }\n\n      20% {\n        transform: rotate(-12deg) scale(0.98, 1.05) translateY(4px) skewY(8deg);\n        filter: brightness(1.15) drop-shadow(0 4px 8px #fa709a66);\n      }\n\n      30% {\n        transform: rotate(10deg) scale(1.04, 0.96) translateY(-8px) skewY(-8deg);\n        filter: brightness(1.1) drop-shadow(0 2px 4px #43e97b66);\n      }\n\n      40% {\n        transform: rotate(-8deg) scale(1.02, 1.02) translateY(2px) skewY(6deg);\n        filter: brightness(1.08) drop-shadow(0 2px 4px #38f9d766);\n      }\n\n      50% {\n        transform: rotate(12deg) scale(1.08, 0.92) translateY(-10px) skewY(-10deg);\n        filter: brightness(1.2) drop-shadow(0 4px 8px #fee14066);\n      }\n\n      60% {\n        transform: rotate(-10deg) scale(0.96, 1.08) translateY(6px) skewY(10deg);\n        filter: brightness(1.1) drop-shadow(0 2px 4px #30cfd066);\n      }\n\n      70% {\n        transform: rotate(8deg) scale(1.04, 0.98) translateY(-4px) skewY(-6deg);\n        filter: brightness(1.12) drop-shadow(0 2px 4px #33086766);\n      }\n\n      80% {\n        transform: rotate(-12deg) scale(1, 1) translateY(0) skewY(0deg);\n        filter: brightness(1.05) drop-shadow(0 0 0 #fff0);\n      }\n\n      100% {\n        transform: rotate(-10deg) scale(1) translateY(0);\n        filter: brightness(1) drop-shadow(0 0 0 #fff0);\n      }\n    }\n\n    .output {\n      margin-top: 1.3rem;\n    }\n\n    #orbit-box {\n      font-family: sans-serif;\n      font-optical-sizing: auto;\n      background-color: rgb(16, 16, 16) !important;\n      color: rgba(255, 255, 255, 0.451);\n    }\n\n\n    /* === Mask demos === */\n    .mask-grid {\n      display: grid;\n      grid-template-columns: repeat(2, minmax(0, 1fr));\n      gap: 1rem;\n    }\n\n    .mask-card {\n      position: relative;\n      min-height: 180px;\n      display: grid;\n      place-items: center;\n      border-radius: 16px;\n      overflow: hidden;\n    }\n\n    .mask-bg {\n      position: absolute;\n      inset: 0;\n      background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Cdefs%3E%3ClinearGradient id='a' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%233b82f6'/%3E%3Cstop offset='60%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%2393c5fd'/%3E%3C/linearGradient%3E%3ClinearGradient id='b' x1='0' y1='1' x2='0' y2='0'%3E%3Cstop offset='0%25' stop-color='%2315803d'/%3E%3Cstop offset='40%25' stop-color='%2322c55e'/%3E%3Cstop offset='100%25' stop-color='%234ade80'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23a)' width='400' height='200'/%3E%3Crect fill='url(%23b)' y='150' width='400' height='150'/%3E%3C/svg%3E\") no-repeat center/cover;\n    }\n\n    .mask-label {\n      position: relative;\n      z-index: 2;\n      background: rgba(255, 255, 255, .75);\n      padding: .3rem .6rem;\n      border-radius: 8px;\n      font-weight: 700;\n    }\n\n    /* CSS mask-image (radial spotlight) */\n    .mask-radial .mask-bg {\n      mask-image: radial-gradient(circle at 60% 40%, rgba(0, 0, 0, 1) 35%, rgba(0, 0, 0, 0) 70%);\n      -webkit-mask-image: radial-gradient(circle at 60% 40%, rgba(0, 0, 0, 1) 35%, rgba(0, 0, 0, 0) 70%);\n    }\n\n    /* PNG/SVG circle via mask (inline data URL, no 404/CORS) */\n    /* PNG/SVG circle mask — longhands + base64 for robust capture */\n    .mask-png .mask-bg {\n      /* Standard longhands */\n      mask-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48Y2lyY2xlIGN4PSIyNTYiIGN5PSIyNTYiIHI9IjIyMCIgZmlsbD0iYmxhY2siLz48L3N2Zz4=\");\n      mask-position: center;\n      mask-size: 60% 60%;\n      mask-repeat: no-repeat;\n\n      /* WebKit longhands (Safari) */\n      -webkit-mask-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48Y2lyY2xlIGN4PSIyNTYiIGN5PSIyNTYiIHI9IjIyMCIgZmlsbD0iYmxhY2siLz48L3N2Zz4=\");\n      -webkit-mask-position: center;\n      -webkit-mask-size: 60% 60%;\n      -webkit-mask-repeat: no-repeat;\n\n      background-color: #9bd3ff;\n    }\n\n\n    /* SVG mask inline */\n    .mask-svg {\n      background: linear-gradient(135deg, #dceefb, #e8d9ff);\n    }\n\n    .mask-svg .masked-box {\n      width: 100%;\n      height: 100%;\n      background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Cdefs%3E%3ClinearGradient id='a' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%233b82f6'/%3E%3Cstop offset='60%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%2393c5fd'/%3E%3C/linearGradient%3E%3ClinearGradient id='b' x1='0' y1='1' x2='0' y2='0'%3E%3Cstop offset='0%25' stop-color='%2315803d'/%3E%3Cstop offset='40%25' stop-color='%2322c55e'/%3E%3Cstop offset='100%25' stop-color='%234ade80'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23a)' width='400' height='200'/%3E%3Crect fill='url(%23b)' y='150' width='400' height='150'/%3E%3C/svg%3E\") center/cover no-repeat;\n      mask: url(#svg-blob-mask);\n      -webkit-mask: url(#svg-blob-mask);\n    }\n\n    /* Forms: asegurar sizing y ancho */\n    .form-demo {\n      display: grid;\n      gap: .8rem;\n      width: 100%;\n    }\n\n    .form-row {\n      display: grid;\n      gap: .35rem;\n    }\n\n    .form-row.inline {\n      grid-template-columns: 1fr 1fr;\n      /* desktop: dos columnas */\n      gap: .6rem;\n    }\n\n    .form-demo input[type=\"text\"],\n    .form-demo input[type=\"email\"],\n    .form-demo select,\n    .form-demo textarea {\n      width: 100%;\n      box-sizing: border-box;\n      border: 2px solid #3658f1;\n      border-radius: 10px;\n      padding: .55rem .7rem;\n      font-size: 1rem;\n      background: #fff;\n      box-shadow: 0 3px 8px rgba(0, 0, 0, .05);\n      min-height: 42px;\n      /* mejora tap target en mobile */\n    }\n\n    .form-demo textarea {\n      display: block;\n      resize: vertical;\n    }\n\n    /* Mobile: apilar filas inline y evitar overflow */\n    @media (max-width: 600px) {\n      .form-row.inline {\n        grid-template-columns: 1fr !important;\n        /* una columna en mobile */\n      }\n\n      .form-demo input[type=\"text\"],\n      .form-demo input[type=\"email\"],\n      .form-demo select,\n      .form-demo textarea {\n        font-size: 16px;\n        /* evita zoom automático en iOS */\n      }\n    }\n\n\n    /* === Iframe demo === */\n    .iframe-wrap {\n      border-radius: 14px;\n      overflow: hidden;\n      border: 2px solid #dfe7ff;\n      box-shadow: 0 6px 20px rgba(0, 0, 0, .08);\n    }\n\n    .iframe-wrap iframe {\n      display: block;\n      width: 100%;\n      height: 240px;\n      background: white;\n    }\n\n    @media (max-width: 600px) {\n      body {\n        padding: 0.75rem;\n      }\n\n      h1 {\n        font-size: 1.5rem;\n        margin-bottom: 0.5rem;\n      }\n\n      .hero-desc {\n        font-size: 0.95rem;\n        margin-bottom: 1.25rem;\n      }\n\n      main {\n        max-width: 100%;\n        padding: 0;\n      }\n\n      section {\n        padding: 1rem;\n        margin-bottom: 1.5rem;\n      }\n\n      .hero-block {\n        padding: 1.25rem 1rem 1rem;\n      }\n\n      .nav-toggle {\n        display: inline-flex;\n        align-items: center;\n      }\n\n      .nav-toggle-text {\n        display: inline;\n      }\n\n      .github-badge {\n        font-size: 0.85rem;\n        padding: 0.4rem 0.7rem;\n      }\n\n      #repo-link {\n        display: none;\n      }\n\n      #github-stars {\n        font-size: 0.85rem;\n      }\n\n      nav.demo-menu {\n        display: grid;\n        grid-template-columns: 1fr 1fr;\n        gap: 0.4rem;\n      }\n\n      nav.demo-menu a {\n        padding: 0.5rem 0.6rem;\n        font-size: 0.85rem;\n      }\n\n      .card,\n      .card.black {\n        padding: 1em;\n        font-size: 0.9em;\n        border-radius: var(--sd-radius-sm);\n      }\n\n      .output {\n        margin-top: 0.7rem;\n      }\n\n      .blend-card {\n        padding: 1em;\n        font-size: 0.85em;\n      }\n\n      .transition-box {\n        padding: 1em;\n        font-size: 0.9em;\n      }\n\n      .dancers {\n        font-size: 1.3rem;\n      }\n\n      button#capture,\n      button {\n        font-size: 0.9em;\n        padding: 0.5em 1em;\n        margin-bottom: 0.5em;\n      }\n\n      .export-text {\n        font-size: 1em;\n      }\n\n      .clip-card {\n        font-size: 1em;\n      }\n\n      .pseudo-box {\n        font-size: 1em;\n      }\n\n      #orbit-box {\n        height: 220px !important;\n        min-width: 0;\n        font-size: 0.8em;\n      }\n\n      .mask-grid {\n        grid-template-columns: 1fr;\n      }\n\n      .benchmark-output {\n        height: 200px;\n      }\n\n      .benchmark-container {\n        gap: 1rem;\n        flex-direction: column;\n      }\n\n      .benchmark-column {\n        max-width: 100%;\n      }\n    }\n\n    .comparison-row {\n      display: flex;\n      flex-wrap: wrap;\n      justify-content: space-between;\n      gap: 1rem;\n      margin-bottom: 1.5rem;\n      margin-top: 1rem;\n      flex-direction: row;\n    }\n\n    .comparison-row>div {\n      flex: 1;\n      min-width: 300px;\n      text-align: center;\n    }\n\n    .label {\n      font-weight: bold;\n      margin-bottom: 0.5rem;\n    }\n\n    .winner-glow {\n      animation: glow 1.2s infinite alternate ease-in-out;\n      border-radius: 20px;\n    }\n\n    .progress-message {\n      margin: 1rem 0;\n      font-size: 0.9rem;\n      color: #555;\n      font-weight: bold;\n    }\n\n    .result-message {\n      margin-top: 0.5rem;\n      font-weight: bold;\n      background: rgba(0, 0, 0, 0.05);\n      padding: 0.3rem;\n      border-radius: 4px;\n    }\n\n    .winner-message {\n      font-size: 1.4rem;\n      margin: 1rem 0;\n      padding: 0.8rem;\n      border-radius: 12px;\n      background: linear-gradient(145deg, #ffffff, #f3f8ff);\n      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n      display: inline-block;\n    }\n\n    .winner-message .speed-badge {\n      display: inline-block;\n      background: #4facfe;\n      color: white;\n      padding: 0.2rem 0.6rem;\n      border-radius: 20px;\n      font-weight: bold;\n      margin-left: 0.5rem;\n    }\n\n    .benchmark-container {\n      display: flex;\n      flex-wrap: wrap;\n      justify-content: center;\n      gap: 2rem;\n      width: 100%;\n    }\n\n    .benchmark-column {\n      flex: 1;\n      min-width: 100px;\n      max-width: calc(50% - 1rem);\n    }\n\n    .benchmark-output {\n      width: 100%;\n      height: 250px;\n      overflow: auto;\n      border: 1px solid #eee;\n      border-radius: 8px;\n      padding: 10px;\n      background: #292e3d24;\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: center;\n    }\n\n    .benchmark-output img,\n    .benchmark-output canvas {\n      max-width: 100%;\n      max-height: 200px;\n      object-fit: contain;\n      margin: 0 auto;\n      display: block;\n    }\n\n    @keyframes glow {\n      from {\n        box-shadow: 0 0 8px 2px rgba(0, 255, 0, 0.4);\n        transform: scale(1.01);\n      }\n\n      to {\n        box-shadow: 0 0 14px 4px rgba(0, 255, 0, 0.6);\n        transform: scale(1.02);\n      }\n    }\n\n    @media (min-width: 601px) {\n      .nav-collapsible {\n        max-height: none !important;\n        padding: 0;\n        margin-top: 0;\n      }\n\n      .nav-toggle {\n        display: none !important;\n      }\n\n      .nav-label {\n        display: block;\n      }\n    }\n\n    @media (max-width: 600px) {\n      .nav-label {\n        display: none;\n      }\n    }\n\n    [id] {\n      scroll-margin-top: 1rem;\n    }\n\n    .card.shadow-demo {\n      width: 100%;\n      max-width: 400px;\n    }\n\n    @media (max-width: 600px) {\n      .benchmark-column {\n        max-width: 100%;\n        min-width: 0;\n      }\n\n      .proxy-ctrls {\n        grid-template-columns: 1fr;\n      }\n    }\n\n    /* CORS Proxy demo */\n    .proxy-card {\n      position: relative;\n      border-radius: 16px;\n      padding: 1rem;\n      background: #fff;\n      border: 2px solid #dfe7ff;\n      box-shadow: 0 6px 20px rgba(0, 0, 0, .08);\n    }\n\n    .proxy-hero {\n      height: 120px;\n      border-radius: 12px;\n      background-repeat: no-repeat;\n      background-size: contain;\n      background-position: center;\n      border: 1px solid #eaeefc;\n      display: grid;\n      place-items: center;\n      overflow: hidden;\n      background-color: white;\n    }\n\n    .proxy-ctrls {\n      display: grid;\n      grid-template-columns: 1fr auto auto;\n      gap: .5rem;\n      margin-top: .75rem;\n      align-items: center;\n    }\n\n    .proxy-ctrls input[type=\"text\"] {\n      width: 100%;\n      box-sizing: border-box;\n      border: 2px solid #3658f1;\n      border-radius: 10px;\n      padding: .5rem .7rem;\n      font-size: 0.95rem;\n      background: #fff;\n    }\n\n    .proxy-credit {\n      margin-top: .6rem;\n      font-size: .9rem;\n      color: #444;\n      opacity: .9;\n      text-align: right;\n    }\n\n  </style>\n  <style>\n    .container-gyro {\n      background: black;\n    }\n\n\n    .gyro {\n      transform-style: preserve-3d;\n      perspective: 300px;\n    }\n\n    .z-15 {\n      transform: translateZ(-15px);\n    }\n\n    .z-35 {\n      transform: translateZ(-35px);\n    }\n\n    .z-75 {\n      transform: translateZ(-75px);\n    }\n\n    .z-85 {\n      transform: translateZ(-85px);\n    }\n\n    .z-110 {\n      transform: translateZ(-110px);\n    }\n\n    .z-120 {\n      transform: translateZ(-120px);\n    }\n\n    .z-155 {\n      transform: translateZ(-155px);\n    }\n\n    .cyan {\n      --o-fill: var(--o-cyan)\n    }\n\n    .cyan-light {\n      --o-fill: var(--o-cyan-light)\n    }\n\n    .cyan-dark-transparent {\n      --o-fill: var(--o-cyan-dark);\n      opacity: 0.6\n    }\n\n    .pink {\n      --o-fill: var(--o-pink)\n    }\n\n    .transparent {\n      --o-fill: none\n    }\n\n    .white {\n      color: white\n    }\n\n    .dashed-small {\n      opacity: 0.5;\n      height: 3px;\n      background: var(--o-cyan-darker);\n    }\n\n    .dashed-big {\n      opacity: 0.8;\n      height: 6px;\n      background: var(--o-cyan);\n\n    }\n\n    .gyro {\n      animation: tiltLoop 10s ease-in-out 5 alternate;\n    }\n\n    @keyframes tiltLoop {\n      0% {\n        transform: rotateX(-35deg) rotateY(-35deg);\n      }\n\n      25% {\n        transform: rotateX(-35deg) rotateY(35deg);\n      }\n\n      50% {\n        transform: rotateX(35deg) rotateY(35deg);\n      }\n\n      75% {\n        transform: rotateX(35deg) rotateY(-35deg);\n      }\n\n      100% {\n        transform: rotateX(-35deg) rotateY(-35deg);\n      }\n    }\n  </style>\n</head>\n\n<body>\n  <header class=\"hero-block\">\n    <h1>SnapDOM</h1>\n    <p class=\"hero-tagline\">Next-generation DOM capture engine — fast, modular, extensible.</p>\n    <a href=\"https://github.com/zumerlab/snapdom\" id=\"github-badge\" class=\"github-badge\" target=\"_blank\" rel=\"noopener\" data-umami-event=\"SnapDOM repo\">\n      <i class=\"fab fa-github\"></i>\n      <span id=\"repo-link\">zumerlab/snapdom</span>\n      <span id=\"github-stars\"><i class=\"fas fa-star\"></i><span id=\"star-count\">…</span></span>\n    </a>\n\n    <div class=\"nav-section\">\n      <p class=\"nav-label\">Browse demos</p>\n      <button id=\"menu-toggle\" class=\"nav-toggle\" aria-expanded=\"false\" aria-controls=\"demo-menu\">\n        <span class=\"nav-icon\" aria-hidden=\"true\"></span>\n        <span class=\"nav-toggle-text\">Browse demos</span>\n      </button>\n      <div id=\"menu-container\" class=\"nav-collapsible\">\n        <nav id=\"demo-menu\" class=\"demo-menu\">\n          <a href=\"./labs.html\">✨ Labs</a>\n          <a href=\"#benchmark\">Benchmark</a>\n          <a href=\"#basic\">Basic</a>\n          <a href=\"#ascii-section\">ASCII Plugin</a>\n          <a href=\"#ts-section\">Timestamp</a>\n          <a href=\"#transition\">Transition</a>\n          <a href=\"#opts-demo\">Shadows</a>\n          <a href=\"#orbit\">Orbit</a>\n          <a href=\"#fonts-demo\">Fonts</a>\n          <a href=\"#shadow\">Shadow DOM</a>\n          <a href=\"#canvas\">Canvas</a>\n          <a href=\"#export\">Export</a>\n          <a href=\"#pseudo\">Pseudo</a>\n          <a href=\"#clip\">Clip-Path</a>\n          <a href=\"#blend\">Blend</a>\n          <a href=\"#iframe\">Iframe</a>\n          <a href=\"#forms\">Inputs</a>\n          <a href=\"#mask\">Masking</a>\n          <a href=\"#cors\">CORS</a>\n          <a href=\"#fullbody\">Full Page</a>\n        </nav>\n      </div>\n    </div>\n  </header>\n\n  <main>\n\n    <!-- Benchmark -->\n    <section id=\"benchmark\">\n      <h2>🏁 Benchmark: snapDOM vs html2canvas</h2>\n      <p style=\"text-align:center; max-width: 700px; margin: 0 auto 1rem; font-size: 1.05rem;\">\n        Each library will capture the same DOM element to canvas 5 times. We'll calculate average speed and show the\n        winner.\n      </p>\n\n      <div style=\"display: flex; justify-content: center; margin: 2rem 0;  margin: 0 auto; \">\n        <div class=\"card pseudo-box\" id=\"benchmark-box\" style=\"text-align: center; max-width: 250px;padding: 1rem;\">\n          This is the benchmark test element to be captured by both libraries.\n        </div>\n      </div>\n\n      <div style=\"text-align:center; margin-bottom: 2rem;\">\n        <button data-umami-event=\"Run benchmarks\" class=\"run-benchmark-button\" onclick=\"runBenchmark()\">Run\n          Benchmark</button>\n      </div>\n\n      <div id=\"benchmark-result\" style=\"text-align: center; margin-bottom: 2rem; font-size: 1.2rem; font-weight: bold;\">\n      </div>\n\n      <div class=\"benchmark-container\">\n        <div class=\"benchmark-column\">\n          <div class=\"label\">snapDOM</div>\n          <div id=\"snapdom-benchmark-output\" class=\"benchmark-output\">\n            <div class=\"progress-message\">Waiting to start...</div>\n          </div>\n        </div>\n        <div class=\"benchmark-column\">\n          <div class=\"label\">html2canvas</div>\n          <div id=\"h2c-benchmark-output\" class=\"benchmark-output\">\n            <div class=\"progress-message\">Waiting to start...</div>\n          </div>\n        </div>\n      </div>\n    </section>\n\n    <!-- Basic -->\n    <section id=\"basic\">\n      <h2>📦 Basic</h2>\n      <div class=\"card\" id=\"basic-box\">\n        <h3>Hello SnapDOM!</h3>\n      </div>\n      <button data-umami-event=\"Capture basic\" onclick=\"captureDemo('basic-box', 'basic-output', this)\">Capture</button>\n      <button data-umami-event=\"Download basic\" onclick=\"downloadDemo('basic-box')\">Download</button>\n      <div class=\"output\" id=\"basic-output\"></div>\n    </section>\n\n    <!-- Transforms & Shadows (isolated target only) -->\n    <section id=\"opts-demo\">\n      <h2>Transforms & Shadows</h2>\n\n      <div style=\"max-width:440px; margin-inline:auto;\">\n        <div id=\"opts-target\" style=\"\n  width: 250px; height: 100px; margin: auto;\n  background: linear-gradient(135deg, #7bdff2, #f7d5b2);\n  border-radius: 14px;\n  box-shadow: 0 14px 28px rgba(26,34,51,.25);\n  transform: rotate(8deg) scale(1.06);\n  transform-origin: 50% 50%;\n  display:grid; place-items:center; color:#1a2233; font-weight:800;\">\n          Transformed + Shadow\n        </div>\n        <p></p>\n        <p style=\"text-align:center; margin:0.5rem 0 0;\">\n          Capture it <strong>just</strong> with <code>outerTransforms</code> /\n          <code>outerShadows</code>.\n        </p>\n      </div>\n\n      <div style=\"display:grid; gap:.6rem; justify-items:center; margin-top:.6rem;\">\n        <label style=\"display:flex; gap:.5rem; align-items:center;\">\n          <input id=\"opts-flat\" type=\"checkbox\" checked />\n          <span><code>outerTransforms</code> (keep external CSS transforms)</span>\n        </label>\n        <label style=\"display:flex; gap:.5rem; align-items:center;\">\n          <input id=\"opts-shadow\" type=\"checkbox\" checked />\n          <span><code>outerShadows</code> (keep external CSS shadows)</span>\n        </label>\n\n        <div style=\"display:flex; gap:.5rem; flex-wrap:wrap;\">\n          <button data-umami-event=\"Capture\" onclick=\"captureOptsTarget(this)\">Capture</button>\n          <button data-umami-event=\"Download\" onclick=\"downloadOptsTarget()\">Download</button>\n        </div>\n      </div>\n\n      <div class=\"output\" id=\"opts-demo-output\"></div>\n    </section>\n\n   <!-- ASCII / SVG -->\n<section id=\"ascii-section\">\n  <h2>🅰️ ASCII Plugin</h2>\n\n  <div class=\"row\" style=\"display:flex;gap:.75rem;align-items:center;margin-bottom:10px;justify-content:center;\">\n    <label for=\"asciiDemoSelect\">Target:</label>\n    <select id=\"asciiDemoSelect\">\n      <option value=\"demo-emoji\">Emoji grid</option>\n      <option value=\"demo-image\">Image poster</option>\n      <option value=\"demo-giant\">Text logo</option>\n    </select>\n\n    <label style=\"display:flex; gap:.5rem; align-items:center;\">\n      <input id=\"ascii-enable\" type=\"checkbox\" />\n      <span>Enable ASCII (otherwise SVG)</span>\n    </label>\n\n    <button id=\"ascii-btn\" onclick=\"captureAsciiSection(this)\">Capture</button>\n  </div>\n\n  <!-- Escenario con los 4 demos -->\n  <div class=\"stage\" style=\"display:grid;gap:1rem;align-items:center;justify-items:center;margin-bottom:12px\">\n\n    <!-- Emoji -->\n    <div id=\"demo-emoji\" class=\"demo hidden\" style=\"\n      width:420px; height:240px; border-radius:14px; color:white; display:grid; place-items:center;\n      box-shadow:0 12px 30px rgba(0,0,0,0.35); overflow:hidden;\n      background: radial-gradient(120% 120% at 20% 20%, #2a2f3a 0%, #14161a 55%, #0d0f12 100%);\n      grid-template-columns:repeat(6,1fr); gap:.2rem; font-size:58px;\">\n      <span>🚀</span><span>✨</span><span>🧠</span><span>🖼️</span><span>🎛️</span><span>🧪</span>\n      <span>💡</span><span>🧩</span><span>📦</span><span>🧭</span><span>⚙️</span><span>🛡️</span>\n      <span>📈</span><span>🖥️</span><span>📐</span><span>🧵</span><span>🕹️</span><span>🎨</span>\n      <span>🔬</span><span>🗜️</span><span>🧰</span><span>🔧</span><span>🪄</span><span>🧷</span>\n    </div>\n\n    <!-- Image (CORS OK) -->\n    <div id=\"demo-image\" class=\"demo hidden\" style=\"\n      width:420px; height:240px; border-radius:14px; color:white; display:grid; grid-template-rows:1fr auto; place-items:center;\n      box-shadow:0 12px 30px rgba(0,0,0,0.35); overflow:hidden; background:#000;\">\n      <img id=\"photo\" alt=\"Landscape demo for capture\" loading=\"lazy\"\n        src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Cdefs%3E%3ClinearGradient id='a' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%233b82f6'/%3E%3Cstop offset='60%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%2393c5fd'/%3E%3C/linearGradient%3E%3ClinearGradient id='b' x1='0' y1='1' x2='0' y2='0'%3E%3Cstop offset='0%25' stop-color='%2315803d'/%3E%3Cstop offset='40%25' stop-color='%2322c55e'/%3E%3Cstop offset='100%25' stop-color='%234ade80'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23a)' width='400' height='200'/%3E%3Crect fill='url(%23b)' y='150' width='400' height='150'/%3E%3C/svg%3E\"\n        style=\"width:100%; height:100%; object-fit:cover; filter: contrast(1.1) saturate(1.15);\" />\n      <div class=\"legend\" style=\"margin:-.5rem 0 .5rem; background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.12);\n        padding:.25rem .5rem; border-radius:6px; font-size:12px;\">Inline SVG landscape</div>\n    </div>\n\n    <!-- Giant text -->\n    <div id=\"demo-giant\" class=\"demo hidden\" style=\"\n      width:420px; height:240px; border-radius:14px; color:white; display:grid; place-items:center;\n      box-shadow:0 12px 30px rgba(0,0,0,0.35); overflow:hidden;\n      background: radial-gradient(circle at 40% 40%, #1c1e26 0%, #090a0f 100%); position:relative;\">\n      <div style=\"font-weight:900; font-size:88px; line-height:.9; letter-spacing:-2px; text-align:center;\n        background: linear-gradient(90deg, #9cf, #0f0); -webkit-background-clip:text; color:transparent;\n        filter: drop-shadow(0 2px 8px rgba(0,255,0,0.5));\">SNAP<br>DOM</div>\n      <div style=\"text-align:center; font-size:24px; color:#8f9; margin-top:12px; letter-spacing:2px; opacity:.85;\">\n        DOM → IMAGE ENGINE\n      </div>\n    </div>\n  </div>\n\n  <div id=\"ascii-output\" class=\"output\" style=\"\n    display:block; max-width: 90vw; margin: 0 auto; text-align:center;\n    border: 1px solid #143; border-radius: 8px; padding: .75rem; background: transparent\">\n    <!-- aquí aparece el ASCII o el SVG -->\n  </div>\n</section>\n<style>\n  .hidden { display: none !important; }\n</style>\n<!-- Timestamp Overlay (afterClone) -->\n<section id=\"ts-section\">\n  <h2>🕒 Timestamp Plugin</h2>\n\n  <div id=\"ts-target\" class=\"demo\" style=\"\n    width: 420px; height: 240px; border-radius:14px; color:white; display:grid; place-items:center;\n    box-shadow:0 12px 30px rgba(0,0,0,.35); overflow:hidden;\n    background: linear-gradient(135deg, #0ea5e9, #22c55e); font-weight:900; font-size:58px; margin: 0 auto 12px;\">\n    <div>SnapDOM</div>\n    <small style=\"opacity:.9; font-size:24px; font-weight:700; white-space:nowrap\">Timestamp demo</small>\n  </div>\n\n  <div style=\"display:flex; gap:.75rem; align-items:center; justify-content:center; flex-wrap:wrap; margin:.5rem 0 1rem\">\n    <label style=\"display:flex; gap:.5rem; align-items:center;\">\n      <input id=\"ts-enable\" type=\"checkbox\" checked />\n      <span>Enable timestamp</span>\n    </label>\n    <button id=\"ts-btn\" onclick=\"captureTimestampDemo(this)\">Capture</button>\n  </div>\n\n  <div id=\"ts-output\" class=\"output\" style=\"\n    display:grid; place-items:center; min-height:160px; border:1px solid rgba(255,255,255,.1); border-radius:10px; padding:10px;\">\n    <!-- acá se muestra la imagen resultante -->\n  </div>\n</section>\n    <!-- Transition -->\n    <section id=\"transition\">\n      <h2>🚀 Fun Transition</h2>\n      <div class=\"card transition-box\" id=\"transition-box\">\n        <div class=\"dancers\">🕺💃</div>\n        <p style=\"margin: 0; font-weight: bold\">I'm dancing and changing color!</p>\n      </div>\n      <button data-umami-event=\"Capture transition\"\n        onclick=\"captureDemo('transition-box', 'transition-output', this)\">Capture</button>\n      <button data-umami-event=\"Download transition\" onclick=\"downloadDemo('transition-box')\">Download</button>\n      <div class=\"output\" id=\"transition-output\"></div>\n    </section>\n\n    <!-- Orbit -->\n    <section id=\"orbit\">\n      <h2>Orbit CSS toolkit - <a href=\"https://github.com/zumerlab/orbit/?utm_source=snapdom\"\n          data-umami-event=\"To orbit repo\" target=\"_blank\">Go to repo</a></h2>\n      <div class=\"card black\" id=\"orbit-box\"\n        style=\"height: 400px; overflow: hidden; background-color: black !important;\">\n        <div class=\"bigbang container-gyro\">\n          <div class=\"gravity-spot gyro\">\n            <div class=\"orbit-6 from-120 range-240 small-dash z-15\">\n              <!-- small dash elements -->\n            </div>\n            <div class=\"orbit-6 z-35\">\n              <o-arc value=\"25\" class=\"from-215 shrink-70 outer-orbit pink\"></o-arc>\n            </div>\n            <div class=\"orbit-5\">\n              <o-arc value=\"25\" class=\"from-315 shrink-80 inner-orbit cyan-light\"></o-arc>\n            </div>\n            <div class=\"orbit-5 from-48 range-130\">\n              <o-arc class=\" shrink-40 outer-orbit cyan-dark-transparent\"></o-arc>\n            </div>\n            <div class=\"orbit-5 from-180 range-100 big-dash\">\n              <!-- small dash elements -->\n            </div>\n            <div class=\"orbit-4 z-85\">\n              <o-arc value=\"40\" class=\"from-10 shrink-40 pink\"></o-arc>\n            </div>\n            <div class=\"orbit-6 from-320 z-110\">\n              <o-arc value=\"40\" class=\"shrink-40 cyan-light\"></o-arc>\n            </div>\n            <div class=\"orbit-6  from-30 z-120\">\n              <o-arc class=\"shrink-60 cyan-light\"></o-arc>\n              <o-arc class=\"shrink-60 transparent\"></o-arc>\n              <o-arc class=\"shrink-60 cyan-light\"></o-arc>\n              <o-arc class=\"shrink-60 transparent\"></o-arc>\n            </div>\n            <div class=\"orbit-6  from-100 z-155\">\n              <o-arc value=\"40\" class=\"shrink-90 inner-orbit cyan-light\"></o-arc>\n            </div>\n            <div class=\"orbit-6 from-185 range-80 z-75\">\n              <o-arc class=\"grow-0.5x cyan-dark-transparent\"></o-arc>\n            </div>\n            <div class=\"orbit-0 white\">ORBIT</div>\n          </div>\n        </div>\n      </div>\n\n\n\n      <script>\n        const container = document.querySelector('.container-gyro');\n        const gyro = document.querySelector('.gyro');\n        const sd = document.querySelector('.small-dash')\n        const bd = document.querySelector('.big-dash')\n\n        const smallDashs = [];\n        const bigDashs = [];\n\n        for (let i = 0; i < 30; i++) {\n          smallDashs.push(\"<div class='vector shrink-40 outer-orbit dashed-small'></div>\");\n        }\n\n        for (let i = 0; i < 15; i++) {\n          bigDashs.push(\"<div class='vector shrink-40 outer-orbit dashed-big'></div>\");\n        }\n\n        sd.innerHTML = smallDashs.join('');\n        bd.innerHTML = bigDashs.join('');\n\n      </script>\n\n      </div>\n      <button data-umami-event=\"Capture orbit\"\n        onclick=\"captureOrbit('orbit-box', 'orbit-output', this)\">Capture</button>\n      <button data-umami-event=\"Download orbit\" onclick=\"downloadDemo('orbit-box')\">Download</button>\n      <div class=\"output\" id=\"orbit-output\">\n\n      </div>\n    </section>\n\n\n\n    <!-- Fonts -->\n    <section id=\"fonts-demo\">\n      <h2>🔤 Google Fonts</h2>\n      <div class=\"card\" id=\"fonts-box\">\n        <h3 style=\"font-family: 'Mansalva', cursive\">Unique Typography!</h3>\n        <p style=\"font-family: 'Unbounded', cursive\">Google Fonts with <code>embedFonts: true</code>.</p>\n      </div>\n      <button data-umami-event=\"Capture fonts\" onclick=\"captureDemo('fonts-box', 'fonts-output', this)\">Capture</button>\n      <button data-umami-event=\"Download fonts\" onclick=\"downloadDemo('fonts-box')\">Download</button>\n      <div class=\"output\" id=\"fonts-output\"></div>\n    </section>\n\n    <!-- Shadow DOM -->\n    <section id=\"shadow\">\n      <h2>🧱 Shadow DOM</h2>\n      <div class=\"card\" id=\"shadow-host\"></div>\n      <button data-umami-event=\"Capture shadow\"\n        onclick=\"captureDemo('shadow-host', 'shadow-output', this)\">Capture</button>\n      <button data-umami-event=\"Download shadow\" onclick=\"downloadDemo('shadow-host')\">Download</button>\n      <div class=\"output\" id=\"shadow-output\"></div>\n    </section>\n\n    <!-- Canvas -->\n    <section id=\"canvas\">\n      <h2>🎨 Canvas</h2>\n      <div class=\"card\"><canvas id=\"myCanvas\" width=\"160\" height=\"160\" style=\"border: 1px solid #000\"></canvas></div>\n      <button data-umami-event=\"Capture canvas\"\n        onclick=\"captureDemo('myCanvas', 'canvas-output', this)\">Capture</button>\n      <button data-umami-event=\"Download canvas\" onclick=\"downloadDemo('myCanvas')\">Download</button>\n      <div class=\"output\" id=\"canvas-output\"></div>\n    </section>\n\n    <!-- Export -->\n    <section id=\"export\">\n      <h2>📁 Export Formats</h2>\n      <div class=\"card\" id=\"export-box\">\n        <div class=\"export-text\"><strong>📤 Export as</strong><br /><span class=\"export-format\">PNG</span>, <span\n            class=\"export-format\">JPG</span> & <span class=\"export-format\">WebP</span>.</div>\n      </div>\n      <button data-umami-event=\"Export formats\" onclick=\"exportFormats('export-box')\">Export</button>\n      <div class=\"output\" id=\"export-output\"></div>\n    </section>\n\n    <!-- Pseudo -->\n    <section id=\"pseudo\">\n      <h2>✨ Pseudo Elements</h2>\n      <div class=\"card pseudo-box\" id=\"pseudo-box\">This element has pseudo-elements.</div>\n      <button data-umami-event=\"Capture pseudo\"\n        onclick=\"captureDemo('pseudo-box', 'pseudo-output', this)\">Capture</button>\n      <button data-umami-event=\"Download pseudo\" onclick=\"downloadDemo('pseudo-box')\">Download</button>\n      <div class=\"output\" id=\"pseudo-output\"></div>\n    </section>\n\n    <!-- Clip-Path -->\n    <section id=\"clip\">\n      <h2>✂️ Clip-Path Demo</h2>\n      <div class=\"card clip-card\" id=\"clip-box\">This shape uses clip-path</div>\n      <button data-umami-event=\"Capture clip path\"\n        onclick=\"captureDemo('clip-box', 'clip-output', this)\">Capture</button>\n      <button data-umami-event=\"Download clip path\" onclick=\"downloadDemo('clip-box')\">Download</button>\n      <div class=\"output\" id=\"clip-output\"></div>\n    </section>\n\n    <!-- Blend -->\n    <section id=\"blend\">\n      <h2>🌀 Mix Blend Mode</h2>\n      <p class=\"demo-explanation\">CSS <code>background-blend-mode: multiply</code> — a gradient image (sky + grass) blended with a blue overlay. SnapDOM captures the final rendered result.</p>\n      <div class=\"card blend-card\" id=\"blend-box\">Gradient × blue overlay = tinted result</div>\n      <button data-umami-event=\"Capture blend\" onclick=\"captureDemo('blend-box', 'blend-output', this)\">Capture</button>\n      <button data-umami-event=\"Download blend\" onclick=\"downloadDemo('blend-box')\">Download</button>\n      <div class=\"output\" id=\"blend-output\"></div>\n    </section>\n\n    <!-- NEW: Iframe same-origin -->\n    <section id=\"iframe\">\n      <h2>🧩 Iframe (same-origin)</h2>\n      <div class=\"card iframe-wrap\">\n        <iframe id=\"demo-iframe\" title=\"Same-origin demo\" loading=\"lazy\" sandbox=\"allow-same-origin allow-scripts\" srcdoc=\"<!DOCTYPE html>\n<html>\n<head>\n<meta charset='utf-8'>\n<style>\n  body { font-family: system-ui, Arial; margin: 0; padding: 16px; background: #f4f8ff; }\n  .box { padding: 14px; border-radius: 12px; background: white; border: 2px solid #3658f1; position: relative; }\n  .box::before { content: 'iframe:'; position: absolute; top: -10px; left: 12px; background: #3658f1; color: white; font-weight: 700; font-size: 12px; padding: 2px 6px; border-radius: 6px; }\n  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; }\n  .pill { display: inline-block; padding: 4px 8px; border-radius: 999px; background: #edf2ff; border: 1px solid #cdd7ff; font-weight: 700; }\n</style>\n<body>\n  <div class='box'>\n    <h4>Same-origin content</h4>\n    <div class='row'><span class='pill'>pseudo-elements</span><span class='pill'>grid</span></div>\n  </div>\n</body>\n</html>\">\n        </iframe>\n      </div>\n      <button data-umami-event=\"Capture iframe\"\n        onclick=\"captureIframeBody('demo-iframe','iframe-output', this)\">Capture</button>\n      <button data-umami-event=\"Download iframe\" onclick=\"downloadIframeBody('demo-iframe')\">Download</button>\n      <div class=\"output\" id=\"iframe-output\"></div>\n    </section>\n\n    <!-- NEW: Inputs & Textarea -->\n    <section id=\"forms\">\n      <h2>⌨️ Inputs & Textarea</h2>\n      <div class=\"card\" id=\"forms-box\">\n        <form class=\"form-demo\" onsubmit=\"return false;\">\n          <div class=\"form-row inline\">\n            <div>\n              <label for=\"name\">Name</label>\n              <input id=\"name\" type=\"text\" placeholder=\"John Doe\" value=\"Ada Lovelace\" />\n            </div>\n            <div>\n              <label for=\"email\">Email</label>\n              <input id=\"email\" type=\"email\" placeholder=\"ada@math.org\" value=\"ada@math.org\" />\n            </div>\n          </div>\n\n          <div class=\"form-row inline\">\n            <div>\n              <label for=\"role\">Role</label>\n              <select id=\"role\">\n                <option>Developer</option>\n                <option selected>Researcher</option>\n                <option>Designer</option>\n              </select>\n            </div>\n            <div style=\"display:grid; grid-template-columns:auto 1fr; align-items:center; gap:.5rem;\">\n              <input id=\"newsletter\" type=\"checkbox\" checked />\n              <label for=\"newsletter\" style=\"margin:0;\">Subscribe</label>\n            </div>\n          </div>\n\n          <div class=\"form-row\" style=\"align-items:center; grid-template-columns:auto auto auto; gap:1rem;\">\n            <label><input type=\"radio\" name=\"level\" value=\"junior\"> Junior</label>\n            <label><input type=\"radio\" name=\"level\" value=\"mid\" checked> Mid</label>\n            <label><input type=\"radio\" name=\"level\" value=\"senior\"> Senior</label>\n          </div>\n\n          <div class=\"form-row\">\n            <label for=\"bio\">Bio</label>\n            <textarea id=\"bio\" rows=\"3\"\n              style=\"resize:vertical;\">Pioneer of computing. Loves analytical engines.</textarea>\n          </div>\n        </form>\n      </div>\n      <button data-umami-event=\"Capture forms\" onclick=\"captureDemo('forms-box','forms-output', this)\">Capture</button>\n      <button data-umami-event=\"Download forms\" onclick=\"downloadDemo('forms-box')\">Download</button>\n      <div class=\"output\" id=\"forms-output\"></div>\n    </section>\n\n    <!-- NEW: Masking Effects -->\n    <section id=\"mask\">\n      <h2>🎭 Masking Effects</h2>\n\n      <!-- SVG defs for mask -->\n      <svg width=\"0\" height=\"0\" style=\"position:absolute;\">\n        <defs>\n          <mask id=\"svg-blob-mask\">\n            <rect x=\"0\" y=\"0\" width=\"100%\" height=\"100%\" fill=\"black\"></rect>\n            <circle cx=\"35%\" cy=\"40%\" r=\"28%\" fill=\"white\"></circle>\n            <circle cx=\"65%\" cy=\"60%\" r=\"30%\" fill=\"white\"></circle>\n          </mask>\n        </defs>\n      </svg>\n\n      <div class=\"mask-grid\">\n        <div class=\"mask-card mask-radial\" id=\"mask-radial\">\n          <div class=\"mask-bg\"></div>\n          <div class=\"mask-label\">CSS radial mask</div>\n        </div>\n\n        <div class=\"mask-card mask-png\" id=\"mask-png\">\n          <div class=\"mask-bg\"></div>\n          <div class=\"mask-label\">PNG circle mask</div>\n        </div>\n\n        <div class=\"mask-card mask-svg\" id=\"mask-svg\">\n          <div class=\"masked-box\"></div>\n          <div class=\"mask-label\">SVG mask</div>\n        </div>\n\n        <div class=\"mask-card\" id=\"mask-gradient\" style=\"background:#111;\">\n          <div\n            style=\"position:absolute; inset:0; background:linear-gradient(135deg,#8ec5fc,#e0c3fc); -webkit-mask-image: linear-gradient(90deg, transparent, black, transparent); mask-image: linear-gradient(90deg, transparent, black, transparent);\">\n          </div>\n          <div class=\"mask-label\">Linear gradient mask</div>\n        </div>\n      </div>\n\n      <div style=\"margin-top:1rem;\">\n        <button data-umami-event=\"Capture mask radial\" onclick=\"captureDemo('mask-radial','mask-output', this)\">Capture\n          radial</button>\n        <button data-umami-event=\"Capture mask png\" onclick=\"captureDemo('mask-png','mask-output', this)\">Capture\n          PNG</button>\n        <button data-umami-event=\"Capture mask svg\" onclick=\"captureDemo('mask-svg','mask-output', this)\">Capture\n          SVG</button>\n        <button data-umami-event=\"Capture mask gradient\"\n          onclick=\"captureDemo('mask-gradient','mask-output', this)\">Capture gradient</button>\n      </div>\n      <div class=\"output\" id=\"mask-output\"></div>\n    </section>\n    <!-- CORS Proxy -->\n   <section id=\"cors\">\n      <h2>🌐 CORS Proxy (useProxy)</h2>\n      <div class=\"proxy-card\">\n        <div id=\"proxy-hero\" class=\"proxy-hero\">\n          <span style=\"font-weight:600;color:#567; padding: .4rem .6rem; background:#eef4ff; border-radius:8px;\">\n            Image preview (background)\n          </span>\n        </div>\n\n        <div class=\"proxy-ctrls\">\n          <input id=\"proxy-url\" type=\"text\" placeholder=\"Paste an image URL (one that usually fails CORS)\"\n            value=\"https://cdn.sstatic.net/Sites/stackoverflow/company/img/logos/so/so-logo.png\" />\n          <button id=\"enable-proxy-btn\">Enable proxy</button>\n          <button id=\"disable-proxy-btn\">Disable</button>\n        </div>\n\n        <div style=\"display:flex; gap:.5rem; margin-top:.6rem; flex-wrap:wrap;\">\n          <button data-umami-event=\"Capture proxy demo\" onclick=\"captureCorsDemo(this)\">Capture</button>\n          <button data-umami-event=\"Download proxy demo\" onclick=\"downloadCorsDemo()\">Download</button>\n        </div>\n\n        <div class=\"proxy-credit\">CORS proxy by <a href=\"https://corsfix.com/\" target=\"_blank\">Corsfix</a></div>\n      </div>\n\n      <div class=\"output\" id=\"proxy-output\"></div>\n    </section> \n\n    <!-- Full Page -->\n    <section id=\"fullbody\">\n      <h2>🧾 Full Page Capture</h2>\n      <button data-umami-event=\"Capture full\" onclick=\"captureDemo('body', 'fullbody-output', this)\">Capture</button>\n      <button data-umami-event=\"Download full\" onclick=\"downloadDemo('body')\">Download</button>\n      <div class=\"output\" id=\"fullbody-output\"></div>\n    </section>\n\n\n     \n\n  </main>\n\n  <script>\n    // Crear componente con shadow DOM\n    class MyBox extends HTMLElement {\n      constructor() {\n        super()\n        const shadow = this.attachShadow({ mode: 'open' })\n        const container = document.createElement('section')\n        container.innerHTML = `\n          <style>\n            .fun-box {\n              display: flex; flex-direction: column; align-items: center;\n              padding: 18px 10px 10px 10px; border-radius: 12px;\n              background: linear-gradient(120deg, #f6d365 0%, #fda085 100%);\n              box-shadow: 0 2px 8px #fda08544; font-weight: bold; text-align: center;\n              font-size: 1.1rem; color: #2d3a4b; position: relative;\n            }\n            .emoji { font-size: 2.5rem; margin-bottom: 0.5rem; animation: spin 1.5s linear infinite alternate; }\n            @keyframes spin { 0% { transform: rotate(-10deg) scale(1); } 100% { transform: rotate(10deg) scale(1.15); } }\n            .msg { font-size: 1.1rem; margin-top: 0.2rem; }\n          </style>\n          <div class=\"fun-box\">\n            <div class=\"emoji\">🦄✨</div>\n            <div class=\"msg\">¡Shadow DOM mágico y divertido!<br>¡Haz una captura y comparte la magia!</div>\n          </div>\n        `\n        shadow.appendChild(container)\n      }\n    }\n    customElements.define('my-box', MyBox)\n\n    document.addEventListener('DOMContentLoaded', async () => {\n      // Shadow DOM ejemplo\n      const host = document.getElementById('shadow-host')\n      if (host && !host.shadowRoot) {\n        const shadow = host.attachShadow({ mode: 'open' })\n        shadow.innerHTML =\n          `<div style='padding: 1em; background: #b2f0ff; border-radius: 8px; font-weight:bold; text-align:center;'>¡Dentro del <strong>Shadow DOM</strong>! 🎩✨</div>`\n      }\n      // await preCache()\n    })\n\n    const canvas = document.getElementById('myCanvas')\n    if (canvas) {\n      const ctx = canvas.getContext('2d')\n      ctx.fillStyle = '#43e97b'\n      ctx.fillRect(20, 20, 100, 100)\n      ctx.strokeStyle = '#e52e71'\n      ctx.lineWidth = 4\n      ctx.strokeRect(20, 20, 100, 100)\n      ctx.font = 'bold 20px sans-serif'\n      ctx.fillStyle = '#fff'\n      ctx.fillText('🎨', 60, 80)\n    }\n\n    let captureDemoLock = false\n    const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent)\n    async function captureDemo(id, outputId, btn) {\n      if (captureDemoLock) return\n      captureDemoLock = true\n      const el = id === 'body' ? document.body : document.getElementById(id)\n      const output = document.getElementById(outputId)\n      if (!el || !output) { captureDemoLock = false; return }\n      if (btn) btn.disabled = true\n\n      try {\n        const opts = {\n          embedFonts: id === 'body' || id === 'fonts-box',\n          scale: id === 'body' ? 0.4 : 1,\n          outerTransforms: true,\n          outerShadows: false,\n          cache: 'auto'\n        }\n        output.innerHTML = ''\n        if (isSafari()) {\n          const dataUrl = await snapdom.toRaw(el, opts)\n          const rect = el.getBoundingClientRect()\n          const iframe = document.createElement('iframe')\n          iframe.setAttribute('sandbox', 'allow-same-origin')\n          iframe.setAttribute('scrolling', 'no')\n          iframe.style.cssText = `width:${rect.width}px;height:${rect.height}px;border:0;overflow:hidden;`\n          iframe.src = dataUrl\n          output.appendChild(iframe)\n        } else {\n          const img = await snapdom.toImg(el, opts)\n          output.appendChild(img)\n        }\n      } finally {\n        captureDemoLock = false\n        if (btn) btn.disabled = false\n      }\n    }\n\n\n    let orbitCaptureLock = false\n    async function captureOrbit(id, outputId, btn) {\n      if (orbitCaptureLock) return\n      orbitCaptureLock = true\n      const el = document.getElementById(id)\n      const output = document.getElementById(outputId)\n      if (!el || !output) { orbitCaptureLock = false; return }\n      if (btn) btn.disabled = true\n      output.innerHTML = ''\n      try {\n        const opts = { outerShadows: true, cache: 'auto' }\n        if (isSafari()) {\n          const dataUrl = await snapdom.toRaw(el, opts)\n          const rect = el.getBoundingClientRect()\n          const iframe = document.createElement('iframe')\n          iframe.setAttribute('sandbox', 'allow-same-origin')\n          iframe.setAttribute('scrolling', 'no')\n          iframe.style.cssText = `width:${rect.width}px;height:${rect.height}px;border:0;overflow:hidden;`\n          iframe.src = dataUrl\n          output.appendChild(iframe)\n        } else {\n          const img = await snapdom.toImg(el, opts)\n          output.appendChild(img)\n        }\n      } finally {\n        orbitCaptureLock = false\n        if (btn) btn.disabled = false\n      }\n    }\n\n\n    async function downloadDemo(id) {\n      const el = id === 'body' ? document.body : document.getElementById(id)\n      if (!el) return\n      await snapdom.download(el, {\n        format: 'png',\n        name: id,\n        scale: id === 'body' ? 0.5 : 1,\n        quality: 1,\n        embedFonts: id === 'body' || id === 'fonts-box' ? true : false,\n        outerTransforms: true,\n        outerShadows: false\n      })\n    }\n\n    async function captureOptsTarget(btn) {\n      const target = document.getElementById('opts-target');\n      const output = document.getElementById('opts-demo-output');\n      if (!target || !output) return;\n\n      const outerTransforms = document.getElementById('opts-flat')?.checked;\n      const outerShadows = document.getElementById('opts-shadow')?.checked;\n\n      if (btn) btn.disabled = true;\n      const img = await snapdom.toImg(target, {\n        embedFonts: false,\n        outerTransforms,\n        outerShadows\n      });\n      output.innerHTML = '';\n      output.appendChild(img);\n      if (btn) btn.disabled = false;\n    }\n\n    async function downloadOptsTarget() {\n      const target = document.getElementById('opts-target');\n      if (!target) return;\n\n      const outerTransforms = document.getElementById('opts-flat')?.checked;\n      const outerShadows = document.getElementById('opts-shadow')?.checked;\n\n      await snapdom.download(target, {\n        format: 'png',\n        quality: 1,\n        embedFonts: false,\n        outerTransforms,\n        outerShadows\n      });\n    }\n\n    async function captureIframeBody(iframeId, outputId, btn) {\n      const iframe = document.getElementById(iframeId)\n      const output = document.getElementById(outputId)\n      if (!iframe || !output) return\n      if (btn) btn.disabled = true\n      const doc = iframe.contentDocument\n      const target = doc?.body\n      if (!target) return\n      const img = await snapdom.toImg(target, {})\n      output.innerHTML = ''\n      output.appendChild(img)\n      if (btn) btn.disabled = false\n    }\n\n    async function downloadIframeBody(iframeId) {\n      const iframe = document.getElementById(iframeId)\n      const doc = iframe?.contentDocument\n      const target = doc?.body\n      if (!target) return\n      await snapdom.download(target, {\n        format: 'png', name: 'iframe-body', scale: 1, quality: 1, outerTransforms: false,\n        outerShadows: true\n      })\n    }\n\n    async function exportFormats(id) {\n      const el = document.getElementById(id)\n      const result = await snapdom(el, {})\n      const output = document.getElementById('export-output')\n      output.innerHTML = ''\n      const [png, jpg, webp] = await Promise.all([result.toPng(), result.toJpg(), result.toWebp()])\n\n      output.append('PNG:', png, document.createElement('br'))\n      output.append('JPG:', jpg, document.createElement('br'))\n      output.append('WebP:', webp)\n    }\n  </script>\n\n  <script>\n    (function setupMobileMenu() {\n      const toggle = document.getElementById('menu-toggle');\n      const panel = document.getElementById('menu-container');\n      const menu = document.getElementById('demo-menu');\n      if (!toggle || !panel || !menu) return;\n\n      function setOpen(open) {\n        toggle.setAttribute('aria-expanded', String(open));\n        panel.classList.toggle('open', open);\n        if (open) panel.scrollTop = 0;            // <--- NUEVO: asegura ver todos los items\n        document.body.style.overflow = open ? 'hidden' : '';\n      }\n\n      function toggleMenu() {\n        const isOpen = panel.classList.contains('open');\n        setOpen(!isOpen);\n        if (!isOpen) {\n          const firstLink = menu.querySelector('a');\n          if (firstLink) firstLink.focus({ preventScroll: true });\n        } else {\n          toggle.focus({ preventScroll: true });\n        }\n      }\n\n      toggle.addEventListener('click', (e) => { e.preventDefault(); toggleMenu(); });\n      menu.addEventListener('click', (e) => {\n        const t = e.target;\n        if (t && t.tagName === 'A' && panel.classList.contains('open')) setOpen(false);\n      });\n      document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) setOpen(false); });\n      const mql = window.matchMedia('(min-width: 601px)');\n      mql.addEventListener?.('change', () => { if (mql.matches) setOpen(false); });\n    })();\n  </script>\n\n\n  <script type=\"module\">\n        import { snapdom } from 'https://unpkg.com/@zumer/snapdom/dist/snapdom.mjs';\n\n\n    window.snapdom = snapdom;\n\n    // html2canvas carga solo al hacer benchmark (evita bloqueo en Safari por ESM)\n    let html2canvas = null;\n    async function loadHtml2Canvas() {\n      if (!html2canvas) {\n        const mod = await import('https://cdn.skypack.dev/html2canvas');\n        html2canvas = mod.default;\n      }\n      return html2canvas;\n    }\n\n    window.runBenchmark = async function () {\n      const el = document.getElementById(\"benchmark-box\");\n      const snapContainer = document.getElementById(\"snapdom-benchmark-output\");\n      const h2cContainer = document.getElementById(\"h2c-benchmark-output\");\n      const resultText = document.getElementById(\"benchmark-result\");\n      const benchmarkBtn = document.querySelector(\".run-benchmark-button\");\n\n      if (!el || !snapContainer || !h2cContainer || !resultText || !benchmarkBtn) return;\n\n      snapContainer.innerHTML = '<div class=\"progress-message\">Starting snapDOM test...</div>';\n      h2cContainer.innerHTML = '<div class=\"progress-message\">Loading html2canvas...</div>';\n      resultText.textContent = \"\";\n      benchmarkBtn.disabled = true;\n      benchmarkBtn.textContent = \"Running benchmark...\";\n\n      let h2c;\n      try {\n        h2c = await loadHtml2Canvas();\n      } catch (e) {\n        h2cContainer.innerHTML = '<div class=\"progress-message\" style=\"color:#c00\">html2canvas failed to load (Safari?). Showing snapDOM only.</div>';\n        h2c = null;\n      }\n\n      const forceRender = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));\n\n      // Benchmark snapDOM\n      let snapTotal = 0;\n      let snapCanvas;\n      for (let i = 0; i < 5; i++) {\n        snapContainer.innerHTML = '<div class=\"progress-message\">Capture ' + (i + 1) + '/5...</div>';\n        await forceRender();\n        const t0 = performance.now();\n        snapCanvas = await snapdom.toImg(el);\n        snapTotal += performance.now() - t0;\n        snapContainer.innerHTML = '';\n        snapContainer.appendChild(snapCanvas);\n        snapContainer.insertAdjacentHTML('beforeend', '<div class=\"progress-message\">snapDOM: ' + (i + 1) + '/5</div>');\n        await forceRender();\n      }\n\n      let h2cTotal = 0;\n      let h2cCanvas;\n      if (h2c) {\n        h2cContainer.innerHTML = '<div class=\"progress-message\">Starting html2canvas test...</div>';\n        await forceRender();\n        for (let i = 0; i < 5; i++) {\n          h2cContainer.innerHTML = '<div class=\"progress-message\">html2canvas: Capture ' + (i + 1) + '/5...</div>';\n          await forceRender();\n          const t0 = performance.now();\n          h2cCanvas = await h2c(el, { logging: false });\n          h2cTotal += performance.now() - t0;\n          h2cContainer.innerHTML = '';\n          h2cContainer.appendChild(h2cCanvas);\n          h2cContainer.insertAdjacentHTML('beforeend', '<div class=\"progress-message\">html2canvas: ' + (i + 1) + '/5</div>');\n          await forceRender();\n        }\n      } else {\n        h2cTotal = snapTotal * 2;\n        h2cCanvas = null;\n      }\n\n      const snapAvg = snapTotal / 5;\n      const h2cAvg = h2cTotal / 5;\n      snapContainer.insertAdjacentHTML('beforeend', '<div class=\"result-message\">Average: ' + snapAvg.toFixed(1) + ' ms</div>');\n      if (h2c) {\n        h2cContainer.insertAdjacentHTML('beforeend', '<div class=\"result-message\">Average: ' + h2cAvg.toFixed(1) + ' ms</div>');\n      }\n\n      const winnerMessage = !h2c\n        ? 'snapDOM benchmark complete (html2canvas unavailable)'\n        : (snapAvg < h2cAvg\n          ? 'snapDOM wins! (' + (h2cAvg / snapAvg).toFixed(1) + 'x faster)'\n          : 'html2canvas wins! (' + (snapAvg / h2cAvg).toFixed(1) + 'x faster)');\n      resultText.innerHTML = '<div class=\"winner-message\">' + winnerMessage + '</div>';\n\n      snapContainer.classList.toggle('winner-glow', !h2c || snapAvg < h2cAvg);\n      h2cContainer.classList.toggle('winner-glow', h2c && h2cAvg < snapAvg);\n\n      benchmarkBtn.disabled = false;\n      benchmarkBtn.textContent = 'Run Benchmark Again';\n    };\n\n    let PROXY_BASE = \"\"; // disabled by default\n\n    /**\n     * Enable Corsfix proxy.\n     * @returns {void}\n     */\n    function enableProxy() {\n      PROXY_BASE = \"https://proxy.corsfix.com/?\";\n      const enableBtn = document.getElementById(\"enable-proxy-btn\");\n      const disableBtn = document.getElementById(\"disable-proxy-btn\");\n      if (enableBtn) enableBtn.disabled = true;\n      if (disableBtn) disableBtn.disabled = false;\n    }\n\n    /**\n     * Disable proxy.\n     * @returns {void}\n     */\n    function disableProxy() {\n      PROXY_BASE = \"\";\n      const enableBtn = document.getElementById(\"enable-proxy-btn\");\n      const disableBtn = document.getElementById(\"disable-proxy-btn\");\n      if (enableBtn) enableBtn.disabled = false;\n      if (disableBtn) disableBtn.disabled = true;\n    }\n\n    /**\n     * Update preview box background with provided URL.\n     * @param {string} url\n     * @returns {void}\n     */\n    function setProxyHeroBackground(url) {\n      const hero = document.getElementById(\"proxy-hero\");\n      if (hero) hero.style.backgroundImage = url ? `url(\"${url}\")` : \"none\";\n    }\n    /**\n     * Demo-only convenience wrapper over snapdom.toImg that injects the current proxy state.\n     * @param {Element} el - Target element to capture.\n     * @param {object} [opts] - Extra snapdom options.\n     * @returns {Promise<HTMLImageElement>}\n     */\n    async function toImgCors(el, opts = {}) {\n      return snapdom.toImg(el, {\n        ...opts,\n        // Honor current proxy toggle\n        useProxy: PROXY_BASE || '',\n        cache: 'disabled'\n      });\n    }\n\n    /**\n     * Read the current URL from the CORS demo input and update the hero background.\n     * @returns {string} - The normalized URL from the input.\n     */\n    function getCorsDemoUrl() {\n      const urlInput = document.getElementById('proxy-url');\n      const url = (urlInput?.value || '').trim();\n      setProxyHeroBackground(url);\n      return url;\n    }\n\n    /**\n     * Capture the CORS demo card using the toImgCors wrapper.\n     * @param {HTMLButtonElement} [btn]\n     * @returns {Promise<void>}\n     */\n    async function captureCorsDemo(btn) {\n      const el = document.getElementById('proxy-hero');\n      const output = document.getElementById('proxy-output');\n      if (!el || !output) return;\n\n      if (btn) btn.disabled = true;\n      // Ensure the preview URL is reflected in CSS background\n      getCorsDemoUrl();\n\n      const img = await toImgCors(el, {});\n\n      output.innerHTML = '';\n      output.appendChild(img);\n      if (btn) btn.disabled = false;\n    }\n\n    /**\n     * Download the CORS demo card honoring the current proxy toggle.\n     * @returns {Promise<void>}\n     */\n    async function downloadCorsDemo() {\n      const el = document.getElementById('proxy-hero');\n      if (!el) return;\n\n      // Ensure background reflects the latest input before downloading\n      getCorsDemoUrl();\n\n      await snapdom.download(el, {\n        format: 'png',\n        name: 'cors-demo',\n        useProxy: PROXY_BASE || '',\n        cache: 'disabled'\n      });\n    }\n\n    document.addEventListener(\"DOMContentLoaded\", () => {\n      const urlInput = document.getElementById(\"proxy-url\");\n      const enableBtn = document.getElementById(\"enable-proxy-btn\");\n      const disableBtn = document.getElementById(\"disable-proxy-btn\");\n\n      // Inicial\n      if (urlInput) setProxyHeroBackground(urlInput.value || \"\");\n\n      urlInput?.addEventListener(\"change\", () => {\n        setProxyHeroBackground(urlInput.value.trim());\n      });\n\n      enableBtn?.addEventListener(\"click\", enableProxy);\n      disableBtn?.addEventListener(\"click\", disableProxy);\n\n      // Estado inicial de botones\n      if (enableBtn) enableBtn.disabled = false;\n      if (disableBtn) disableBtn.disabled = true;\n\n\n    });\n    Object.assign(window, {\n      captureCorsDemo,\n      downloadCorsDemo,\n      enableProxy,\n      disableProxy,\n    });\n\n// --- Helpers de UI para elegir demo ---\nconst asciiSelect = document.getElementById('asciiDemoSelect');\nfunction showAsciiDemo(id) {\n  document.querySelectorAll('#ascii-section .demo').forEach(el => el.classList.add('hidden'));\n  const el = document.getElementById(id);\n  if (el) el.classList.remove('hidden');\n}\nif (asciiSelect) {\n  showAsciiDemo(asciiSelect.value);\n  asciiSelect.addEventListener('change', () => showAsciiDemo(asciiSelect.value));\n}\n\n// --- Plugin mínimo: result.toAscii(opts) -> Promise<string HTML> ---\nfunction asciiExportPlugin() {\n  return {\n    name: 'ascii-export',\n    async defineExports(ctx) {\n      async function ascii(_ctx, opts = {}) {\n        const options = { cols: 120, contrast: 0.8, invert: false, charAspect: 2.0, ...opts };\n        const canvas = await ctx.exports.canvas();\n        return canvasToAsciiHTML(canvas, options);\n      }\n      return { ascii };\n    }\n  };\n}\n\n// Conversión Canvas -> ASCII (HTML con <pre>)\nfunction canvasToAsciiHTML(canvas, opts) {\n  const { cols, contrast, invert, charAspect } = opts;\n  const charset = '@%#WMNHQ$OC?7>!1=+;:~-,._  ';\n  const rampLen = charset.length;\n  const W = canvas.width|0, H = canvas.height|0;\n  const g = canvas.getContext('2d', { willReadFrequently: true });\n  const data = g.getImageData(0,0,W,H).data;\n\n  const blockW = Math.max(1, (W/cols)|0);\n  const blockH = Math.max(1, (blockW*charAspect)|0);\n  const rows = Math.max(1, (H/blockH)|0);\n\n  let out = '<pre style=\"margin:0;font:7px/1 monospace;white-space:pre;\">';\n  for (let r=0;r<rows;r++){\n    const y0=r*blockH, y1=Math.min(H,y0+blockH);\n    for (let c=0;c<cols;c++){\n      const x0=c*blockW, x1=Math.min(W,x0+blockW);\n      let sumR=0,sumG=0,sumB=0,sumL=0,count=0;\n      for (let y=y0;y<y1;y++){\n        const rowOff = y*W*4;\n        for (let x=x0;x<x1;x++){\n          const i = rowOff + (x<<2);\n          const R=data[i], G=data[i+1], B=data[i+2];\n          sumR+=R; sumG+=G; sumB+=B; sumL += 0.2126*R + 0.7152*G + 0.0722*B; count++;\n        }\n      }\n      const inv = count?1/count:0;\n      const Rm=(sumR*inv)|0, Gm=(sumG*inv)|0, Bm=(sumB*inv)|0;\n      let norm = (sumL*inv)/255;\n      norm = (contrast + 1) * (norm - 0.5) + 0.5;\n      if (norm<0) norm=0; else if (norm>1) norm=1;\n      const idx = invert ? ((norm*(rampLen-1))+.5)|0 : (((1-norm)*(rampLen-1))+.5)|0;\n      const ch = charset[idx];\n      const safe = ch===' ' ? '&nbsp;' : (ch==='&'?'&amp;':ch==='<'?'&lt;':ch==='>'?'&gt;':ch);\n      out += `<span style=\"color:rgba(${Rm},${Gm},${Bm},1)\">${safe}</span>`;\n    }\n    if (r<rows-1) out += '\\n'; // ← escapado para evitar line break literal en template strings\n  }\n  out += '</pre>';\n  return out;\n}\n\n// Acción del botón principal de la sección\nwindow.captureAsciiSection = async function(btn) {\n  const output = document.getElementById('ascii-output');\n  const enabled = !!document.getElementById('ascii-enable')?.checked;\n  const id = document.getElementById('asciiDemoSelect')?.value || 'demo-gradient';\n  const target = document.getElementById(id);\n  if (!target || !output) return;\n\n  if (btn) btn.disabled = true;\n  output.innerHTML = 'Rendering…';\n\n  try {\n    // Captura base\n   \n\n    if (enabled) {\n      // ASCII\n      const result =  await snapdom(target, { \n        outerShadows: true,\n        plugins: [asciiExportPlugin()]\n      });\n    //  snapdom.plugins(asciiExportPlugin());\n      const asciiHTML = await result.toAscii({ cols: 100, invert:true });\n      output.innerHTML = asciiHTML;\n    } else {\n      // SVG\n      const svg =  await snapdom.toImg(target, { outerShadows: true });\n      output.innerHTML = '';\n      if (typeof svg === 'string') output.innerHTML = svg; else output.appendChild(svg);\n    }\n  } finally {\n    if (btn) btn.disabled = false;\n  }\n};\n\n\n/**\n * Plugin per-capture: timestamp overlay en esquina inferior derecha.\n * Hook: afterClone\n *\n * Uso por captura:\n *   const result = await snapdom(target, { plugins: [timestampOverlayPlugin(opts)] });\n */\nfunction timestampOverlayPlugin(options = {}) {\n  const defaults = {\n    fontFamily: \"system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif\",\n    fontSize: 15,\n    color: \"#fff\",\n    background: \"rgba(0,0,0,.15)\",\n    padding: \".25rem .5rem\",\n    borderRadius: 8,\n    right: 8,\n    bottom: 6,\n    zIndex: 2147483647,\n    stampFn: null, // (d:Date)=>string | null\n    text: null     // si está, se usa tal cual\n  };\n\n  function pad(n){ return n < 10 ? \"0\"+n : \"\"+n; }\n  function defaultStamp(d){\n    return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ` +\n           `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;\n  }\n\n  const cfg = { ...defaults, ...options };\n\n  return {\n    name: \"timestamp-overlay\",\n    async afterClone(ctx) {\n      const root = ctx.clone;\n      if (!root || !(root instanceof HTMLElement)) return;\n\n      const doc = root.ownerDocument || document;\n      const win = doc.defaultView || window;\n\n      // Asegurar posicionamiento del contenedor\n      const cs = win.getComputedStyle(root);\n      if (!/relative|absolute|fixed|sticky/.test(cs.position)) {\n        root.style.position = \"relative\";\n      }\n\n      // Construir label\n      const label = doc.createElement(\"div\");\n      const stamp = cfg.text ?? (typeof cfg.stampFn === \"function\" ? cfg.stampFn(new Date()) : defaultStamp(new Date()));\n      label.textContent = stamp;\n\n      Object.assign(label.style, {\n        position: \"absolute\",\n        right: (cfg.right|0) + \"px\",\n        bottom: (cfg.bottom|0) + \"px\",\n        fontFamily: cfg.fontFamily,\n        fontSize: (cfg.fontSize|0) + \"px\",\n        color: cfg.color,\n        background: cfg.background,\n        padding: cfg.padding,\n        borderRadius: (cfg.borderRadius|0) + \"px\",\n        lineHeight: \"1\",\n        letterSpacing: \".3px\",\n        userSelect: \"none\",\n        pointerEvents: \"none\",\n        zIndex: String(cfg.zIndex|0),\n        boxShadow: \"0 2px 8px rgba(0,0,0,.25)\",\n        backdropFilter: \"blur(1px)\"\n      });\n\n      root.appendChild(label);\n    }\n  };\n}\n\n/**\n * Handler del botón de la sección Timestamp.\n * Usa plugin por captura (options.plugins) y exporta a PNG.\n */\nwindow.captureTimestampDemo = async function(btn) {\n  const target = document.getElementById(\"ts-target\");\n  const output = document.getElementById(\"ts-output\");\n  const enabled = !!document.getElementById(\"ts-enable\")?.checked;\n  const fmt = document.getElementById(\"ts-format\")?.value || \"iso\";\n  if (!target || !output) return;\n\n  btn && (btn.disabled = true);\n  output.textContent = \"Rendering…\";\n\n  try {\n    // Construir lista de plugins per-capture\n    const plugins = [];\n    if (enabled) {\n      const stampFn = fmt === \"locale\"\n        ? (d) => d.toLocaleString()\n        : null; // usa formato ISO por defecto si null\n      plugins.push(timestampOverlayPlugin({ stampFn }));\n    }\n\n    // Capturar con plugins per-capture (sin registrar globalmente)\n    const result = await snapdom(target, {\n      outerShadows: true,\n      plugins\n    });\n\n    // Export final (PNG)\n    const png = await result.toPng({ quality: 0.95 });\n    output.innerHTML = \"\";\n    if (png instanceof HTMLImageElement) {\n      output.appendChild(png);\n    } else if (typeof png === \"string\") {\n      const el = new Image(); el.src = png; output.appendChild(el);\n    } else if (png instanceof Blob) {\n      const url = URL.createObjectURL(png);\n      const el = new Image();\n      el.onload = () => URL.revokeObjectURL(url);\n      el.src = url;\n      output.appendChild(el);\n    }\n  } finally {\n    btn && (btn.disabled = false);\n  }\n};\n\n  </script>\n\n  <script>\n    async function updateGitHubStars() {\n      try {\n        const res = await fetch('https://api.github.com/repos/zumerlab/snapdom')\n        if (!res.ok) throw new Error('No stars')\n        const data = await res.json()\n        const stars = data.stargazers_count\n        document.getElementById('star-count').textContent = stars\n      } catch (e) {\n        document.getElementById('star-count').textContent = ''\n      }\n    }\n    updateGitHubStars()\n    setInterval(updateGitHubStars, 60000)\n  </script>\n  <script src=\"https://unpkg.com/@zumer/orbit/dist/orbit.min.js\" crossorigin></script>\n</body>\n\n</html>\n"
  },
  {
    "path": "docs/labs.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\"/>\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n  <title>snapDOM Labs — Plugins Playground & html-in-canvas Demo</title>\n  <meta name=\"description\" content=\"SnapDOM Labs: Dog ID Card plugin demo (replace text, CSS filters, timestamp overlay) and html-in-canvas capture. Try per-capture plugin composition.\"/>\n  <meta name=\"robots\" content=\"index, follow\"/>\n  <link rel=\"canonical\" href=\"https://snapdom.dev/labs.html\"/>\n  <link rel=\"preconnect\" href=\"https://cdnjs.cloudflare.com\" crossorigin/>\n  <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css\" crossorigin=\"anonymous\"/>\n  <style>\n    :root{\n      --sd-primary:#0f172a;\n      --sd-primary-light:#1e293b;\n      --sd-accent:#6366f1;\n      --sd-gradient:linear-gradient(90deg,#f97316 0%,#ec4899 100%);\n      --sd-surface:rgba(255,255,255,.92);\n      --sd-text:#0f172a;\n      --sd-text-muted:#475569;\n      --sd-border:rgba(99,102,241,.2);\n      --sd-shadow:0 4px 24px rgba(15,23,42,.08);\n      --sd-star:#fbbf24;\n      --sd-radius:14px;\n    }\n    *{box-sizing:border-box}\n    body{\n      margin:0; font-family:'Segoe UI','Roboto',-apple-system,sans-serif;\n      background:linear-gradient(160deg,#f0f4ff 0%,#e8ecff 50%,#eef2ff 100%);\n      min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:1.5rem;\n      -webkit-font-smoothing:antialiased;\n    }\n\n    .hero-block{\n      width:100%; max-width:720px; text-align:center;\n      padding:2rem 1.25rem 1.5rem; margin-bottom:0.5rem;\n    }\n    .hero-block h1{\n      color:var(--sd-primary); margin:0 0 .35rem; font-size:clamp(1.5rem,4vw,2.25rem);\n      font-weight:800; letter-spacing:-.02em;\n    }\n    .hero-tagline{font-size:1rem; color:var(--sd-text-muted); margin:0 auto 1rem; max-width:400px}\n    .nav-section{margin-top:1.25rem; padding-top:1rem; border-top:1px solid var(--sd-border)}\n    .back-home{\n      display:inline-block; text-decoration:none; color:var(--sd-accent); font-weight:600;\n      margin-bottom:.75rem; font-size:.9rem;\n    }\n    .back-home:hover{text-decoration:underline}\n    .nav-toggle{\n      border:0; background:var(--sd-primary); color:#fff; border-radius:10px;\n      padding:.5rem 1rem; font-weight:700; cursor:pointer; display:inline-flex; align-items:center; gap:.4rem;\n    }\n    .nav-icon{display:inline-block; width:1.15rem; height:2px; background:currentColor; border-radius:2px; position:relative}\n    .nav-icon::before,.nav-icon::after{content:\"\"; position:absolute; left:0; right:0; height:2px; background:currentColor; border-radius:2px}\n    .nav-icon::before{top:-6px}.nav-icon::after{top:6px}\n    .nav-collapsible{overflow:hidden; max-height:0; transition:max-height .3s ease; border-radius:10px}\n    .nav-collapsible.open{\n      max-height:75vh; overflow-y:auto; -webkit-overflow-scrolling:touch;\n      padding:1rem 0 .5rem; margin-top:.75rem; border-top:1px solid var(--sd-border);\n    }\n    nav.demo-menu{display:flex; flex-wrap:wrap; gap:.5rem; justify-content:center}\n    nav.demo-menu a{\n      color:var(--sd-primary); text-decoration:none; font-weight:600; font-size:.9rem;\n      padding:.45rem .85rem; border-radius:10px; transition:background .2s,color .2s;\n    }\n    nav.demo-menu a:hover{background:rgba(99,102,241,.1); color:var(--sd-accent)}\n    section a{color:var(--sd-accent); text-decoration:none; font-weight:600}\n    section a:hover{text-decoration:underline}\n\n    .github-badge{\n      display:inline-flex; align-items:center; gap:.5rem; text-decoration:none; color:var(--sd-primary);\n      font-weight:700; font-size:.9rem; background:#fff; padding:.4rem .8rem; border-radius:999px;\n      box-shadow:var(--sd-shadow); border:1px solid var(--sd-border); transition:transform .2s,box-shadow .2s;\n    }\n    .github-badge:hover{transform:translateY(-2px); box-shadow:0 8px 24px rgba(99,102,241,.15)}\n    #github-stars{display:inline-flex; align-items:center; gap:.3rem; background:linear-gradient(135deg,#fef3c7,#fde68a); padding:.2rem .5rem; border-radius:999px; font-weight:800; color:var(--sd-primary)}\n    #github-stars i{color:var(--sd-star)}\n\n    main{width:100%; max-width:720px; display:grid; gap:1.5rem; margin-top:.5rem}\n\n    .card{\n      width:min(100%,720px);\n      position:relative; border-radius:var(--sd-radius); overflow:hidden;\n      background:radial-gradient(140% 140% at 20% 20%,#2a2f3a 0%,#14161a 55%,#0d0f12 100%);\n      color:#fff; box-shadow:0 12px 32px rgba(0,0,0,.2);\n      display:grid; grid-template-columns:1.05fr 2fr; gap:0; margin:0 auto;\n      aspect-ratio:16/9;\n    }\n    @media (max-width:760px){ .card{ grid-template-columns:1fr; aspect-ratio:auto; } }\n    .photo{ display:grid; place-items:center; padding:18px;\n      background: radial-gradient(120% 120% at 35% 20%, #344055 0%, #1b2232 55%, #0f1422 100%);\n    }\n    .avatar{\n      width:min(60vmin, 240px); height:min(60vmin, 240px); max-width:240px; max-height:240px;\n      border-radius:18px; display:grid; place-items:center; background:#0004; border:1px solid #ffffff22;\n      box-shadow:0 12px 28px rgba(0,0,0,.35); font-size:clamp(64px,14vw,140px);\n    }\n    .badge{\n      position:absolute; top:12px; left:12px; background:#ffcc00; color:#1c1c1c; font-weight:900;\n      padding:.28rem .6rem; border-radius:999px; letter-spacing:.4px; box-shadow:0 6px 16px rgba(0,0,0,.25);\n    }\n    .content{ padding:18px 18px 22px; display:grid; gap:10px; align-content:center }\n    .title{ display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap }\n    .title h2{ margin:0; font-size:clamp(22px,4vw,32px) }\n    .type{ font:800 12px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial; padding:.25rem .5rem; border-radius:8px;\n      background:#ffffff12; color:#cfe3ff; border:1px solid #ffffff20 }\n    .grid{ display:grid; grid-template-columns: 1fr 1fr; gap:10px }\n    @media (max-width:760px){ .grid{ grid-template-columns:1fr } }\n    .field{ background:#0f173b; border:1px solid #243079; border-radius:12px; padding:10px; display:grid; gap:6px; min-height:64px }\n    .label{ font-size:.8rem; opacity:.9 }\n    .val{ font-weight:800; letter-spacing:.2px; font-size:clamp(14px,3.6vw,18px) }\n    .owner{ display:flex; align-items:center; gap:.6rem }\n    .owner .dot{ width:10px; height:10px; border-radius:50%; background:#4f7cff; box-shadow:0 0 0 2px #1b255a }\n\n    .panel{\n      background:var(--sd-surface); border-radius:var(--sd-radius); padding:1.25rem;\n      box-shadow:var(--sd-shadow); border:1px solid var(--sd-border);\n      display:grid; gap:1rem;\n    }\n    .row{display:flex; flex-wrap:wrap; gap:.6rem; align-items:center}\n    .control{display:grid; gap:6px; min-width:220px; flex:1}\n    .control input[type=\"text\"], .control select{\n      width:100%; padding:.55rem .6rem; border-radius:10px; border:1px solid #c9d8ff; background:#fff; color:#1a2233\n    }\n    button{\n      appearance:none; border:0; border-radius:12px; padding:.55rem 1.25rem; font-weight:700;\n      background:var(--sd-gradient); color:#fff; cursor:pointer; font-size:.95rem;\n      box-shadow:0 2px 12px rgba(249,115,22,.35); transition:transform .2s,box-shadow .2s\n    }\n    button:hover:not(:disabled){ transform:translateY(-2px); box-shadow:0 4px 20px rgba(249,115,22,.45) }\n\n    .output{display:flex;justify-content:center;align-items:center;min-height:160px;border:1px solid rgba(20,67,51,.18);border-radius:12px;padding:10px;background:rgba(0,0,0,.04)}\n    .output img{max-width:100%;width:auto;height:auto;object-fit:contain;display:block}\n\n    /* Anchor scroll offset for sticky header */\n    [id^=\"demo-\"]{scroll-margin-top:1.5rem}\n\n    @media (max-width:600px){\n      body{padding:1rem .75rem}\n      .hero-block{padding:1.25rem 1rem}\n      .nav-toggle{display:inline-flex}\n      nav.demo-menu{display:grid; grid-template-columns:1fr 1fr; gap:.4rem}\n      #repo-link{display:none}\n      .card{grid-template-columns:1fr; aspect-ratio:auto}\n    }\n    @media (min-width:601px){\n      .nav-collapsible{max-height:none!important; padding:0; margin-top:.75rem; border-top:1px solid var(--sd-border)}\n      .nav-toggle{display:none!important}\n    }\n    [id^=\"demo-\"]{scroll-margin-top:5rem}\n  </style>\n</head>\n<body>\n  <header class=\"hero-block\">\n    <h1>SnapDOM Labs</h1>\n    <p class=\"hero-tagline\">Plugins playground &amp; experimental demos</p>\n    <a href=\"https://github.com/zumerlab/snapdom\" class=\"github-badge\" target=\"_blank\" rel=\"noopener\">\n      <i class=\"fab fa-github\"></i>\n      <span id=\"repo-link\">zumerlab/snapdom</span>\n      <span id=\"github-stars\"><i class=\"fas fa-star\"></i><span id=\"star-count\">…</span></span>\n    </a>\n    <div class=\"nav-section\">\n      <a class=\"back-home\" href=\"./index.html\">← Back to showcase</a>\n      <button id=\"menu-toggle\" class=\"nav-toggle\" aria-expanded=\"false\" aria-controls=\"demo-menu\">\n        <span class=\"nav-icon\" aria-hidden=\"true\"></span>\n        Jump to demo\n      </button>\n      <div id=\"menu-container\" class=\"nav-collapsible\">\n        <nav id=\"demo-menu\" class=\"demo-menu\">\n          <a href=\"#demo-plugins\">Dog ID Card (Plugins)</a>\n          <a href=\"#demo-htmlcanvas\">html-in-canvas</a>\n        </nav>\n      </div>\n    </div>\n  </header>\n\n  <main>\n    <!-- Demo 1: Plugins Playground -->\n    <section id=\"demo-plugins\" class=\"panel\" style=\"padding:1.5rem; margin-bottom:2rem\">\n      <h3 style=\"text-align:center; margin-top:0\">Dog ID Card (Plugins Playground)</h3>\n      <p style=\"text-align:center; font-size:0.95rem; color:var(--muted); max-width:560px; margin:0.5rem auto 1rem\">\n        SnapDOM plugins let you modify the cloned element before render (text replacement, filters, overlays) and add custom exports like <code>toPdf()</code> or <code>toAscii()</code>.\n        <a href=\"https://github.com/zumerlab/snapdom?tab=readme-ov-file#plugins-beta\" target=\"_blank\">Create your own plugin →</a>\n      </p>\n    <!-- Dog ID Card -->\n    <section class=\"card\" id=\"dog-card\">\n      <div class=\"badge\">OFFICIAL • Dog ID</div>\n      <div class=\"photo\">\n        <div class=\"avatar\" aria-label=\"Dog avatar\">🐶</div>\n      </div>\n      <div class=\"content\">\n        <div class=\"title\">\n          <h2 id=\"dog-name\">FRIDA</h2>\n          <span class=\"type\">Registered Dog</span>\n        </div>\n\n        <div class=\"grid\">\n          <div class=\"field\">\n            <div class=\"label\">Breed</div>\n            <div class=\"val\" id=\"dog-breed\">Border Collie (Dog)</div>\n          </div>\n          <div class=\"field\">\n            <div class=\"label\">Age</div>\n            <div class=\"val\" id=\"dog-age\">2 years (Dog adult)</div>\n          </div>\n          <div class=\"field\">\n            <div class=\"label\">Owner</div>\n            <div class=\"val owner\"><span class=\"dot\"></span><span id=\"dog-owner\">John Peters</span></div>\n          </div>\n          <div class=\"field\">\n            <div class=\"label\">Microchip</div>\n            <div class=\"val\" id=\"dog-chip\">ID-0xD0G-0001</div>\n          </div>\n        </div>\n\n        <div class=\"field\" style=\"margin-top:6px\">\n          <div class=\"label\">Notes</div>\n          <div class=\"val\" id=\"dog-notes\">Friendly Dog. Loves fetch, hates vacuums.</div>\n        </div>\n      </div>\n    </section>\n\n    <!-- Controls -->\n    <section class=\"panel\" aria-label=\"Compose plugins\" style=\"margin-top:1rem\">\n      <h4 style=\"margin:0 0 0.75rem; font-size:0.95rem; color:var(--ink)\">Compose plugins (toggle to enable)</h4>\n      <div class=\"row\" style=\"justify-content:flex-start; gap:1rem; flex-wrap:wrap\">\n        <label style=\"cursor:pointer; display:flex; align-items:center; gap:0.4rem\">\n          <input type=\"checkbox\" id=\"p-replace\"> Replace text\n        </label>\n        <label style=\"cursor:pointer; display:flex; align-items:center; gap:0.4rem\">\n          <input type=\"checkbox\" id=\"p-filter\"> CSS filter\n        </label>\n        <label style=\"cursor:pointer; display:flex; align-items:center; gap:0.4rem\">\n          <input type=\"checkbox\" id=\"p-red\" checked> Tint text (orange)\n        </label>\n        <label style=\"cursor:pointer; display:flex; align-items:center; gap:0.4rem\">\n          <input type=\"checkbox\" id=\"p-stamp\" checked> Timestamp overlay\n        </label>\n      </div>\n\n      <div class=\"row\" style=\"margin-top:0.75rem\">\n        <div class=\"control\">\n          <label for=\"p-find\" class=\"label\" style=\"color:var(--muted)\">Find</label>\n          <input id=\"p-find\" type=\"text\" >\n        </div>\n        <div class=\"control\">\n          <label for=\"p-repl\" class=\"label\" style=\"color:var(--muted)\">Replace with</label>\n          <input id=\"p-repl\" type=\"text\" >\n        </div>\n        <div class=\"control\">\n          <label for=\"p-filter-kind\" class=\"label\" style=\"color:var(--muted)\">Filter</label>\n          <select id=\"p-filter-kind\">\n            <option value=\"none\">none</option>\n            <option value=\"grayscale(100%)\">grayscale</option>\n            <option value=\"blur(2px)\">blur (2px)</option>\n            <option value=\"contrast(1.25)\">contrast (+25%)</option>\n            <option value=\"saturate(1.3)\">saturate (+30%)</option>\n          </select>\n        </div>\n        <div class=\"control\">\n          <label for=\"p-stamp-kind\" class=\"label\" style=\"color:var(--muted)\">Timestamp format</label>\n          <select id=\"p-stamp-kind\">\n            <option value=\"iso\">ISO</option>\n            <option value=\"locale\" selected>Locale</option>\n          </select>\n        </div>\n      </div>\n\n      <div class=\"row\" style=\"justify-content:center\">\n        <button id=\"btn-capture\">Capture PNG</button>\n      </div>\n    </section>\n\n      <div class=\"output\" id=\"out\" style=\"margin-top:1rem\">—</div>\n    </section>\n\n    <!-- Demo 2: html-in-canvas (Issue #172) -->\n    <section id=\"demo-htmlcanvas\" class=\"panel\" style=\"margin-top:2rem; padding:1.5rem\">\n      <h3 style=\"text-align:center; margin-top:0\">WICG html-in-canvas (Issue #172)</h3>\n      <p style=\"text-align:center; font-size:0.9rem; color:var(--muted)\">\n        Uses <code>drawElementImage()</code> to render the clone directly into canvas, bypassing SVG/foreignObject.\n        Requires Chrome with <code>chrome://flags/#canvas-draw-element</code> enabled.\n      </p>\n      <div id=\"target-htmlcanvas\" style=\"background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;padding:1rem;border-radius:12px;margin:1rem 0\">\n        <strong>Target for html-in-canvas</strong>\n        <p style=\"margin:0.5rem 0 0;opacity:0.95\">This box is captured via both PNG and drawElementImage.</p>\n      </div>\n      <div class=\"row\" style=\"justify-content:center; gap:0.75rem\">\n        <button id=\"btn-htmlcanvas-default\">Capture (PNG)</button>\n        <button id=\"btn-htmlcanvas-api\">Capture via html-in-canvas</button>\n      </div>\n      <div class=\"row\" style=\"margin-top:1rem; gap:1.5rem; flex-wrap:wrap\">\n        <div style=\"flex:1; min-width:200px\">\n          <div style=\"font-size:0.85rem; color:var(--muted); margin-bottom:0.25rem\">Default (SVG)</div>\n          <div class=\"output\" id=\"out-htmlcanvas-default\" style=\"min-height:80px\">—</div>\n        </div>\n        <div style=\"flex:1; min-width:200px\">\n          <div style=\"font-size:0.85rem; color:var(--muted); margin-bottom:0.25rem\">html-in-canvas</div>\n          <div class=\"output\" id=\"out-htmlcanvas-api\" style=\"min-height:80px\">—</div>\n          <div id=\"status-htmlcanvas\" style=\"font-size:0.75rem; color:var(--muted); margin-top:0.25rem\"></div>\n        </div>\n      </div>\n    </section>\n  </main>\n\n  <!-- Scripts -->\n  <script>\n    // Mobile menu (mismo comportamiento que el home)\n    (function setupMobileMenu() {\n      const toggle = document.getElementById('menu-toggle');\n      const panel = document.getElementById('menu-container');\n      const menu  = document.getElementById('demo-menu');\n      if (!toggle || !panel || !menu) return;\n\n      function setOpen(open) {\n        toggle.setAttribute('aria-expanded', String(open));\n        panel.classList.toggle('open', open);\n        if (open) panel.scrollTop = 0;\n        document.body.style.overflow = open ? 'hidden' : '';\n      }\n      function toggleMenu() {\n        const isOpen = panel.classList.contains('open');\n        setOpen(!isOpen);\n        if (!isOpen) {\n          const firstLink = menu.querySelector('a'); firstLink?.focus({ preventScroll:true });\n        } else {\n          toggle.focus({ preventScroll:true });\n        }\n      }\n      toggle.addEventListener('click', (e)=>{ e.preventDefault(); toggleMenu(); });\n      menu.addEventListener('click', (e)=>{ if (e.target?.tagName === 'A' && panel.classList.contains('open')) setOpen(false); });\n      document.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && panel.classList.contains('open')) setOpen(false); });\n      const mql = window.matchMedia('(min-width: 601px)');\n      mql.addEventListener?.('change', ()=>{ if (mql.matches) setOpen(false); });\n    })();\n\n    // GH stars\n    (async function updateGitHubStars(){\n      try{\n        const res = await fetch('https://api.github.com/repos/zumerlab/snapdom');\n        if(!res.ok) throw new Error();\n        const data = await res.json();\n        document.getElementById('star-count').textContent = String(data?.stargazers_count ?? '');\n      }catch(e){\n        document.getElementById('star-count').textContent = '';\n      }\n    })();\n  </script>\n\n  <script type=\"module\">\n    import { snapdom } from 'https://unpkg.com/@zumer/snapdom/dist/snapdom.mjs';\n    window.snapdom = snapdom;\n\n    // --- Plugins ---\n    function redTextPlugin(color = 'orange') {\n      return {\n        name: 'red-text',\n        async afterClone(ctx) {\n          const root = ctx.clone; if (!(root instanceof HTMLElement)) return;\n          const all = root.querySelectorAll('*');\n          for (const el of all) {\n            if (el instanceof HTMLElement) {\n              el.style.setProperty('color', color, 'important');\n              el.style.setProperty('-webkit-text-fill-color', color, 'important');\n            }\n          }\n          const svgTexts = root.querySelectorAll('svg text, svg tspan');\n          for (const node of svgTexts) {\n            node.setAttribute('fill', color);\n            node.style?.setProperty('fill', color, 'important');\n          }\n        }\n      };\n    }\n    function replaceTextPlugin(find, repl) {\n      if (!find) return { name:'noop', async afterClone(){} };\n      const safe = (s)=> s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n      const pattern = new RegExp(safe(find), 'gi');\n      return {\n        name: 'replace-text',\n        async afterClone(ctx) {\n          const root = ctx.clone; if (!(root instanceof HTMLElement)) return;\n          const tw = (root.ownerDocument || document).createTreeWalker(root, NodeFilter.SHOW_TEXT);\n          const nodes = []; for (let n = tw.nextNode(); n; n = tw.nextNode()) nodes.push(n);\n          for (const tn of nodes) tn.nodeValue = tn.nodeValue.replace(pattern, repl ?? '');\n        }\n      };\n    }\n    function filterPlugin(value) {\n      return {\n        name: 'css-filter',\n        async afterClone(ctx) {\n          const root = ctx.clone; if (!(root instanceof HTMLElement)) return;\n          root.style.filter = value || 'none';\n        }\n      };\n    }\n    \n    function timestampOverlayPlugin(kind='locale') {\n  const pad = n => (n<10 ? '0'+n : ''+n);\n  const iso = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;\n\n  return {\n    name:'timestamp-overlay',\n    async afterClone(ctx){\n      const root = ctx.clone; if(!(root instanceof HTMLElement)) return;\n      const doc = root.ownerDocument || document;\n      const cs = getComputedStyle(root);\n      if (!/relative|absolute|fixed|sticky/.test(cs.position)) root.style.position = 'relative';\n\n      const tag = doc.createElement('div');\n      const stamp = (kind==='iso') ? iso(new Date()) : new Date().toLocaleString();\n      tag.textContent = `Issued: ${stamp}`;\n\n      Object.assign(tag.style, {\n        position:'absolute', right:'12px', bottom:'10px',\n        background:'linear-gradient(180deg, rgba(0,0,0,.55), rgba(0,0,0,.70))',\n        color:'#fff',                           // valor base\n        padding:'.35rem .6rem', borderRadius:'10px',\n        font:'12px system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif',\n        letterSpacing:'.3px', lineHeight:'1', pointerEvents:'none',\n        boxShadow:'0 3px 10px rgba(0,0,0,.35)', zIndex:'2147483647',\n        border:'1px solid rgba(255,255,255,.14)',\n        backdropFilter:'blur(1px)',            // Safari soporta 'backdrop-filter'\n        WebkitBackdropFilter:'blur(1px)',\n        // —— hints para Safari/foreignObject ——\n        willChange:'transform',\n        transform:'translateZ(0)',             // fuerza layer en WebKit\n        mixBlendMode:'normal',\n        textRendering:'optimizeLegibility'\n      });\n\n      // ⚠️ Safari: asegurar fill blanco sin stroke, con prioridad máxima\n      tag.style.setProperty('color', '#fff', 'important');\n      tag.style.setProperty('-webkit-text-fill-color', '#fff', 'important');\n      tag.style.setProperty('-webkit-text-stroke-width', '0', 'important');\n      tag.style.setProperty('-webkit-text-stroke-color', 'transparent', 'important');\n      // Sutil refuerzo de contraste (no obligatorio)\n      tag.style.setProperty('text-shadow', '0 1px 1px rgba(0,0,0,.35)', 'important');\n\n      root.appendChild(tag);\n    }\n  };\n}\n\n    // Capture flow\n    const btn = document.getElementById('btn-capture');\n    const target = document.getElementById('dog-card');\n    const out = document.getElementById('out');\n\n    btn.addEventListener('click', async () => {\n      btn.disabled = true; out.textContent = 'Rendering…';\n      try{\n        const plugins = [];\n        if(document.getElementById('p-replace').checked){\n          const f = (document.getElementById('p-find').value || 'Dog').trim();\n          const r = document.getElementById('p-repl').value || 'Cat';\n          plugins.push(replaceTextPlugin(f, r));\n        }\n        if(document.getElementById('p-filter').checked){\n          const v = document.getElementById('p-filter-kind').value;\n          plugins.push(filterPlugin(v));\n        }\n         if(document.getElementById('p-red').checked){\n          plugins.push(redTextPlugin('orange'));\n        }\n        if(document.getElementById('p-stamp').checked){\n          const kind = document.getElementById('p-stamp-kind').value;\n          plugins.push(timestampOverlayPlugin(kind));\n        }\n\n        // Orden sugerido: Replace → Red → Filter → Timestamp\n        const result = await snapdom(target, { noShadows:true, plugins });\n        const png = await result.toPng({ quality:0.95 });\n\n        out.innerHTML = '';\n        if (png instanceof HTMLImageElement) out.appendChild(png);\n        else if (typeof png === 'string') { const img = new Image(); img.src = png; out.appendChild(img); }\n        else if (png instanceof Blob) { const url = URL.createObjectURL(png); const img = new Image(); img.onload=()=>URL.revokeObjectURL(url); img.src=url; out.appendChild(img); }\n      } finally {\n        btn.disabled = false;\n      }\n    });\n\n    // --- html-in-canvas (Issue #172) ---\n    (function setupHtmlInCanvas() {\n      const target = document.getElementById('target-htmlcanvas');\n      const outDefault = document.getElementById('out-htmlcanvas-default');\n      const outApi = document.getElementById('out-htmlcanvas-api');\n      const statusApi = document.getElementById('status-htmlcanvas');\n\n      function isDrawElementImageAvailable() {\n        try {\n          const c = document.createElement('canvas');\n          const ctx = c.getContext('2d');\n          return ctx && typeof ctx.drawElementImage === 'function';\n        } catch { return false; }\n      }\n\n      const available = isDrawElementImageAvailable();\n      if (!available) {\n        statusApi.innerHTML = '<span style=\"color:#c00\">drawElementImage not available. Enable chrome://flags/#canvas-draw-element</span>';\n      } else {\n        statusApi.textContent = 'drawElementImage available';\n      }\n\n      const htmlInCanvasPlugin = () => ({\n        name: 'html-in-canvas',\n        beforeRender(state) {\n          if (!available || !state.clone || !state.element) return;\n          state.options.__htmlInCanvas = {\n            clone: state.clone,\n            baseCSS: state.baseCSS || '',\n            fontsCSS: state.fontsCSS || '',\n            classCSS: state.classCSS || '',\n            element: state.element,\n            w0: null, h0: null\n          };\n        },\n        afterRender(state) {\n          if (!available) return;\n          const meta = state.options?.meta;\n          const stored = state.options?.__htmlInCanvas;\n          if (meta && stored) { stored.w0 = meta.w0; stored.h0 = meta.h0; }\n        },\n        async defineExports(ctx) {\n          if (!available) return {};\n          const stored = ctx.__htmlInCanvas || ctx.options?.__htmlInCanvas;\n          if (!stored) return {};\n          return {\n            htmlInCanvas: async (opts = {}) => {\n              const { clone, baseCSS, fontsCSS, classCSS, element } = stored;\n              const w0 = stored.w0 ?? element?.offsetWidth;\n              const h0 = stored.h0 ?? element?.offsetHeight;\n              const scale = opts.scale ?? ctx.scale ?? 1;\n              const dpr = window.devicePixelRatio || 1;\n              const rect = element?.getBoundingClientRect?.();\n              const width = w0 ?? rect?.width ?? 100;\n              const height = h0 ?? rect?.height ?? 100;\n              const outW = Math.round(width * scale * dpr);\n              const outH = Math.round(height * scale * dpr);\n\n              const canvas = document.createElement('canvas');\n              canvas.width = outW;\n              canvas.height = outH;\n              canvas.setAttribute('layoutsubtree', '');\n\n              const wrapper = document.createElement('div');\n              wrapper.style.cssText = `width:${width}px;height:${height}px;overflow:visible;box-sizing:border-box;`;\n              wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');\n\n              const styleTag = document.createElement('style');\n              styleTag.textContent = (baseCSS || '') + (fontsCSS || '') + 'svg{overflow:visible;}' + (classCSS || '');\n              wrapper.appendChild(styleTag);\n              wrapper.appendChild(clone.cloneNode(true));\n\n              canvas.appendChild(wrapper);\n\n              const container = document.createElement('div');\n              container.id = 'snapdom-html-in-canvas-temp';\n              container.style.cssText = 'position:fixed;left:-9999px;top:0;visibility:hidden;';\n              container.appendChild(canvas);\n              document.body.appendChild(container);\n\n              try {\n                await new Promise(r => requestAnimationFrame(r));\n                const ctx2d = canvas.getContext('2d');\n                if (!ctx2d || typeof ctx2d.drawElementImage !== 'function')\n                  throw new Error('drawElementImage not available');\n                ctx2d.save();\n                ctx2d.scale(dpr * scale, dpr * scale);\n                ctx2d.drawElementImage(wrapper, 0, 0, width, height);\n                ctx2d.restore();\n                return canvas;\n              } finally {\n                try { document.body.removeChild(container); } catch {}\n              }\n            }\n          };\n        }\n      });\n\n      document.getElementById('btn-htmlcanvas-default').onclick = async () => {\n        outDefault.textContent = 'Capturing…';\n        try {\n          const result = await snapdom(target, { embedFonts: true });\n          const img = await result.toPng();\n          outDefault.innerHTML = '';\n          outDefault.appendChild(img);\n        } catch (e) {\n          outDefault.innerHTML = '<span style=\"color:#c00\">' + e.message + '</span>';\n        }\n      };\n\n      document.getElementById('btn-htmlcanvas-api').onclick = async () => {\n        if (!available) return;\n        outApi.textContent = 'Capturing…';\n        try {\n          const result = await snapdom(target, { embedFonts: true, plugins: [htmlInCanvasPlugin()] });\n          const canvas = await result.toHtmlInCanvas();\n          outApi.innerHTML = '';\n          if (canvas) {\n            const img = document.createElement('img');\n            img.src = canvas.toDataURL('image/png');\n            outApi.appendChild(img);\n          } else {\n            outApi.innerHTML = '<span style=\"color:#c00\">toHtmlInCanvas not available</span>';\n          }\n        } catch (e) {\n          outApi.innerHTML = '<span style=\"color:#c00\">' + e.message + '</span>';\n        }\n      };\n    })();\n  </script>\n</body>\n</html>\n"
  },
  {
    "path": "esbuild.config.mjs",
    "content": "import { build } from 'esbuild'\nimport { readFileSync, rmSync } from 'node:fs'\n\n/** @type {import('esbuild').BuildOptions} */\nconst common = {\n  bundle: true,\n  sourcemap: false,\n  logLevel: 'info',\n}\n\nconst pkg = JSON.parse(readFileSync('./package.json', 'utf8'))\nconst version = pkg.version || '0.0.0'\n\nconst banner = {\n  js: `/*\n* SnapDOM\n* v${version}\n* Author: Juan Martin Muda\n* License: MIT\n*/`,\n}\n\n/**\n * 1. LEGACY IIFE (script tag / require)\n * Salida: dist/snapdom.js\n */\nasync function buildLegacy() {\n  await build({\n    ...common,\n    entryPoints: ['src/index.browser.js'],\n    outfile: 'dist/snapdom.js',\n    globalName: 'snapdom',\n    platform: 'neutral',\n    minify: true,\n    target: ['es2020'],\n    banner,\n  })\n}\n\n/**\n * 2. ESM MONOLÍTICO (tree-shakeable, bundlers + CDN)\n * Salida: dist/snapdom.mjs\n */\nasync function buildESM() {\n  await build({\n    ...common,\n    entryPoints: ['src/index.js'],\n    outfile: 'dist/snapdom.mjs',\n    format: 'esm',\n    minify: true,\n    splitting: false,\n    banner,\n  })\n}\n\n/**\n * 3. SUBPATH EXPORTS (preCache, plugins)\n * Salida: dist/preCache.mjs, dist/plugins.mjs\n */\nasync function buildSubpaths() {\n  await build({\n    ...common,\n    entryPoints: {\n      'preCache': 'src/api/preCache.js',\n      'plugins': 'src/core/plugins.js',\n    },\n    outdir: 'dist',\n    outExtension: { '.js': '.mjs' },\n    format: 'esm',\n    minify: true,\n    splitting: false,\n    banner,\n  })\n}\n\nasync function main() {\n  try { rmSync('dist/modules', { recursive: true, force: true }) } catch { /* ok */ }\n  await Promise.all([\n    buildLegacy(),\n    buildESM(),\n    buildSubpaths(),\n  ])\n}\n\nmain().catch((err) => {\n  // eslint-disable-next-line\n  console.error(err)\n  // eslint-disable-next-line\n  process.exit(1)\n})\n"
  },
  {
    "path": "eslint.config.cjs",
    "content": "const js = require(\"@eslint/js\")\nconst globals = require(\"globals\")\n\nmodule.exports = [\n  js.configs.recommended,\n  {\n    files: [\"__tests__/**/*.js\",\"src/**/*.js\"],\n    languageOptions: {\n      ecmaVersion: \"latest\",\n      sourceType: \"module\",\n      globals: {\n        ...globals.browser,\n        WebKitCSSMatrix: \"readonly\"\n      }\n    },\n    rules: {\n      \"no-multiple-empty-lines\": [\"error\", { max: 1, maxEOF: 0 }],\n      \"eol-last\": [\"error\", \"always\"],\n      \"no-unused-vars\": [\"warn\", { argsIgnorePattern: \"^_\", varsIgnorePattern: \"^_\" }],\n      \"arrow-spacing\": [\"error\", { before: true, after: true }],\n      \"no-trailing-spaces\": \"error\",\n      \"quotes\": [\"error\", \"single\", { avoidEscape: true }],\n      \"semi\": [\"error\", \"never\"],\n      \"no-empty\": [\"error\", { allowEmptyCatch: true }]\n    }\n  }\n]\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@zumer/snapdom\",\n  \"version\": \"2.5.0\",\n  \"description\": \"snapDOM captures HTML elements to images with exceptional speed and accuracy.\",\n  \"type\": \"module\",\n  \"main\": \"./dist/snapdom.js\",\n  \"module\": \"./dist/snapdom.mjs\",\n  \"types\": \"./types/snapdom.d.ts\",\n  \"sideEffects\": false,\n  \"exports\": {\n    \".\": {\n      \"types\": \"./types/snapdom.d.ts\",\n      \"import\": \"./dist/snapdom.mjs\",\n      \"require\": \"./dist/snapdom.js\",\n      \"default\": \"./dist/snapdom.mjs\"\n    },\n    \"./preCache\": {\n      \"import\": \"./dist/preCache.mjs\"\n    },\n    \"./plugins\": {\n      \"import\": \"./dist/plugins.mjs\"\n    }\n  },\n  \"files\": [\n    \"dist/\",\n    \"types/snapdom.d.ts\",\n    \"README.md\"\n  ],\n  \"scripts\": {\n    \"compile\": \"node esbuild.config.mjs\",\n    \"lint\": \"eslint src __tests__ --ext .js\",\n    \"lint:fix\": \"eslint src __tests__ --ext .js --fix\",\n    \"test\": \"npm run lint:fix && npx vitest run --browser.headless --reporter=verbose\",\n    \"test:coverage\": \"npx vitest run --browser.headless --coverage\",\n    \"test:benchmark\": \"npx vitest bench --browser.headless --watch=false\",\n    \"bump:dry\": \"npx @zumerbox/bump -d\",\n    \"bump\": \"npx @zumerbox/bump && npx @zumerbox/changelog\",\n    \"build\": \"npm run compile && npm pack\",\n    \"prebuild\": \"git add CHANGELOG.md && git commit -m \\\"Bumped version\\\" && git push --follow-tags\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/zumerlab/snapdom.git\"\n  },\n  \"keywords\": [\n    \"zumerlab\",\n    \"snapDOM\",\n    \"screenshot\",\n    \"engine\",\n    \"html capture\",\n    \"dom capture\",\n    \"html to image\",\n    \"dom to image\",\n    \"html screenshot\",\n    \"capture element\",\n    \"html snapshot\",\n    \"element screenshot\",\n    \"web capture\",\n    \"snapshot tool\",\n    \"render html\",\n    \"capture dom\",\n    \"web snapshot\",\n    \"html export\",\n    \"dom snapshot\",\n    \"html to png\",\n    \"html to svg\"\n  ],\n  \"author\": \"Juan Martin Muda\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/zumerlab/snapdom/issues\"\n  },\n  \"homepage\": \"https://zumerlab.github.io/snapdom/\",\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.36.0\",\n    \"@vitest/browser\": \"^3.1.2\",\n    \"@vitest/coverage-v8\": \"^3.1.2\",\n    \"eslint\": \"^9.36.0\",\n    \"globals\": \"^16.4.0\",\n    \"playwright\": \"^1.52.0\",\n    \"esbuild\": \"^0.24.0\"\n  }\n}\n"
  },
  {
    "path": "plugins/html-in-canvas.js",
    "content": "/**\n * Experimental plugin for WICG html-in-canvas (issue #172).\n * Uses drawElementImage() to render the snapdom clone directly into canvas,\n * bypassing SVG/foreignObject.\n *\n * Requires: Chrome with chrome://flags/#canvas-draw-element enabled.\n * @see https://github.com/WICG/html-in-canvas\n */\n\nconst PLUGIN_NAME = 'html-in-canvas'\n\nfunction isDrawElementImageAvailable() {\n  try {\n    const c = document.createElement('canvas')\n    const ctx = c.getContext('2d')\n    return ctx && typeof ctx.drawElementImage === 'function'\n  } catch {\n    return false\n  }\n}\n\n/**\n * @returns {import('../src/core/plugins.js').Plugin}\n */\nexport function htmlInCanvasPlugin() {\n  const available = isDrawElementImageAvailable()\n  if (!available) {\n    console.warn('[snapdom] html-in-canvas plugin: drawElementImage not available. Enable chrome://flags/#canvas-draw-element')\n  }\n\n  return {\n    name: PLUGIN_NAME,\n\n    beforeRender(state) {\n      if (!available) return\n      if (!state.clone || !state.element) return\n      state.options.__htmlInCanvas = {\n        clone: state.clone,\n        baseCSS: state.baseCSS || '',\n        fontsCSS: state.fontsCSS || '',\n        classCSS: state.classCSS || '',\n        element: state.element,\n        w0: null,\n        h0: null\n      }\n    },\n\n    afterRender(state) {\n      if (!available) return\n      const meta = state.options?.meta\n      const stored = state.options?.__htmlInCanvas\n      if (meta && stored) {\n        stored.w0 = meta.w0\n        stored.h0 = meta.h0\n      }\n    },\n\n    async defineExports(ctx) {\n      if (!available) return {}\n      const stored = ctx.__htmlInCanvas\n      if (!stored) return {}\n\n      return {\n        htmlInCanvas: async (opts = {}) => {\n          const { clone, baseCSS, fontsCSS, classCSS, element } = stored\n          const w0 = stored.w0 ?? element?.offsetWidth\n          const h0 = stored.h0 ?? element?.offsetHeight\n          const scale = opts.scale ?? ctx.scale ?? 1\n          const dpr = opts.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1)\n\n          const rect = element?.getBoundingClientRect?.()\n          const width = w0 ?? rect?.width ?? 100\n          const height = h0 ?? rect?.height ?? 100\n          const outW = Math.round(width * scale * dpr)\n          const outH = Math.round(height * scale * dpr)\n\n          const canvas = document.createElement('canvas')\n          canvas.width = outW\n          canvas.height = outH\n          canvas.setAttribute('layoutsubtree', '')\n\n          const wrapper = document.createElement('div')\n          wrapper.style.cssText = `width:${width}px;height:${height}px;overflow:visible;box-sizing:border-box;`\n          wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')\n\n          const styleTag = document.createElement('style')\n          styleTag.textContent = `${baseCSS}${fontsCSS}svg{overflow:visible;}${classCSS}`\n          wrapper.appendChild(styleTag)\n\n          const cloneCopy = clone.cloneNode(true)\n          wrapper.appendChild(cloneCopy)\n\n          canvas.appendChild(wrapper)\n\n          const container = document.createElement('div')\n          container.id = 'snapdom-html-in-canvas-temp'\n          container.style.cssText = 'position:fixed;left:-9999px;top:0;visibility:hidden;'\n          container.appendChild(canvas)\n          document.body.appendChild(container)\n\n          try {\n            await new Promise(r => requestAnimationFrame(r))\n            const ctx2d = canvas.getContext('2d')\n            if (!ctx2d || typeof ctx2d.drawElementImage !== 'function') {\n              throw new Error('drawElementImage not available')\n            }\n            ctx2d.save()\n            ctx2d.scale(dpr * scale, dpr * scale)\n            ctx2d.drawElementImage(wrapper, 0, 0, width, height)\n            ctx2d.restore()\n            return canvas\n          } finally {\n            try {\n              document.body.removeChild(container)\n            } catch {}\n          }\n        }\n      }\n    }\n  }\n}\n\nexport default htmlInCanvasPlugin\n"
  },
  {
    "path": "src/api/preCache.js",
    "content": "// src/api/preCache.js\nimport { getStyle, inlineSingleBackgroundEntry, precacheCommonTags, isSafari } from '../utils'\nimport { embedCustomFonts, collectUsedFontVariants, collectUsedCodepoints, ensureFontsReady } from '../modules/fonts.js'\nimport { snapFetch } from '../modules/snapFetch.js'\nimport { cache, applyCachePolicy, EvictingMap } from '../core/cache.js'\nimport { inlineBackgroundImages } from '../modules/background.js'\n\n/**\n * Preloads images, background images, and (optionally) fonts into cache before DOM capture.\n * @param {Element|Document} [root=document]\n * @param {Object} [options={}]\n * @param {boolean} [options.embedFonts=true]\n * @param {'full'|'soft'|'auto'|'disabled'} [options.cache='full']\n * @param {string}  [options.useProxy=\"\"]\n * @param {{family:string,src:string,weight?:string|number,style?:string,stretchPct?:number}[]} [options.localFonts=[]]\n * @param {{families?:string[], domains?:string[], subsets?:string[]}} [options.excludeFonts]\n * @param {string[]} [options.fontStylesheetDomains]  // extra domains to fetch cross-origin CSS from (#309)\n * @returns {Promise<void>}\n */\nexport async function preCache(root = document, options = {}) {\n  const {\n    embedFonts = true,\n    useProxy = '',\n  } = options\n  // Accept both `cache` (JSDoc) and legacy `cacheOpt`\n  const cacheMode = options.cache ?? options.cacheOpt ?? 'full'\n\n  applyCachePolicy(cacheMode)\n\n  // Ensure font metrics are ready (non-throwing)\n  try { await document.fonts?.ready } catch {}\n\n  // Warm common tag/style caches (no-op if already done)\n  try { precacheCommonTags() } catch {}\n\n  // Ensure session caches\n  cache.session = cache.session || {}\n  if (!cache.session.styleCache) {\n    cache.session.styleCache = new WeakMap()\n  }\n  cache.image = cache.image || new EvictingMap(100)\n  cache.background = cache.background || new EvictingMap(100)\n\n  // Pre-inline background images into cache (best-effort)\n  try {\n    await inlineBackgroundImages(root, /* mirror */ undefined, cache.session.styleCache, { useProxy })\n  } catch {}\n\n  // Collect elements for prefetch\n  let imgEls = [], allEls = []\n  try {\n    // 🔸 Importante: incluir al root si es un Element\n    if (root && root.nodeType === 1 /* ELEMENT_NODE */) {\n      const descendants = root.querySelectorAll ? Array.from(root.querySelectorAll('*')) : []\n      allEls = [root, ...descendants]\n      // Sólo imágenes dentro del subtree (el root también puede ser <img>)\n      imgEls = []\n      if (root.tagName === 'IMG' && root.getAttribute('src')) imgEls.push(root)\n      imgEls.push(...Array.from(root.querySelectorAll?.('img[src]') || []))\n    } else if (root?.querySelectorAll) {\n      // Document o DocumentFragment\n      imgEls = Array.from(root.querySelectorAll('img[src]'))\n      allEls = Array.from(root.querySelectorAll('*'))\n    }\n  } catch {}\n\n  const promises = []\n\n  // Prefetch <img> sources to dataURL and cache\n  for (const img of imgEls) {\n    const src = img?.currentSrc || img?.src\n    if (!src) continue\n    if (!cache.image.has(src)) {\n      const p = Promise.resolve()\n        .then(async () => {\n          const res = await snapFetch(src, { as: 'dataURL', useProxy })\n          if (res?.ok && typeof res.data === 'string') {\n            cache.image.set(src, res.data)\n          }\n        })\n        .catch(() => {})\n      promises.push(p)\n    }\n  }\n\n  // Prefetch background-image url(...) entries\n  for (const el of allEls) {\n    let bg = ''\n    try {\n      // Preferir estilo autor (estable en JSDOM/test); fallback a computado\n      bg = el?.style?.backgroundImage || ''\n      if (!bg || bg === 'none') {\n        bg = getStyle(el).backgroundImage\n      }\n    } catch {}\n    if (bg && bg !== 'none') {\n      // Extraer SOLO capas url(...) (robusto ante comas de gradients)\n      const urlEntries = bg.match(/url\\((?:[^()\"']+|\"(?:[^\"]*)\"|'(?:[^']*)')\\)/gi) || []\n      for (const entry of urlEntries) {\n        const p = Promise.resolve()\n          .then(() => inlineSingleBackgroundEntry(entry, { ...options, useProxy }))\n          .catch(() => {})\n        promises.push(p)\n      }\n\n      // (quedó como compat opcional por si querés volver a splitBackgroundImage)\n      // const parts = splitBackgroundImage(bg)\n      // for (const entry of parts) {\n      //   if (entry.startsWith('url(')) {\n      //     const p = Promise.resolve()\n      //       .then(() => inlineSingleBackgroundEntry(entry, { ...options, useProxy }))\n      //       .catch(() => {})\n      //     promises.push(p)\n      //   }\n      // }\n    }\n  }\n\n  // Optional: preload/embed fonts\n  if (embedFonts) {\n    try {\n      const required = collectUsedFontVariants(root)\n      const usedCodepoints = collectUsedCodepoints(root)\n\n      // Safari warmup: ensure families are ready before embedding\n      const safari = (typeof isSafari === 'function') ? isSafari() : !!isSafari\n      if (safari) {\n        const families = new Set(\n          Array.from(required)\n            .map(k => String(k).split('__')[0])\n            .filter(Boolean)\n        )\n        await ensureFontsReady(families, 3)\n      }\n\n      await embedCustomFonts({\n        required,\n        usedCodepoints,\n        exclude: options.excludeFonts,\n        localFonts: options.localFonts,\n        useProxy: options.useProxy ?? useProxy,\n        fontStylesheetDomains: options.fontStylesheetDomains,\n      })\n    } catch {}\n  }\n\n  await Promise.allSettled(promises)\n}\n"
  },
  {
    "path": "src/api/snapdom.js",
    "content": "// src/api/snapdom.js\nimport { captureDOM } from '../core/capture.js'\nimport { extendIconFonts } from '../modules/iconFonts.js'\nimport { createContext } from '../core/context.js'\nimport { isSafari } from '../utils/browser.js'\nimport { debugWarn } from '../utils/debug.js'\nimport { registerPlugins, runHook, runAll, attachSessionPlugins } from '../core/plugins.js'\nimport { collectUsedFontVariants, ensureFontsReady } from '../modules/fonts.js'\nexport { preCache } from './preCache.js'\n\n// API pública (registro global de plugins)\nexport function plugins(...defs) { registerPlugins(...defs); return snapdom }\nexport const snapdom = Object.assign(main, { plugins })\n\n// Token to prevent public use of snapdom.capture\nconst INTERNAL_TOKEN = Symbol('snapdom.internal')\n// Token interno para llamadas de export \"silenciosas\" desde plugins (no hooks)\nconst INTERNAL_EXPORT_TOKEN = Symbol('snapdom.internal.silent')\n\nlet _safariWarmup = false\n\n/**\n * Main function that captures a DOM element and returns export utilities.\n * Local-first plugins: `options.plugins` override globals for this capture.\n *\n * @param {HTMLElement} element - The DOM element to capture.\n * @param {object} userOptions - Options for rendering/exporting.\n * @returns {Promise<object>} Object with exporter methods:\n *   - url: The raw data URL\n *   - toRaw(): Gets raw data URL\n *   - toImg(): Converts to Image element\n *   - toSvg(): Converts to SVG Image element\n *   - toCanvas(): Converts to HTMLCanvasElement\n *   - toBlob(): Converts to Blob\n *   - toPng(): Converts to PNG format\n *   - toJpg(): Converts to JPEG format\n *   - toWebp(): Converts to WebP format\n *   - download(): Triggers file download\n */\nasync function main(element, userOptions) {\n  if (!element) throw new Error('Element cannot be null or undefined')\n\n  // Normalize options into a capture context\n  const context = createContext(userOptions)\n\n  // Attach per-capture plugins (local-first) without removing globals\n  attachSessionPlugins(context, userOptions && userOptions.plugins)\n\n  // Safari warm-up: WebKit Bug #219770 — SVG with embedded font triggers img.onload\n  // before font is available. First canvas draw is blank; second+ works. We run\n  // pre-captures + drawImage to prime the font/decode pipeline. Fidelity > speed.\n  // See: https://bugs.webkit.org/show_bug.cgi?id=219770\n  if (isSafari() && (context.embedFonts === true || hasBackgroundOrMask(element))) {\n    if (context.embedFonts) {\n      try {\n        const required = collectUsedFontVariants(element)\n        const families = new Set([...required].map(k => String(k).split('__')[0]).filter(Boolean))\n        await ensureFontsReady(families, 1)\n      } catch { /* non-blocking */ }\n    }\n    const attempts = context.safariWarmupAttempts ?? 3\n    for (let i = 0; i < attempts; i++) {\n      try {\n        await safariWarmup(element, userOptions)\n      } catch {\n        // swallow error\n      }\n    }\n  }\n\n  if (context.iconFonts && context.iconFonts.length > 0) extendIconFonts(context.iconFonts)\n\n  if (!context.snap) {\n    // Mantener compat: atajos disponibles en context.snap\n    context.snap = {\n      toPng: (el, opts) => snapdom.toPng(el, opts),\n      toSvg: (el, opts) => snapdom.toSvg(el, opts),\n    }\n  }\n\n  return snapdom.capture(element, context, INTERNAL_TOKEN)\n}\n\n/**\n * Internal capture method that returns helper methods for transformation/export.\n * Integrates export hooks: beforeExport → work() → afterExport → afterSnap(once per URL)\n * @private\n * @param {HTMLElement} el - The DOM element to capture.\n * @param {object} context - Normalized context options.\n * @param {symbol} _token - Internal security token.\n * @returns {Promise<object>} Exporter functions.\n */\nsnapdom.capture = async (el, context, _token) => {\n  if (_token !== INTERNAL_TOKEN) throw new Error('[snapdom.capture] is internal. Use snapdom(...) instead.')\n\n  const url = await captureDOM(el, context)\n\n  // ——— 1) Core exports por defecto (carga lazy en cada tipo) ———\n  // NOTA: no importamos estáticamente los exportadores aquí.\n  const coreExports = {\n    img: async (ctx, opts) => {\n      const { toImg } = await import('../exporters/toImg.js')\n      return toImg(url, { ...ctx, ...(opts || {}) })\n    },\n    svg: async (ctx, opts) => {\n      const { toSvg } = await import('../exporters/toImg.js')\n      return toSvg(url, { ...ctx, ...(opts || {}) })\n    },\n    canvas: async (ctx, opts) => {\n      const { toCanvas } = await import('../exporters/toCanvas.js')\n      return toCanvas(url, { ...ctx, ...(opts || {}) })\n    },\n    blob: async (ctx, opts) => {\n      const { toBlob } = await import('../exporters/toBlob.js')\n      return toBlob(url, { ...ctx, ...(opts || {}) })\n    },\n    png: async (ctx, opts) => {\n      const { rasterize } = await import('../modules/rasterize.js')\n      return rasterize(url, { ...ctx, ...(opts || {}), format: 'png' })\n    },\n    jpeg: async (ctx, opts) => {\n      const { rasterize } = await import('../modules/rasterize.js')\n      return rasterize(url, { ...ctx, ...(opts || {}), format: 'jpeg' })\n    },\n    webp: async (ctx, opts) => {\n      const { rasterize } = await import('../modules/rasterize.js')\n      return rasterize(url, { ...ctx, ...(opts || {}), format: 'webp' })\n    },\n    download: async (ctx, opts) => {\n      const { download } = await import('../exporters/download.js')\n      return download(url, { ...ctx, ...(opts || {}) })\n    },\n  }\n\n  // ——— 2) Exports declarados por plugins ———\n  // Fachada reutilizable “silenciosa” (sin hooks) para uso en defineExports()\n  const _pluginExports = {}\n  for (const k of ['img', 'svg', 'canvas', 'blob', 'png', 'jpeg', 'webp']) {\n    _pluginExports[k] = async (opts) =>\n      coreExports[k](context, { ...(opts || {}), [INTERNAL_EXPORT_TOKEN]: true })\n  }\n  _pluginExports.jpg = _pluginExports.jpeg\n\n  // Contexto extendido para defineExports (incluye URL y la fachada para reuso)\n  const _defineCtx = { ...context, export: { url }, exports: _pluginExports }\n\n  const providedMaps = await runAll('defineExports', _defineCtx)\n  const provided = Object.assign({}, ...providedMaps.filter(x => x && typeof x === 'object'))\n\n  // Merge (plugins pueden overridear core)\n  const exportsMap = { ...coreExports, ...provided }\n\n  // —— Alias: jpg → jpeg (para toJpg y to('jpg')) ——\n  if (exportsMap.jpeg && !exportsMap.jpg) {\n    exportsMap.jpg = (ctx, opts) => exportsMap.jpeg(ctx, opts)\n  }\n\n  // —— Normalizador para opciones por tipo (p.ej. JPEG: fondo blanco) ——\n  function normalizeExportOptions(type, opts) {\n    const next = { ...context, ...(opts || {}) }\n    if (type === 'jpeg' || type === 'jpg') {\n      const noBg = next.backgroundColor == null || next.backgroundColor === 'transparent'\n      if (noBg) next.backgroundColor = '#ffffff'\n    }\n    return next\n  }\n\n  // —— Runner unificado con beforeExport/afterExport y cola por sesión ——\n  let afterSnapFired = false\n  let _exportQueue = Promise.resolve()\n  async function runExport(type, opts) {\n    const job = async () => {\n      const work = exportsMap[type]\n      if (!work) throw new Error(`[snapdom] Unknown export type: ${type}`)\n      const nextOpts = normalizeExportOptions(type, opts)\n      const ctx = { ...context, export: { type, options: nextOpts, url } }\n      await runHook('beforeExport', ctx)\n      const result2 = await work(ctx, nextOpts)\n      await runHook('afterExport', ctx, result2)\n      if (!afterSnapFired) {\n        afterSnapFired = true\n        await runHook('afterSnap', context)\n      }\n      return result2\n    }\n    return _exportQueue = _exportQueue.then(job)\n  }\n\n  // —— Helpers esperados por los tests + API azúcar ——\n  const result = {\n    url,\n    toRaw: () => url,\n    to: (type, opts) => runExport(type, opts),\n\n    // Métodos “clásicos” que los tests esperan:\n    toImg: (opts) => runExport('img', opts),\n    toSvg: (opts) => runExport('svg', opts),\n    toCanvas: (opts) => runExport('canvas', opts),\n    toBlob: (opts) => runExport('blob', opts),\n    toPng: (opts) => runExport('png', opts),\n    toJpg: (opts) => runExport('jpg', opts),     // alias requerido por tests\n    toWebp: (opts) => runExport('webp', opts),\n    download: (opts) => runExport('download', opts)\n  }\n\n  // Azúcar dinámico por cada export registrado (plugins incluidos)\n  for (const key of Object.keys(exportsMap)) {\n    const helper = 'to' + key.charAt(0).toUpperCase() + key.slice(1)\n    if (!result[helper]) {\n      result[helper] = (opts) => runExport(key, opts)\n    }\n  }\n\n  return result\n}\n\n/**\n * Returns the raw data URL from a captured element.\n * @param {HTMLElement} el - DOM element to capture.\n * @param {object} [options] - Rendering options.\n * @returns {Promise<string>} Raw data URL.\n */\nsnapdom.toRaw = (el, options) => snapdom(el, options).then(result => result.toRaw())\n\n/**\n * Returns an HTMLImageElement from a captured element.\n * @param {HTMLElement} el - DOM element to capture.\n * @param {object} [options] - Rendering options.\n * @returns {Promise<HTMLImageElement>} Loaded image element.\n */\nsnapdom.toImg = (el, options) => snapdom(el, options).then(result => result.toImg())\nsnapdom.toSvg = (el, options) => snapdom(el, options).then(result => result.toSvg())\n\n/**\n * Returns a Canvas element from a captured element.\n * @param {HTMLElement} el - DOM element to capture.\n * @param {object} [options] - Rendering options.\n * @returns {Promise<HTMLCanvasElement>} Rendered canvas element.\n */\nsnapdom.toCanvas = (el, options) => snapdom(el, options).then(result => result.toCanvas())\n\n/**\n * Returns a Blob from a captured element.\n * @param {HTMLElement} el - DOM element to capture.\n * @param {object} [options] - Rendering options.\n * @returns {Promise<Blob>} Image blob.\n */\nsnapdom.toBlob = (el, options) => snapdom(el, options).then(result => result.toBlob())\n\n/**\n * Returns a PNG image from a captured element.\n * @param {HTMLElement} el - DOM element to capture.\n * @param {object} [options] - Rendering options.\n * @returns {Promise<HTMLImageElement>} PNG image element.\n */\nsnapdom.toPng = (el, options) => snapdom(el, { ...options, format: 'png' }).then(result => result.toPng())\n\n/**\n * Returns a JPEG image from a captured element.\n * @param {HTMLElement} el - DOM element to capture.\n * @param {object} [options] - Rendering options.\n * @returns {Promise<HTMLImageElement>} JPEG image element.\n */\nsnapdom.toJpg = (el, options) => snapdom(el, { ...options, format: 'jpeg' }).then(result => result.toJpg())\n\n/**\n * Returns a WebP image from a captured element.\n * @param {HTMLElement} el - DOM element to capture.\n * @param {object} [options] - Rendering options.\n * @returns {Promise<HTMLImageElement>} WebP image element.\n */\nsnapdom.toWebp = (el, options) => snapdom(el, { ...options, format: 'webp' }).then(result => result.toWebp())\n\n/**\n * Downloads the captured image in the specified format.\n * @param {HTMLElement} el - DOM element to capture.\n * @param {object} options - Download options including filename.\n * @param {string} options.filename - Name for the downloaded file.\n * @param {string} [options.format='png'] - Image format ('png', 'jpeg', 'webp', 'svg').\n * @returns {Promise<void>}\n */\nsnapdom.download = (el, options) => snapdom(el, options).then(result => result.download())\n\n/**\n * Safari/WebKit warmup: primes font and image decode pipeline.\n * Workaround for WebKit #219770 (img.onload fires before embedded font ready).\n * - ensureFontsReady (when embedFonts) runs before first iteration\n * - Mini pre-capture (scale 0.2) → load as Image + decode\n * - drawImage to offscreen canvas (consumes \"first draw blank\" so real capture works)\n * - Double rAF for layout stabilization\n * - Poke canvas elements for Chart.js etc.\n * Skipped after first session warmup (_safariWarmup) to avoid repeated cost.\n */\nasync function safariWarmup(element, baseOptions) {\n  if (_safariWarmup) return\n\n  const preflight = {\n    ...baseOptions,\n    fast: true,\n    embedFonts: true,\n    scale: 0.2\n  }\n\n  let url\n  try {\n    url = await captureDOM(element, preflight)\n  } catch (e) {\n    debugWarn(baseOptions, 'safariWarmup pre-capture failed', e)\n  }\n\n  // 1) estabiliza layout/paint en WebKit\n  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))\n\n  if (url) {\n    await new Promise((resolve) => {\n      const img = new Image()\n      try { img.decoding = 'sync'; img.loading = 'eager' } catch (e) {\n        debugWarn(baseOptions, 'safariWarmup img hints failed', e)\n      }\n      img.style.cssText =\n        'position:fixed;left:0px;top:0px;width:10px;height:10px;opacity:0.01;pointer-events:none;'\n      img.src = url\n      document.body.appendChild(img)\n\n      ;(async () => {\n        try { if (typeof img.decode === 'function') await img.decode() } catch (e) {\n          debugWarn(baseOptions, 'safariWarmup img.decode failed', e)\n        }\n        const start = performance.now()\n        while (!(img.complete && img.naturalWidth > 0) && performance.now() - start < 900) {\n          await new Promise(r => setTimeout(r, 200))\n        }\n        await new Promise(r => requestAnimationFrame(r))\n        // Key: drawImage primes the canvas path. WebKit #219770 — first draw is blank,\n        // second works. We consume the blank draw here so the real capture works.\n        try {\n          const c = document.createElement('canvas')\n          c.width = Math.max(1, img.naturalWidth || 10)\n          c.height = Math.max(1, img.naturalHeight || 10)\n          const ctx = c.getContext('2d')\n          if (ctx) ctx.drawImage(img, 0, 0)\n        } catch { /* non-blocking */ }\n        await new Promise(r => requestAnimationFrame(r))\n        try { img.remove() } catch (e) {\n          debugWarn(baseOptions, 'safariWarmup img.remove failed', e)\n        }\n        resolve()\n      })()\n    })\n  }\n\n  // 3) “poke” a los canvas del elemento (Chart.js, etc.)\n  element.querySelectorAll('canvas').forEach(c => {\n    try {\n      const ctx = c.getContext('2d', { willReadFrequently: true })\n      if (ctx) { ctx.getImageData(0, 0, 1, 1) }\n    } catch (e) {\n      debugWarn(baseOptions, 'safariWarmup canvas poke failed', e)\n    }\n  })\n\n  _safariWarmup = true\n}\n\n/**\n * Checks if the element (or its descendants) use background or mask images.\n */\nfunction hasBackgroundOrMask(el) {\n  const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT)\n  while (walker.nextNode()) {\n    const node = /** @type {Element} */ (walker.currentNode)\n    const cs = getComputedStyle(node)\n\n    const bg = cs.backgroundImage && cs.backgroundImage !== 'none'\n    const mask = (cs.maskImage && cs.maskImage !== 'none') ||\n      (cs.webkitMaskImage && cs.webkitMaskImage !== 'none')\n\n    if (bg || mask) return true\n    if (node.tagName === 'CANVAS') return true\n  }\n  return false\n}\n\nexport default snapdom\n"
  },
  {
    "path": "src/core/cache.js",
    "content": "/** Max entries before evicting oldest (FIFO). Keeps lib lightweight, avoids memory leaks. */\nconst MAX_IMAGE = 100\nconst MAX_BACKGROUND = 100\nconst MAX_RESOURCE = 150\nconst MAX_BASE_STYLE = 50\nconst MAX_DEFAULT_STYLE = 30\n\n/**\n * Map that evicts oldest entries when exceeding maxSize. FIFO order.\n * @extends Map\n */\nclass EvictingMap extends Map {\n  constructor(maxSize = 100, ...args) {\n    super(...args)\n    this._maxSize = maxSize\n  }\n  set(key, value) {\n    if (this.size >= this._maxSize && !this.has(key)) {\n      const first = this.keys().next().value\n      if (first !== undefined) this.delete(first)\n    }\n    return super.set(key, value)\n  }\n}\n\n/**\n * Global caches for images, styles, and resources.\n * Persistent caches use EvictingMap to avoid unbounded memory growth.\n */\nexport const cache = {\n  image: new EvictingMap(MAX_IMAGE),\n  background: new EvictingMap(MAX_BACKGROUND),\n  resource: new EvictingMap(MAX_RESOURCE),\n  defaultStyle: new EvictingMap(MAX_DEFAULT_STYLE),\n  baseStyle: new EvictingMap(MAX_BASE_STYLE),\n  computedStyle: new WeakMap(),\n  font: new Set(),\n  session: {\n    styleMap: new Map(),\n    styleCache: new WeakMap(),\n    nodeMap: new Map(),\n  }\n}\n\nexport { EvictingMap }\n\n/**\n * Normalizes shorthand values to canonical cache policies.\n *  - true  => \"soft\"\n *  - false => \"disabled\"\n *  - \"auto\" => \"auto\"\n *  - \"full\" => \"full\"\n * @param {unknown} v\n * @returns {\"soft\"|\"auto\"|\"full\"|\"disabled\"}\n */\nexport function normalizeCachePolicy(v) {\n  if (v === true) return 'soft'\n  if (v === false) return 'disabled'\n  if (typeof v === 'string') {\n    const s = v.toLowerCase().trim()\n    if (s === 'auto') return 'auto'\n    if (s === 'full') return 'full'\n    if (s === 'soft' || s === 'disabled') return s\n  }\n  return 'soft' // default\n}\n\n/**\n * Applies the cache policy.\n * @param {\"soft\"|\"auto\"|\"full\"|\"disabled\"} policy\n */\nexport function applyCachePolicy(policy = 'soft') {\n  cache.session.__counterEpoch = (cache.session.__counterEpoch || 0) + 1\n  switch (policy) {\n    case 'auto': {\n      cache.session.styleMap = new Map()\n      cache.session.nodeMap  = new Map()\n      return\n    }\n    case 'soft': {\n      cache.session.styleMap   = new Map()\n      cache.session.nodeMap    = new Map()\n      cache.session.styleCache = new WeakMap()\n      return\n    }\n    case 'full': {\n      return\n    }\n    case 'disabled': {\n      cache.session.styleMap   = new Map()\n      cache.session.nodeMap    = new Map()\n      cache.session.styleCache = new WeakMap()\n\n      cache.computedStyle = new WeakMap()\n      cache.baseStyle     = new EvictingMap(MAX_BASE_STYLE)\n      cache.defaultStyle  = new EvictingMap(MAX_DEFAULT_STYLE)\n      cache.image         = new EvictingMap(MAX_IMAGE)\n      cache.background    = new EvictingMap(MAX_BACKGROUND)\n      cache.resource      = new EvictingMap(MAX_RESOURCE)\n      cache.font          = new Set()\n      return\n    }\n    default: {\n      // fallback → soft\n      cache.session.styleMap   = new Map()\n      cache.session.nodeMap    = new Map()\n      cache.session.styleCache = new WeakMap()\n      return\n    }\n  }\n}\n"
  },
  {
    "path": "src/core/capture.js",
    "content": "/**\n * Core logic for capturing DOM elements as SVG data URLs.\n * @module capture\n */\n\nimport { prepareClone } from './prepare.js'\nimport { inlineImages } from '../modules/images.js'\nimport { inlineBackgroundImages } from '../modules/background.js'\nimport { ligatureIconToImage } from '../modules/iconFonts.js'\nimport { idle, collectUsedTagNames, generateDedupedBaseCSS, isSafari, getStyle } from '../utils/index.js'\nimport { embedCustomFonts, collectUsedFontVariants, collectUsedCodepoints, ensureFontsReady } from '../modules/fonts.js'\nimport { cache, applyCachePolicy } from '../core/cache.js'\nimport { lineClampTree } from '../modules/lineClamp.js'\nimport { runHook } from './plugins.js'\nimport {\n  stripRootShadows,\n  sanitizeCloneForXHTML,\n  shrinkAutoSizeBoxes,\n  estimateKeptHeight,\n  limitDecimals,\n  collectScrollbarCSS\n} from '../utils/capture.helpers.js'\nimport {\n  parseBoxShadow,\n  parseFilterBlur,\n  parseOutline,\n  parseFilterDropShadows,\n  normalizeRootTransforms,\n  bboxWithOriginFull,\n  parseTransformOriginPx,\n  readIndividualTransforms,\n  readTotalTransformMatrix,\n  hasBBoxAffectingTransform,\n} from '../utils/transforms.helpers.js'\n/**\n * Captures an HTML element as an SVG data URL, inlining styles, images, backgrounds, and optionally fonts.\n *\n * @param {Element} element - DOM element to capture\n * @param {Object} [options={}] - Capture options\n * @param {boolean} [options.embedFonts=false] - Whether to embed custom fonts\n * @param {boolean} [options.fast=true] - Whether to skip idle delay for faster results\n * @param {number} [options.scale=1] - Output scale multiplier\n * @param {string[]} [options.exclude] - CSS selectors for elements to exclude\n * @param {Function} [options.filter] - Custom filter function\n * @param {boolean} [options.outerTransforms=false] - Normalize root by removing translate/rotate (keep scale/skew)\n * @param {boolean} [options.outerShadows=false] - Do not expand bleed for shadows/blur/outline on root (and strip root shadows visually)\n * @returns {Promise<string>} Promise that resolves to an SVG data URL\n */\nexport async function captureDOM(element, options) {\n  if (!element) throw new Error('Element cannot be null or undefined')\n  applyCachePolicy(options.cache)\n  const fast = options.fast\n  const outerTransforms = options.outerTransforms !== false   // default: true\n\n  const outerShadows = !!options.outerShadows\n  let state = { element, options, plugins: options.plugins }\n\n  let clone, classCSS, styleCache\n  let fontsCSS = ''\n  let baseCSS = ''\n  let dataURL\n  let svgString\n  // NEW: store root transform (scale/skew) when outerTransforms is on\n  let rootTransform2D = null\n  // BEFORESNAP\n  await runHook('beforeSnap', state)\n  // BEFORECLONE\n  await runHook('beforeClone', state)\n  const undoClamp = lineClampTree(state.element)\n  try {\n    ({ clone, classCSS, styleCache } = await prepareClone(state.element, state.options))\n\n    // state = {clone, classCSS, styleCache, ...state}\n\n    if (!outerTransforms && clone) {\n      rootTransform2D = normalizeRootTransforms(state.element, clone) // {a,b,c,d} or null\n    }\n    if (!outerShadows && clone) {\n      stripRootShadows(state.element, clone, state.options)\n    }\n  } finally {\n    undoClamp()\n  }\n\n  // AFTERCLONE\n  state = { clone, classCSS, styleCache, ...state }\n  await runHook('afterClone', state)\n  sanitizeCloneForXHTML(state.clone)\n  // Shrink pass ONLY when excludeMode === 'remove'\n  if (state.options?.excludeMode === 'remove') {\n    try {\n      shrinkAutoSizeBoxes(state.element, state.clone, state.styleCache)\n    } catch (e) {\n      console.warn('[snapdom] shrink pass failed:', e)\n    }\n  }\n  try {\n    await ligatureIconToImage(state.clone, state.element)\n  } catch { /* non-blocking */ }\n\n  await new Promise((resolve) => {\n    idle(async () => {\n      await inlineImages(state.clone, state.options)\n      resolve()\n    }, { fast })\n  })\n\n  await new Promise((resolve) => {\n    idle(async () => {\n      await inlineBackgroundImages(state.element, state.clone, state.styleCache, state.options)\n      resolve()\n    }, { fast })\n  })\n\n  if (options.embedFonts) {\n    await new Promise((resolve) => {\n      idle(async () => {\n        const required = collectUsedFontVariants(state.element)\n        const usedCodepoints = collectUsedCodepoints(state.element)\n        if (isSafari()) {\n          const families = new Set(\n            Array.from(required).map((k) => String(k).split('__')[0]).filter(Boolean)\n          )\n          await ensureFontsReady(families, 1)\n        }\n        fontsCSS = await embedCustomFonts({\n          required,\n          usedCodepoints,\n          preCached: false,\n          exclude: state.options.excludeFonts,\n          useProxy: state.options.useProxy,\n          fontStylesheetDomains: state.options.fontStylesheetDomains\n        })\n        resolve()\n      }, { fast })\n    })\n  }\n\n  const usedTags = collectUsedTagNames(state.clone).sort()\n  const tagKey = usedTags.join(',')\n  if (cache.baseStyle.has(tagKey)) {\n    baseCSS = cache.baseStyle.get(tagKey)\n  } else {\n    await new Promise((resolve) => {\n      idle(() => {\n        baseCSS = generateDedupedBaseCSS(usedTags)\n        cache.baseStyle.set(tagKey, baseCSS)\n        resolve()\n      }, { fast })\n    })\n  }\n  // #334: inject ::-webkit-scrollbar rules so custom scrollbar styles apply in capture\n  const scrollbarCSS = collectScrollbarCSS(state.element?.ownerDocument || document)\n  state = { fontsCSS, baseCSS, scrollbarCSS, ...state }\n  await runHook('beforeRender', state)\n\n  await new Promise((resolve) => {\n    idle(() => {\n      const csEl = getStyle(state.element)\n\n      const rect = state.element.getBoundingClientRect()\n      let w0 = Math.max(1, limitDecimals(state.element.offsetWidth || parseFloat(csEl.width) || rect.width || 1))\n      let h0 = Math.max(1, limitDecimals(state.element.offsetHeight || parseFloat(csEl.height) || rect.height || 1))\n      // body/documentElement: measure clone in-document to get true content height (Chrome clamps offset/scroll)\n      // Use element's ownerDocument for iframe support (#371)\n      const elDoc = state.element.ownerDocument || document\n      const isRoot = state.element === elDoc.body || state.element === elDoc.documentElement\n      if (isRoot) {\n        const docH = Math.max(\n          state.element.scrollHeight || 0,\n          elDoc.documentElement?.scrollHeight || 0,\n          elDoc.body?.scrollHeight || 0\n        )\n        const docW = Math.max(\n          state.element.scrollWidth || 0,\n          elDoc.documentElement?.scrollWidth || 0,\n          elDoc.body?.scrollWidth || 0\n        )\n        if (docH > 0) h0 = Math.max(h0, limitDecimals(docH))\n        if (docW > 0) w0 = Math.max(w0, limitDecimals(docW))\n        // Also measure clone in a temp container with injected styles (clone may layout differently)\n        try {\n          const wrap = elDoc.createElement('div')\n          wrap.style.cssText = 'position:absolute!important;left:-9999px!important;top:0!important;width:' + w0 + 'px!important;overflow:visible!important;visibility:hidden!important;'\n          const styleNode = elDoc.createElement('style')\n          styleNode.textContent = (state.scrollbarCSS || '') + state.baseCSS + state.fontsCSS + 'svg{overflow:visible;} foreignObject{overflow:visible;}' + state.classCSS\n          wrap.appendChild(styleNode)\n          wrap.appendChild(state.clone.cloneNode(true))\n          elDoc.body.appendChild(wrap)\n          const csh = wrap.scrollHeight\n          const csw = wrap.scrollWidth\n          elDoc.body.removeChild(wrap)\n          if (csh > 0) h0 = Math.max(h0, limitDecimals(csh))\n          if (csw > 0) w0 = Math.max(w0, limitDecimals(csw))\n        } catch { /* fallback: use doc dimensions above */ }\n      }\n      // === NEW: recompute height using the kept-children span (no offscreen) ===\n      if (state.options?.excludeMode === 'remove') {\n        const hEst = estimateKeptHeight(state.element, state.options) // border+padding+contentSpan\n        // Safety: nunca mayor al original, y con un epsilon para evitar recortes por redondeo\n        const EPS = 1 // px\n        if (Number.isFinite(hEst) && hEst > 0) {\n          h0 = Math.max(1, Math.min(h0, limitDecimals(hEst + EPS)))\n        }\n        // En ancho casi nunca conviene ajustar; si lo necesitás, podés hacer análogo con estimateKeptWidth(...)\n      }\n      const coerceNum = (v, def = NaN) => {\n        const n = typeof v === 'string' ? parseFloat(v) : v\n        return Number.isFinite(n) ? n : def\n      }\n\n      const optW = coerceNum(state.options.width)\n      const optH = coerceNum(state.options.height)\n      let w = w0, h = h0\n\n      const hasW = Number.isFinite(optW)\n      const hasH = Number.isFinite(optH)\n      const aspect0 = h0 > 0 ? w0 / h0 : 1\n\n      if (hasW && hasH) {\n        w = Math.max(1, limitDecimals(optW))\n        h = Math.max(1, limitDecimals(optH))\n      } else if (hasW) {\n        w = Math.max(1, limitDecimals(optW))\n        h = Math.max(1, limitDecimals(w / (aspect0 || 1)))\n      } else if (hasH) {\n        h = Math.max(1, limitDecimals(optH))\n        w = Math.max(1, limitDecimals(h * (aspect0 || 1)))\n      } else {\n        w = w0\n        h = h0\n      }\n\n      // ——— BBOX ———\n      let minX = 0, minY = 0, maxX = w0, maxY = h0\n\n      // NEW: if outerTransforms => expand bbox using the post-normalization 2D matrix\n      if (!outerTransforms && rootTransform2D && Number.isFinite(rootTransform2D.a)) {\n        const M2 = {\n          a: rootTransform2D.a,\n          b: rootTransform2D.b || 0,\n          c: rootTransform2D.c || 0,\n          d: rootTransform2D.d || 1,\n          e: 0,\n          f: 0\n        }\n        const bb2 = bboxWithOriginFull(w0, h0, M2, 0, 0)\n        minX = limitDecimals(bb2.minX)\n        minY = limitDecimals(bb2.minY)\n        maxX = limitDecimals(bb2.maxX)\n        maxY = limitDecimals(bb2.maxY)\n      } else {\n        const useTFBBox = outerTransforms && hasTFBBox(state.element)\n        if (useTFBBox) {\n          const baseTransform2 = csEl.transform && csEl.transform !== 'none' ? csEl.transform : ''\n          const ind2 = readIndividualTransforms(state.element)\n          const TOTAL = readTotalTransformMatrix({\n            baseTransform: baseTransform2,\n            rotate: ind2.rotate || '0deg',\n            scale: ind2.scale,\n            translate: ind2.translate\n          })\n          const { ox: ox2, oy: oy2 } = parseTransformOriginPx(csEl, w0, h0)\n          const M = TOTAL.is2D ? TOTAL : new DOMMatrix(TOTAL.toString())\n          const bb = bboxWithOriginFull(w0, h0, M, ox2, oy2)\n          minX = limitDecimals(bb.minX)\n          minY = limitDecimals(bb.minY)\n          maxX = limitDecimals(bb.maxX)\n          maxY = limitDecimals(bb.maxY)\n        }\n      }\n\n      // ——— BLEED ———\n      const bleedShadow = parseBoxShadow(csEl)\n      const bleedBlur = parseFilterBlur(csEl)\n      const bleedOutline = parseOutline(csEl)\n      const drop = parseFilterDropShadows(csEl)\n\n      const bleed = (!outerShadows)\n        ? { top: 0, right: 0, bottom: 0, left: 0 }\n        : {\n          top: limitDecimals(bleedShadow.top + bleedBlur.top + bleedOutline.top + drop.bleed.top),\n          right: limitDecimals(bleedShadow.right + bleedBlur.right + bleedOutline.right + drop.bleed.right),\n          bottom: limitDecimals(bleedShadow.bottom + bleedBlur.bottom + bleedOutline.bottom + drop.bleed.bottom),\n          left: limitDecimals(bleedShadow.left + bleedBlur.left + bleedOutline.left + drop.bleed.left)\n        }\n\n      minX = limitDecimals(minX - bleed.left)\n      minY = limitDecimals(minY - bleed.top)\n      maxX = limitDecimals(maxX + bleed.right)\n      maxY = limitDecimals(maxY + bleed.bottom)\n\n      const vbW0 = Math.max(1, limitDecimals(maxX - minX))\n      const vbH0 = Math.max(1, limitDecimals(maxY - minY))\n      const scaleW = (hasW || hasH) ? limitDecimals(w / w0) : 1\n      const scaleH = (hasH || hasW) ? limitDecimals(h / h0) : 1\n      const outW = Math.max(1, limitDecimals(vbW0 * scaleW))\n      const outH = Math.max(1, limitDecimals(vbH0 * scaleH))\n\n      const svgNS = 'http://www.w3.org/2000/svg'\n      // Safari workaround: pad only when root has bbox-affecting transforms (avoids edge clipping)\n      const basePad = (isSafari() && hasTFBBox(state.element)) ? 1 : 0\n      const extraPad = !outerTransforms ? 1 : 0\n      const pad = limitDecimals(basePad + extraPad)\n\n      const fo = document.createElementNS(svgNS, 'foreignObject')\n      const vbMinX = limitDecimals(minX)\n      const vbMinY = limitDecimals(minY)\n      fo.setAttribute('x', String(limitDecimals(-(vbMinX - pad))))\n      fo.setAttribute('y', String(limitDecimals(-(vbMinY - pad))))\n      fo.setAttribute('width', String(limitDecimals(w0 + pad * 2)))\n      fo.setAttribute('height', String(limitDecimals(h0 + pad * 2)))\n      fo.style.overflow = 'visible'\n\n      const styleTag = document.createElement('style')\n      styleTag.textContent =\n        (state.scrollbarCSS || '') + state.baseCSS + state.fontsCSS + 'svg{overflow:visible;} foreignObject{overflow:visible;}' + state.classCSS\n      fo.appendChild(styleTag)\n\n      const container = document.createElement('div')\n      container.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')\n      // #372: isolate wrapper from iframe CSS cascade (e.g. div { border: 10px solid red })\n      container.style.cssText =\n        'all:initial;box-sizing:border-box;display:block;overflow:visible;' +\n        `width:${limitDecimals(w0)}px;height:${limitDecimals(h0)}px`\n\n      //state.clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')\n      container.appendChild(state.clone)\n      fo.appendChild(container)\n\n      const serializer = new XMLSerializer()\n      const foString = serializer.serializeToString(fo)\n      const vbW = limitDecimals(vbW0 + pad * 2)\n      const vbH = limitDecimals(vbH0 + pad * 2)\n      const wantsSize = hasW || hasH\n\n      options.meta = { w0, h0, vbW, vbH, targetW: w, targetH: h }\n\n      const svgOutW = (isSafari() && wantsSize)\n        ? vbW\n        : limitDecimals(outW + pad * 2)\n      const svgOutH = (isSafari() && wantsSize)\n        ? vbH\n        : limitDecimals(outH + pad * 2)\n\n      const rootFontSize = parseFloat(getStyle(elDoc.documentElement)?.fontSize) || 16\n      const svgHeader = `<svg xmlns=\"${svgNS}\" width=\"${svgOutW}\" height=\"${svgOutH}\" viewBox=\"0 0 ${vbW} ${vbH}\" font-size=\"${rootFontSize}px\">`\n      const svgFooter = '</svg>'\n      svgString = svgHeader + foString + svgFooter\n      dataURL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`\n      state = { svgString, dataURL, ...state }\n      resolve()\n    }, { fast })\n  })\n  // afterRender(context)\n  await runHook('afterRender', state)\n\n  const sandbox = document.getElementById('snapdom-sandbox')\n  if (sandbox && sandbox.style.position === 'absolute') sandbox.remove()\n  return state.dataURL\n}\n\nfunction hasTFBBox(el) {\n  return hasBBoxAffectingTransform(el)\n}\n"
  },
  {
    "path": "src/core/clone.js",
    "content": "/**\n * Deep cloning utilities for DOM elements, including styles and shadow DOM.\n * @module clone\n */\n\nimport { inlineAllStyles } from '../modules/styles.js'\nimport { NO_CAPTURE_TAGS } from '../utils/css.js'\nimport { resolveCSSVars } from '../modules/CSSVar.js'\nimport { debugWarn } from '../utils/index.js'\nimport {\n  idleCallback,\n  rewriteShadowCSS,\n  nextShadowScopeId,\n  extractShadowCSS,\n  injectScopedStyle,\n  freezeImgSrcset,\n  collectCustomPropsFromCSS,\n  buildSeedCustomPropsRule,\n  markSlottedSubtree,\n  rasterizeIframe,\n  getUnscaledDimensions,\n  createCheckboxRadioReplacement\n} from '../utils/clone.helpers.js'\nimport { isFirefox } from '../utils/browser.js'\n\n// helper implementations moved to ../utils/clone.helpers.js\n\nexport async function deepClone(node, sessionCache, options) {\n  if (!node) throw new Error('Invalid node')\n  const clonedAssignedNodes = new Set()\n  let pendingSelectValue = null\n  let pendingTextAreaValue = null\n  if (node.nodeType === Node.ELEMENT_NODE) {\n    const tag = (node.localName || node.tagName || '').toLowerCase()\n    if (node.id === 'snapdom-sandbox' || node.hasAttribute('data-snapdom-sandbox')) {\n      return null\n    }\n    if (NO_CAPTURE_TAGS.has(tag)) {\n      return null\n    }\n  }\n  if (node.nodeType === Node.TEXT_NODE) {\n    return node.cloneNode(true)\n  }\n  if (node.nodeType !== Node.ELEMENT_NODE) {\n    return node.cloneNode(true)\n  }\n  if (node.getAttribute('data-capture') === 'exclude') {\n    if (options.excludeMode === 'hide') {\n      const spacer = document.createElement('div')\n      const { width, height } = getUnscaledDimensions(node)\n      const w = width || node.getBoundingClientRect().width || 0\n      const h = height || node.getBoundingClientRect().height || 0\n      spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;`\n      return spacer\n    } else if (options.excludeMode === 'remove') {\n      return null\n    }\n  }\n  if (options.exclude && Array.isArray(options.exclude)) {\n    for (const selector of options.exclude) {\n      try {\n        if (node.matches?.(selector)) {\n          if (options.excludeMode === 'hide') {\n            const spacer = document.createElement('div')\n            const { width, height } = getUnscaledDimensions(node)\n            const w = width || node.getBoundingClientRect().width || 0\n            const h = height || node.getBoundingClientRect().height || 0\n            spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;`\n            return spacer\n          } else if (options.excludeMode === 'remove') {\n            return null\n          }\n        }\n      } catch (err) {\n        console.warn(`Invalid selector in exclude option: ${selector}`, err)\n      }\n    }\n  }\n  if (typeof options.filter === 'function') {\n    try {\n      if (!options.filter(node)) {\n        if (options.filterMode === 'hide') {\n          const spacer = document.createElement('div')\n          const { width, height } = getUnscaledDimensions(node)\n          const w = width || node.getBoundingClientRect().width || 0\n          const h = height || node.getBoundingClientRect().height || 0\n          spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;`\n          return spacer\n        } else if (options.filterMode === 'remove') {\n          return null\n        }\n      }\n    } catch (err) {\n      console.warn('Error in filter function:', err)\n    }\n  }\n  if (node.tagName === 'IFRAME') {\n    let sameOrigin = false\n    try { sameOrigin = !!(node.contentDocument || node.contentWindow?.document) } catch (e) {\n      debugWarn(sessionCache, 'iframe same-origin probe failed', e)\n    }\n\n    if (sameOrigin) {\n      try {\n        const wrapper = await rasterizeIframe(node, sessionCache, options)\n        return wrapper\n      } catch (err) {\n        console.warn('[SnapDOM] iframe rasterization failed, fallback:', err)\n        // fall through\n      }\n    }\n\n    // Fallback actual (placeholder o spacer)\n    if (options.placeholders) {\n      const { width, height } = getUnscaledDimensions(node)\n      const fallback = document.createElement('div')\n      fallback.style.cssText =\n        `width:${width}px;height:${height}px;` +\n        'background-image:repeating-linear-gradient(45deg,#ddd,#ddd 5px,#f9f9f9 5px,#f9f9f9 10px);' +\n        'display:flex;align-items:center;justify-content:center;font-size:12px;color:#555;border:1px solid #aaa;'\n      inlineAllStyles(node, fallback, sessionCache, options)\n      return fallback\n    } else {\n      const { width, height } = getUnscaledDimensions(node)\n      const spacer = document.createElement('div')\n      spacer.style.cssText = `display:inline-block;width:${width}px;height:${height}px;visibility:hidden;`\n      inlineAllStyles(node, spacer, sessionCache, options)\n      return spacer\n    }\n  }\n\n  if (node.getAttribute('data-capture') === 'placeholder') {\n    const clone2 = node.cloneNode(false)\n    sessionCache.nodeMap.set(clone2, node)\n    inlineAllStyles(node, clone2, sessionCache, options)\n    const placeholder = document.createElement('div')\n    placeholder.textContent = node.getAttribute('data-placeholder-text') || ''\n    placeholder.style.cssText = 'color:#666;font-size:12px;text-align:center;line-height:1.4;padding:0.5em;box-sizing:border-box;'\n    clone2.appendChild(placeholder)\n    return clone2\n  }\n  if (node.tagName === 'CANVAS') {\n    // Safari-safe snapshot: poke + rAF + retry + scratch fallback\n    let url = ''\n    try {\n      const ctx = node.getContext('2d', { willReadFrequently: true })\n      try { ctx && ctx.getImageData(0, 0, 1, 1) } catch { }\n      await new Promise(r => requestAnimationFrame(r)) // deja materializar el frame\n\n      url = node.toDataURL('image/png')\n\n      if (!url || url === 'data:,') {\n        // reintento rápido\n        try { ctx && ctx.getImageData(0, 0, 1, 1) } catch { }\n        await new Promise(r => requestAnimationFrame(r))\n        url = node.toDataURL('image/png')\n\n        // último recurso: copiar a un scratch-canvas y leer desde ahí\n        if (!url || url === 'data:,') {\n          const scratch = document.createElement('canvas')\n          scratch.width = node.width\n          scratch.height = node.height\n          const sctx = scratch.getContext('2d')\n          if (sctx) {\n            sctx.drawImage(node, 0, 0)\n            url = scratch.toDataURL('image/png')\n          }\n        }\n      }\n    } catch (e) {\n      debugWarn(sessionCache, 'Canvas toDataURL failed, using empty/fallback', e)\n    }\n\n    const img = document.createElement('img')\n    try { img.decoding = 'sync'; img.loading = 'eager' } catch (e) {\n      debugWarn(sessionCache, 'img decoding/loading hints failed', e)\n    }\n    if (url) img.src = url\n\n    // conservar dimensiones intrínsecas del bitmap\n    img.width = node.width\n    img.height = node.height\n\n    // conservar caja CSS para no romper layout usando dimensiones pre-transform\n    const { width, height } = getUnscaledDimensions(node)\n    if (width > 0) img.style.width = `${width}px`\n    if (height > 0) img.style.height = `${height}px`\n\n    sessionCache.nodeMap.set(img, node)\n    inlineAllStyles(node, img, sessionCache, options)\n    return img\n  }\n\n  let clone\n  try {\n    clone = node.cloneNode(false)\n    resolveCSSVars(node, clone)\n    sessionCache.nodeMap.set(clone, node)\n    if (node.tagName === 'IMG') {\n      freezeImgSrcset(node, clone)\n      // Record original image dimensions (pre-transform) for fallback usage when inlining fails\n      try {\n        const { width, height } = getUnscaledDimensions(node)\n        const w = Math.round(width || 0)\n        const h = Math.round(height || 0)\n        if (w) clone.dataset.snapdomWidth = String(w)\n        if (h) clone.dataset.snapdomHeight = String(h)\n      } catch (e) {\n        debugWarn(sessionCache, 'getUnscaledDimensions for IMG failed', e)\n      }\n\n      // Si el autor usó % o auto, o el alto/ ancho efectivos dan 0,\n      // escribimos px en línea para evitar que el clon “pierda” la imagen.\n      try {\n        const authored = node.getAttribute('style') || ''\n        const cs = window.getComputedStyle(node)\n        const usesPercentOrAuto = (prop) => {\n          const a = authored.match(new RegExp(`${prop}\\\\s*:\\\\s*([^;]+)`, 'i'))\n          const v = a ? a[1].trim() : cs.getPropertyValue(prop)\n          return /%|auto/i.test(String(v || ''))\n        }\n\n        const w = parseInt(clone.dataset.snapdomWidth || '0', 10)\n        const h = parseInt(clone.dataset.snapdomHeight || '0', 10)\n\n        const needFreezeW = usesPercentOrAuto('width') || !w\n        const needFreezeH = usesPercentOrAuto('height') || !h\n\n        if (needFreezeW && w) clone.style.width = `${w}px`\n        if (needFreezeH && h) clone.style.height = `${h}px`\n\n        // Blindaje extra: evita que una clase agregada luego anule el fix\n        if (w) clone.style.minWidth = `${w}px`\n        if (h) clone.style.minHeight = `${h}px`\n      } catch (e) {\n        debugWarn(sessionCache, 'IMG dimension freeze failed', e)\n      }\n\n    }\n  } catch (err) {\n    console.error('[Snapdom] Failed to clone node:', node, err)\n    throw err\n  }\n  let applyInputVisual = null\n  if (node instanceof HTMLTextAreaElement) {\n    const { width, height } = getUnscaledDimensions(node)\n    const w = width || node.getBoundingClientRect().width || 0\n    const h = height || node.getBoundingClientRect().height || 0\n    if (w) clone.style.width = `${w}px`\n    if (h) clone.style.height = `${h}px`\n  }\n  if (node instanceof HTMLInputElement) {\n    const type = (node.type || 'text').toLowerCase()\n    const isCheckboxOrRadio = type === 'checkbox' || type === 'radio'\n    if (isCheckboxOrRadio && isFirefox()) {\n      const { el: replacement, applyVisual } = createCheckboxRadioReplacement(node)\n      sessionCache.nodeMap.set(replacement, node)\n      applyInputVisual = applyVisual\n      clone = replacement\n    } else {\n      clone.value = node.value\n      clone.setAttribute('value', node.value)\n      if (node.checked !== void 0) {\n        clone.checked = node.checked\n        if (node.checked) clone.setAttribute('checked', '')\n        if (node.indeterminate) clone.indeterminate = node.indeterminate\n      }\n    }\n  }\n  if (node instanceof HTMLSelectElement) {\n    pendingSelectValue = node.value\n  }\n  if (node instanceof HTMLTextAreaElement) {\n    pendingTextAreaValue = node.value\n  }\n  inlineAllStyles(node, clone, sessionCache, options)\n  if (applyInputVisual) { applyInputVisual() }\n  if (node.shadowRoot) {\n    try {\n      const slots = node.shadowRoot.querySelectorAll('slot')\n      for (const s of slots) {\n        let assigned = []\n        try {\n          assigned = s.assignedNodes?.({ flatten: true }) || s.assignedNodes?.() || []\n        } catch {\n          assigned = s.assignedNodes?.() || []\n        }\n        for (const an of assigned) clonedAssignedNodes.add(an)\n      }\n    } catch {\n    }\n    const scopeId = nextShadowScopeId(sessionCache)\n    const scopeSelector = `[data-sd=\"${scopeId}\"]`\n    try {\n      clone.setAttribute('data-sd', scopeId)\n    } catch {\n    }\n    const rawCSS = extractShadowCSS(node.shadowRoot)\n    const rewritten = rewriteShadowCSS(rawCSS, scopeSelector)\n    const neededVars = collectCustomPropsFromCSS(rawCSS)\n    const seed = buildSeedCustomPropsRule(node, neededVars, scopeSelector)\n    injectScopedStyle(clone, seed + rewritten, scopeId)\n    const shadowFrag = document.createDocumentFragment()\n    function callback(child, resolve) {\n      if (child.nodeType === Node.ELEMENT_NODE && child.tagName === 'STYLE') {\n        return resolve(null)\n      } else {\n        deepClone(child, sessionCache, options).then((clonedChild) => {\n          resolve(clonedChild || null)\n        }).catch(() => {\n          resolve(null)\n        })\n      }\n    }\n\n    const cloneList = await idleCallback(Array.from(node.shadowRoot.childNodes), callback, options.fast)\n    shadowFrag.append(...cloneList.filter(clonedChild => !!clonedChild))\n    clone.appendChild(shadowFrag)\n  }\n  if (node.tagName === 'SLOT') {\n    const assigned = node.assignedNodes?.({ flatten: true }) || []\n    const nodesToClone = assigned.length > 0 ? assigned : Array.from(node.childNodes)\n    const fragment = document.createDocumentFragment()\n\n    function callback(child, resolve) {\n      deepClone(child, sessionCache, options).then((clonedChild) => {\n        if (clonedChild) {\n          markSlottedSubtree(clonedChild)\n        }\n        resolve(clonedChild || null)\n      }).catch(() => {\n        resolve(null)\n      })\n    }\n    const cloneList = await idleCallback(Array.from(nodesToClone), callback, options.fast)\n    fragment.append(...cloneList.filter(clonedChild => !!clonedChild))\n    return fragment\n  }\n\n  function callback(child, resolve) {\n    if (clonedAssignedNodes.has(child)) return resolve(null)\n    deepClone(child, sessionCache, options).then((clonedChild) => {\n      resolve(clonedChild || null)\n    }).catch(() => {\n      resolve(null)\n    })\n  }\n  const cloneList = await idleCallback(Array.from(node.childNodes), callback, options.fast)\n  clone.append(...cloneList.filter(clonedChild => !!clonedChild))\n\n  // Adjust select value after children are cloned\n  if (pendingSelectValue !== null && clone instanceof HTMLSelectElement) {\n    clone.value = pendingSelectValue\n    for (const opt of clone.options) {\n      if (opt.value === pendingSelectValue) {\n        opt.setAttribute('selected', '')\n      } else {\n        opt.removeAttribute('selected')\n      }\n    }\n  }\n  if (pendingTextAreaValue !== null && clone instanceof HTMLTextAreaElement) {\n    clone.textContent = pendingTextAreaValue\n\n  }\n  return clone\n}\n"
  },
  {
    "path": "src/core/context.js",
    "content": "/**\n * @typedef {\"disabled\"|\"full\"|\"auto\"|\"soft\"} CachePolicy\n */\n\nimport { normalizeCachePolicy } from './cache.js'\n\n/**\n * Creates a normalized capture context for SnapDOM.\n * @param {Object} [options={}]\n * @param {boolean} [options.debug]\n * @param {boolean} [options.fast]\n * @param {number}  [options.scale]\n * @param {Array<string|RegExp>} [options.exclude]\n * @param {string}  [options.excludeMode]\n * @param {(node: Node)=>boolean} [options.filter]\n * @param {string}  [options.filterMode]\n * @param {boolean} [options.embedFonts]\n * @param {string|string[]} [options.iconFonts]\n * @param {string[]} [options.localFonts]\n * @param {string[]|undefined} [options.excludeFonts]\n * @param {string[]} [options.fontStylesheetDomains]      // extra domains to fetch cross-origin CSS from (#309)\n * @param {string|function} [options.fallbackURL]\n * @param {string}  [options.useProxy]\n * @param {number|null} [options.width]\n * @param {number|null} [options.height]\n * @param {\"png\"|\"jpg\"|\"jpeg\"|\"webp\"|\"svg\"} [options.format]\n * @param {\"svg\"|\"img\"|\"canvas\"|\"blob\"} [options.type]\n * @param {number}  [options.quality]\n * @param {number}  [options.dpr]\n * @param {string|null} [options.backgroundColor]\n * @param {string}  [options.filename]\n * @param {unknown} [options.cache] // \"disabled\"|\"full\"|\"auto\"|\"soft\"\n * @param {boolean} [options.outerTransforms] // NEW\n * @param {boolean} [options.outerShadows]      // NEW\n * @param {RegExp|((prop: string) => boolean)} [options.excludeStyleProps] - Skip props when snapshotting (#348). e.g. /^--/ to exclude CSS vars\n * @returns {Object}\n */\nexport function createContext(options = {}) {\n  let resolvedFormat = options.format ?? 'png'\n  if (resolvedFormat === 'jpg') resolvedFormat = 'jpeg'\n  /** @type {CachePolicy} */\n  const cachePolicy = normalizeCachePolicy(options.cache)\n\n  return {\n    // Debug & perf\n    debug: options.debug ?? false,\n    fast: options.fast ?? true,\n    scale: options.scale ?? 1,\n\n    // DOM filters\n    exclude: options.exclude ?? [],\n    excludeMode: options.excludeMode ?? 'hide',\n    filter: options.filter ?? null,\n    filterMode: options.filterMode ?? 'hide',\n\n    // Placeholders\n    placeholders: options.placeholders !== false, // default true\n\n    // Fonts\n    embedFonts: options.embedFonts ?? false,\n    iconFonts: Array.isArray(options.iconFonts) ? options.iconFonts\n      : (options.iconFonts ? [options.iconFonts] : []),\n    localFonts: Array.isArray(options.localFonts) ? options.localFonts : [],\n    excludeFonts: options.excludeFonts ?? undefined,\n    fontStylesheetDomains: Array.isArray(options.fontStylesheetDomains) ? options.fontStylesheetDomains : [],\n    fallbackURL: options.fallbackURL ?? undefined,\n\n    /** @type {CachePolicy} */\n    cache: cachePolicy,\n\n    // Network\n    useProxy: typeof options.useProxy === 'string' ? options.useProxy : '',\n\n    // Output\n    width: options.width ?? null,\n    height: options.height ?? null,\n    format: resolvedFormat,\n    type: options.type ?? 'svg',\n    quality: options.quality ?? 0.92,\n    dpr: options.dpr ?? (window.devicePixelRatio || 1),\n    backgroundColor:\n      options.backgroundColor ?? (['jpeg', 'webp'].includes(resolvedFormat) ? '#ffffff' : null),\n    filename: options.filename ?? 'snapDOM',\n\n    // NEW flags (user-friendly)\n    outerTransforms: options.outerTransforms ?? true,\n    outerShadows: options.outerShadows ?? false,\n\n    // Safari warmup (WebKit #219770): iterations to prime font/decode pipeline. 1–3.\n    safariWarmupAttempts: Math.min(3, Math.max(1, (options.safariWarmupAttempts ?? 3) | 0)),\n\n    // #348: exclude style props from snapshot (reduces cost when :root has thousands of CSS vars)\n    excludeStyleProps: options.excludeStyleProps ?? null,\n\n    // Plugins (reservado)\n    // plugins: normalizePlugins(...),\n  }\n}\n"
  },
  {
    "path": "src/core/exporters.js",
    "content": "/**\n * Exporters registry (by format).\n * An exporter declares supported formats and an export() method:\n *\n * interface Exporter {\n *   format: string | string[];                 // e.g., 'png' or ['png','image/png']\n *   export(context: object, args: { format: string, options: object, url: string }): Promise<any> | any;\n * }\n */\n\nconst __exporters = new Map()\n\n/**\n * Normalize an exporter def: Factory, [Factory, options], { exporter, options }, instance.\n * @param {any} spec\n * @returns {any|null}\n */\nexport function normalizeExporter(spec) {\n  if (!spec) return null\n  if (Array.isArray(spec)) {\n    const [factory, options] = spec\n    return typeof factory === 'function' ? factory(options) : factory\n  }\n  if (typeof spec === 'object' && 'exporter' in spec) {\n    const { exporter, options } = spec\n    return typeof exporter === 'function' ? exporter(options) : exporter\n  }\n  if (typeof spec === 'function') return spec()\n  return spec\n}\n\n/**\n * Register one or many exporters.\n * Last one wins on format collision.\n * @param  {...any} defs\n */\nexport function registerExporters(...defs) {\n  const flat = defs.flat()\n  for (const d of flat) {\n    const inst = normalizeExporter(d)\n    if (!inst) continue\n    const formats = Array.isArray(inst.format) ? inst.format : [inst.format]\n    for (const fmtRaw of formats) {\n      const fmt = String(fmtRaw || '').toLowerCase().trim()\n      if (!fmt) continue\n      __exporters.set(fmt, inst)\n    }\n  }\n}\n\n/**\n * Resolve the exporter for a format (case-insensitive).\n * @param {string} format\n * @returns {any|null}\n */\nexport function getExporter(format) {\n  if (!format) return null\n  const key = String(format).toLowerCase().trim()\n  return __exporters.get(key) || null\n}\n\n/** Utilities for tests */\nexport function _exportersMap() { return new Map(__exporters) }\nexport function _clearExporters() { __exporters.clear() }\n\n/* -------------------------------------------------------------------------- */\n/* 🧩 Export Hooks Integration (auto-once afterSnap)                          */\n/* -------------------------------------------------------------------------- */\n\nimport { runHook } from './plugins.js'\n\n/** Keeps track of which captures have already triggered afterSnap */\nconst finished = new Set()\n\n/**\n * Runs export-related hooks around a given export task.\n *\n * Flow:\n *   beforeExport → work() → afterExport → afterSnap(once per URL)\n *\n * @template T\n * @param {object} ctx - Capture context extended with { export:{ type, options, url } }\n * @param {() => Promise<T>} work - Async exporter function\n * @returns {Promise<T>} - The export result\n */\nexport async function runExportHooks(ctx, work) {\n  await runHook('beforeExport', ctx)\n\n  ctx.export.result = await work()\n\n  await runHook('afterExport', ctx)\n\n  const key = ctx.export?.url\n  if (key && !finished.has(key)) {\n    finished.add(key)\n    await runHook('afterSnap', ctx)\n\n  }\n\n  return ctx.export.result\n}\n"
  },
  {
    "path": "src/core/plugins.js",
    "content": "/**\n * Plugin core for SnapDOM (minimalistic, local-first compatible).\n *\n * Public hooks:\n *  - beforeSnap(context)\n *  - beforeClone(context)\n *  - afterClone(context)\n *  - beforeRender(context)\n *  - afterRender(context)\n *  - beforeExport(context, { format, options })\n *  - afterExport(context, { format, options, result })\n *  - afterSnap(context)\n *\n * Hook signature: (context, payload?) => void | any | Promise<void|any>\n *\n * Global plugins are registered via registerPlugins()\n * Local (per-capture) plugins can be attached using attachSessionPlugins().\n */\n\nconst __plugins = []\n\n/**\n * Normalize any plugin definition form into an instance.\n * Supports plain objects, [factory, options], { plugin, options }, or functions.\n * @param {any} spec\n * @returns {any|null}\n */\nexport function normalizePlugin(spec) {\n  if (!spec) return null\n  if (Array.isArray(spec)) {\n    const [factory, options] = spec\n    return typeof factory === 'function' ? factory(options) : factory\n  }\n  if (typeof spec === 'object' && 'plugin' in spec) {\n    const { plugin, options } = spec\n    return typeof plugin === 'function' ? plugin(options) : plugin\n  }\n  if (typeof spec === 'function') return spec()\n  return spec\n}\n\n/**\n * Register global plugins (deduped by name, preserves order).\n * @param  {...any} defs\n */\nexport function registerPlugins(...defs) {\n  const flat = defs.flat()\n  for (const d of flat) {\n    const inst = normalizePlugin(d)\n    if (!inst) continue\n    // 🔒 de-dup por name\n    if (!__plugins.some(p => p && p.name && inst.name && p.name === inst.name)) {\n      __plugins.push(inst)\n    }\n  }\n}\n\n/**\n * INTERNAL: pick the plugin list for a given context.\n * If the context defines a per-capture plugin list, use that (local-first).\n * Otherwise, fall back to the global registry.\n * @param {any} context\n * @returns {readonly any[]}\n */\nfunction getContextPlugins(context) {\n  const arr = context && Array.isArray(context.plugins) ? context.plugins : __plugins\n  return arr || __plugins\n}\n\n/**\n * Llama un hook y propaga un acumulador (compat con tu runHook actual).\n * Usa los plugins locales si existen, o los globales en fallback.\n * @param {string} name\n * @param {any} context\n * @param {any} payload\n */\nexport async function runHook(name, context, payload) {\n  let acc = payload\n  const list = getContextPlugins(context)\n  for (const p of list) {\n    const fn = p && typeof p[name] === 'function' ? p[name] : null\n    if (!fn) continue\n    const out = await fn(context, acc)\n    if (typeof out !== 'undefined') acc = out\n  }\n  return acc\n}\n\n/**\n * NUEVO: recolecta los valores devueltos por TODOS los plugins para un hook.\n * Útil para `defineExports` (cada plugin devuelve un mapa propio).\n * Usa plugins locales si existen, o los globales en fallback.\n * @param {string} name\n * @param {any} context\n * @param {any} payload\n */\nexport async function runAll(name, context, payload) {\n  const outs = []\n  const list = getContextPlugins(context)\n  for (const p of list) {\n    const fn = p && typeof p[name] === 'function' ? p[name] : null\n    if (!fn) continue\n    const out = await fn(context, payload)\n    if (typeof out !== 'undefined') outs.push(out)\n  }\n  return outs\n}\n\n/**\n * Return a shallow copy of currently registered global plugins.\n * @returns {any[]}\n */\nexport function pluginsList() { return __plugins.slice() }\n\n/** Clear all globally registered plugins (mostly for tests). */\nexport function clearPlugins() { __plugins.length = 0 }\n\n/* ──────────────────────────────────────────────────────────────────────────────\n * NEW: Local-first per-capture support (without removing global APIs)\n * ────────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Merge local (per-capture) plugin defs with the global registry (local-first).\n * - Local plugins override globals by `name`.\n * - Accepts plain instances, factories ([factory, options]) and {plugin, options}.\n * - Returns a frozen array for immutability & GC safety.\n * @param {any[]|undefined} localDefs\n * @returns {ReadonlyArray<any>}\n */\nexport function mergePlugins(localDefs) {\n  /** @type {any[]} */\n  const out = []\n\n  // 1️⃣ Locals first (priority)\n  if (Array.isArray(localDefs)) {\n    for (const d of localDefs) {\n      const inst = normalizePlugin(d)\n      if (!inst || !inst.name) continue\n      const i = out.findIndex(x => x && x.name === inst.name)\n      if (i >= 0) out.splice(i, 1)\n      out.push(inst)\n    }\n  }\n\n  // 2️⃣ Then globals if not already present\n  for (const g of __plugins) {\n    if (g && g.name && !out.some(x => x.name === g.name)) {\n      out.push(g)\n    }\n  }\n\n  return Object.freeze(out)\n}\n\n/**\n * Attach a per-capture plugin list on the given context (local-first).\n * Idempotent: if `context.plugins` already exists, it remains unless `force` is true.\n * @param {any} context\n * @param {any[]|undefined} localDefs\n * @param {boolean} [force=false]\n * @returns {any} the same context (for chaining)\n */\nexport function attachSessionPlugins(context, localDefs, force = false) {\n  if (!context || (context.plugins && !force)) return context\n  context.plugins = mergePlugins(localDefs)\n  return context\n}\n\n/**\n * Shallow copy of current global plugins (handy for tests or introspection).\n * @returns {any[]}\n */\nexport function getGlobalPlugins() {\n  return __plugins.slice()\n}\n"
  },
  {
    "path": "src/core/prepare.js",
    "content": "/**\n * Prepares a deep clone of an element, inlining pseudo-elements and generating CSS classes.\n * @module prepare\n */\n\nimport { generateCSSClasses, stripTranslate, debugWarn, getStyle } from '../utils/index.js'\nimport { deepClone } from './clone.js'\nimport { inlinePseudoElements } from '../modules/pseudo.js'\nimport { inlineExternalDefsAndSymbols } from '../modules/svgDefs.js'\nimport { cache } from '../core/cache.js'\nimport { freezeSticky } from '../modules/changeCSS.js'\nimport { resolveBlobUrlsInTree } from '../utils/clone.helpers.js'\nimport { stabilizeLayout } from '../utils/prepare.helpers.js'\n\n/**\n * Prepares a clone of an element for capture, inlining pseudo-elements and generating CSS classes.\n *\n * @param {Element} element - Element to clone\n * @param {boolean} [embedFonts=false] - Whether to embed custom fonts\n * @param {Object} [options={}] - Capture options\n * @param {string[]} [options.exclude] - CSS selectors for elements to exclude\n * @param {Function} [options.filter] - Custom filter function\n * @returns {Promise<Object>} Object containing the clone, generated CSS, and style cache\n */\n\nexport async function prepareClone(element, options = {}) {\n  const sessionCache = {\n    styleMap: cache.session.styleMap,\n    styleCache: cache.session.styleCache,\n    nodeMap: cache.session.nodeMap,\n    options\n  }\n\n  let clone\n  let classCSS = ''\n  let shadowScopedCSS = ''\n\n  stabilizeLayout(element)\n\n  try {\n    inlineExternalDefsAndSymbols(element)\n  } catch (e) {\n    console.warn('inlineExternal defs or symbol failed:', e)\n  }\n\n  try {\n    clone = await deepClone(element, sessionCache, options)\n  } catch (e) {\n    console.warn('deepClone failed:', e)\n    throw e\n  }\n  try {\n    await inlinePseudoElements(element, clone, sessionCache, options)\n  } catch (e) {\n    console.warn('inlinePseudoElements failed:', e)\n  }\n  await resolveBlobUrlsInTree(clone, sessionCache)\n  // --- Pull shadow-scoped CSS out of the clone (avoid visible CSS text) ---\n\n  try {\n    const styleNodes = clone.querySelectorAll('style[data-sd]')\n    for (const s of styleNodes) {\n      shadowScopedCSS += s.textContent || ''\n      s.remove() // Do not leave <style> inside the visual clone\n    }\n  } catch (e) {\n    debugWarn(sessionCache, 'Failed to extract shadow CSS from style[data-sd]', e)\n  }\n\n  const keyToClass = generateCSSClasses(sessionCache.styleMap)\n  classCSS = Array.from(keyToClass.entries())\n    .map(([key, className]) => `.${className}{${key}}`)\n    .join('')\n\n  // #359: suppress native ::before/::after on elements where we inlined them (avoids double render from cloned <style>)\n  const PSEUDO_SUPPRESS = '[data-snapdom-has-after]::after,[data-snapdom-has-before]::before{content:none!important;display:none!important}'\n  // prepend shadow CSS so variables/rules are available for everything\n  classCSS = shadowScopedCSS + PSEUDO_SUPPRESS + classCSS\n\n  for (const [node, key] of sessionCache.styleMap.entries()) {\n    if (node.tagName === 'STYLE') continue\n    /* c8 ignore next 4 */\n    if (node.getRootNode && node.getRootNode() instanceof ShadowRoot) {\n      node.setAttribute('style', key.replace(/;/g, '; '))\n      continue\n    }\n\n    // Fuera de Shadow DOM: aplica clase generada para compresión\n    const className = keyToClass.get(key)\n    if (className) node.classList.add(className)\n\n    // Reaplica backgroundImage para evitar que se pierda (si existe)\n    const bgImage = node.style?.backgroundImage\n    const hasIcon = node.dataset?.snapdomHasIcon\n    if (bgImage && bgImage !== 'none') node.style.backgroundImage = bgImage\n    /* c8 ignore next 4 */\n    if (hasIcon) {\n      node.style.verticalAlign = 'middle'\n      node.style.display = 'inline'\n    }\n  }\n\n  for (const [cloneNode, originalNode] of sessionCache.nodeMap.entries()) {\n    const scrollX = originalNode.scrollLeft\n    const scrollY = originalNode.scrollTop\n    const hasScroll = scrollX || scrollY\n    if (hasScroll && cloneNode instanceof HTMLElement) {\n      cloneNode.style.overflow = 'hidden'\n      cloneNode.style.scrollbarWidth = 'none'\n      cloneNode.style.msOverflowStyle = 'none'\n      const inner = document.createElement('div')\n      inner.style.transform = `translate(${-scrollX}px, ${-scrollY}px)`\n      inner.style.willChange = 'transform'\n      inner.style.display = 'inline-block'\n      inner.style.width = '100%'\n      while (cloneNode.firstChild) {\n        inner.appendChild(cloneNode.firstChild)\n      }\n      cloneNode.appendChild(inner)\n    }\n  }\n  const contentRoot =\n  (clone instanceof HTMLElement && clone.firstElementChild instanceof HTMLElement)\n    ? clone.firstElementChild\n    : clone\n\n  // Congela header/footer: header => top = topInit + scrollTop\n  //                        footer => bottom = bottomInit - scrollTop\n  freezeSticky(element, contentRoot)\n\n  if (element === sessionCache.nodeMap.get(clone)) {\n    const computed = sessionCache.styleCache.get(element) || getStyle(element)\n    sessionCache.styleCache.set(element, computed)\n    const transform = stripTranslate(computed.transform)\n    clone.style.margin = '0'\n    // clone.style.position = \"static\";\n    clone.style.top = 'auto'\n    clone.style.left = 'auto'\n    clone.style.right = 'auto'\n    clone.style.bottom = 'auto'\n    //clone.style.zIndex = \"auto\";\n    clone.style.animation = 'none'\n    clone.style.transition = 'none'\n    clone.style.willChange = 'auto'\n    clone.style.float = 'none'\n    clone.style.clear = 'none'\n    clone.style.transform = transform || ''\n  }\n\n  for (const [cloneNode, originalNode] of sessionCache.nodeMap.entries()) {\n    if (originalNode.tagName === 'PRE') {\n      cloneNode.style.marginTop = '0'\n      cloneNode.style.marginBlockStart = '0'\n    }\n  }\n  return { clone, classCSS, styleCache: sessionCache.styleCache }\n}\n\n// helpers (stabilizeLayout, resolveBlobUrlsInTree) ahora vienen de utils; bloque antiguo eliminado.\n"
  },
  {
    "path": "src/exporters/download.js",
    "content": "// src/exporters/download.js\nimport { toBlob } from './toBlob.js'\nimport { toCanvas } from './toCanvas.js'\nimport { isIOS } from '../utils/browser.js'\n\n/**\n * Attempts to share a file via the Web Share API (iOS fallback).\n * @param {Blob} blob - The image blob.\n * @param {string} filename - The filename to share.\n * @returns {Promise<boolean>} True if share was handled (including user cancel), false if unavailable.\n */\nasync function shareFile(blob, filename) {\n  const file = new File([blob], filename, { type: blob.type })\n  if (!navigator.canShare?.({ files: [file] })) return false\n  try {\n    await navigator.share({ files: [file], title: filename })\n  } catch (error) {\n    if (error.name !== 'AbortError') return false\n  }\n  return true\n}\n\n/**\n * Triggers download of the generated image.\n * @param {string} url - Image data URL.\n * @param {object} options - Context including format, quality, filename.\n * @param {string} options.format - Output format ('png', 'jpeg', 'webp', 'svg').\n * @param {string} options.filename - Download filename.\n * @param {number} [options.quality] - Image quality for lossy formats.\n * @param {string} [options.backgroundColor] - Optional background color.\n * @returns {Promise<void>}\n */\nexport async function download(url, options) {\n  const format = (options?.format || options?.type || '').toLowerCase()\n  const normalizedFormat = format === 'jpg' ? 'jpeg' : format || 'png'\n  const filename = options?.filename || `snapdom.${normalizedFormat}`\n  const nextOptions = { ...(options || {}), format: normalizedFormat, type: normalizedFormat }\n  nextOptions.dpr = 1\n  const foundIOS = isIOS()\n\n  if (normalizedFormat === 'svg') {\n    const blob = await toBlob(url, { ...nextOptions, type: 'svg' })\n    if (foundIOS && await shareFile(blob, filename)) return\n    const objectURL = URL.createObjectURL(blob)\n    const a = document.createElement('a')\n    a.href = objectURL\n    a.download = filename\n    document.body.appendChild(a)\n    a.click()\n    URL.revokeObjectURL(objectURL)\n    a.remove()\n    return\n  }\n\n  const canvas = await toCanvas(url, nextOptions) // backgroundColor inline\n\n  if (foundIOS) {\n    const mimeType = `image/${normalizedFormat}`\n    const blob = await new Promise(resolve => canvas.toBlob(resolve, mimeType, options?.quality))\n    if (blob && await shareFile(blob, filename)) return\n  }\n\n  const a = document.createElement('a')\n  a.href = canvas.toDataURL(`image/${normalizedFormat}`, options?.quality)\n  a.download = filename\n  document.body.appendChild(a)\n  a.click()\n  a.remove()\n}\n"
  },
  {
    "path": "src/exporters/toBlob.js",
    "content": "// src/exporters/toBlob.js\nimport { toCanvas } from './toCanvas.js'\n\n/**\n * Converts the rendered output to a Blob.\n * @param {string} url - Image data URL.\n * @param {object} options - Context including type and quality.\n * @param {string} options.type - Image type ('png', 'jpeg', 'webp', 'svg').\n * @param {number} [options.quality] - Image quality for lossy formats.\n * @param {string} [options.backgroundColor] - Optional background color.\n * @returns {Promise<Blob>} Resolves with the image Blob.\n */\nexport async function toBlob(url, options) {\n  const type = options.type\n  if (type === 'svg') {\n    const svgText = decodeURIComponent(url.split(',')[1])\n    return new Blob([svgText], { type: 'image/svg+xml' })\n  }\n\n  const canvas = await toCanvas(url, options) // backgroundColor inline\n  return new Promise((resolve) =>\n    canvas.toBlob((blob) => resolve(blob), `image/${type}`, options.quality)\n  )\n}\n"
  },
  {
    "path": "src/exporters/toCanvas.js",
    "content": "// src/exporters/toCanvas.js\nimport { isSafari } from '../utils/browser'\n\n/**\n * Converts a data URL to a Canvas element.\n * Safari: render offscreen in a per-call temporary slot to avoid flicker, then remove it.\n *\n * @param {string} url - The image data URL.\n * @param {{ scale: number, dpr: number }} options - Context including scale and dpr (already normalized upstream).\n * @returns {Promise<HTMLCanvasElement>} Resolves with the rendered Canvas element.\n */\n// ——— helpers ———\nfunction isSvgDataURL(u) {\n  return typeof u === 'string' && /^data:image\\/svg\\+xml/i.test(u)\n}\nfunction decodeSvgFromDataURL(u) {\n  const i = u.indexOf(',')\n  return i >= 0 ? decodeURIComponent(u.slice(i + 1)) : ''\n}\nfunction encodeSvgToDataURL(svgText) {\n  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`\n}\nfunction splitDecls(s) {\n  let parts = [], buf = '', depth = 0\n  for (let i = 0; i < s.length; i++) {\n    const ch = s[i]\n    if (ch === '(') depth++\n    if (ch === ')') depth = Math.max(0, depth - 1)\n    if (ch === ';' && depth === 0) { parts.push(buf); buf='' } else buf += ch\n  }\n  if (buf.trim()) parts.push(buf)\n  return parts.map(x => x.trim()).filter(Boolean)\n}\nfunction boxShadowToDropShadow(value) {\n  // divide por capas sin romper paréntesis de colores\n  const layers = []\n  let buf = '', depth = 0\n  for (let i = 0; i < value.length; i++) {\n    const ch = value[i]\n    if (ch === '(') depth++\n    if (ch === ')') depth = Math.max(0, depth - 1)\n    if (ch === ',' && depth === 0) { layers.push(buf.trim()); buf = '' }\n    else buf += ch\n  }\n  if (buf.trim()) layers.push(buf.trim())\n\n  const fns = []\n  for (const layer of layers) {\n    if (/\\binset\\b/i.test(layer)) continue // no hay equivalente en drop-shadow\n    const nums = layer.match(/-?\\d+(?:\\.\\d+)?px/gi) || []\n    const [ox='0px', oy='0px', blur='0px'] = nums // spread no existe en drop-shadow\n    // color ≈ lo que quede tras quitar px e 'inset'\n    let color = layer.replace(/-?\\d+(?:\\.\\d+)?px/gi, '')\n                     .replace(/\\binset\\b/ig, '')\n                     .trim().replace(/\\s{2,}/g, ' ')\n    const hasColor = !!color && color !== ',' // muy tolerante\n    fns.push(`drop-shadow(${ox} ${oy} ${blur}${hasColor ? ` ${color}` : ''})`)\n  }\n  return fns.join(' ')\n}\nfunction rewriteDeclList(list) {\n  const decls = splitDecls(list)\n  let filter = null, wfilter = null, box = null\n  const rest = []\n  for (const d of decls) {\n    const idx = d.indexOf(':')\n    if (idx < 0) continue\n    const prop = d.slice(0, idx).trim().toLowerCase()\n    const val  = d.slice(idx + 1).trim()\n    if (prop === 'box-shadow') box = val\n    else if (prop === 'filter') filter = val\n    else if (prop === '-webkit-filter') wfilter = val\n    else rest.push([prop, val])\n  }\n  if (box) {\n    const ds = boxShadowToDropShadow(box)\n    if (ds) {\n      filter = filter ? `${filter} ${ds}` : ds\n      wfilter = wfilter ? `${wfilter} ${ds}` : ds\n      // opcional: eliminar el box-shadow original para evitar que reaparezca\n      // (no es estrictamente necesario dentro del SVG)\n    }\n  }\n  const out = [...rest]\n  if (filter) out.push(['filter', filter])\n  if (wfilter) out.push(['-webkit-filter', wfilter])\n  return out.map(([k, v]) => `${k}:${v}`).join(';')\n}\nfunction rewriteCssBlock(css) {\n  return css.replace(/([^{}]+)\\{([^}]*)\\}/g, (_m, sel, body) => `${sel}{${rewriteDeclList(body)}}`)\n}\nfunction rewriteSvgBoxShadowToDropShadow(svgText) {\n  // 1) <style>…</style>\n  svgText = svgText.replace(/<style[^>]*>([\\s\\S]*?)<\\/style>/gi, (m, css) =>\n    m.replace(css, rewriteCssBlock(css))\n  )\n  // 2) style=\"…\"\n  svgText = svgText.replace(/style=(['\"])([\\s\\S]*?)\\1/gi, (m, q, body) =>\n    `style=${q}${rewriteDeclList(body)}${q}`\n  )\n  return svgText\n}\nfunction maybeConvertBoxShadowForSafari(url) {\n  if (!isSafari() || !isSvgDataURL(url)) return url\n  try {\n    const svg = decodeSvgFromDataURL(url)\n    const fixed = rewriteSvgBoxShadowToDropShadow(svg)\n    return encodeSvgToDataURL(fixed)\n  } catch {\n    return url\n  }\n}\n\n/**\n * Rasterize SVG (o data URL) en un canvas respetando width/height + scale.\n * Soporta aplanar un background color sin canvas intermedio.\n * @param {string} url\n * @param {{\n *   width?:number,\n *   height?:number,\n *   scale?:number,\n *   dpr?:number,\n *   meta?:object,\n *   backgroundColor?: string // <- NUEVO: color opcional para aplanar fondo\n * }} options\n * @returns {Promise<HTMLCanvasElement>}\n */\nexport async function toCanvas(url, options) {\n  let { width: optW, height: optH, scale = 1, dpr = 1, meta = {}, backgroundColor } = options\n  url = maybeConvertBoxShadowForSafari(url)\n\n  const img = new Image()\n  img.loading = 'eager'\n  img.decoding = 'sync'\n  img.crossOrigin = 'anonymous'\n  img.src = url\n  await img.decode()\n\n  const natW = img.naturalWidth\n  const natH = img.naturalHeight\n\n  const refW = Number.isFinite(meta.w0) ? meta.w0 : natW\n  const refH = Number.isFinite(meta.h0) ? meta.h0 : natH\n\n  let outW, outH\n  const hasW = Number.isFinite(optW)\n  const hasH = Number.isFinite(optH)\n\n  if (hasW && hasH) {\n    outW = Math.max(1, optW)\n    outH = Math.max(1, optH)\n  } else if (hasW) {\n    const k = optW / Math.max(1, refW)\n    outW = optW\n    outH = refH * k\n  } else if (hasH) {\n    const k = optH / Math.max(1, refH)\n    outH = optH\n    outW = refW * k\n  } else {\n    outW = natW\n    outH = natH\n  }\n\n  outW = outW * scale\n  outH = outH * scale\n\n  const canvas = document.createElement('canvas')\n  canvas.width = outW * dpr\n  canvas.height = outH * dpr\n  canvas.style.width = `${outW}px`\n  canvas.style.height = `${outH}px`\n\n  const ctx = canvas.getContext('2d')\n  if (dpr !== 1) ctx.scale(dpr, dpr)\n\n  if (backgroundColor) {\n    ctx.save()\n    ctx.fillStyle = backgroundColor\n    ctx.fillRect(0, 0, outW, outH)\n    ctx.restore()\n  }\n\n  ctx.drawImage(img, 0, 0, outW, outH)\n  return canvas\n}\n"
  },
  {
    "path": "src/exporters/toImg.js",
    "content": "// src/exporters/toImg.js\nimport { isSafari, debugWarn } from '../utils'\nimport { rasterize } from '../modules/rasterize'\n/**\n * Converts a data URL to an HTMLImageElement.\n * @param {string} url - The data URL of the image.\n * @param {object} options - Context options including scale.\n * @param {number} [options.scale=1] - Scale factor for the image dimensions.\n * @returns {Promise<HTMLImageElement>} Resolves with the loaded Image element.\n */\nexport async function toImg(url, options) {\n  const { scale = 1, width, height, meta = {} } = options\n  const hasW = Number.isFinite(width)\n  const hasH = Number.isFinite(height)\n  const wantsScale = (Number.isFinite(scale) && scale !== 1) || hasW || hasH\n  if (isSafari() && wantsScale) {\n    const pngUrl = await rasterize(url, {...options, format: 'png', quality: 1, meta})\n\n    return pngUrl\n  }\nconst img = new Image()\n  img.decoding = 'sync'\n  img.loading = 'eager'\n  img.src = url\n  await img.decode()\n  if (hasW && hasH) {\n    img.style.width = `${width}px`\n    img.style.height = `${height}px`\n  } else if (hasW) {\n    const refW = Number.isFinite(meta.w0) ? meta.w0 : img.naturalWidth\n    const refH = Number.isFinite(meta.h0) ? meta.h0 : img.naturalHeight\n    const k = width / Math.max(1, refW)\n    img.style.width = `${width}px`\n    img.style.height = `${Math.round(refH * k)}px`\n  } else if (hasH) {\n    const refW = Number.isFinite(meta.w0) ? meta.w0 : img.naturalWidth\n    const refH = Number.isFinite(meta.h0) ? meta.h0 : img.naturalHeight\n    const k = height / Math.max(1, refH)\n    img.style.height = `${height}px`\n    img.style.width = `${Math.round(refW * k)}px`\n  } else {\n     const cssW = Math.round(img.naturalWidth * scale)\n     const cssH = Math.round(img.naturalHeight * scale)\n   img.style.width = `${cssW}px`\n   img.style.height = `${cssH}px`\n   if (typeof url === 'string' && url.startsWith('data:image/svg+xml')) {\n     try {\n       const decoded = decodeURIComponent(url.split(',')[1])\n       const patched = decoded\n         .replace(/width=\"[^\"]*\"/, `width=\"${cssW}\"`)\n         .replace(/height=\"[^\"]*\"/, `height=\"${cssH}\"`)\n       url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(patched)}`\n       img.src = url\n     } catch (e) {\n       debugWarn(options, 'SVG width/height patch in toImg failed', e)\n     }\n   }\n }\n return img\n}\n\nexport { toImg as toSvg }\n"
  },
  {
    "path": "src/exporters/toJpg.js",
    "content": "import { rasterize } from '../modules/rasterize.js'\nimport { captureDOM } from '../core/capture.js'\n\nexport async function toJpg(elOrUrl, opts = {}) {\n  // El normalizador de JPEG→fondo blanco ya corre en snapdom.capture(),\n  // pero por si alguien llama directo al exporter:\n  const next = { backgroundColor: '#ffffff', ...opts }\n  const url = typeof elOrUrl === 'string' ? elOrUrl : await captureDOM(elOrUrl, next)\n  return rasterize(url, { ...next, format: 'jpeg' })\n}\n"
  },
  {
    "path": "src/exporters/toPng.js",
    "content": "// PNG via rasterize; acepta Element o dataURL (SVG)\nimport { rasterize } from '../modules/rasterize.js'\nimport { captureDOM } from '../core/capture.js'\n\n/**\n * @param {HTMLElement|string} elOrUrl\n * @param {object} opts\n * @returns {Promise<HTMLImageElement|string|HTMLCanvasElement|Blob>} según tu contrato de `rasterize`\n */\nexport async function toPng(elOrUrl, opts = {}) {\n  const url = typeof elOrUrl === 'string' ? elOrUrl : await captureDOM(elOrUrl, opts)\n  return rasterize(url, { ...opts, format: 'png' })\n}\n"
  },
  {
    "path": "src/exporters/toSvg.js",
    "content": "// Reexpone el toSvg que ya definís en toImg.js para habilitar el sub-path\nexport { toSvg } from './toImg.js'\n"
  },
  {
    "path": "src/exporters/toWebp.js",
    "content": "import { rasterize } from '../modules/rasterize.js'\nimport { captureDOM } from '../core/capture.js'\n\nexport async function toWebp(elOrUrl, opts = {}) {\n  const url = typeof elOrUrl === 'string' ? elOrUrl : await captureDOM(elOrUrl, opts)\n  return rasterize(url, { ...opts, format: 'webp' })\n}\n"
  },
  {
    "path": "src/index.browser.js",
    "content": "/**\n * Entry point for snapDOM library exports.\n *\n * @file index.browser.js\n */\n\nimport { snapdom } from './api/snapdom.js'\nimport { preCache } from './api/preCache.js'\n\nif (typeof window !== 'undefined') {\n  window.snapdom = snapdom\n  window.preCache = preCache\n}\n"
  },
  {
    "path": "src/index.js",
    "content": "/**\n * Entry point for snapDOM library exports.\n *\n * @file index.js\n */\n\nexport { snapdom } from './api/snapdom.js'\nexport { preCache } from './api/preCache.js'\n"
  },
  {
    "path": "src/modules/CSSVar.js",
    "content": "// src/utils/resolveCSSVars.js\n\n/** Props donde típicamente aparece var() y conviene “materializar” si difieren del baseline */\nconst KEY_PROPS = ['fill', 'stroke', 'color', 'background-color', 'stop-color']\n\n/** Cache de estilos base por (namespaceURI + tagName) */\nconst __BASELINE_CACHE = new Map()\n\n/** Obtiene el estilo computado “base” (sin clase ni estilo) para un tag/namespace */\nfunction getBaselineComputed(tagName, ns) {\n  const key = ns + '::' + tagName.toLowerCase()\n  let entry = __BASELINE_CACHE.get(key)\n  if (entry) return entry\n\n  // Crear elemento del mismo tipo fuera del flujo visual\n  const doc = document\n  const el = ns === 'http://www.w3.org/2000/svg'\n    ? doc.createElementNS(ns, tagName)\n    : doc.createElement(tagName)\n\n  // Lo insertamos de forma que el UA pueda computar estilos, pero sin afectar layout\n  // (un shadowRoot vacío temporal funciona bien)\n  const holder = doc.createElement('div')\n  holder.style.cssText = 'position:absolute;left:-99999px;top:-99999px;contain:strict;display:block;'\n  holder.appendChild(el)\n  doc.documentElement.appendChild(holder)\n\n  const cs = getComputedStyle(el)\n  const base = {}\n  for (const p of KEY_PROPS) {\n    base[p] = cs.getPropertyValue(p) || ''\n  }\n\n  holder.remove()\n  __BASELINE_CACHE.set(key, base)\n  return base\n}\n\n/**\n * General: resuelve var() en estilos inline/atributos. Además, si no hay var()\n * pero el valor computado de KEY_PROPS difiere del baseline, inlina ese valor.\n */\nexport function resolveCSSVars(sourceEl, cloneEl) {\n  if (!(sourceEl instanceof Element) || !(cloneEl instanceof Element)) return\n\n  // --- 0) Pre-chequeo ultra barato\n  const styleAttr = sourceEl.getAttribute?.('style')\n  let hasVar = !!(styleAttr && styleAttr.includes('var('))\n\n  if (!hasVar && sourceEl.attributes?.length) {\n    const attrs = sourceEl.attributes\n    for (let i = 0; i < attrs.length; i++) {\n      const a = attrs[i]\n      if (a && typeof a.value === 'string' && a.value.includes('var(')) { hasVar = true; break }\n    }\n  }\n\n  // Leemos cs sólo si hace falta o si vamos a comparar con baseline\n  let cs = null\n  if (hasVar) {\n    try { cs = getComputedStyle(sourceEl) } catch {}\n  }\n\n  // --- 1) Resolver var() en estilos inline\n  if (hasVar) {\n    const author = sourceEl.style\n    if (author && author.length) {\n      for (let i = 0; i < author.length; i++) {\n        const prop = author[i]\n        const val = author.getPropertyValue(prop)\n        if (!val || !val.includes('var(')) continue\n        const resolved = cs && cs.getPropertyValue(prop)\n        if (resolved) {\n          try { cloneEl.style.setProperty(prop, resolved.trim(), author.getPropertyPriority(prop)) } catch {}\n        }\n      }\n    }\n  }\n\n  // --- 2) Resolver var() en atributos (genérico; si prop no existe en CSS, setProperty no hace nada)\n  if (hasVar && sourceEl.attributes?.length) {\n    const attrs = sourceEl.attributes\n    for (let i = 0; i < attrs.length; i++) {\n      const a = attrs[i]\n      if (!a || typeof a.value !== 'string' || !a.value.includes('var(')) continue\n      const propName = a.name\n      const resolved = cs && cs.getPropertyValue(propName)\n      if (resolved) {\n        try { cloneEl.style.setProperty(propName, resolved.trim()) } catch {}\n      }\n    }\n  }\n\n  // --- 3) Fallback general: cubrir reglas de hoja (clases) SIN buscar en CSSOM\n  // Si NO vimos var() inline/attrs, quizás la clase aplicó var(). En ese caso,\n  // comparamos KEY_PROPS contra baseline del mismo tag/namespace y, si difiere,\n  // inlinamos el valor computado. Esto materializa p.ej. `.css-var-fill { fill: var(--x) }`\n  if (!hasVar) {\n    // Leemos cs aquí sólo si lo vamos a usar\n    if (!cs) {\n      try { cs = getComputedStyle(sourceEl) } catch { cs = null }\n    }\n    if (!cs) return\n\n    const ns = sourceEl.namespaceURI || 'html'\n    const base = getBaselineComputed(sourceEl.tagName, ns)\n\n    for (const prop of KEY_PROPS) {\n      const v = cs.getPropertyValue(prop) || ''\n      const b = base[prop] || ''\n      if (v && v !== b) {\n        // Es distinto al baseline => hay estilo de hoja afectando (posiblemente via var()).\n        try { cloneEl.style.setProperty(prop, v.trim()) } catch {}\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/background.js",
    "content": "/**\n * Utilities for inlining background images as data URLs.\n * @module background\n */\n\nimport { getStyle, inlineSingleBackgroundEntry, splitBackgroundImage } from '../utils'\n\n/**\n * Recursively inlines background-related images and masks from the source element to its clone.\n *\n * This function walks through the source DOM tree and its clone, copying inline styles for\n * background images, masks, and border images to ensure the clone retains all visual image\n * resources inline (e.g., data URLs), avoiding external dependencies.\n *\n * It also preserves the `background-color` property if it is not transparent.\n *\n * Special handling is done for `border-image` related properties: the\n * `border-image-slice`, `border-image-width`, `border-image-outset`, and `border-image-repeat`\n * are only copied if `border-image` or `border-image-source` are present and active.\n *\n * @param {HTMLElement} source The original source element from which styles are read.\n * @param {HTMLElement} clone The cloned element to which inline styles are applied.\n * @param {Object} [options={}] Optional parameters passed to image inlining functions.\n * @returns {Promise<void>} Resolves when all inlining operations (including async image fetches) complete.\n */\n/**\n * Inlines URL-bearing properties (background/mask/border-image)\n * and also preserves mask positioning longhands (position/size/repeat).\n * This fixes cases like `mask: url(...) center/60% 60% no-repeat`.\n */\nexport async function inlineBackgroundImages(source, clone, styleCache, options = {}) {\n  const queue = [[source, clone]]\n\n  /** Props that can contain url(...) and may need inlining */\n  const URL_PROPS = [\n    'background-image',\n\n    // Mask shorthands & images (both standard and WebKit)\n    'mask',\n    'mask-image',\n    '-webkit-mask',\n    '-webkit-mask-image',\n\n    // Mask sources (rare, but keep)\n    'mask-source',\n    'mask-box-image-source',\n    'mask-border-source',\n    '-webkit-mask-box-image-source',\n\n    // Border image\n    'border-image',\n    'border-image-source',\n  ]\n\n  /** Mask longhands to preserve spatial layout (copy as-is) */\n  const MASK_LAYOUT_PROPS = [\n    'mask-position',\n    'mask-size',\n    'mask-repeat',\n    // WebKit variants\n    '-webkit-mask-position',\n    '-webkit-mask-size',\n    '-webkit-mask-repeat',\n    // Extra (optional but helpful across engines)\n    'mask-origin',\n    'mask-clip',\n    '-webkit-mask-origin',\n    '-webkit-mask-clip',\n    // Some engines expose X/Y position separately:\n    '-webkit-mask-position-x',\n    '-webkit-mask-position-y',\n  ]\n  const BG_LAYOUT_PROPS = [\n    'background-position', 'background-position-x', 'background-position-y',\n    'background-size', 'background-repeat',\n    'background-origin', 'background-clip',\n    'background-attachment', 'background-blend-mode'\n  ]\n  /** Border-image aux longhands (copy only when active) */\n  const BORDER_AUX_PROPS = [\n    'border-image-slice',\n    'border-image-width',\n    'border-image-outset',\n    'border-image-repeat',\n  ]\n\n  while (queue.length) {\n    const [srcNode, cloneNode] = queue.shift()\n\n    if (!cloneNode) continue\n\n    // Style cache\n    const style = styleCache.get(srcNode) || getStyle(srcNode)\n    if (!styleCache.has(srcNode)) styleCache.set(srcNode, style)\n    // Border-image present?\n    const hasBorderImage = (() => {\n      const bi = style.getPropertyValue('border-image')\n      const bis = style.getPropertyValue('border-image-source')\n      return (bi && bi !== 'none') || (bis && bis !== 'none')\n    })()\n    for (const prop of BG_LAYOUT_PROPS) {\n      const v = style.getPropertyValue(prop)\n      if (!v) continue\n      cloneNode.style.setProperty(prop, v)\n    }\n    // 1) Inline URL-bearing properties\n    for (const prop of URL_PROPS) {\n      let val = style.getPropertyValue(prop)\n      // Fallback: when background-image is none/empty, parse url() from background shorthand (#343)\n      if ((prop === 'background-image') && (!val || val === 'none')) {\n        const bgShorthand = style.getPropertyValue('background')\n        if (bgShorthand && /url\\s*\\(/.test(bgShorthand)) {\n          val = splitBackgroundImage(bgShorthand).find(p => /url\\s*\\(/.test(p)) || val\n        }\n      }\n      if (!val || val === 'none') continue\n\n      // Split multiple layers (comma-separated)\n      const splits = splitBackgroundImage(val)\n\n      const inlined = await Promise.all(\n        splits.map(entry => inlineSingleBackgroundEntry(entry, options))\n      )\n\n      if (inlined.some(p => p && p !== 'none' && !/^url\\(undefined/.test(p))) {\n        cloneNode.style.setProperty(prop, inlined.join(', '))\n      }\n    }\n    // 2) Copy mask layout longhands (position / size / repeat, etc.)\n    for (const prop of MASK_LAYOUT_PROPS) {\n      const val = style.getPropertyValue(prop)\n      // Skip empty/initial defaults to avoid bloating\n      if (!val || val === 'initial') continue\n      cloneNode.style.setProperty(prop, val)\n    }\n    // 3) Copy border-image auxiliaries only if border-image is active\n    if (hasBorderImage) {\n      for (const prop of BORDER_AUX_PROPS) {\n        const val = style.getPropertyValue(prop)\n        if (!val || val === 'initial') continue\n        cloneNode.style.setProperty(prop, val)\n      }\n    }\n    // 4) Recurse\n    // Fix: When srcNode is a shadow DOM host, its light DOM children are empty\n    // (content lives in shadowRoot). Use shadowRoot.children instead, filtering\n    // out <style> elements which are skipped during shadow DOM cloning.\n    const sChildren = srcNode.shadowRoot\n      ? Array.from(srcNode.shadowRoot.children).filter(el => el.tagName !== 'STYLE')\n      : Array.from(srcNode.children)\n    const cChildren = Array.from(cloneNode.children)\n      .filter(el => {\n        if (el.dataset?.snapdomPseudo) return false\n        // Only exclude injected <style data-sd> tags, not shadow DOM host elements\n        if (el.tagName === 'STYLE' && el.dataset?.sd) return false\n        return true\n      })\n\n    for (let i = 0; i < Math.min(sChildren.length, cChildren.length); i++) {\n      queue.push([sChildren[i], cChildren[i]])\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/changeCSS.js",
    "content": "/**\n * Freeze sticky elements by converting them to absolutely-positioned overlays.\n * Also creates an invisible absolute placeholder behind each sticky (lower z-index).\n *\n * Robust against sibling-index drift:\n * - Placeholders are marked data-snap-ph=\"1\".\n * - Path resolution in the clone ignores those placeholders to keep indices aligned.\n *\n * Only processes real vertical stickies (numeric top OR bottom).\n *\n * @param {HTMLElement} originalRoot\n * @param {HTMLElement} cloneRoot\n */\nexport function freezeSticky(originalRoot, cloneRoot) {\n  if (!originalRoot || !cloneRoot) return\n\n  const scrollTop = originalRoot.scrollTop || 0\n  // Avoid touching layout if nothing is actually stuck yet\n  if (!scrollTop) return\n\n  // Ensure clone is a containing block\n  if (getComputedStyle(cloneRoot).position === 'static') {\n    cloneRoot.style.position = 'relative'\n  }\n\n  const rootRect = originalRoot.getBoundingClientRect()\n  const viewportH = originalRoot.clientHeight\n  const PH_ATTR = 'data-snap-ph'\n\n  const walker = document.createTreeWalker(originalRoot, NodeFilter.SHOW_ELEMENT)\n  while (walker.nextNode()) {\n    const el = /** @type {HTMLElement} */ (walker.currentNode)\n    const cs = getComputedStyle(el)\n\n    // Hard filter: only sticky\n    const pos = cs.position\n    if (pos !== 'sticky' && pos !== '-webkit-sticky') continue\n\n    // Must have a vertical anchor\n    const topInit = _toPx(cs.top)\n    const bottomInit = _toPx(cs.bottom)\n    if (topInit == null && bottomInit == null) continue\n\n    // Resolve twin BEFORE mutating siblings; resolution ignores placeholders\n    const path = _pathOf(el, originalRoot)\n    const cloneEl = _findByPathIgnoringPlaceholders(cloneRoot, path, PH_ATTR)\n    if (!cloneEl) continue\n\n    // Measure on original\n    const elRect = el.getBoundingClientRect()\n    const widthPx = elRect.width\n    const heightPx = elRect.height\n    const leftPx = elRect.left - rootRect.left\n    if (!(widthPx > 0 && heightPx > 0)) continue\n    if (!Number.isFinite(leftPx)) continue\n\n    // Compute absolute top for frozen state\n    const topAbsPx = topInit != null\n      ? topInit + scrollTop\n      : scrollTop + (viewportH - heightPx - /** bottomInit non-null */ bottomInit)\n    if (!Number.isFinite(topAbsPx)) continue\n\n    // Layering\n    const zParsed = Number.parseInt(cs.zIndex, 10)\n    const hasZ = Number.isFinite(zParsed)\n    const overlayZ = hasZ ? Math.max(zParsed, 1) + 1 : 2\n    const placeholderZ = hasZ ? zParsed - 1 : 0\n\n    // 1) Absolute, invisible placeholder (behind). Mark it to ignore in future path resolutions.\n    const ph = cloneEl.cloneNode(false)\n    ph.setAttribute(PH_ATTR, '1')\n    ph.style.position = 'sticky'\n    ph.style.left = `${leftPx}px`\n    ph.style.top = `${topAbsPx}px`\n    ph.style.width = `${widthPx}px`\n    ph.style.height = `${heightPx}px`\n    ph.style.visibility = 'hidden'\n    ph.style.zIndex = String(placeholderZ)\n    ph.style.overflow = 'hidden'\n    ph.style.background = 'transparent'\n    ph.style.boxShadow = 'none'\n    ph.style.filter = 'none'\n\n    cloneEl.parentElement?.insertBefore(ph, cloneEl)\n\n    // 2) Turn the clone twin into visible absolute overlay (do NOT mark it)\n    cloneEl.style.position = 'absolute'\n   // cloneEl.style.width = `${widthPx}px`;\n   // cloneEl.style.height = `${heightPx}px`;\n    cloneEl.style.left = `${leftPx}px`\n    cloneEl.style.top = `${topAbsPx}px`\n    cloneEl.style.bottom = 'auto'\n    cloneEl.style.zIndex = String(overlayZ)\n    cloneEl.style.pointerEvents = 'none'\n  }\n}\n\nfunction _toPx(v) {\n  if (!v || v === 'auto') return null\n  const n = Number.parseFloat(v)\n  return Number.isFinite(n) ? n : null\n}\n\nfunction _pathOf(el, root) {\n  const path = []\n  for (let cur = el; cur && cur !== root; ) {\n    const p = cur.parentElement\n    if (!p) break\n    path.push(Array.prototype.indexOf.call(p.children, cur))\n    cur = p\n  }\n  return path.reverse()\n}\n\n/**\n * Resolve a node in the clone by path of element indices, but ignoring any\n * children marked as placeholders (data-snap-ph=\"1\") that were injected later.\n * This keeps indices aligned with the original DOM structure.\n *\n * @param {HTMLElement} root\n * @param {number[]} path\n * @param {string} phAttr\n * @returns {HTMLElement|null}\n */\nfunction _findByPathIgnoringPlaceholders(root, path, phAttr) {\n  let cur = root\n  for (let i = 0; i < path.length; i++) {\n    const kids = _childrenWithoutPlaceholders(cur, phAttr)\n    cur = /** @type {HTMLElement|undefined} */ (kids[path[i]])\n    if (!cur) return null\n  }\n  return cur instanceof HTMLElement ? cur : null\n}\n\n/**\n * @param {Element} el\n * @param {string} phAttr\n * @returns {Element[]}\n */\nfunction _childrenWithoutPlaceholders(el, phAttr) {\n  const out = []\n  const ch = el.children\n  for (let i = 0; i < ch.length; i++) {\n    const c = ch[i]\n    if (!c.hasAttribute(phAttr)) out.push(c)\n  }\n  return out\n}\n"
  },
  {
    "path": "src/modules/counter.js",
    "content": "import { cache } from '../core/cache'\n\n/**\n * Lightweight CSS counter resolver for SnapDOM.\n * - Supports counter(name[, style]) and counters(name, sep[, style])\n * - counter-reset push vs replace (push if parent has that counter, replace otherwise)\n * - Carries state across siblings in document order\n * - Simple OL/UL indexing (start, li[value]); reversed not handled intentionally\n *\n * @module counters\n */\n\n/** Detects if a content string uses counter()/counters(). */\nexport function hasCounters(input) {\n  return /\\bcounter\\s*\\(|\\bcounters\\s*\\(/.test(input || '')\n}\n\n/** Replace every CSS string token \"...\" with its raw content (keeps single quotes). */\nexport function unquoteDoubleStrings(s) {\n  return (s || '').replace(/\"([^\"]*)\"/g, '$1')\n}\n\n/**\n * a, b, ..., z, aa, ab, ...\n * @param {number} n\n * @param {boolean} upper\n * @returns {string}\n */\nfunction alpha(n, upper = false) {\n  let s = '', x = Math.max(1, n)\n  while (x > 0) { x--; s = String.fromCharCode(97 + (x % 26)) + s; x = Math.floor(x / 26) }\n  return upper ? s.toUpperCase() : s\n}\n\n/**\n * Roman numerals (1..3999)\n * @param {number} n\n * @param {boolean} upper\n * @returns {string}\n */\nfunction roman(n, upper = true) {\n  const map = [[1000,'M'],[900,'CM'],[500,'D'],[400,'CD'],[100,'C'],[90,'XC'],[50,'L'],[40,'XL'],[10,'X'],[9,'IX'],[5,'V'],[4,'IV'],[1,'I']]\n  let num = Math.max(1, Math.min(3999, n)), out = ''\n  for (const [v, sym] of map) while (num >= v) { out += sym; num -= v }\n  return upper ? out : out.toLowerCase()\n}\n\n/**\n * Format a numeric counter value according to CSS counter-style keyword.\n * NOTE: Keeps your original clamp to 0 in decimal variants.\n * @param {number} value\n * @param {string} style\n * @returns {string}\n */\nfunction formatCounter(value, style) {\n  switch ((style || 'decimal').toLowerCase()) {\n    case 'decimal': return String(Math.max(0, value))\n    case 'decimal-leading-zero': return (value < 10 ? '0' : '') + String(Math.max(0, value))\n    case 'lower-alpha': return alpha(value, false)\n    case 'upper-alpha': return alpha(value, true)\n    case 'lower-roman': return roman(value, false)\n    case 'upper-roman': return roman(value, true)\n    default: return String(Math.max(0, value))\n  }\n}\n\n/**\n * Build a counter context by walking the DOM once.\n * It stores, for each Element, a Map<counterName, number[]> (stack).\n *\n * Rules:\n * - counter-reset on element:\n *    * if parent had that counter -> push (nest)\n *    * else -> replace (start a fresh stack with [value])\n * - counter-increment on element: add to top, creating top=0 if needed\n * - list-item: sets 'list-item' value for LI in OL/UL (supports start, li[value])\n *\n * @param {Document|Element} root\n * @returns {{ get(node: Element, name: string): number, getStack(node: Element, name: string): number[] }}\n */\nexport function buildCounterContext(root) {\n  const getEpoch = () => (cache?.session?.__counterEpoch ?? 0)\n   let run = getEpoch()\n  const nodeCounters = new WeakMap()\n  const rootEl = (root instanceof Document) ? root.documentElement : root\n\n  const isLi = (el) => el && el.tagName === 'LI'\n  const countPrevLi = (li) => {\n    let c = 0, p = li?.parentElement\n    if (!p) return 0\n    for (const sib of p.children) { if (sib === li) break; if (sib.tagName === 'LI') c++ }\n    return c\n  }\n  const cloneMap = (m) => {\n    const out = new Map()\n    for (const [k, arr] of m) out.set(k, arr.slice())\n    return out\n  }\n\n  // Apply resets/increments/list-item given base map and the *parent* map (to decide push vs replace)\n  const applyTo = (baseMap, parentMap, el) => {\n    const map = cloneMap(baseMap)\n\n    // counter-reset\n    let reset\n    try { reset = el.style?.counterReset || getComputedStyle(el).counterReset } catch {}\n    if (reset && reset !== 'none') {\n      for (const part of reset.split(',')) {\n        const toks = part.trim().split(/\\s+/)\n        const name = toks[0]\n        const val = Number.isFinite(Number(toks[1])) ? Number(toks[1]) : 0\n        if (!name) continue\n\n        const parentStack = parentMap.get(name)\n        if (parentStack && parentStack.length) {\n          const s = parentStack.slice() // nest on parent's stack\n          s.push(val)\n          map.set(name, s)\n        } else {\n          map.set(name, [val])         // replace any carried state\n        }\n      }\n    }\n\n    // counter-increment\n    let inc\n    try { inc = el.style?.counterIncrement || getComputedStyle(el).counterIncrement } catch {}\n    if (inc && inc !== 'none') {\n      for (const part of inc.split(',')) {\n        const toks = part.trim().split(/\\s+/)\n        const name = toks[0]\n        const by = Number.isFinite(Number(toks[1])) ? Number(toks[1]) : 1\n        if (!name) continue\n        const stack = map.get(name) || []\n        if (stack.length === 0) stack.push(0)\n        stack[stack.length - 1] += by\n        map.set(name, stack)\n      }\n    }\n\n    // list-item for LI in OL/UL (start, li[value])\n    try {\n      const cs = getComputedStyle(el)\n      if (cs.display === 'list-item' && isLi(el)) {\n        const p = el.parentElement\n        let idx = 1\n        if (p && p.tagName === 'OL') {\n          const startAttr = p.getAttribute('start')\n          const start = Number.isFinite(Number(startAttr)) ? Number(startAttr) : 1\n          const prev = countPrevLi(el)\n          const ownAttr = el.getAttribute('value')\n          idx = Number.isFinite(Number(ownAttr)) ? Number(ownAttr) : (start + prev)\n        } else {\n          idx = 1 + countPrevLi(el)\n        }\n        const s = map.get('list-item') || []\n        if (s.length === 0) s.push(0)\n        s[s.length - 1] = idx\n        map.set('list-item', s)\n      }\n    } catch {}\n\n    return map\n  }\n\n  // Recursive build with (parentMap, carryMap) and carry state across siblings\n  const build = (el, parentMap, carryMap) => {\n    const curr = applyTo(carryMap, parentMap, el)\n    nodeCounters.set(el, curr)\n\n    let nextCarry = curr\n    for (const child of el.children) {\n      const childCarry = build(child, curr, nextCarry)\n      nextCarry = childCarry\n    }\n    return curr // for the next sibling of the parent\n  }\n\n  const empty = new Map()\n  build(rootEl, empty, empty)\n\n    // Si cambió el epoch, reconstruimos el mapa antes de responder\n  function ensureFresh() {\n    const now = getEpoch()\n    if (now !== run) {\n      run = now\n      const empty = new Map()\n      build(rootEl, empty, empty)\n    }\n  }\n\n  return {\n    /**\n     * Get top value for counter name at given node.\n     * @param {Element} node\n     * @param {string} name\n     */\n    get(node, name) {\n      ensureFresh()\n      const s = nodeCounters.get(node)?.get(name)\n      return s && s.length ? s[s.length - 1] : 0\n    },\n    /**\n     * Get full stack for counter name at given node.\n     * @param {Element} node\n     * @param {string} name\n     */\n    getStack(node, name) {\n      ensureFresh()\n      const s = nodeCounters.get(node)?.get(name)\n      return s ? s.slice() : []\n    }\n  }\n}\n\n/**\n * Resolves counter()/counters() calls inside a content string for a specific node,\n * returning a plain string suitable for textContent. Also strips double-quote tokens.\n *\n * @param {string} raw\n * @param {Element} node\n * @param {{get(node: Element, name: string): number, getStack(node: Element, name: string): number[]}} ctx\n */\nexport function resolveCountersInContent(raw, node, ctx) {\n  if (!raw || raw === 'none') return raw\n  try {\n    const RX = /\\b(counter|counters)\\s*\\(([^)]+)\\)/g\n    let out = raw.replace(RX, (_, fn, args) => {\n      const parts = String(args).split(',').map(s => s.trim())\n      if (fn === 'counter') {\n        const name = parts[0]?.replace(/^[\"']|[\"']$/g, '')\n        const style = (parts[1] || 'decimal').toLowerCase()\n        const v = ctx.get(node, name)\n        return formatCounter(v, style)\n      } else { // counters(name, sep, style?)\n        const name = parts[0]?.replace(/^[\"']|[\"']$/g, '')\n        const sep  = (parts[1]?.replace(/^[\"']|[\"']$/g, '')) ?? ''\n        const style = (parts[2] || 'decimal').toLowerCase()\n        const stack = ctx.getStack(node, name)\n        if (!stack.length) return '' // empty, no trailing sep\n        const pieces = stack.map(v => formatCounter(v, style))\n        return pieces.join(sep)\n      }\n    })\n    return unquoteDoubleStrings(out)\n  } catch {\n    return '- '\n  }\n}\n\n/**\n * Create a derived counter context that applies a pseudo's counter-reset /\n * counter-increment *for this node only*, before resolving content.\n * Works with ::before / ::after (and any pseudo with content).\n *\n * @param {Element} node\n * @param {CSSStyleDeclaration|null} pseudoStyle getComputedStyle(node, '::before' | '::after')\n * @param {{get(node: Element, name: string): number, getStack(node: Element, name: string): number[]}} baseCtx\n */\nexport function deriveCounterCtxForPseudo(node, pseudoStyle, baseCtx) {\n  const modStacks = new Map()\n\n  /** Parse \"a 1, b -2\" -> [{name:'a', num:1}, {name:'b', num:-2}] */\n  function parseListDecl(value) {\n    const out = []\n    if (!value || value === 'none') return out\n    for (const part of String(value).split(',')) {\n      const toks = part.trim().split(/\\s+/)\n      const name = toks[0]\n      const num = Number.isFinite(Number(toks[1])) ? Number(toks[1]) : undefined\n      if (name) out.push({ name, num })\n    }\n    return out\n  }\n\n  const resets = parseListDecl(pseudoStyle?.counterReset)\n  const incs   = parseListDecl(pseudoStyle?.counterIncrement)\n\n  function getStackDerived(name) {\n    if (modStacks.has(name)) return modStacks.get(name).slice()\n\n    // base stack at this node from the element context\n    let stack = baseCtx.getStack(node, name)\n    stack = stack.length ? stack.slice() : []\n\n    // counter-reset (push if exists, replace if not)\n    const r = resets.find(x => x.name === name)\n    if (r) {\n      const val = Number.isFinite(r.num) ? r.num : 0\n      if (stack.length) {\n        stack = stack.slice()\n        stack.push(val)\n      } else {\n        stack = [val]\n      }\n    }\n\n    // counter-increment (on top; create top=0 if missing)\n    const inc = incs.find(x => x.name === name)\n    if (inc) {\n      const by = Number.isFinite(inc.num) ? inc.num : 1\n      if (stack.length === 0) stack = [0]\n      stack[stack.length - 1] += by\n    }\n\n    modStacks.set(name, stack.slice())\n    return stack\n  }\n\n  return {\n    get(_node, name) {\n      const s = getStackDerived(name)\n      return s.length ? s[s.length - 1] : 0\n    },\n    getStack(_node, name) {\n      return getStackDerived(name)\n    }\n  }\n}\n\n/**\n * Convenience helper: resolve the final text to render for a pseudo's `content`,\n * correctly applying the pseudo's own counter-reset/increment before evaluation.\n *\n * @param {Element} node\n * @param {'::before'|'::after'} pseudo\n * @param {{get(node: Element, name: string): number, getStack(node: Element, name: string): number[]}} baseCtx\n * @returns {string} resolved content (without surrounding double quotes)\n */\nexport function resolvePseudoContent(node, pseudo, baseCtx) {\n  let ps\n  try { ps = getComputedStyle(node, pseudo) } catch {}\n  const raw = ps?.content\n  if (!raw || raw === 'none' || raw === 'normal') return ''\n  const derived = deriveCounterCtxForPseudo(node, ps, baseCtx)\n  let out = resolveCountersInContent(raw, node, derived)\n  return unquoteDoubleStrings(out)\n}\n"
  },
  {
    "path": "src/modules/fonts.js",
    "content": "/**\n * Utilities for handling and embedding web fonts and icon fonts.\n * @module fonts\n */\n\nimport { extractURL } from '../utils/helpers'\nimport { cache } from '../core/cache'\nimport { isIconFont } from '../modules/iconFonts.js'\nimport { snapFetch } from './snapFetch.js'\n\n/**\n * Converts a unicode character from an icon font into a data URL image.\n *\n * @export\n * @param {string} unicodeChar - The unicode character to render\n * @param {string} fontFamily - The font family name\n * @param {string|number} fontWeight - The font weight\n * @param {number} [fontSize=32] - The font size in pixels\n * @param {string} [color=\"#000\"] - The color to use\n * @returns {Promise<{dataUrl:string,width:number,height:number}>} Data URL and intrinsic size\n */\nexport async function iconToImage(unicodeChar, fontFamily, fontWeight, fontSize = 32, color = '#000') {\n  fontFamily = fontFamily.replace(/^['\"]+|['\"]+$/g, '')\n  const dpr = window.devicePixelRatio || 1\n\n  try { await document.fonts.ready } catch {}\n\n  const span = document.createElement('span')\n  span.textContent = unicodeChar\n  span.style.position = 'absolute'\n  span.style.visibility = 'hidden'\n  span.style.fontFamily = `\"${fontFamily}\"`\n  span.style.fontWeight = fontWeight || 'normal'\n  span.style.fontSize = `${fontSize}px`\n  span.style.lineHeight = '1'\n  span.style.whiteSpace = 'nowrap'\n  span.style.padding = '0'\n  span.style.margin = '0'\n  document.body.appendChild(span)\n\n  const rect = span.getBoundingClientRect()\n  const width = Math.ceil(rect.width)\n  const height = Math.ceil(rect.height)\n  document.body.removeChild(span)\n\n  const canvas = document.createElement('canvas')\n  canvas.width = Math.max(1, width * dpr)\n  canvas.height = Math.max(1, height * dpr)\n\n  const ctx = canvas.getContext('2d')\n  ctx.scale(dpr, dpr)\n  ctx.font = fontWeight ? `${fontWeight} ${fontSize}px \"${fontFamily}\"` : `${fontSize}px \"${fontFamily}\"`\n  ctx.textAlign = 'left'\n  ctx.textBaseline = 'top'\n  ctx.fillStyle = color\n  ctx.fillText(unicodeChar, 0, 0)\n\n  return {\n    dataUrl: canvas.toDataURL(),\n    width,\n    height\n  }\n}\n\n// ---- Font helpers (module-scope; shared by collectors & embedCustomFonts) ----\n\n/** Generic CSS family names to ignore when picking primary family */\nconst GENERIC_FAMILIES = new Set([\n  'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',\n  'emoji', 'math', 'fangsong', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded'\n])\n\n/** Common libraries that include web fonts (for cross-origin stylesheet detection) */\nconst FONT_LIBRARIES = ['katex', 'mathjax', 'mathml']\n\n/**\n * Normalize a CSS font-family list to the first non-generic family.\n * E.g. `\"Roboto\", Arial, sans-serif` -> `Roboto`\n * @param {string} familyList\n * @returns {string}\n */\nfunction pickPrimaryFamily(familyList) {\n  if (!familyList) return ''\n  for (let raw of familyList.split(',')) {\n    let f = raw.trim().replace(/^['\"]+|['\"]+$/g, '')\n    if (!f) continue\n    if (!GENERIC_FAMILIES.has(f.toLowerCase())) return f\n  }\n  return ''\n}\n\n/**\n * Normalize weight to 100..900 (maps \"normal\"->400, \"bold\"->700).\n * @param {string|number} w\n */\nfunction normWeight(w) {\n  const t = String(w ?? '400').trim().toLowerCase()\n  if (t === 'normal') return 400\n  if (t === 'bold') return 700\n  const n = parseInt(t, 10)\n  return Number.isFinite(n) ? Math.min(900, Math.max(100, n)) : 400\n}\n\n/**\n * Normalize style to \"normal\" | \"italic\" | \"oblique\".\n * @param {string} s\n * @returns {\"normal\"|\"italic\"|\"oblique\"}\n */\nfunction normStyle(s) {\n  const t = String(s ?? 'normal').trim().toLowerCase()\n  if (t.startsWith('italic')) return 'italic'\n  if (t.startsWith('oblique')) return 'oblique'\n  return 'normal'\n}\n\n/**\n * Normalize font-stretch to a percentage number (50..200). Defaults to 100.\n * @param {string} st\n * @returns {number}\n */\nfunction normStretchPct(st) {\n  const m = String(st ?? '100%').match(/(\\d+(?:\\.\\d+)?)\\s*%/)\n  return m ? Math.max(50, Math.min(200, parseFloat(m[1]))) : 100\n}\n\nfunction parseWeightSpec(spec) {\n  const s = String(spec || '400').trim()\n  const m = s.match(/^(\\d{2,3})\\s+(\\d{2,3})$/)\n  if (m) {\n    const a = normWeight(m[1]), b = normWeight(m[2])\n    return { min: Math.min(a, b), max: Math.max(a, b) }\n  }\n  const v = normWeight(s)\n  return { min: v, max: v }\n}\n\nfunction parseStyleSpec(spec) {\n  const t = String(spec || 'normal').trim().toLowerCase()\n  if (t === 'italic') return { kind: 'italic' }\n  if (t.startsWith('oblique')) return { kind: 'oblique' }\n  return { kind: 'normal' }\n}\n\nfunction parseStretchSpec(spec) {\n  const s = String(spec || '100%').trim()\n  const mm = s.match(/(\\d+(?:\\.\\d+)?)\\s*%\\s+(\\d+(?:\\.\\d+)?)\\s*%/)\n  if (mm) {\n    const a = parseFloat(mm[1]), b = parseFloat(mm[2])\n    return { min: Math.min(a, b), max: Math.max(a, b) }\n  }\n  const m = s.match(/(\\d+(?:\\.\\d+)?)\\s*%/)\n  const v = m ? parseFloat(m[1]) : 100\n  return { min: v, max: v }\n}\n\n/**\n * Return true if a stylesheet URL is likely to contain @font-face rules.\n * Conservative allowlist for cross-origin fetches.\n * - Same-origin: always allowed (we read CSSOM, no fetch).\n * - Cross-origin: allow only well-known font hosts or URLs containing family hints.\n * @param {string} href\n * @param {Set<string>} requiredFamilies // plain names e.g. \"Unbounded\", \"Mansalva\"\n */\n/**\n * Extract base font name for URL matching (e.g. \"Nunito Variable\" -> \"nunito\", \"Nunito Sans Variable\" -> \"nunito-sans\").\n * Fixes #370: similar names like Nunito vs Nunito Sans must match distinct CDN paths.\n */\nfunction baseFamilyToken(family) {\n  if (!family || typeof family !== 'string') return ''\n  let base = family\n    .replace(/\\s+(variable|vf|v[0-9]+)$/i, '')\n    .trim()\n    .toLowerCase()\n  return base.replace(/\\s+/g, '-')\n}\n\nfunction isLikelyFontStylesheet(href, requiredFamilies, allowedDomains = []) {\n  if (!href) return false\n  try {\n    const u = new URL(href, location.href)\n    const sameOrigin = (u.origin === location.origin)\n    if (sameOrigin) return true // read via CSSOM, no network fetch here\n\n    const host = u.host.toLowerCase()\n    const FONT_HOSTS = [\n      'fonts.googleapis.com', 'fonts.gstatic.com',\n      'use.typekit.net', 'p.typekit.net', 'kit.fontawesome.com', 'use.fontawesome.com',\n      'cdn.jsdelivr.net', 'unpkg.com', 'cdnjs.cloudflare.com', 'esm.sh'\n    ]\n    if (FONT_HOSTS.some(h => host.endsWith(h))) return true\n    if (allowedDomains.some(d => host === d.toLowerCase() || host.endsWith('.' + d.toLowerCase()))) return true\n\n    const path = (u.pathname + u.search).toLowerCase()\n    if (/\\bfont(s)?\\b/.test(path) || /\\.woff2?(\\b|$)/.test(path)) return true\n\n    // Check for common libraries that include web fonts (e.g., KaTeX for math rendering)\n    if (FONT_LIBRARIES.some(lib => path.includes(lib))) return true\n\n    for (const fam of requiredFamilies) {\n      const tokenA = fam.toLowerCase().replace(/\\s+/g, '+')\n      const tokenB = fam.toLowerCase().replace(/\\s+/g, '-')\n      const baseToken = baseFamilyToken(fam)\n      if (path.includes(tokenA) || path.includes(tokenB)) return true\n      if (baseToken && path.includes(baseToken)) return true\n    }\n    return false\n  } catch {\n    return false\n  }\n}\n\n/**\n * Handy: build the set of plain family names from the required keys.\n * required key format: \"family__weight__style__stretchPct\"\n * @param {Set<string>} required\n */\nfunction familiesFromRequired(required) {\n  const out = new Set()\n  for (const k of (required || [])) {\n    const fam = String(k).split('__')[0]?.trim()\n    if (fam) out.add(fam)\n  }\n  return out\n}\n\n// ----------------------------------------------------------------------------\n// Import inliner + relative URL rewriter per stylesheet level (with cycle guard)\n// ----------------------------------------------------------------------------\n\n/** Rewrites all relative url(...) using the given baseHref. */\nfunction rewriteRelativeUrls(cssText, baseHref) {\n  if (!cssText) return cssText\n  return cssText.replace(\n    /url\\(\\s*(['\"]?)([^)'\"]+)\\1\\s*\\)/g,\n    (m, q, u) => {\n      const src = (u || '').trim()\n      if (!src || /^data:|^blob:|^https?:|^file:|^about:/i.test(src)) return m\n      let abs = src\n      try { abs = new URL(src, baseHref || location.href).href } catch {}\n      return `url(\"${abs}\")`\n    }\n  )\n}\n\n// Supports both @import url(\"...\") and @import \"...\"\nconst IMPORT_ANY_RE = /@import\\s+(?:url\\(\\s*(['\"]?)([^)\"']+)\\1\\s*\\)|(['\"])([^\"']+)\\3)([^;]*);/g\n\nconst MAX_IMPORT_DEPTH = 4\n\n/**\n * Flattens @import recursively and rewrites relative urls at each level\n * using that sheet's base href. Uses snapFetch (with proxy) on purpose\n * to bypass CSSOM CORS blocks. Guards cycles and too-deep trees.\n * @param {string} cssText\n * @param {string} ownerHref\n * @param {string} useProxy\n */\nasync function inlineImportsAndRewrite(cssText, ownerHref, useProxy) {\n  if (!cssText) return cssText\n\n  const visited = new Set()\n\n  function normalizeUrl(u, base) {\n    try { return new URL(u, base || location.href).href } catch { return u }\n  }\n\n  async function resolveOnce(text, baseHref, depth = 0) {\n    if (depth > MAX_IMPORT_DEPTH) {\n      console.warn(`[snapDOM] @import depth exceeded (${MAX_IMPORT_DEPTH}) at ${baseHref}`)\n      return text\n    }\n\n    let out = ''\n    let last = 0\n    let m\n    while ((m = IMPORT_ANY_RE.exec(text))) {\n      out += text.slice(last, m.index)\n      last = IMPORT_ANY_RE.lastIndex\n\n      const rawUrl = (m[2] || m[4] || '').trim()\n      const absUrl = normalizeUrl(rawUrl, baseHref)\n\n      if (visited.has(absUrl)) {\n        console.warn(`[snapDOM] Skipping circular @import: ${absUrl}`)\n        continue\n      }\n      visited.add(absUrl)\n\n      let imported = ''\n      try {\n        const r = await snapFetch(absUrl, { as: 'text', useProxy, silent: true })\n        if (r.ok && typeof r.data === 'string') imported = r.data\n      } catch { /* noop */ }\n\n      if (imported) {\n        imported = rewriteRelativeUrls(imported, absUrl)\n        imported = await resolveOnce(imported, absUrl, depth + 1)\n        out += `\\n/* inlined: ${absUrl} */\\n${imported}\\n`\n      } else {\n        // keep original @import if we couldn't fetch (CORS/offline)\n        out += m[0]\n      }\n    }\n    out += text.slice(last)\n    return out\n  }\n\n  let rewritten = rewriteRelativeUrls(cssText, ownerHref || location.href)\n  rewritten = await resolveOnce(rewritten, ownerHref || location.href, 0)\n  return rewritten\n}\n\n// ----------------------------------------------------------------------------\n\n/** Regexes local to embedCustomFonts */\nconst URL_RE = /url\\(([\"']?)([^\"')]+)\\1\\)/g\nconst FACE_RE = /@font-face[^{}]*\\{[^}]*\\}/g\n\n/** @param {string} ur */\nfunction parseUnicodeRange(ur) {\n  if (!ur) return []\n  const ranges = []\n  const parts = ur.split(',').map(s => s.trim()).filter(Boolean)\n  for (const p of parts) {\n    const m = p.match(/^U\\+([0-9A-Fa-f?]+)(?:-([0-9A-Fa-f?]+))?$/)\n    if (!m) continue\n    const a = m[1], b = m[2]\n    const expand = (hex) => {\n      if (!hex.includes('?')) return parseInt(hex, 16)\n      const min = parseInt(hex.replace(/\\?/g, '0'), 16)\n      const max = parseInt(hex.replace(/\\?/g, 'F'), 16)\n      return [min, max]\n    }\n    if (b) {\n      const A = expand(a), B = expand(b)\n      const min = Array.isArray(A) ? A[0] : A\n      const max = Array.isArray(B) ? B[1] : B\n      ranges.push([Math.min(min, max), Math.max(min, max)])\n    } else {\n      const X = expand(a)\n      if (Array.isArray(X)) ranges.push([X[0], X[1]])\n      else ranges.push([X, X])\n    }\n  }\n  return ranges\n}\n\n/** @param {Set<number>} used @param {Array<[number,number]>} ranges */\nfunction unicodeIntersects(used, ranges) {\n  if (!ranges.length) return true\n  if (!used || used.size === 0) return true // don't over-filter if unknown\n  for (const cp of used) {\n    for (const [a, b] of ranges) if (cp >= a && cp <= b) return true\n  }\n  return false\n}\n\n/** @param {string} srcValue @param {string} baseHref */\nfunction extractSrcUrls(srcValue, baseHref) {\n  const urls = []\n  if (!srcValue) return urls\n  for (const m of srcValue.matchAll(URL_RE)) {\n    let u = (m[2] || '').trim()\n    if (!u || u.startsWith('data:')) continue\n    if (!/^https?:/i.test(u)) {\n      try { u = new URL(u, baseHref || location.href).href } catch {}\n    }\n    urls.push(u)\n  }\n  return urls\n}\n\n/** @param {string} cssBlock @param {string} baseHref */\nasync function inlineUrlsInCssBlock(cssBlock, baseHref, useProxy = '') {\n  let out = cssBlock\n  for (const m of cssBlock.matchAll(URL_RE)) {\n    const raw = extractURL(m[0])\n    if (!raw) continue\n    let abs = raw\n    if (!abs.startsWith('http') && !abs.startsWith('data:')) {\n      try { abs = new URL(abs, baseHref || location.href).href } catch {}\n    }\n    if (isIconFont(abs)) continue\n\n    if (cache.resource?.has(abs)) {\n      cache.font?.add(abs)\n      out = out.replace(m[0], `url(${cache.resource.get(abs)})`)\n      continue\n    }\n    if (cache.font?.has(abs)) continue\n\n    try {\n      const r = await snapFetch(abs, { as: 'dataURL', useProxy, silent: true })\n      if (r.ok && typeof r.data === 'string') {\n        const b64 = r.data\n        cache.resource?.set(abs, b64)\n        cache.font?.add(abs)\n        out = out.replace(m[0], `url(${b64})`)\n      }\n    } catch {\n      console.warn('[snapDOM] Failed to fetch font resource:', abs)\n    }\n  }\n  return out\n}\n\n// ---- simple exclude builder (families/domains/subsets) ----\nfunction subsetFromRanges(ranges) {\n  if (!ranges.length) return null\n  const hit = (a, b) => ranges.some(([x, y]) => !(y < a || x > b))\n  const latin = hit(0x0000, 0x00FF) || hit(0x0131, 0x0131)\n  const latinExt = hit(0x0100, 0x024F) || hit(0x1E00, 0x1EFF)\n  const greek = hit(0x0370, 0x03FF)\n  const cyr = hit(0x0400, 0x04FF)\n  const viet = hit(0x1EA0, 0x1EF9) || hit(0x0102, 0x0103) || hit(0x01A0, 0x01A1) || hit(0x01AF, 0x01B0)\n  if (viet) return 'vietnamese'\n  if (cyr) return 'cyrillic'\n  if (greek) return 'greek'\n  if (latinExt) return 'latin-ext'\n  if (latin) return 'latin'\n  return null\n}\n\nfunction buildSimpleExcluder(ex = {}) {\n  const famSet = new Set((ex.families || []).map(s => String(s).toLowerCase()))\n  const domSet = new Set((ex.domains || []).map(s => String(s).toLowerCase()))\n  const subSet = new Set((ex.subsets || []).map(s => String(s).toLowerCase()))\n  return (meta, parsedRanges) => {\n    if (famSet.size && famSet.has(meta.family.toLowerCase())) return true\n    if (domSet.size) {\n      for (const u of meta.srcUrls) {\n        try { if (domSet.has(new URL(u).host.toLowerCase())) return true } catch {}\n      }\n    }\n    if (subSet.size) {\n      const label = subsetFromRanges(parsedRanges)\n      if (label && subSet.has(label)) return true\n    }\n    return false\n  }\n}\n\nfunction dedupeFontFaces(cssText) {\n  if (!cssText) return cssText\n\n  const FACE_RE_G = /@font-face[^{}]*\\{[^}]*\\}/gi\n\n  const seen = new Set()\n  const out = []\n\n  for (const block of cssText.match(FACE_RE_G) || []) {\n    const familyRaw = block.match(/font-family:\\s*([^;]+);/i)?.[1] || ''\n    const family = pickPrimaryFamily(familyRaw)\n    const weightSpec = (block.match(/font-weight:\\s*([^;]+);/i)?.[1] || '400').trim()\n    const styleSpec = (block.match(/font-style:\\s*([^;]+);/i)?.[1] || 'normal').trim()\n    const stretchSpec = (block.match(/font-stretch:\\s*([^;]+);/i)?.[1] || '100%').trim()\n    const urange = (block.match(/unicode-range:\\s*([^;]+);/i)?.[1] || '').trim()\n    const srcRaw = (block.match(/src\\s*:\\s*([^;}]+)[;}]/i)?.[1] || '').trim()\n\n    const urls = extractSrcUrls(srcRaw, location.href)\n    const srcPart = urls.length\n      ? urls.map(u => String(u).toLowerCase()).sort().join('|')\n      : srcRaw.toLowerCase()\n\n    const key = [\n      String(family || '').toLowerCase(),\n      weightSpec, styleSpec, stretchSpec,\n      urange.toLowerCase(),\n      srcPart\n    ].join('|')\n\n    if (!seen.has(key)) {\n      seen.add(key)\n      out.push(block)\n    }\n  }\n\n  if (out.length === 0) return cssText\n\n  let i = 0\n  return cssText.replace(FACE_RE_G, () => out[i++] || '')\n}\n\n// ---- cache key per capture signature (avoid cross-pollution between different targets) ----\nfunction buildFontsCacheKey(required, exclude, localFonts, useProxy, fontStylesheetDomains) {\n  const req = Array.from(required || []).sort().join('|')\n  const ex = exclude ? JSON.stringify({\n    families: (exclude.families || []).map(s => String(s).toLowerCase()).sort(),\n    domains: (exclude.domains || []).map(s => String(s).toLowerCase()).sort(),\n    subsets: (exclude.subsets || []).map(s => String(s).toLowerCase()).sort(),\n  }) : ''\n  const lf = (localFonts || [])\n    .map(f => `${(f.family || '').toLowerCase()}::${f.weight || 'normal'}::${f.style || 'normal'}::${f.src || ''}`)\n    .sort()\n    .join('|')\n  const px = useProxy || ''\n  const fd = (fontStylesheetDomains || []).map(s => String(s).toLowerCase()).sort().join('|')\n  return `fonts-embed-css::req=${req}::ex=${ex}::lf=${lf}::px=${px}::fd=${fd}`\n}\n\n// ----------------------------------------------------------------------------\n// CSSOM recursive collector (descends into CSSImportRule) with cycle guard\n// ----------------------------------------------------------------------------\n\n/**\n * Recursively collect @font-face from a CSSStyleSheet, honoring baseHref for each subsheet.\n * Guards cycles and excessive import depth.\n * @param {CSSStyleSheet} sheet\n * @param {string} baseHref\n * @param {(css:string)=>Promise<void>|void} emitFace\n * @param {Object} ctx\n * @param {Map} ctx.requiredIndex\n * @param {Set<number>} ctx.usedCodepoints\n * @param {(fam:string,styleSpec:string,weightSpec:string,stretchSpec:string)=>boolean} ctx.faceMatchesRequired\n * @param {(meta:any, ranges:any)=>boolean} ctx.simpleExcluder\n * @param {string} ctx.useProxy\n * @param {Set<string>} ctx.visitedSheets\n * @param {number} ctx.depth\n */\nasync function collectFacesFromSheet(sheet, baseHref, emitFace, ctx) {\n  let rules\n  try {\n    rules = sheet.cssRules || []\n  } catch {\n    // CSSOM blocked (CORS) → handled in <link> pass via fetch+inline\n    return\n  }\n\n  const normalizeUrl = (u, base) => {\n    try { return new URL(u, base || location.href).href } catch { return u }\n  }\n\n  for (const rule of rules) {\n    if (rule.type === CSSRule.IMPORT_RULE && rule.styleSheet) {\n      const childHref = rule.href ? normalizeUrl(rule.href, baseHref) : baseHref\n\n      if (ctx.depth >= MAX_IMPORT_DEPTH) {\n        console.warn(`[snapDOM] CSSOM import depth exceeded (${MAX_IMPORT_DEPTH}) at ${childHref}`)\n        continue\n      }\n      if (childHref && ctx.visitedSheets.has(childHref)) {\n        console.warn(`[snapDOM] Skipping circular CSSOM import: ${childHref}`)\n        continue\n      }\n      if (childHref) ctx.visitedSheets.add(childHref)\n\n      const nextCtx = { ...ctx, depth: (ctx.depth || 0) + 1 }\n      await collectFacesFromSheet(rule.styleSheet, childHref, emitFace, nextCtx)\n      continue\n    }\n\n    if (rule.type === CSSRule.FONT_FACE_RULE) {\n      const famRaw = (rule.style.getPropertyValue('font-family') || '').trim()\n      const family = pickPrimaryFamily(famRaw)\n      if (!family || isIconFont(family)) continue\n\n      const weightSpec  = (rule.style.getPropertyValue('font-weight')   || '400').trim()\n      const styleSpec   = (rule.style.getPropertyValue('font-style')    || 'normal').trim()\n      const stretchSpec = (rule.style.getPropertyValue('font-stretch')  || '100%').trim()\n      const srcRaw      = (rule.style.getPropertyValue('src')           || '').trim()\n      const urange      = (rule.style.getPropertyValue('unicode-range') || '').trim()\n\n      if (!ctx.faceMatchesRequired(family, styleSpec, weightSpec, stretchSpec)) continue\n      const ranges = parseUnicodeRange(urange)\n      if (!unicodeIntersects(ctx.usedCodepoints, ranges)) continue\n\n      const meta = {\n        family, weightSpec, styleSpec, stretchSpec,\n        unicodeRange: urange,\n        srcRaw,\n        srcUrls: extractSrcUrls(srcRaw, baseHref || location.href),\n        href: baseHref || location.href\n      }\n      if (ctx.simpleExcluder && ctx.simpleExcluder(meta, ranges)) continue\n\n      if (/url\\(/i.test(srcRaw)) {\n        const inlinedSrc = await inlineUrlsInCssBlock(srcRaw, baseHref || location.href, ctx.useProxy)\n        await emitFace(`@font-face{font-family:${family};src:${inlinedSrc};font-style:${styleSpec};font-weight:${weightSpec};font-stretch:${stretchSpec};${urange ? `unicode-range:${urange};` : ''}}`)\n      } else {\n        await emitFace(`@font-face{font-family:${family};src:${srcRaw};font-style:${styleSpec};font-weight:${weightSpec};font-stretch:${stretchSpec};${urange ? `unicode-range:${urange};` : ''}}`)\n      }\n    }\n  }\n}\n\n/**\n * Embed only the @font-face rules that match required variants AND intersect used unicode ranges.\n * Smart by default + simple \"exclude\" knobs (no regex for end users).\n *\n * @typedef {{family:string, weightSpec:string, styleSpec:string, stretchSpec:string, unicodeRange:string, srcRaw:string, srcUrls:string[], href:string}} FontFaceMeta\n *\n * @param {Object} options\n * @param {Set<string>} options.required                     // keys: \"family__weight__style__stretchPct\"\n * @param {Set<number>} options.usedCodepoints               // codepoints used in the captured subtree\n * @param {{families?:string[], domains?:string[], subsets?:string[]}} [options.exclude] // simple exclude\n * @param {Array<{family:string,src:string,weight?:string|number,style?:string,stretchPct?:number}>} [options.localFonts=[]]\n * @param {string}  [options.useProxy=\"\"]\n * @param {string[]} [options.fontStylesheetDomains=[]]      // extra domains to fetch cross-origin CSS from (#309)\n * @returns {Promise<string>} inlined @font-face CSS\n */\nexport async function embedCustomFonts({\n  required,\n  usedCodepoints,\n  exclude = undefined,\n  localFonts = [],\n  useProxy = '',\n  fontStylesheetDomains = [],\n} = {}) {\n  // ---------- Normalize inputs ----------\n  if (!(required instanceof Set)) required = new Set()\n  if (!(usedCodepoints instanceof Set)) usedCodepoints = new Set()\n\n  // Build index: family -> [{w,s,st}]\n  const requiredIndex = new Map()\n  for (const key of required) {\n    const [fam, w, s, st] = String(key).split('__')\n    if (!fam) continue\n    const arr = requiredIndex.get(fam) || []\n    arr.push({ w: parseInt(w, 10), s, st: parseInt(st, 10) })\n    requiredIndex.set(fam, arr)\n  }\n\n  /**\n * Decide if a given @font-face matches at least one of the required variants\n * for the specified family.\n *\n * - Prioriza match exacto (peso, estilo, stretch).\n * - Luego permite \"near weight\" manteniendo estilo/stretch.\n * - Y como fallback específico:\n *   Si SOLO hay @font-face `normal` para una familia,\n *   pero el DOM pide `italic`/`oblique`,\n *   acepta este face normal (si el peso/stretch son razonables),\n *   de modo que el motor pueda generar cursiva sintética.\n *\n * @param {string} fam\n * @param {string} styleSpec   font-style desde @font-face (p.ej. \"normal\" o \"italic\")\n * @param {string} weightSpec  font-weight desde @font-face (p.ej. \"400\" o \"400 700\")\n * @param {string} stretchSpec font-stretch desde @font-face (p.ej. \"100%\")\n */\nfunction faceMatchesRequired(fam, styleSpec, weightSpec, stretchSpec) {\n  if (!requiredIndex.has(fam)) return false\n\n  const need = requiredIndex.get(fam)\n  const ws = parseWeightSpec(weightSpec)\n  const ss = parseStyleSpec(styleSpec)\n  const ts = parseStretchSpec(stretchSpec)\n\n  const faceIsRange = ws.min !== ws.max\n  const faceSingleW = ws.min\n\n  const styleOK = (reqKind) => (\n    (ss.kind === 'normal' && reqKind === 'normal') ||\n    (ss.kind !== 'normal' && (reqKind === 'italic' || reqKind === 'oblique'))\n  )\n\n  let exactMatched = false\n\n  // 1) Match exacto\n  for (const r of need) {\n    const wOk = faceIsRange ? (r.w >= ws.min && r.w <= ws.max) : (r.w === faceSingleW)\n    const sOk = styleOK(normStyle(r.s))\n    const tOk = (r.st >= ts.min && r.st <= ts.max)\n\n    if (wOk && sOk && tOk) {\n      exactMatched = true\n      break\n    }\n  }\n\n  if (exactMatched) return true\n\n  // 2) \"Near weight\" manteniendo estilo/stretch\n  if (!faceIsRange) {\n    for (const r of need) {\n      const sOk = styleOK(normStyle(r.s))\n      const tOk = (r.st >= ts.min && r.st <= ts.max)\n      const nearWeight = Math.abs(faceSingleW - r.w) <= 300\n      if (nearWeight && sOk && tOk) return true\n    }\n  }\n\n  // 3) Fallback: DOM pide italic/oblique pero solo hay @font-face normal\n  //    para ESTA familia: aceptamos el face normal si peso/stretch son razonables.\n  if (!faceIsRange && ss.kind === 'normal') {\n    const hasItalicRequest = need.some((r) => normStyle(r.s) !== 'normal')\n    if (hasItalicRequest) {\n      for (const r of need) {\n        const nearWeight = Math.abs(faceSingleW - r.w) <= 300\n        const stretchOK = (r.st >= ts.min && r.st <= ts.max)\n        if (nearWeight && stretchOK) {\n          return true\n        }\n      }\n    }\n  }\n\n  return false\n}\n\n  const simpleExcluder = buildSimpleExcluder(exclude)\n\n  const cacheKey = buildFontsCacheKey(required, exclude, localFonts, useProxy, fontStylesheetDomains)\n  if (cache.resource?.has(cacheKey)) {\n    return cache.resource.get(cacheKey)\n  }\n\n  // ---- Ensure only likely @import font styles become reachable (<link>), avoid noise ----\n  const requiredFamilies = familiesFromRequired(required)\n\n  const importUrls = []\n  const IMPORT_ANY_RE_LOCAL = IMPORT_ANY_RE\n\n  for (const styleTag of document.querySelectorAll('style')) {\n    const cssText = styleTag.textContent || ''\n    for (const m of cssText.matchAll(IMPORT_ANY_RE_LOCAL)) {\n      const u = (m[2] || m[4] || '').trim()\n      if (!u || isIconFont(u)) continue\n      const hasLink = !!document.querySelector(`link[rel=\"stylesheet\"][href=\"${u}\"]`)\n      if (!hasLink) importUrls.push(u)\n    }\n  }\n  if (importUrls.length) {\n    await Promise.all(importUrls.map((u) => new Promise((resolve) => {\n      if (document.querySelector(`link[rel=\"stylesheet\"][href=\"${u}\"]`)) return resolve(null)\n      const link = document.createElement('link')\n      link.rel = 'stylesheet'\n      link.href = u\n      link.setAttribute('data-snapdom', 'injected-import')\n      link.onload = () => resolve(link)\n      link.onerror = () => resolve(null)\n      document.head.appendChild(link)\n    })))\n  }\n\n  let finalCSS = ''\n\n  // ---------- 1) External <link rel=\"stylesheet\"> ----------\n  const linkNodes = Array.from(document.querySelectorAll('link[rel=\"stylesheet\"]')).filter(l => !!l.href)\n\n  for (const link of linkNodes) {\n    try {\n      if (isIconFont(link.href)) continue\n\n      let cssText = ''\n      let sameOrigin = false\n      try { sameOrigin = new URL(link.href, location.href).origin === location.origin } catch {}\n\n      if (!sameOrigin) {\n        const allowedDomains = Array.isArray(fontStylesheetDomains) ? fontStylesheetDomains : []\n        if (!isLikelyFontStylesheet(link.href, requiredFamilies, allowedDomains)) continue\n      }\n\n      if (sameOrigin) {\n        const sheet = Array.from(document.styleSheets).find(s => s.href === link.href)\n        if (sheet) {\n          try {\n            const rules = sheet.cssRules || []\n            cssText = Array.from(rules).map(r => r.cssText).join('')\n          } catch {\n            // fallback to fetch below\n          }\n        }\n      }\n\n      if (!cssText) {\n        const res = await snapFetch(link.href, { as: 'text', useProxy })\n        if (res?.ok && typeof res.data === 'string') cssText = res.data\n        if (isIconFont(link.href)) continue\n      }\n\n      // Flatten nested @import and rewrite relative urls per-level using link.href as base\n      cssText = await inlineImportsAndRewrite(cssText, link.href, useProxy)\n\n      let facesOut = ''\n      for (const face of cssText.match(FACE_RE) || []) {\n        const famRaw = (face.match(/font-family:\\s*([^;]+);/i)?.[1] || '').trim()\n        const family = pickPrimaryFamily(famRaw)\n        if (!family || isIconFont(family)) continue\n\n        const weightSpec = (face.match(/font-weight:\\s*([^;]+);/i)?.[1] || '400').trim()\n        const styleSpec = (face.match(/font-style:\\s*([^;]+);/i)?.[1] || 'normal').trim()\n        const stretchSpec = (face.match(/font-stretch:\\s*([^;]+);/i)?.[1] || '100%').trim()\n        const urange = (face.match(/unicode-range:\\s*([^;]+);/i)?.[1] || '').trim()\n        const srcRaw = (face.match(/src\\s*:\\s*([^;}]+)[;}]/i)?.[1] || '').trim()\n        const srcUrls = extractSrcUrls(srcRaw, link.href)\n\n        if (!faceMatchesRequired(family, styleSpec, weightSpec, stretchSpec)) continue\n        const ranges = parseUnicodeRange(urange)\n        if (!unicodeIntersects(usedCodepoints, ranges)) continue\n\n        const meta = { family, weightSpec, styleSpec, stretchSpec, unicodeRange: urange, srcRaw, srcUrls, href: link.href }\n        if (exclude && simpleExcluder(meta, ranges)) continue\n\n        const newFace = /url\\(/i.test(srcRaw)\n          ? await inlineUrlsInCssBlock(face, link.href, useProxy)\n          : face\n        facesOut += newFace\n      }\n\n      if (facesOut.trim()) finalCSS += facesOut\n    } catch {\n      console.warn('[snapDOM] Failed to process stylesheet:', link.href)\n    }\n  }\n\n  // ---------- 2) CSSOM (inline/imported) ----------\n  const ctx = {\n    requiredIndex,\n    usedCodepoints,\n    faceMatchesRequired,\n    simpleExcluder: exclude ? buildSimpleExcluder(exclude) : null,\n    useProxy,\n    visitedSheets: new Set(),\n    depth: 0\n  }\n\n  for (const sheet of document.styleSheets) {\n    if (sheet.href && linkNodes.some(l => l.href === sheet.href)) continue\n    try {\n      const rootHref = sheet.href || (location.origin + '/')\n      if (rootHref) ctx.visitedSheets.add(rootHref)\n      await collectFacesFromSheet(\n        sheet,\n        rootHref,\n        async (faceCss) => { finalCSS += faceCss },\n        ctx\n      )\n    } catch {\n      // cross-origin protected CSSOM; ignore (text pass already tried)\n    }\n  }\n\n  // ---------- 3) document.fonts with _snapdomSrc ----------\n  try {\n    for (const f of document.fonts || []) {\n      if (!f || !f.family || f.status !== 'loaded' || !f._snapdomSrc) continue\n      const fam = String(f.family).replace(/^['\"]+|['\"]+$/g, '')\n      if (isIconFont(fam)) continue\n      if (!requiredIndex.has(fam)) continue\n\n      if (exclude?.families && exclude.families.some(n => String(n).toLowerCase() === fam.toLowerCase())) {\n        continue\n      }\n\n      let b64 = f._snapdomSrc\n      if (!String(b64).startsWith('data:')) {\n        if (cache.resource?.has(f._snapdomSrc)) {\n          b64 = cache.resource.get(f._snapdomSrc)\n          cache.font?.add(f._snapdomSrc)\n        } else if (!cache.font?.has(f._snapdomSrc)) {\n          try {\n            const r = await snapFetch(f._snapdomSrc, { as: 'dataURL', useProxy, silent: true })\n            if (r.ok && typeof r.data === 'string') {\n              b64 = r.data\n              cache.resource?.set(f._snapdomSrc, b64)\n              cache.font?.add(f._snapdomSrc)\n            } else {\n              continue\n            }\n          } catch {\n            console.warn('[snapDOM] Failed to fetch dynamic font src:', f._snapdomSrc)\n            continue\n          }\n        }\n      }\n      finalCSS += `@font-face{font-family:'${fam}';src:url(${b64});font-style:${f.style || 'normal'};font-weight:${f.weight || 'normal'};}`\n    }\n  } catch {}\n\n  // ---------- 4) user-provided localFonts ----------\n  for (const font of localFonts) {\n    if (!font || typeof font !== 'object') continue\n    const family = String(font.family || '').replace(/^['\"]+|['\"]+$/g, '')\n    if (!family || isIconFont(family)) continue\n    if (!requiredIndex.has(family)) continue\n    if (exclude?.families && exclude.families.some(n => String(n).toLowerCase() === family.toLowerCase())) continue\n\n    const weight = font.weight != null ? String(font.weight) : 'normal'\n    const style = font.style != null ? String(font.style) : 'normal'\n    const stretch = font.stretchPct != null ? `${font.stretchPct}%` : '100%'\n    const src = String(font.src || '')\n\n    let b64 = src\n    if (!b64.startsWith('data:')) {\n      if (cache.resource?.has(src)) {\n        b64 = cache.resource.get(src)\n        cache.font?.add(src)\n      } else if (!cache.font?.has(src)) {\n        try {\n          const r = await snapFetch(src, { as: 'dataURL', useProxy, silent: true })\n          if (r.ok && typeof r.data === 'string') {\n            b64 = r.data\n            cache.resource?.set(src, b64)\n            cache.font?.add(src)\n          } else {\n            continue\n          }\n        } catch {\n          console.warn('[snapDOM] Failed to fetch localFonts src:', src)\n          continue\n        }\n      }\n    }\n    finalCSS += `@font-face{font-family:'${family}';src:url(${b64});font-style:${style};font-weight:${weight};font-stretch:${stretch};}`\n  }\n\n  // ---------- Cache + return ----------\n  if (finalCSS) {\n    finalCSS = dedupeFontFaces(finalCSS)\n    cache.resource?.set(cacheKey, finalCSS)\n  }\n  return finalCSS\n}\n\n// ----------------------------------------------------------------------------\n// Collectors for required variants and used codepoints\n// ----------------------------------------------------------------------------\n\n/**\n * Collects used font variants (family, weight, style, stretch) in subtree.\n * @param {Element} root\n * @returns {Set<string>} keys \"family__weight__style__stretchPct\"\n */\nexport function collectUsedFontVariants(root) {\n  const req = /* @__PURE__ */ new Set()\n  if (!root) return req\n  const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null)\n  const addFromStyle = (cs) => {\n    const family = pickPrimaryFamily(cs.fontFamily)\n    if (!family) return\n    const key = (w, s, st) => `${family}__${normWeight(w)}__${normStyle(s)}__${normStretchPct(st)}`\n    req.add(key(cs.fontWeight, cs.fontStyle, cs.fontStretch))\n  }\n  addFromStyle(getComputedStyle(root))\n  const csBeforeRoot = getComputedStyle(root, '::before')\n  if (csBeforeRoot && csBeforeRoot.content && csBeforeRoot.content !== 'none') addFromStyle(csBeforeRoot)\n  const csAfterRoot = getComputedStyle(root, '::after')\n  if (csAfterRoot && csAfterRoot.content && csAfterRoot.content !== 'none') addFromStyle(csAfterRoot)\n  while (tw.nextNode()) {\n    const el = /** @type {Element} */ (tw.currentNode)\n    const cs = getComputedStyle(el)\n    addFromStyle(cs)\n    const b = getComputedStyle(el, '::before')\n    if (b && b.content && b.content !== 'none') addFromStyle(b)\n    const a = getComputedStyle(el, '::after')\n    if (a && a.content && a.content !== 'none') addFromStyle(a)\n  }\n  return req\n}\n\n/**\n * Collects used codepoints in subtree (including ::before/::after content).\n * @param {Element} root\n * @returns {Set<number>}\n */\nexport function collectUsedCodepoints(root) {\n  const used = /* @__PURE__ */ new Set()\n  const pushText = (txt) => {\n    if (!txt) return\n    for (const ch of txt) used.add(ch.codePointAt(0))\n  }\n  const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null)\n  while (walker.nextNode()) {\n    const n = walker.currentNode\n    if (n.nodeType === Node.TEXT_NODE) {\n      pushText(n.nodeValue || '')\n    } else if (n.nodeType === Node.ELEMENT_NODE) {\n      const el = /** @type {Element} */ (n)\n      for (const pseudo of ['::before', '::after']) {\n        const cs = getComputedStyle(el, pseudo)\n        const c = cs?.getPropertyValue('content')\n        if (!c || c === 'none') continue\n        if (/^\"/.test(c) || /^'/.test(c)) {\n          pushText(c.slice(1, -1))\n        } else {\n          const matches = c.match(/\\\\[0-9A-Fa-f]{1,6}/g)\n          if (matches) {\n            for (const m of matches) {\n              try { used.add(parseInt(m.slice(1), 16)) } catch {}\n            }\n          }\n        }\n      }\n    }\n  }\n  return used\n}\n\n/**\n * Ensures web fonts are fully resolved before capture, with a Safari-friendly warm-up.\n * - Awaits document.fonts.ready\n * - Forces layout/rasterization for each family by painting hidden spans\n * - Optionally retries a couple of times if Safari is still lazy\n *\n * @param {Set<string>|string[]} families - Plain family names (e.g., \"Mansalva\", \"Unbounded\")\n * @param {number} [warmupRepetitions=2] - How many times to warm-up each family\n * @returns {Promise<void>}\n */\nexport async function ensureFontsReady(families, warmupRepetitions = 2) {\n  try { await document.fonts.ready } catch {}\n\n  const fams = Array.from(families || []).filter(Boolean)\n  if (fams.length === 0) return\n\n  const warmupOnce = () => {\n    const container = document.createElement('div')\n    container.style.cssText = 'position:absolute!important;left:-9999px!important;top:0!important;opacity:0!important;pointer-events:none!important;contain:layout size style;'\n\n    for (const fam of fams) {\n      const span = document.createElement('span')\n      span.textContent = 'AaBbGg1234ÁÉÍÓÚçñ—∞'\n      span.style.fontFamily = `\"${fam}\"`\n      span.style.fontWeight = '700'\n      span.style.fontStyle = 'italic'\n      span.style.fontSize = '32px'\n      span.style.lineHeight = '1'\n      span.style.whiteSpace = 'nowrap'\n      span.style.margin = '0'\n      span.style.padding = '0'\n      container.appendChild(span)\n    }\n\n    document.body.appendChild(container)\n    // Force layout\n    container.offsetWidth\n    document.body.removeChild(container)\n  }\n\n  for (let i = 0; i < Math.max(1, warmupRepetitions); i++) {\n    warmupOnce()\n    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))\n  }\n}\n"
  },
  {
    "path": "src/modules/iconFonts.js",
    "content": "// iconFonts.js\n\n// ---------------------------------------------------------------------------\n// Detection / configuration (kept as-is + extensible)\n// ---------------------------------------------------------------------------\nexport const defaultIconFonts = [\n  // /uicons/i,\n  /font\\s*awesome/i,\n  /material\\s*icons/i,\n  /ionicons/i,\n  /glyphicons/i,\n  /feather/i,\n  /bootstrap\\s*icons/i,\n  /remix\\s*icons/i,\n  /heroicons/i,\n  /layui/i,\n  /lucide/i\n]\n\n// Static, non-variable fallbacks (safe to override from the host app)\nexport const ICON_FONT_URLS = Object.assign({\n  materialIconsFilled:  'https://fonts.gstatic.com/s/materialicons/v48/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2',\n  materialIconsOutlined:'https://fonts.gstatic.com/s/materialiconsoutlined/v110/gok-H7zzDkdnRel8-DQ6KAXJ69wP1tGnf4ZGhUcel5euIg.woff2',\n  materialIconsRound:   'https://fonts.gstatic.com/s/materialiconsround/v109/LDItaoyNOAY6Uewc665JcIzCKsKc_M9flwmPq_HTTw.woff2',\n  materialIconsSharp:   'https://fonts.gstatic.com/s/materialiconssharp/v110/oPWQ_lt5nv4pWNJpghLP75WiFR4kLh3kvmvRImcycg.woff2'\n}, (typeof window !== 'undefined' && window.__SNAPDOM_ICON_FONTS__) || {})\n\nlet userIconFonts = []\n\nexport function extendIconFonts(fonts) {\n  const list = Array.isArray(fonts) ? fonts : [fonts]\n  for (const f of list) {\n    if (f instanceof RegExp) userIconFonts.push(f)\n    else if (typeof f === 'string') userIconFonts.push(new RegExp(f, 'i'))\n    else console.warn('[snapdom] Ignored invalid iconFont value:', f)\n  }\n}\n\nexport function isIconFont(input) {\n  const text = typeof input === 'string' ? input : ''\n  const candidates = [...defaultIconFonts, ...userIconFonts]\n  for (const rx of candidates) {\n    if (rx instanceof RegExp && rx.test(text)) return true\n  }\n  if (/icon/i.test(text) || /glyph/i.test(text) || /symbols/i.test(text) || /feather/i.test(text) || /fontawesome/i.test(text)) return true\n  return false\n}\n\n// ---------------------------------------------------------------------------\n// Material Symbols (ligatures) helpers\n// ---------------------------------------------------------------------------\nexport function isMaterialFamily(family = '') {\n  const s = String(family).toLowerCase()\n  return /\\bmaterial\\s*icons\\b/.test(s) || /\\bmaterial\\s*symbols\\b/.test(s)\n}\n\nconst loadedCanvasFamilies = new Map()\n\nfunction parseAxes(variation = '') {\n  const out = Object.create(null)\n  const v = String(variation || '')\n  const rx = /['\"]?\\s*([A-Za-z]{3,4})\\s*['\"]?\\s*([+-]?\\d+(?:\\.\\d+)?)\\s*/g\n  let m; while ((m = rx.exec(v))) out[m[1].toUpperCase()] = Number(m[2])\n  return out\n}\n\n/**\n * Strategy:\n * - If family is Material *Icons* (legacy, non-variable) → keep it (already stable).\n * - If family is Material *Symbols* (variable):\n *     * Detect style: outlined / rounded / sharp\n *     * Detect FILL axis (0/1)\n *     * For FILL=1 → pick a static filled family when we have a good match:\n *         - outlined → materialIconsFilled\n *         - rounded  → materialIconsRound\n *         - sharp    → materialIconsSharp\n *     * For FILL=0 → stay on the original Symbols family (no override).\n *\n * This avoids forcing \"Icons\" when Symbols are present, and only swaps when we\n * can guarantee the desired \"filled\" appearance on canvas.\n */\nasync function ensureLigatureCanvasFont(cssFamily, className, axes) {\n  const fam = String(cssFamily || '')\n  const lowerFam = fam.toLowerCase()\n  const cls = String(className || '').toLowerCase()\n\n  // Already non-variable icons → keep as-is\n  if (/\\bmaterial\\s*icons\\b/.test(lowerFam) && !/\\bsymbols\\b/.test(lowerFam)) {\n    return { familyForMeasure: fam, familyForCanvas: fam }\n  }\n\n  const isSymbols = /\\bmaterial\\s*symbols\\b/.test(lowerFam)\n  if (!isSymbols) {\n    // Not Symbols → keep incoming family (Font Awesome / Lucide / etc.)\n    return { familyForMeasure: fam, familyForCanvas: fam }\n  }\n\n  // Decide style and fill from class/axes\n  const FILL = axes && (axes.FILL ?? axes.fill)\n  let style = 'outlined' // default\n  if (/\\brounded\\b/.test(cls) || /\\bround\\b/.test(cls)) style = 'rounded'\n  else if (/\\bsharp\\b/.test(cls)) style = 'sharp'\n  else if (/\\boutlined\\b/.test(cls)) style = 'outlined'\n\n  const filled = FILL === 1\n\n  // Only override to static non-variable when need \"filled\" on canvas\n  let pick = null\n  if (filled) {\n    if (style === 'outlined' && ICON_FONT_URLS.materialIconsFilled) {\n      pick = { url: ICON_FONT_URLS.materialIconsFilled, alias: 'snapdom-mi-filled' }\n    } else if (style === 'rounded' && ICON_FONT_URLS.materialIconsRound) {\n      pick = { url: ICON_FONT_URLS.materialIconsRound, alias: 'snapdom-mi-round' }\n    } else if (style === 'sharp' && ICON_FONT_URLS.materialIconsSharp) {\n      pick = { url: ICON_FONT_URLS.materialIconsSharp, alias: 'snapdom-mi-sharp' }\n    }\n  }\n\n  // If no override (either outlined or missing static), keep Symbols\n  if (!pick) {\n    return { familyForMeasure: fam, familyForCanvas: fam }\n  }\n\n  if (!loadedCanvasFamilies.has(pick.alias)) {\n    try {\n      const ff = new FontFace(pick.alias, `url(${pick.url})`, { style: 'normal', weight: '400' })\n      document.fonts.add(ff)\n      await ff.load()\n      loadedCanvasFamilies.set(pick.alias, true)\n    } catch {\n      // If loading fails, stay on Symbols\n      return { familyForMeasure: fam, familyForCanvas: fam }\n    }\n  }\n\n  const quoted = `\"${pick.alias}\"`\n  return { familyForMeasure: quoted, familyForCanvas: quoted }\n}\n\nexport async function ensureMaterialFontsReady(family = 'Material Icons', px = 24) {\n  try {\n    await Promise.all([\n      document.fonts.load(`400 ${px}px \"${String(family).replace(/[\"']/g, '')}\"`),\n      document.fonts.ready\n    ])\n  } catch { /* noop */ }\n}\n\nfunction resolvePaintColor(cs) {\n  let fill = cs.getPropertyValue('-webkit-text-fill-color')?.trim() || ''\n  const isTransparent = /^transparent$/i.test(fill) || /rgba?\\(\\s*0\\s*,\\s*0\\s*,\\s*0\\s*,\\s*0\\s*\\)/i.test(fill)\n  if (fill && !isTransparent && fill.toLowerCase() !== 'currentcolor') return fill\n  const c = cs.color?.trim()\n  return c && c !== 'inherit' ? c : '#000'\n}\n\nexport async function materialIconToImage(\n  ligatureText,\n  {\n    family = 'Material Icons',\n    weight = 'normal',\n    fontSize = 32,\n    color = '#000',\n    variation = '',\n    className = ''\n  } = {}\n) {\n  const fam = String(family || '').replace(/^['\"]+|['\"]+$/g, '')\n  const dpr = window.devicePixelRatio || 1\n  const axes = parseAxes(variation)\n\n  const { familyForMeasure, familyForCanvas } =\n    await ensureLigatureCanvasFont(fam, className, axes)\n\n  await ensureMaterialFontsReady(familyForCanvas.replace(/^[\"']+|[\"']+$/g, ''), fontSize)\n\n  // Measure with same family used on canvas\n  const span = document.createElement('span')\n  span.textContent = ligatureText\n  span.style.position = 'absolute'\n  span.style.visibility = 'hidden'\n  span.style.left = '-99999px'\n  span.style.whiteSpace = 'nowrap'\n  span.style.fontFamily = familyForMeasure\n  span.style.fontWeight = String(weight || 'normal')\n  span.style.fontSize = `${fontSize}px`\n  span.style.lineHeight = '1'\n  span.style.margin = '0'\n  span.style.padding = '0'\n  span.style.fontFeatureSettings = '\\'liga\\' 1'\n  span.style.fontVariantLigatures = 'normal'\n  span.style.color = color\n\n  document.body.appendChild(span)\n  const rect = span.getBoundingClientRect()\n  const width = Math.max(1, Math.ceil(rect.width))\n  const height = Math.max(1, Math.ceil(rect.height))\n  document.body.removeChild(span)\n\n  const canvas = document.createElement('canvas')\n  canvas.width = width * dpr\n  canvas.height = height * dpr\n  const ctx = canvas.getContext('2d')\n  ctx.scale(dpr, dpr)\n  ctx.font = `${weight ? `${weight} ` : ''}${fontSize}px ${familyForCanvas}`\n  ctx.textAlign = 'left'\n  ctx.textBaseline = 'top'\n  ctx.fillStyle = color\n  try { ctx.fontKerning = 'normal' } catch {}\n  ctx.fillText(ligatureText, 0, 0)\n\n  return {\n    dataUrl: canvas.toDataURL(),\n    width,\n    height\n  }\n}\n\n/**\n * Replace Material ligature nodes in the CLONE by <img>.\n * Reads styles from SOURCE for accurate size/color/variation/class.\n */\nexport async function ligatureIconToImage(cloneRoot, sourceRoot) {\n  if (!(cloneRoot instanceof Element)) return 0\n\n  const selector = '.material-icons, [class*=\"material-symbols\"]'\n\n  const cloneNodes = Array.from(\n    cloneRoot.querySelectorAll(selector)\n  ).filter(n => n && n.textContent && n.textContent.trim())\n\n  if (cloneNodes.length === 0) return 0\n\n  const sourceNodes = (sourceRoot instanceof Element)\n    ? Array.from(sourceRoot.querySelectorAll(selector)).filter(n => n && n.textContent && n.textContent.trim())\n    : []\n\n  let replaced = 0\n\n  for (let i = 0; i < cloneNodes.length; i++) {\n    const el = cloneNodes[i]\n    const src = sourceNodes[i] || null\n\n    try {\n      const cs = src ? getComputedStyle(src) : getComputedStyle(el)\n      const family = cs.fontFamily || 'Material Icons'\n      if (!isMaterialFamily(family)) continue\n\n      const text = (src || el).textContent.trim()\n      if (!text) continue\n\n      const size = parseInt(cs.fontSize, 10) || 24\n      const weight = (cs.fontWeight && cs.fontWeight !== 'normal') ? cs.fontWeight : 'normal'\n      const color = resolvePaintColor(cs)\n      const variation = cs.fontVariationSettings && cs.fontVariationSettings !== 'normal'\n        ? cs.fontVariationSettings\n        : ''\n      const className = (src || el).className || ''\n\n      const { dataUrl, width, height } = await materialIconToImage(text, {\n        family,\n        weight,\n        fontSize: size,\n        color,\n        variation,\n        className\n      })\n\n      el.textContent = ''\n      const img = el.ownerDocument.createElement('img')\n      img.src = dataUrl\n      img.alt = text\n      img.style.height = `${size}px`\n      img.style.width = `${Math.max(1, Math.round((width / height) * size))}px`\n      img.style.objectFit = 'contain'\n      img.style.verticalAlign = getComputedStyle(el).verticalAlign || 'baseline'\n      el.appendChild(img)\n\n      replaced++\n    } catch { /* continue */ }\n  }\n\n  return replaced\n}\n"
  },
  {
    "path": "src/modules/images.js",
    "content": "/**\n * Utilities for inlining <img> and SVG <image> elements as data URLs or placeholders.\n * Fixes #341: SVG <image href=\"https://...\"> now inlined like HTML <img>.\n * @module images\n */\n\nimport { snapFetch } from './snapFetch.js'\n\nconst XLINK_NS = 'http://www.w3.org/1999/xlink'\n\nfunction getSvgImageHref(el) {\n  return el.getAttribute('href') || el.getAttribute('xlink:href') ||\n    (typeof el.getAttributeNS === 'function' ? el.getAttributeNS(XLINK_NS, 'href') : null)\n}\n\n/**\n * Extract dimensions from an image element in priority order\n * @param {HTMLImageElement} img\n * @returns {{ width: number, height: number }}\n */\nfunction extractImageDimensions(img) {\n  const dsW = parseInt(img.dataset?.snapdomWidth || '', 10) || 0\n  const dsH = parseInt(img.dataset?.snapdomHeight || '', 10) || 0\n  const attrW = parseInt(img.getAttribute('width') || '', 10) || 0\n  const attrH = parseInt(img.getAttribute('height') || '', 10) || 0\n  const styleW = parseFloat(img.style?.width || '') || 0\n  const styleH = parseFloat(img.style?.height || '') || 0\n\n  const w = dsW || styleW || attrW || img.width || img.naturalWidth || 100\n  const h = dsH || styleH || attrH || img.height || img.naturalHeight || 100\n\n  return { width: w, height: h }\n}\n\n/**\n * Converts all <img> elements in the clone to data URLs or replaces them with\n * placeholders if loading fails. Compatible with the new non-throwing snapFetch.\n *\n * - Success: result.ok === true && typeof result.data === 'string' (DataURL)\n * - Failure: any other case → replace <img> with a sized fallback <div>\n *\n * @param {Element} clone - Clone of the original element\n * @param {{ useProxy?: string }} [options={}] - Options for image processing\n * @returns {Promise<void>}\n */\nexport async function inlineImages(clone, options = {}) {\n  const imgs = Array.from(clone.querySelectorAll('img'))\n  /** @param {HTMLImageElement} img */\n  const processImg = async (img) => {\n    // Normalize src/srcset/sizes to a single concrete URL\n    if (!img.getAttribute('src')) {\n      const eff = img.currentSrc || img.src || ''\n      if (eff) img.setAttribute('src', eff)\n    }\n\n    img.removeAttribute('srcset')\n    img.removeAttribute('sizes')\n\n    const src = img.src || ''\n    if (!src) return\n\n    const r = await snapFetch(src, { as: 'dataURL', useProxy: options.useProxy })\n    if (r.ok && typeof r.data === 'string' && r.data.startsWith('data:')) {\n      // Success path: inline DataURL and ensure dimensions for layout fidelity\n      img.src = r.data\n      if (!img.width) img.width = img.naturalWidth || 100\n      if (!img.height) img.height = img.naturalHeight || 100\n      return\n    }\n    // Try fallbackURL (string or callback)\n    const { width: fbW, height: fbH } = extractImageDimensions(img)\n    const { fallbackURL } = options || {}\n    if (fallbackURL) {\n      try {\n        const fallbackUrl =\n          typeof fallbackURL === 'function'\n            ? await fallbackURL({ width: fbW, height: fbH, src, element: img })\n            : fallbackURL\n\n        if (fallbackUrl) {\n          const fallbackData = await snapFetch(fallbackUrl, { as: 'dataURL', useProxy: options.useProxy })\n          if (fallbackData?.ok && typeof fallbackData.data === 'string') {\n            img.src = fallbackData.data\n            if (!img.width) img.width = fbW\n            if (!img.height) img.height = fbH\n            return\n          }\n        }\n      } catch {\n        // noop → cae al placeholder\n      }\n    }\n\n    if (options.placeholders !== false) {\n      const fallback = document.createElement('div')\n      fallback.style.cssText = [\n        `width:${fbW}px`,\n        `height:${fbH}px`,\n        'background:#ccc',\n        'display:inline-block',\n        'text-align:center',\n        `line-height:${fbH}px`,\n        'color:#666',\n        'font-size:12px',\n        'overflow:hidden'\n      ].join(';')\n      fallback.textContent = 'img'\n      img.replaceWith(fallback)\n    } else {\n      const spacer = document.createElement('div')\n      spacer.style.cssText = `display:inline-block;width:${fbW}px;height:${fbH}px;visibility:hidden;`\n      img.replaceWith(spacer)\n    }\n  }\n\n  for (let i = 0; i < imgs.length; i += 4) {\n    const group = imgs.slice(i, i + 4).map(processImg)\n    await Promise.allSettled(group)\n  }\n\n  // #341: Inline SVG <image href=\"https://...\"> (e.g. Highcharts, D3)\n  const svgImages = Array.from(clone.querySelectorAll('image'))\n  const processSvgImage = async (el) => {\n    const href = getSvgImageHref(el)\n    if (!href || href.startsWith('data:') || href.startsWith('blob:')) return\n\n    const r = await snapFetch(href, { as: 'dataURL', useProxy: options.useProxy })\n    if (r.ok && typeof r.data === 'string' && r.data.startsWith('data:')) {\n      el.setAttribute('href', r.data)\n      el.removeAttribute('xlink:href')\n      if (typeof el.removeAttributeNS === 'function') el.removeAttributeNS(XLINK_NS, 'href')\n    }\n  }\n  for (let i = 0; i < svgImages.length; i += 4) {\n    const group = svgImages.slice(i, i + 4).map(processSvgImage)\n    await Promise.allSettled(group)\n  }\n}\n"
  },
  {
    "path": "src/modules/lineClamp.js",
    "content": "// src/core/lineClamp.js\n\n/**\n * Apply line-clamp to element AND all descendants that have -webkit-line-clamp.\n * Fixes #386: ellipsis now renders for nested elements, not just the root.\n *\n * @param {Element} el - Root element (and its subtree) to process\n * @returns {() => void} Combined undo function\n */\nexport function lineClampTree(el) {\n  if (!el) return () => {}\n  const undos = []\n  function walk(node) {\n    const undo = lineClamp(node)\n    if (undo) undos.push(undo)\n    for (const child of node.children || []) walk(child)\n  }\n  walk(el)\n  return () => undos.forEach((u) => u())\n}\n\n/**\n * Apply a multi-line ellipsis ONLY if the target element declares\n * -webkit-line-clamp/line-clamp. Uses the real layout (scrollHeight) and\n * mutates the ORIGINAL node briefly (binary search on text + '…'),\n * then returns an undo() that restores everything right after cloning.\n *\n * @param {Element} el\n * @returns {() => void} undo function (no-op if nothing changed)\n */\nexport function lineClamp(el) {\n  if (!el) return () => {}\n\n  const lines = getClamp(el)\n  if (lines <= 0) return () => {}\n\n  if (!isPlainTextContainer(el)) return () => {}\n\n  const cs = getComputedStyle(el)\n  const targetH = Math.round(usedLineHeightPx(cs) * lines + vpad(cs))\n\n  const original = el.textContent ?? ''\n  // Guarda para restaurar\n  const prevText = original\n\n  // Si ya entra completo en N líneas, no hacemos nada (igual que el clamp nativo)\n  if (el.scrollHeight <= targetH + 0.5) {\n    return () => {}\n  }\n\n  // ==== Binary search sobre el largo del prefijo que entra con ellipsis ====\n  let lo = 0, hi = original.length, best = -1\n  while (lo <= hi) {\n    const mid = (lo + hi) >> 1\n    el.textContent = original.slice(0, mid) + '…'\n    // Forzamos layout leyendo scrollHeight\n    if (el.scrollHeight <= targetH + 0.5) {\n      best = mid; lo = mid + 1\n    } else {\n      hi = mid - 1\n    }\n  }\n\n  // Aplica el mejor corte (si nada entra, queda solo '…')\n  el.textContent = (best >= 0 ? original.slice(0, best) : '') + '…'\n\n  // Devuelve undo() para restaurar el DOM original tras clonar\n  return () => {\n    el.textContent = prevText\n  }\n}\n\n/* ---------------- helpers: idénticos a tu snippet ---------------- */\n\nfunction getClamp(el) {\n  const cs = getComputedStyle(el)\n  let v = cs.getPropertyValue('-webkit-line-clamp') || cs.getPropertyValue('line-clamp')\n  v = (v || '').trim()\n  const n = parseInt(v, 10)\n  return Number.isFinite(n) && n > 0 ? n : 0\n}\n\nfunction usedLineHeightPx(cs) {\n  const lh = (cs.lineHeight || '').trim()\n  const fs = parseFloat(cs.fontSize) || 16\n  if (!lh || lh === 'normal') return Math.round(fs * 1.2)\n  if (lh.endsWith('px')) return parseFloat(lh)\n  if (/^\\d+(\\.\\d+)?$/.test(lh)) return Math.round(parseFloat(lh) * fs)\n  if (lh.endsWith('%')) return Math.round((parseFloat(lh) / 100) * fs)\n  return Math.round(fs * 1.2)\n}\n\nfunction vpad(cs) {\n  return (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)\n}\n\n/** Plain text container: sin hijos element, sólo nodos de texto/espacios. */\nfunction isPlainTextContainer(el) {\n  if (el.childElementCount > 0) return false\n  return Array.from(el.childNodes).some(n => n.nodeType === Node.TEXT_NODE)\n}\n"
  },
  {
    "path": "src/modules/pseudo.js",
    "content": "/**\n * Utilities for inlining ::before and ::after pseudo-elements.\n * @module pseudo\n */\n\nimport {\n  getStyle,\n  snapshotComputedStyle,\n  extractURL,\n  safeEncodeURI,\n  inlineSingleBackgroundEntry,\n  splitBackgroundImage,\n  getStyleKey,\n  debugWarn\n} from '../utils/index.js'\nimport { iconToImage } from '../modules/fonts.js'\nimport { isIconFont } from '../modules/iconFonts.js'\nimport {\n  buildCounterContext,\n  resolveCountersInContent,\n  hasCounters\n} from '../modules/counter.js'\nimport { snapFetch } from './snapFetch.js'\nimport { cache } from '../core/cache.js'\n\n/** Weak memo for per-document preflight results keyed by a cheap style fingerprint */\nconst __preflightMemo = new WeakMap()\n\n/** Max number of CSS rules to scan across all sheets (keeps it fast) */\nconst CSS_RULE_SCAN_BUDGET = 300\n\n/**\n * Returns whether to process pseudos, but also memoizes the last fingerprint\n * seen in the provided sessionCache to avoid stale results between tests/runs.\n * @param {Document} doc\n * @param {Map|Object} sessionCache\n * @returns {boolean}\n */\nfunction preflightWithFp(doc, sessionCache) {\n  const fp = styleFingerprint(doc)\n  if (!sessionCache) return shouldProcessPseudos(doc)\n  // Recompute when the fingerprint changes\n  if (sessionCache.__pseudoPreflightFp !== fp) {\n    sessionCache.__pseudoPreflight = shouldProcessPseudos(doc)\n    sessionCache.__pseudoPreflightFp = fp\n  }\n  return !!sessionCache.__pseudoPreflight\n}\n/**\n * Safely returns cssRules for a stylesheet, or null when cross-origin/blocked.\n * @param {CSSStyleSheet} sheet\n * @returns {CSSRuleList | null}\n */\nfunction safeRules(sheet) {\n  try {\n    return sheet && sheet.cssRules ? sheet.cssRules : null\n  } catch {\n    return null\n  }\n}\n\n/**\n * Builds a cheap fingerprint of the document's stylesheet landscape.\n * Includes:\n *  - count and basic hash of <style> and <link rel=\"stylesheet\">\n *  - adoptedStyleSheets length\n *  - total rules count (safe, no cssText) to reflect CSSOM insertRule changes\n * @param {Document} doc\n * @returns {string}\n */\nfunction styleFingerprint(doc) {\n  const nodes = doc.querySelectorAll('style,link[rel~=\"stylesheet\"]')\n  let fp = `n:${nodes.length}|`\n  let totalRules = 0\n\n  for (let i = 0; i < nodes.length; i++) {\n    const n = nodes[i]\n    if (n.tagName === 'STYLE') {\n      const len = n.textContent ? n.textContent.length : 0\n      fp += `S${len}|`\n      // If same-origin, cssRules is readable; include count to reflect insertRule\n      const sheet = /** @type {HTMLStyleElement} */(n).sheet\n      const rules = sheet ? safeRules(sheet) : null\n      if (rules) totalRules += rules.length\n    } else {\n      const href = n.getAttribute('href') || ''\n      const media = n.getAttribute('media') || 'all'\n      fp += `L${href}|m:${media}|`\n      const sheet = /** @type {HTMLLinkElement} */(n).sheet\n      const rules = sheet ? safeRules(sheet) : null\n      if (rules) totalRules += rules.length\n    }\n  }\n\n  const ass = /** @type {any} */ (doc).adoptedStyleSheets\n  fp += `ass:${Array.isArray(ass) ? ass.length : 0}|tr:${totalRules}`\n\n  return fp\n}\n\n/**\n * Scans a stylesheet's rules for any needle strings within a limited budget.\n * @param {CSSStyleSheet} sheet\n * @param {string[]} needles\n * @param {{budget:number}} state\n * @returns {boolean}\n */\nfunction sheetHasNeedles(sheet, needles, state) {\n  const rules = safeRules(sheet)\n  if (!rules) return false\n\n  for (let i = 0; i < rules.length; i++) {\n    if (state.budget <= 0) return false\n    const rule = rules[i]\n    // Only read cssText when needed and decrement budget\n    const css = rule && rule.cssText ? rule.cssText : ''\n    state.budget--\n    for (const k of needles) {\n      if (css.includes(k)) return true\n    }\n    // Nested group rules: @media, @supports, etc.\n    // @ts-ignore - CSSGroupingRule may not exist in all envs\n    if (rule && rule.cssRules && rule.cssRules.length) {\n      for (let j = 0; j < rule.cssRules.length && state.budget > 0; j++) {\n        const inner = rule.cssRules[j]\n        const innerCss = inner && inner.cssText ? inner.cssText : ''\n        state.budget--\n        for (const k of needles) {\n          if (innerCss.includes(k)) return true\n        }\n      }\n    }\n    if (state.budget <= 0) return false\n  }\n  return false\n}\n\n/**\n * Fast preflight to decide whether pseudo/counter inlining is needed at all.\n * Triggers true if detects any:\n *  - ::before / ::after / ::first-letter (and single-colon variants)\n *  - counter( / counters( / counter-increment / counter-reset\n *\n * Strategy (fast → slower):\n *  1) Scan inline <style> textContent\n *  2) Scan adoptedStyleSheets (cssRules) if available\n *  3) Scan a small budget of cssRules in <style>/<link> same-origin\n *  4) Cheap DOM hint for inline styles with counter(\n *\n * Memoized by document + style fingerprint.\n *\n * @param {Document} doc\n * @returns {boolean}\n */\nexport function shouldProcessPseudos(doc = document) {\n  const fp = styleFingerprint(doc)\n  const memo = __preflightMemo.get(doc)\n  if (memo && memo.fingerprint === fp) return memo.result\n\n  const NEEDLES = [\n    // double-colon\n    '::before', '::after', '::first-letter',\n    // single-colon robustness\n    ':before', ':after', ':first-letter',\n    // counters\n    'counter(', 'counters(', 'counter-increment', 'counter-reset'\n  ]\n\n  // 1) Inline <style> text scan (O(total style text))\n  const styleEls = doc.querySelectorAll('style')\n  for (let i = 0; i < styleEls.length; i++) {\n    const t = styleEls[i].textContent || ''\n    for (const k of NEEDLES) if (t.includes(k)) {\n      __preflightMemo.set(doc, { fingerprint: fp, result: true })\n      return true\n    }\n  }\n\n  // 2) adoptedStyleSheets cssRules scan (safe and fast)\n  const ass = /** @type {any} */ (doc).adoptedStyleSheets\n  if (Array.isArray(ass) && ass.length) {\n    const state = { budget: CSS_RULE_SCAN_BUDGET }\n    try {\n      for (const sheet of ass) {\n        if (sheetHasNeedles(sheet, NEEDLES, state)) {\n          __preflightMemo.set(doc, { fingerprint: fp, result: true })\n          return true\n        }\n      }\n    } catch { /* ignore */ }\n  }\n\n  // 3) cssRules scan in <style> / <link rel=\"stylesheet\"> (same-origin only), bounded by budget\n  {\n    const nodes = doc.querySelectorAll('style,link[rel~=\"stylesheet\"]')\n    const state = { budget: CSS_RULE_SCAN_BUDGET }\n    for (let i = 0; i < nodes.length && state.budget > 0; i++) {\n      const n = nodes[i]\n      /** @type {CSSStyleSheet | null} */\n      let sheet = null\n      if (n.tagName === 'STYLE') {\n        sheet = /** @type {HTMLStyleElement} */(n).sheet || null\n      } else {\n        sheet = /** @type {HTMLLinkElement} */(n).sheet || null\n      }\n      if (sheet && sheetHasNeedles(sheet, NEEDLES, state)) {\n        __preflightMemo.set(doc, { fingerprint: fp, result: true })\n        return true\n      }\n    }\n  }\n\n  // 4) Ultra-cheap inline style hint\n  if (doc.querySelector('[style*=\"counter(\"], [style*=\"counters(\"]')) {\n    __preflightMemo.set(doc, { fingerprint: fp, result: true })\n    return true\n  }\n\n  __preflightMemo.set(doc, { fingerprint: fp, result: false })\n  return false\n}\n\n/** Acumulador de contadores por padre para propagar increments en pseudos entre hermanos */\nvar __siblingCounters = new WeakMap() // parentElement -> Map<counterName, number>\nvar __pseudoEpoch = -1\n\n/** Remove only enclosing double-quoted tokens from CSS content (keeps single quotes). */\nfunction unquoteDoubleStrings(s) {\n  return (s || '').replace(/\"([^\"]*)\"/g, '$1')\n}\n\n/**\n * Concatena tokens de content (p.ej. `\"1\" \".\"`) sin introducir espacios.\n * Si no hay comillas, devuelve el unquote estándar.\n * @param {string} raw\n */\nfunction collapseCssContent(raw) {\n  if (!raw) return ''\n  const tokens = []\n  const rx = /\"([^\"]*)\"/g\n  let m\n  while ((m = rx.exec(raw))) tokens.push(m[1])\n  // Si hay tokens con comillas, concatenar sin espacios (comportamiento del browser)\n  if (tokens.length) return tokens.join('')\n  return unquoteDoubleStrings(raw)\n}\n\n/**\n * Crea un contexto base envuelto que aplica overrides de hermanos (si existen).\n * @param {Element} node\n * @param {{get:Function, getStack:Function}} base\n */\nfunction withSiblingOverrides(node, base) {\n  const parent = node.parentElement\n  const map = parent ? __siblingCounters.get(parent) : null\n  if (!map) return base\n  return {\n    get(n, name) {\n      const v = base.get(n, name)\n      const ov = map.get(name)\n      // usar el mayor (o el override si existe) para mantener secuencia\n      return typeof ov === 'number' ? Math.max(v, ov) : v\n    },\n    getStack(n, name) {\n      const s = base.getStack(n, name)\n      if (!s.length) return s\n      const ov = map.get(name)\n      if (typeof ov === 'number') {\n        const out = s.slice()\n        out[out.length - 1] = Math.max(out[out.length - 1], ov)\n        return out\n      }\n      return s\n    }\n  }\n}\n\n/**\n * Aplica counter-reset / counter-increment del pseudo *solo para este nodo*,\n * partiendo de un contexto base (ya envuelto con overrides de hermanos).\n * @param {Element} node\n * @param {CSSStyleDeclaration|null} pseudoStyle\n * @param {{get:Function, getStack:Function}} baseCtx\n */\nfunction deriveCounterCtxForPseudo(node, pseudoStyle, baseCtx) {\n  const modStacks = new Map()\n\n  function parseListDecl(value) {\n    const out = []\n    if (!value || value === 'none') return out\n    for (const part of String(value).split(',')) {\n      const toks = part.trim().split(/\\s+/)\n      const name = toks[0]\n      const num = Number.isFinite(Number(toks[1])) ? Number(toks[1]) : undefined\n      if (name) out.push({ name, num })\n    }\n    return out\n  }\n\n  const resets = parseListDecl(pseudoStyle?.counterReset)\n  const incs = parseListDecl(pseudoStyle?.counterIncrement)\n\n  function getStackDerived(name) {\n    if (modStacks.has(name)) return modStacks.get(name).slice()\n    let stack = baseCtx.getStack(node, name)\n    stack = stack.length ? stack.slice() : []\n\n    // reset: push si hay stack, replace si no\n    const r = resets.find(x => x.name === name)\n    if (r) {\n      const val = Number.isFinite(r.num) ? r.num : 0\n      stack = stack.length ? [...stack, val] : [val]\n    }\n\n    // increment: sobre el top, crear top=0 si no existe\n    const inc = incs.find(x => x.name === name)\n    if (inc) {\n      const by = Number.isFinite(inc.num) ? inc.num : 1\n      if (stack.length === 0) stack = [0]\n      stack[stack.length - 1] += by\n    }\n\n    modStacks.set(name, stack.slice())\n    return stack\n  }\n\n  return {\n    get(_node, name) {\n      const s = getStackDerived(name)\n      return s.length ? s[s.length - 1] : 0\n    },\n    getStack(_node, name) {\n      return getStackDerived(name)\n    },\n    /** expone increments del pseudo para que el caller pueda propagar a hermanos */\n    __incs: incs\n  }\n}\n\n/**\n * Resuelve el `content` del pseudo aplicando:\n * 1) overrides de hermanos (para continuidad entre siblings),\n * 2) reset/increment del pseudo,\n * 3) colapso de tokens `\"...\"` sin espacios intermedios.\n *\n * @param {Element} node\n * @param {'::before'|'::after'} pseudo\n * @param {{get:Function, getStack:Function}} baseCtx\n * @returns {{ text: string, incs: Array<{name:string,num:number|undefined}> }}\n */\nfunction resolvePseudoContentAndIncs(node, pseudo, baseCtx) {\n  let ps\n  try { ps = getStyle(node, pseudo) } catch { }\n  const raw = ps?.content\n  if (!raw || raw === 'none' || raw === 'normal') return { text: '', incs: [] }\n\n  // 1) aplicar overrides de hermanos\n  const baseWithSiblings = withSiblingOverrides(node, baseCtx)\n\n  // 2) derivar (aplica reset/increment del pseudo)\n  const derived = deriveCounterCtxForPseudo(node, ps, baseWithSiblings)\n\n  // 3) resolver counter()/counters()\n  let resolved = hasCounters(raw)\n    ? resolveCountersInContent(raw, node, derived)\n    : raw\n\n  // 4) colapsar tokens (quita espacios entre \"1\" \".\" -> \"1.\")\n  const text = collapseCssContent(resolved)\n  return { text, incs: derived.__incs || [] }\n}\n\n/**\n * Creates elements to represent ::before, ::after, and ::first-letter pseudo-elements, inlining their styles and content.\n *\n * @param {Element} source - Original element\n * @param {Element} clone - Cloned element\n * @param {Map} sessionCache - styleMap cache etc.\n * @param {Object} options - capture options\n * @returns {Promise<void>}\n */\nexport async function inlinePseudoElements(source, clone, sessionCache, options) {\n  if (!(source instanceof Element) || !(clone instanceof Element)) return\n  // --- NEW: preflight once per session/doc ---\n  const doc = source.ownerDocument || document\n  if (!preflightWithFp(doc, sessionCache)) {\n    return\n  }\n\n  // Reset per-capture: si cambió el epoch, limpiamos overrides de hermanos\n  const epoch = (cache?.session?.__counterEpoch ?? 0)\n  if (__pseudoEpoch !== epoch) {\n    __siblingCounters = new WeakMap()\n    if (sessionCache) sessionCache.__counterCtx = null\n    __pseudoEpoch = epoch\n  }\n\n  if (!sessionCache.__counterCtx) {\n    try { sessionCache.__counterCtx = buildCounterContext(source.ownerDocument || document) } catch (e) {\n      debugWarn(sessionCache, 'buildCounterContext failed', e)\n    }\n  }\n  const counterCtx = sessionCache.__counterCtx\n\n  for (const pseudo of ['::before', '::after', '::first-letter']) {\n    try {\n      const style = getStyle(source, pseudo)\n      if (!style) continue\n      // Skip visually empty pseudo-elements early\n      const isEmptyPseudo =\n        style.content === 'none' &&\n        style.backgroundImage === 'none' &&\n        style.backgroundColor === 'transparent' &&\n        (style.borderStyle === 'none' || parseFloat(style.borderWidth) === 0) &&\n        (!style.transform || style.transform === 'none') &&\n        style.display === 'inline'\n\n      if (isEmptyPseudo) continue\n\n      if (pseudo === '::first-letter') {\n        const normal = getStyle(source)\n        const isMeaningful =\n          style.color !== normal.color ||\n          style.fontSize !== normal.fontSize ||\n          style.fontWeight !== normal.fontWeight\n        if (!isMeaningful) continue\n\n        const textNode = Array.from(clone.childNodes).find(\n          (n) => n.nodeType === Node.TEXT_NODE && n.textContent?.trim().length > 0\n        )\n        if (!textNode) continue\n\n        const text = textNode.textContent\n        const match = text.match(/^([^\\p{L}\\p{N}\\s]*[\\p{L}\\p{N}](?:['’])?)/u)\n        const first = match?.[0]\n        const rest = text.slice(first?.length || 0)\n        if (!first || /[\\uD800-\\uDFFF]/.test(first)) continue\n\n        const span = document.createElement('span')\n        span.textContent = first\n        span.dataset.snapdomPseudo = '::first-letter'\n        const snapshot = snapshotComputedStyle(style)\n        const key = getStyleKey(snapshot, 'span')\n        sessionCache.styleMap.set(span, key)\n\n        const restNode = document.createTextNode(rest)\n        clone.replaceChild(restNode, textNode)\n        clone.insertBefore(span, restNode)\n        continue\n      }\n\n      // ---------- CONTENT (pseudo-aware counters + collapse tokens) ----------\n      const rawContent = style.content ?? ''\nconst isNoExplicitContent =\n  rawContent === '' || rawContent === 'none' || rawContent === 'normal'\nconst { text: cleanContent, incs } =\n  resolvePseudoContentAndIncs(source, pseudo, counterCtx)\n\n      const bg = style.backgroundImage\n      const bgColor = style.backgroundColor\n      const fontFamily = style.fontFamily\n      const fontSize = parseInt(style.fontSize) || 32\n      const fontWeight = parseInt(style.fontWeight) || false\n      const color = style.color || '#000'\n      const borderStyle = style.borderStyle\n      const borderWidth = parseFloat(style.borderWidth)\n      const transform = style.transform\n\n      const isIconFont2 = isIconFont(fontFamily)\n\nconst hasExplicitContent = !isNoExplicitContent && cleanContent !== ''\n      const hasBg = bg && bg !== 'none'\n      const hasBgColor =\n        bgColor && bgColor !== 'transparent' && bgColor !== 'rgba(0, 0, 0, 0)'\n      const hasBorder =\n        borderStyle && borderStyle !== 'none' && borderWidth > 0\n      const hasTransform = transform && transform !== 'none'\n\n      const shouldRender =\n        hasExplicitContent || hasBg || hasBgColor || hasBorder || hasTransform\n\n      if (!shouldRender) {\n        // Aun si no renderizamos caja, si el pseudo tenía increments, propagar a hermanos\n        if (incs && incs.length && source.parentElement) {\n          const map = __siblingCounters.get(source.parentElement) || new Map()\n          // Para cada counter incrementado en el pseudo, guardar el valor resuelto final\n          for (const { name } of incs) {\n            if (!name) continue\n            // reconstruir valor final desde derived: volvemos a pedirlo\n            // Usamos withSiblingOverrides + derive para ser consistentes\n            const baseWithSibs = withSiblingOverrides(source, counterCtx)\n            const derived = deriveCounterCtxForPseudo(source, getStyle(source, pseudo), baseWithSibs)\n            const finalVal = derived.get(source, name)\n            map.set(name, finalVal)\n          }\n          __siblingCounters.set(source.parentElement, map)\n        }\n        continue\n      }\n\n      const pseudoEl = document.createElement('span')\n      pseudoEl.dataset.snapdomPseudo = pseudo\n      // pseudoEl.style.display = 'inline'\n      // pseudoEl.style.verticalAlign = 'baseline'\n      pseudoEl.style.pointerEvents = 'none'\n      const snapshot = snapshotComputedStyle(style)\n      const key = getStyleKey(snapshot, 'span')\n      sessionCache.styleMap.set(pseudoEl, key)\n\n      // ---- Content handling (icon-font glyphs / url() / text) ----\n      if (isIconFont2 && cleanContent && cleanContent.length === 1) {\n        const { dataUrl, width: w, height: h } =\n          await iconToImage(cleanContent, fontFamily, fontWeight, fontSize, color)\n        const imgEl = document.createElement('img')\n        imgEl.src = dataUrl\n        imgEl.style = `height:${fontSize}px;width:${(w / h) * fontSize}px;object-fit:contain;`\n        pseudoEl.appendChild(imgEl)\n        clone.dataset.snapdomHasIcon = 'true'\n      } else if (cleanContent && cleanContent.startsWith('url(')) {\n        // content: url(...)\n        const rawUrl = extractURL(cleanContent)\n        if (rawUrl?.trim()) {\n          try {\n            const dataUrl = await snapFetch(safeEncodeURI(rawUrl), { as: 'dataURL', useProxy: options.useProxy })\n            if (dataUrl?.ok && typeof dataUrl.data === 'string') {\n              const imgEl = document.createElement('img')\n              imgEl.src = dataUrl.data\n              imgEl.style = `width:${fontSize}px;height:auto;object-fit:contain;`\n              pseudoEl.appendChild(imgEl)\n            }\n          } catch (e) {\n            console.error(`[snapdom] Error in pseudo ${pseudo} for`, source, e)\n          }\n        }\n      } else if (!isIconFont2 && hasExplicitContent) {\n        pseudoEl.textContent = cleanContent // <- ya sin espacios extra\n      }\n\n      // ---- Backgrounds / colors ----\n      pseudoEl.style.backgroundImage = 'none'\n      if ('maskImage' in pseudoEl.style) pseudoEl.style.maskImage = 'none'\n      if ('webkitMaskImage' in pseudoEl.style) pseudoEl.style.webkitMaskImage = 'none'\n\n      try {\n        pseudoEl.style.backgroundRepeat = style.backgroundRepeat\n        pseudoEl.style.backgroundSize = style.backgroundSize\n        if (style.backgroundPositionX && style.backgroundPositionY) {\n          pseudoEl.style.backgroundPositionX = style.backgroundPositionX\n          pseudoEl.style.backgroundPositionY = style.backgroundPositionY\n        } else {\n          pseudoEl.style.backgroundPosition = style.backgroundPosition\n        }\n        pseudoEl.style.backgroundOrigin = style.backgroundOrigin\n        pseudoEl.style.backgroundClip = style.backgroundClip\n        pseudoEl.style.backgroundAttachment = style.backgroundAttachment\n        pseudoEl.style.backgroundBlendMode = style.backgroundBlendMode\n      } catch { }\n\n      if (hasBg) {\n        try {\n          const bgSplits = splitBackgroundImage(bg)\n          const newBgParts = await Promise.all(bgSplits.map(inlineSingleBackgroundEntry))\n          pseudoEl.style.backgroundImage = newBgParts.join(', ')\n        } catch (e) {\n          console.warn(`[snapdom] Failed to inline background-image for ${pseudo}`, e)\n        }\n      }\n      if (hasBgColor) pseudoEl.style.backgroundColor = bgColor\n\n      const hasContent2 =\n        pseudoEl.childNodes.length > 0 || (pseudoEl.textContent?.trim() !== '')\n      const hasVisibleBox =\n        hasContent2 || hasBg || hasBgColor || hasBorder || hasTransform\n\n      // Antes de insertar, si hubo increments en el pseudo, propagar valor final a los hermanos\n      if (incs && incs.length && source.parentElement) {\n        const map = __siblingCounters.get(source.parentElement) || new Map()\n        const baseWithSibs = withSiblingOverrides(source, counterCtx)\n        const derived = deriveCounterCtxForPseudo(source, getStyle(source, pseudo), baseWithSibs)\n        for (const { name } of incs) {\n          if (!name) continue\n          const finalVal = derived.get(source, name)\n          map.set(name, finalVal)\n        }\n        __siblingCounters.set(source.parentElement, map)\n      }\n\n      if (!hasVisibleBox) continue\n\n      // #359: mark parent so we can suppress native ::before/::after in cloned <style> (avoids double render)\n      if (pseudo === '::before') {\n        clone.dataset.snapdomHasBefore = '1'\n        clone.insertBefore(pseudoEl, clone.firstChild)\n      } else {\n        clone.dataset.snapdomHasAfter = '1'\n        clone.appendChild(pseudoEl)\n      }\n    } catch (e) {\n      console.warn(`[snapdom] Failed to capture ${pseudo} for`, source, e)\n    }\n  }\n\n  // Recurse – use nodeMap (clone→source) for alignment instead of index,\n  // because deepClone filters out NO_CAPTURE_TAGS (script, link, etc.),\n  // which causes index mismatch between source.children and clone.children.\n  const cChildren = Array.from(clone.children).filter((child) => !child.dataset.snapdomPseudo)\n  if (sessionCache.nodeMap) {\n    for (const cChild of cChildren) {\n      const sChild = sessionCache.nodeMap.get(cChild)\n      if (sChild instanceof Element) {\n        await inlinePseudoElements(sChild, cChild, sessionCache, options)\n      }\n    }\n  } else {\n    const sChildren = Array.from(source.children)\n    for (let i = 0; i < Math.min(sChildren.length, cChildren.length); i++) {\n      await inlinePseudoElements(sChildren[i], cChildren[i], sessionCache, options)\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/rasterize.js",
    "content": "// src/exporters/rasterize.js\nimport { toCanvas } from '../exporters/toCanvas.js'\n\n/**\n * Converts to an HTMLImageElement with raster format.\n * @param {string} url\n * @param {{ format:'png'|'jpeg'|'webp', dpr:number, quality?:number, backgroundColor?:string }} options\n * @returns {Promise<HTMLImageElement>}\n */\nexport async function rasterize(url, options) {\n  const canvas = await toCanvas(url, options) // backgroundColor ya aplicado si existe\n\n  const img = new Image()\n  img.src = canvas.toDataURL(`image/${options.format}`, options.quality)\n  await img.decode()\n\n  img.style.width = `${canvas.width / options.dpr}px`\n  img.style.height = `${canvas.height / options.dpr}px`\n  return img\n}\n"
  },
  {
    "path": "src/modules/snapFetch.js",
    "content": "// src/modules/snapFetch.js\nimport { safeEncodeURI } from '../utils/helpers.js'\n\n/**\n * snapFetch — unified fetch for SnapDOM\n * - Single inflight queue & error cache (with TTL)\n * - Timeout via AbortController\n * - Optional proxy handling (\"...{url}\" or \"...?url=\")\n * - Non-throwing: always resolves { ok, data|null, status, url, reason, ... }\n * - Thin, deduplicated logging: `[snapDOM]` warn/error with TTL + session cap\n *\n * @typedef {'text'|'blob'|'dataURL'} FetchAs\n *\n * @typedef {Object} SnapFetchOptions\n * @property {FetchAs} [as='blob']               Expected result type.\n * @property {number}  [timeout=3000]            Timeout in ms.\n * @property {string}  [useProxy='']             Proxy template or base URL. Supports \"{url}\" or \"?url=\" patterns.\n * @property {number}  [errorTTL=8000]           ms to cache errors to avoid retry storms.\n * @property {RequestCredentials} [credentials]  Override inferred credentials.\n * @property {Record<string,string>} [headers]   Optional headers.\n * @property {boolean} [silent=false]            If true, disables console logging for this call.\n * @property {(r:SnapFetchResult)=>void} [onError] Optional hook when a fetch fails (ok=false).\n *\n * @typedef {Object} SnapFetchResult\n * @property {boolean} ok\n * @property {string|Blob|null} data             Text, Blob, or DataURL (depending on `as`).\n * @property {number} status                     HTTP status (0 on network/timeout).\n * @property {string} url                        Final URL (after proxy if applied).\n * @property {boolean} fromCache                 Served from inflight/error cache.\n * @property {string} [mime]                     Best-effort MIME (blob/dataURL modes).\n * @property {string} [reason]                   Failure reason (e.g., 'http_error','timeout','abort','network').\n */\n\n// ---------------------------------------------------------------------------\n// Slim logger: dedup + TTL + session cap\n// ---------------------------------------------------------------------------\n\nfunction createSnapLogger(prefix = '[snapDOM]', { ttlMs = 5 * 60_000, maxEntries = 12 } = {}) {\n  const seen = new Map()\n  let emitted = 0\n\n  function log(level, key, msg) {\n    if (emitted >= maxEntries) return\n    const now = Date.now()\n    const until = seen.get(key) || 0\n    if (until > now) return // still under TTL\n    seen.set(key, now + ttlMs)\n    emitted++\n    if (level === 'warn' && console && console.warn) console.warn(`${prefix} ${msg}`)\n    else if (console && console.error) console.error(`${prefix} ${msg}`)\n  }\n\n  return {\n    warnOnce(key, msg) { log('warn', key, msg) },\n    errorOnce(key, msg) { log('error', key, msg) },\n    reset() { seen.clear(); emitted = 0 },\n  }\n}\n\nconst snapLogger = createSnapLogger('[snapDOM]', { ttlMs: 3 * 60_000, maxEntries: 10 })\n\n// ---------------------------------------------------------------------------\n// Internal state\n// ---------------------------------------------------------------------------\n\nconst _inflight = new Map()\nconst _errorCache = new Map()\n\n// ---------------------------------------------------------------------------\n// Utilities\n// ---------------------------------------------------------------------------\n\n/** data:/blob:/about:blank should not go through proxy decision */\nfunction isSpecialURL(url) {\n  return /^data:|^blob:|^about:blank$/i.test(url)\n}\n\n/** Avoid re-proxying an URL that's already going through the proxy */\nfunction isAlreadyProxied(url, useProxy) {\n  try {\n    const baseHref = (typeof location !== 'undefined' && location.href) ? location.href : 'http://localhost/'\n    const proxyBaseRaw = useProxy.includes('{url}') ? useProxy.split('{url}')[0] : useProxy\n    const proxyBase = new URL(proxyBaseRaw || '.', baseHref)\n    const u = new URL(url, baseHref)\n\n    // Same origin as proxy → likely already proxied\n    if (u.origin === proxyBase.origin) return true\n\n    // Common query keys used by proxies\n    const sp = u.searchParams\n    if (sp && (sp.has('url') || sp.has('target'))) return true\n  } catch {}\n  return false\n}\n\n/** Decide whether to proxy based on origin/config */\nfunction shouldProxy(url, useProxy) {\n  if (!useProxy) return false\n  if (isSpecialURL(url)) return false\n  if (isAlreadyProxied(url, useProxy)) return false\n  try {\n    const base = (typeof location !== 'undefined' && location.href) ? location.href : 'http://localhost/'\n    const u = new URL(url, base)\n    return (typeof location !== 'undefined') ? (u.origin !== location.origin) : true\n  } catch {\n    // If URL can't be parsed but a proxy is configured, err on the side of proxying\n    return !!useProxy\n  }\n}\n\n/**\n * Apply proxy in multiple formats:\n * - Template: \"...?url={url}\" (query-encoded) or \"/proxy/{urlRaw}\" (path-style)\n * - Explicit query base: \"...?url=\" or matches /[?&]url=$/\n * - Ends with \"?\" → append \"url=\" before value (tests expect this)\n * - Path-style base: endsWith(\"/\")\n * - Fallback: append \"?url=\"\n */\nfunction applyProxy(url, useProxy) {\n  if (!useProxy) return url\n\n  // Template tokens\n  if (useProxy.includes('{url}')) {\n    return useProxy\n      .replace('{urlRaw}', safeEncodeURI(url))     // path-style (1.9.9 compatible)\n      .replace('{url}', encodeURIComponent(url))  // query-style\n  }\n\n  // Explicit query base\n  if (/[?&]url=?$/.test(useProxy)) {\n    return `${useProxy}${encodeURIComponent(url)}`\n  }\n  // Ends with '?' → tests want '?url=' prefix\n  if (useProxy.endsWith('?')) {\n    return `${useProxy}url=${encodeURIComponent(url)}`\n  }\n\n  // Path-style base\n  if (useProxy.endsWith('/')) {\n    return `${useProxy}${safeEncodeURI(url)}`     // DO NOT use encodeURIComponent here\n  }\n\n  // Fallback query param\n  const sep = useProxy.includes('?') ? '&' : '?'\n  return `${useProxy}${sep}url=${encodeURIComponent(url)}`\n}\n\nfunction blobToDataURL(blob) {\n  return new Promise((res, rej) => {\n    const fr = new FileReader()\n    fr.onload = () => res(String(fr.result || ''))\n    fr.onerror = () => rej(new Error('read_failed'))\n    fr.readAsDataURL(blob)\n  })\n}\n\nfunction makeKey(url, o) {\n  return [\n    o.as || 'blob',\n    o.timeout ?? 3000,\n    o.useProxy || '',\n    o.errorTTL ?? 8000,\n    url\n  ].join('|')\n}\n\n// ---------------------------------------------------------------------------\n// snapFetch\n// ---------------------------------------------------------------------------\n\n/**\n * Unified, non-throwing fetch with minimal, deduplicated logging.\n * @param {string} url\n * @param {SnapFetchOptions} [options]\n * @returns {Promise<SnapFetchResult>}\n */\nexport async function snapFetch(url, options = {}) {\n  const as = options.as ?? 'blob'\n  const timeout = options.timeout ?? 3000\n  const useProxy = options.useProxy || ''\n  const errorTTL = options.errorTTL ?? 8000\n  const headers = options.headers || {}\n  const silent = !!options.silent\n\n  // --- Special schemes: handle explicitly so tests expect data: outputs ---\n\n  // data:\n  if (/^data:/i.test(url)) {\n    try {\n      if (as === 'text') {\n        return { ok: true, data: String(url), status: 200, url, fromCache: false }\n      }\n      if (as === 'dataURL') {\n        return {\n          ok: true,\n          data: String(url),\n          status: 200,\n          url,\n          fromCache: false,\n          mime: String(url).slice(5).split(';')[0] || ''\n        }\n      }\n      // as === 'blob' → decode data: to Blob\n      const [, meta = '', data = ''] = String(url).match(/^data:([^,]*),(.*)$/) || []\n      const isBase64 = /;base64/i.test(meta)\n      const bin = isBase64 ? atob(data) : decodeURIComponent(data)\n      const bytes = new Uint8Array([...bin].map(c => c.charCodeAt(0)))\n      const b = new Blob([bytes], { type: (meta || '').split(';')[0] || '' })\n      return { ok: true, data: b, status: 200, url, fromCache: false, mime: b.type || '' }\n    } catch {\n      return { ok: false, data: null, status: 0, url, fromCache: false, reason: 'special_url_error' }\n    }\n  }\n\n  // blob:\n  if (/^blob:/i.test(url)) {\n    try {\n      const resp = await fetch(url)\n      if (!resp.ok) {\n        return { ok: false, data: null, status: resp.status, url, fromCache: false, reason: 'http_error' }\n      }\n      const blob = await resp.blob()\n      const mime = blob.type || resp.headers.get('content-type') || ''\n      if (as === 'dataURL') {\n        const dataURL = await blobToDataURL(blob)\n        return { ok: true, data: dataURL, status: resp.status, url, fromCache: false, mime }\n      }\n      if (as === 'text') {\n        const text = await blob.text()\n        return { ok: true, data: text, status: resp.status, url, fromCache: false, mime }\n      }\n      return { ok: true, data: blob, status: resp.status, url, fromCache: false, mime }\n    } catch {\n      // Do NOT memoize blob: failures — these are often transient (revocations)\n      return { ok: false, data: null, status: 0, url, fromCache: false, reason: 'network' }\n    }\n  }\n\n  // about:blank\n  if (/^about:blank$/i.test(url)) {\n    if (as === 'dataURL') {\n      return {\n        ok: true,\n        data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==',\n        status: 200,\n        url,\n        fromCache: false,\n        mime: 'image/png'\n      }\n    }\n    return { ok: true, data: as === 'text' ? '' : new Blob([]), status: 200, url, fromCache: false }\n  }\n\n  // ---- Normal http(s) path ----\n\n  const key = makeKey(url, { as, timeout, useProxy, errorTTL })\n\n  // Error cache\n  const e = _errorCache.get(key)\n  if (e && e.until > Date.now()) {\n    return { ...e.result, fromCache: true }\n  } else if (e) {\n    _errorCache.delete(key)\n  }\n\n  // Inflight dedupe\n  const inflight = _inflight.get(key)\n  if (inflight) return inflight\n\n  // Final URL (with robust proxying) & credentials\n  const finalURL = shouldProxy(url, useProxy) ? applyProxy(url, useProxy) : url\n\n  let cred = options.credentials\n  if (!cred) {\n    try {\n      const base = (typeof location !== 'undefined' && location.href) ? location.href : 'http://localhost/'\n      const u = new URL(url, base)\n      const sameOrigin = (typeof location !== 'undefined') && (u.origin === location.origin)\n      cred = sameOrigin ? 'include' : 'omit'\n    } catch {\n      cred = 'omit'\n    }\n  }\n\n  // Timeout controller\n  const ctrl = new AbortController()\n  const timer = setTimeout(() => ctrl.abort('timeout'), timeout)\n\n  const p = (async () => {\n    try {\n      const resp = await fetch(finalURL, { signal: ctrl.signal, credentials: cred, headers })\n\n      if (!resp.ok) {\n        const result = { ok: false, data: null, status: resp.status, url: finalURL, fromCache: false, reason: 'http_error' }\n        if (errorTTL > 0) _errorCache.set(key, { until: Date.now() + errorTTL, result })\n        if (!silent) {\n          const short = `${resp.status} ${resp.statusText || ''}`.trim()\n          snapLogger.warnOnce(\n            `http:${resp.status}:${as}:${(new URL(url, (location?.href ?? 'http://localhost/'))).origin}`,\n            `HTTP error ${short} while fetching ${as} ${url}`\n          )\n        }\n        options.onError && options.onError(result)\n        return result\n      }\n\n      if (as === 'text') {\n        const text = await resp.text()\n        return { ok: true, data: text, status: resp.status, url: finalURL, fromCache: false }\n      }\n\n      const blob = await resp.blob()\n      const mime = blob.type || resp.headers.get('content-type') || ''\n\n      if (as === 'dataURL') {\n        const dataURL = await blobToDataURL(blob)\n        return { ok: true, data: dataURL, status: resp.status, url: finalURL, fromCache: false, mime }\n      }\n\n      // default 'blob'\n      return { ok: true, data: blob, status: resp.status, url: finalURL, fromCache: false, mime }\n\n    } catch (err) {\n      const reason =\n        (err && typeof err === 'object' && 'name' in err && err.name === 'AbortError')\n          ? (String(err.message || '').includes('timeout') ? 'timeout' : 'abort')\n          : 'network'\n\n      const result = { ok: false, data: null, status: 0, url: finalURL, fromCache: false, reason }\n\n      // Persist HTTP network failures; avoid memoizing non-HTTP (handled above)\n      if (!/^blob:/i.test(url) && errorTTL > 0) {\n        _errorCache.set(key, { until: Date.now() + errorTTL, result })\n      }\n\n      if (!silent) {\n        const k = `${reason}:${as}:${(new URL(url, (location?.href ?? 'http://localhost/'))).origin}`\n        const tips = reason === 'timeout'\n          ? `Timeout after ${timeout}ms. Consider increasing timeout or using a proxy for ${url}`\n          : reason === 'abort'\n            ? `Request aborted while fetching ${as} ${url}`\n            : `Network/CORS issue while fetching ${as} ${url}. A proxy may be required`\n        snapLogger.errorOnce(k, tips)\n      }\n\n      options.onError && options.onError(result)\n      return result\n\n    } finally {\n      clearTimeout(timer)\n      _inflight.delete(key)\n    }\n  })()\n\n  _inflight.set(key, p)\n  return p\n}\n"
  },
  {
    "path": "src/modules/styles.js",
    "content": "import { getStyleKey, shouldIgnoreProp } from '../utils/index.js'\nimport { cache } from '../core/cache.js'\n\nconst snapshotCache = new WeakMap()\nconst snapshotKeyCache = new Map()\nlet __epoch = 0\nfunction bumpEpoch() { __epoch++ }\n\nexport function notifyStyleEpoch() { bumpEpoch() }\n\nlet __wired = false\nfunction setupInvalidationOnce(root = document.documentElement) {\n  if (__wired) return\n  __wired = true\n  try {\n    const domObs = new MutationObserver(() => bumpEpoch())\n    domObs.observe(root, { subtree: true, childList: true, characterData: true, attributes: true })\n  } catch { }\n  try {\n    const headObs = new MutationObserver(() => bumpEpoch())\n    headObs.observe(document.head, { subtree: true, childList: true, characterData: true, attributes: true })\n  } catch { }\n  try {\n    const f = document.fonts\n    if (f) {\n      f.addEventListener?.('loadingdone', bumpEpoch)\n      f.ready?.then(() => bumpEpoch()).catch(() => { })\n    }\n  } catch { }\n}\n\nfunction snapshotComputedStyleFull(style, options = {}) {\n  const out = {}\n  const vis = style.getPropertyValue('visibility')\n  const excludeStyleProps = options.excludeStyleProps\n  for (let i = 0; i < style.length; i++) {\n    const prop = style[i]\n    if (shouldIgnoreProp(prop)) continue\n    if (excludeStyleProps) {\n      if (excludeStyleProps instanceof RegExp && excludeStyleProps.test(prop)) continue\n      if (typeof excludeStyleProps === 'function' && excludeStyleProps(prop)) continue\n    }\n    let val = style.getPropertyValue(prop)\n    if ((prop === 'background-image' || prop === 'content') && val.includes('url(') && !val.includes('data:')) {\n      val = 'none'\n    }\n    out[prop] = val\n  }\n    // Asegurar props de decoración de texto (algunos motores no las listan en la iteración)\n  const EXTRA_TEXT_DECORATION_PROPS = [\n    'text-decoration-line',\n    'text-decoration-color',\n    'text-decoration-style',\n    'text-decoration-thickness',\n    'text-underline-offset',\n    'text-decoration-skip-ink'\n  ]\n  for (const prop of EXTRA_TEXT_DECORATION_PROPS) {\n    if (out[prop]) continue\n    try {\n      const v = style.getPropertyValue(prop)\n      if (v) out[prop] = v\n    } catch {}\n  }\n  // #340: -webkit-text-stroke en Safari – asegurar que se capture aunque no esté en la iteración\n  const TEXT_STROKE_PROPS = [\n    '-webkit-text-stroke',\n    '-webkit-text-stroke-width',\n    '-webkit-text-stroke-color',\n    'paint-order'\n  ]\n  for (const prop of TEXT_STROKE_PROPS) {\n    if (out[prop]) continue\n    try {\n      const v = style.getPropertyValue(prop)\n      if (v) out[prop] = v\n    } catch {}\n  }\n  if (options.embedFonts) {\n    const EXTRA_FONT_PROPS = [\n      'font-feature-settings',\n      'font-variation-settings',\n      'font-kerning',\n      'font-variant',\n      'font-variant-ligatures',\n      'font-optical-sizing',\n    ]\n    for (const prop of EXTRA_FONT_PROPS) {\n      if (out[prop]) continue\n      try {\n        const v = style.getPropertyValue(prop)\n        if (v) out[prop] = v\n      } catch { }\n    }\n  }\n  if (vis === 'hidden') out.opacity = '0'\n\n  // #362: Tailwind's * { border: 0 solid } renders incorrectly in capture.\n  // When all border widths are 0, normalize to border: none for unambiguous output.\n  const bt = parseFloat(style.getPropertyValue('border-top-width') || 0) || 0\n  const br = parseFloat(style.getPropertyValue('border-right-width') || 0) || 0\n  const bb = parseFloat(style.getPropertyValue('border-bottom-width') || 0) || 0\n  const bl = parseFloat(style.getPropertyValue('border-left-width') || 0) || 0\n  if (bt === 0 && br === 0 && bb === 0 && bl === 0) {\n    // If border-image is being used (even with zero border widths), do NOT force\n    // the shorthand `border: none` because it can override the intended rendering.\n    // (Decorative border-image + 0 widths is valid CSS in some setups.)\n    const bis = (style.getPropertyValue('border-image-source') || '').trim()\n    const hasBorderImage = bis && bis !== 'none'\n    const BORDER_PROPS = [\n      'border', 'border-top', 'border-right', 'border-bottom', 'border-left',\n      'border-width', 'border-style', 'border-color',\n      'border-top-width', 'border-top-style', 'border-top-color',\n      'border-right-width', 'border-right-style', 'border-right-color',\n      'border-bottom-width', 'border-bottom-style', 'border-bottom-color',\n      'border-left-width', 'border-left-style', 'border-left-color',\n      'border-block', 'border-block-width', 'border-block-style', 'border-block-color',\n      'border-inline', 'border-inline-width', 'border-inline-style', 'border-inline-color',\n    ]\n    for (const p of BORDER_PROPS) delete out[p]\n    if (!hasBorderImage) out['border'] = 'none'\n  }\n\n  return out\n}\nconst __snapshotSig = new WeakMap()\nfunction styleSignature(snap) {\n  let sig = __snapshotSig.get(snap)\n  if (sig) return sig\n  const entries = Object.entries(snap).sort((a, b) => a[0] < b[0] ? -1 : (a[0] > b[0] ? 1 : 0))\n  sig = entries.map(([k, v]) => `${k}:${v}`).join(';')\n  __snapshotSig.set(snap, sig)\n  return sig\n}\nfunction getSnapshot(el, preStyle = null, options = {}) {\n  const rec = snapshotCache.get(el)\n  if (rec && rec.epoch === __epoch) return rec.snapshot\n  const style = preStyle || getComputedStyle(el)\n  const snap = snapshotComputedStyleFull(style, options)\n  stripHeightForWrappers(el, style, snap)\n  snapshotCache.set(el, { epoch: __epoch, snapshot: snap })\n  return snap\n}\n\nfunction _resolveCtx(sessionOrCtx, opts) {\n  if (sessionOrCtx && sessionOrCtx.session && sessionOrCtx.persist) return sessionOrCtx\n  if (sessionOrCtx && (sessionOrCtx.styleMap || sessionOrCtx.styleCache || sessionOrCtx.nodeMap)) {\n    return {\n      session: sessionOrCtx,\n      persist: {\n        snapshotKeyCache,\n        defaultStyle: cache.defaultStyle,\n        baseStyle: cache.baseStyle,\n        image: cache.image,\n        resource: cache.resource,\n        background: cache.background,\n        font: cache.font,\n      },\n      options: opts || {},\n    }\n  }\n\n  return {\n    session: cache.session,\n    persist: {\n      snapshotKeyCache,\n      defaultStyle: cache.defaultStyle,\n      baseStyle: cache.baseStyle,\n      image: cache.image,\n      resource: cache.resource,\n      background: cache.background,\n      font: cache.font,\n    },\n    options: (sessionOrCtx || opts || {}),\n  }\n}\n\n/**\n * Replaces the clone's inline style with computed (cascade-resolved) values for each\n * property that was authored inline on the source. This ensures !important rules in\n * stylesheets correctly override inline styles in the clone (fixes #328).\n * @param {Element} source\n * @param {Element} clone\n * @param {CSSStyleDeclaration} computed\n */\nfunction normalizeInlineStyleToComputed(source, clone, computed) {\n  if (!source.style || source.style.length === 0) return\n  for (let i = 0; i < source.style.length; i++) {\n    const prop = source.style[i]\n    const val = computed.getPropertyValue(prop)\n    if (val) clone.style.setProperty(prop, val)\n  }\n}\n\nexport async function inlineAllStyles(source, clone, sessionOrCtx, opts) {\n  if (source.tagName === 'STYLE') return\n\n  const ctx = _resolveCtx(sessionOrCtx, opts)\n  const resetMode = (ctx.options && ctx.options.cache) || 'auto'\n\n  if (resetMode !== 'disabled') setupInvalidationOnce(document.documentElement)\n\n  if (resetMode === 'disabled' && !ctx.session.__bumpedForDisabled) {\n    bumpEpoch()\n    snapshotKeyCache.clear()\n    ctx.session.__bumpedForDisabled = true\n  }\n\n  const { session, persist } = ctx\n\n  if (!session.styleCache.has(source)) {\n    session.styleCache.set(source, getComputedStyle(source))\n  }\n  const pre = session.styleCache.get(source)\n\n  // Replace authored inline style with computed values so !important in stylesheets\n  // correctly overrides inline styles in the clone (fixes #328)\n  if (source.getAttribute?.('style')) {\n    normalizeInlineStyleToComputed(source, clone, pre)\n  }\n\n  const snap = getSnapshot(source, pre, ctx.options)\n\n  const sig = styleSignature(snap)\n  let key = persist.snapshotKeyCache.get(sig)\n  if (!key) {\n    const tag = source.tagName?.toLowerCase() || 'div'\n    key = getStyleKey(snap, tag)\n    persist.snapshotKeyCache.set(sig, key)\n  }\n  session.styleMap.set(clone, key)\n}\n/**\n * @param {Element} el\n * @returns {boolean}\n */\nfunction isReplaced(el) {\n  return el instanceof HTMLImageElement ||\n         el instanceof HTMLCanvasElement ||\n         el instanceof HTMLVideoElement ||\n         el instanceof HTMLIFrameElement ||\n         el instanceof SVGElement ||\n         el instanceof HTMLObjectElement ||\n         el instanceof HTMLEmbedElement\n}\n\n/**\n * Caja “visual”: bg/border/padding u overflow ≠ visible.\n * @param {CSSStyleDeclaration} cs\n */\nfunction hasBox(cs) {\n  if (cs.backgroundImage && cs.backgroundImage !== 'none') return true\n  if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') return true\n  if ((parseFloat(cs.borderTopWidth) || 0) > 0) return true\n  if ((parseFloat(cs.borderBottomWidth) || 0) > 0) return true\n  if ((parseFloat(cs.paddingTop) || 0) > 0) return true\n  if ((parseFloat(cs.paddingBottom) || 0) > 0) return true\n  const ob = cs.overflowBlock || cs.overflowY || 'visible'\n  return ob !== 'visible'\n}\n\n/**\n * Item de flex/grid (mirando display del padre, 1 getComputedStyle).\n * @param {Element} el\n */\nfunction isFlexOrGridItem(el) {\n  const p = el.parentElement\n  if (!p) return false\n  const pd = getComputedStyle(p).display || ''\n  return pd.includes('flex') || pd.includes('grid')\n}\n\n/**\n * ¿Hay contenido en flujo? Versión rápida:\n *  - Texto no vacío → true (no dispara layout).\n *  - <br> inmediato → true.\n *  - Geometry probe: scrollHeight > padding (abspos NO suma) → true.\n * @param {Element} el\n * @param {CSSStyleDeclaration} cs  // ya lo tenemos en mano\n */\nfunction hasFlowFast(el, cs) {\n  if (el.textContent && /\\S/.test(el.textContent)) return true\n  const f = el.firstElementChild, l = el.lastElementChild\n  if ((f && f.tagName === 'BR') || (l && l.tagName === 'BR')) return true\n\n  // Probe geométrico (1 lectura de layout): evita recorrer hijos\n  // Nota: scrollHeight no incluye hijos absolute; si sólo hay absolute → ≈ padding\n  const sh = el.scrollHeight\n  if (sh === 0) return false\n  const pt = parseFloat(cs.paddingTop) || 0\n  const pb = parseFloat(cs.paddingBottom) || 0\n  return sh > pt + pb\n}\n\n/**\n * Best-effort: quita height/block-size en wrappers transparentes de flujo para permitir\n * margin-collapsing, etc. sin romper KaTeX, Orbit, ni layouts con height explícito.\n *\n * @param {Element} el\n * @param {CSSStyleDeclaration} cs\n * @param {Record<string, any>} snap\n */\nfunction stripHeightForWrappers(el, cs, snap) {\n  // 1) Respeta height inline del autor\n  if (el instanceof HTMLElement && el.style && el.style.height) return\n\n  // 2) Solo div/section/article/main/aside/header/footer/nav (no ol/ul/li: layout de listas)\n  const tag = el.tagName && el.tagName.toLowerCase()\n  const ALLOWED_TAGS = ['div', 'section', 'article', 'main', 'aside', 'header', 'footer', 'nav']\n  if (!tag || !ALLOWED_TAGS.includes(tag)) return\n\n  // 2b) Solo quitar si height parece \"auto\" (≈scrollHeight); si difiere, el autor lo fijó\n  const usedH = parseFloat(cs.height)\n  const TOL = 2\n  if (Number.isFinite(usedH) && el.scrollHeight > 0 && Math.abs(usedH - el.scrollHeight) > TOL) return\n\n  // 2c) aspect-ratio define dimensiones derivadas; respetar\n  if (cs.aspectRatio && cs.aspectRatio !== 'none' && cs.aspectRatio !== 'auto') return\n\n  // 3) Orbit: si el elemento es flex/grid, no tocar su height\n  const disp = cs.display || ''\n  if (disp.includes('flex') || disp.includes('grid')) return\n\n  // 4) Guardas existentes\n  if (isReplaced(el)) return\n\n  const pos = cs.position\n  if (pos === 'absolute' || pos === 'fixed' || pos === 'sticky') return\n  if (cs.transform !== 'none') return\n  if (hasBox(cs)) return\n  if (isFlexOrGridItem(el)) return\n\n  // 5) No tocar wrappers que se usan para ocultar / accesibilidad (KaTeX, screen-reader hacks, etc.)\n  const overflowX = cs.overflowX || cs.overflow || 'visible'\n  const overflowY = cs.overflowY || cs.overflow || 'visible'\n  if (overflowX !== 'visible' || overflowY !== 'visible') return\n\n  const clip = cs.clip\n  if (clip && clip !== 'auto' && clip !== 'rect(auto, auto, auto, auto)') return\n\n  if (cs.visibility === 'hidden' || cs.opacity === '0') return\n\n  // 6) Solo wrappers \"en flujo\" realmente neutros\n  if (!hasFlowFast(el, cs)) return\n\n  // 7) Ahora sí: quitamos height y block-size del snapshot\n  delete snap.height\n  delete snap['block-size']\n}\n"
  },
  {
    "path": "src/modules/svgDefs.js",
    "content": "/**\n * Inline external <defs> and <symbol> dependencies needed by an SVG subtree (or multiple SVGs),\n * so that serialization does not break. Handles:\n *  1) <use href=\"#...\"> targets (symbols/defs)\n *  2) Attributes/inline styles that reference url(#id) (gradients, patterns, filters, clipPath, mask, marker-*)\n *  3) Recursive chains via href/xlink:href and nested url(#...) inside cloned defs\n *\n * Fast path: no computed styles, no layout reads. Only DOM queries + cloning.\n *\n * @param {Element} element - SVG root or container holding one/more SVGs.\n * @param {Document|ParentNode} [lookupRoot] - Where to search for external defs/symbols (defaults to element.ownerDocument).\n */\nexport function inlineExternalDefsAndSymbols(element, lookupRoot) {\n  if (!element || !(element instanceof Element)) return\n\n  const doc = element.ownerDocument || document\n  const searchRoot = lookupRoot || doc\n\n  /** Collect all SVG roots under element (or element if it's an <svg>) */\n  const svgRoots =\n    element instanceof SVGSVGElement\n      ? [element]\n      : Array.from(element.querySelectorAll('svg'))\n\n  if (svgRoots.length === 0) return\n\n  const URL_ID_RE = /url\\(\\s*#([^)]+)\\)/g\n  const URL_ATTRS = [\n    'fill', 'stroke', 'filter', 'clip-path', 'mask',\n    'marker', 'marker-start', 'marker-mid', 'marker-end'\n  ]\n\n  const cssEscape = (s) =>\n    (window.CSS && CSS.escape) ? CSS.escape(s) : s.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&')\n\n  const XLINK_NS = 'http://www.w3.org/1999/xlink'\n\n  /**\n   * Robustly get any SVG href-like attribute, including namespaced ones:\n   *  - href\n   *  - xlink:href\n   *  - getAttributeNS(xlinkNS, 'href')\n   *  - any other prefix:*:href (e.g. ns1:href used by some serializers)\n   * @param {Element} el\n   * @returns {string|null}\n   */\n  const getHrefAttr = (el) => {\n    if (!el || !el.getAttribute) return null\n\n    let href =\n      el.getAttribute('href') ||\n      el.getAttribute('xlink:href') ||\n      (typeof el.getAttributeNS === 'function'\n        ? el.getAttributeNS(XLINK_NS, 'href')\n        : null)\n\n    if (href) return href\n\n    // Fallback: scan any prefix:href attributes (e.g. ns1:href)\n    const attrs = el.attributes\n    if (!attrs) return null\n    for (let i = 0; i < attrs.length; i++) {\n      const a = attrs[i]\n      if (!a || !a.name) continue\n      if (a.name === 'href') return a.value\n      const idx = a.name.indexOf(':')\n      if (idx !== -1 && a.name.slice(idx + 1) === 'href') {\n        return a.value\n      }\n    }\n    return null\n  }\n\n  /** IDs ya presentes en TODO el contenedor root (no solo por-svg) */\n  const globalExistingIds = new Set(\n    Array.from(element.querySelectorAll('[id]')).map(n => n.id)\n  )\n\n  /** IDs referenciados (por cualquiera de los svgRoots) que no están locales aún */\n  const neededIds = new Set()\n\n  /** Flag para saber si hubo referencias (aunque luego no existan matches) */\n  let sawAnyReference = false\n\n  /**\n   * Extrae ids de url(#id) de un valor de atributo/inline style.\n   * Opcionalmente también encola los ids para resolución recursiva.\n   * @param {string|null} val\n   * @param {Set<string>|null} queueForResolve\n   */\n  const addUrlIdsFromValue = (val, queueForResolve = null) => {\n    if (!val) return\n    URL_ID_RE.lastIndex = 0\n    let m\n    while ((m = URL_ID_RE.exec(val))) {\n      sawAnyReference = true\n      const id = (m[1] || '').trim()\n      if (!id) continue\n\n      if (!globalExistingIds.has(id)) {\n        neededIds.add(id)\n        if (queueForResolve && !queueForResolve.has(id)) {\n          queueForResolve.add(id)\n        }\n      }\n    }\n  }\n\n  const collectReferencesInSvg = (rootSvg) => {\n    // <use ...href=\"#...\"> (cualquier namespace/prefix)\n    const uses = rootSvg.querySelectorAll('use')\n    for (const u of uses) {\n      const href = getHrefAttr(u)\n      if (!href || !href.startsWith('#')) continue\n      sawAnyReference = true\n      const id = href.slice(1).trim()\n      if (id && !globalExistingIds.has(id)) neededIds.add(id)\n    }\n\n    // url(#...) en attrs/estilos\n    const query =\n      '*[style*=\"url(\"],' +\n      '*[fill^=\"url(\"], *[stroke^=\"url(\"],*[filter^=\"url(\"],' +\n      '*[clip-path^=\"url(\"],*[mask^=\"url(\"],*[marker^=\"url(\"],' +\n      '*[marker-start^=\"url(\"],*[marker-mid^=\"url(\"],*[marker-end^=\"url(\"]'\n\n    const candidates = rootSvg.querySelectorAll(query)\n    for (const el of candidates) {\n      addUrlIdsFromValue(el.getAttribute('style') || '')\n      for (const a of URL_ATTRS) addUrlIdsFromValue(el.getAttribute(a))\n    }\n  }\n\n  // 1) Recolectar referencias de TODOS los svgRoots con dedupe global\n  for (const svg of svgRoots) collectReferencesInSvg(svg)\n\n  // 2) Si no hay referencias, no crear contenedor (cumple test \"does nothing...\")\n  if (!sawAnyReference) return\n\n  // 3) Crear (o reutilizar) un ÚNICO contenedor oculto en 'element'\n  let defsHost = element.querySelector('svg.inline-defs-container')\n  if (!defsHost) {\n    defsHost = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')\n    defsHost.classList.add('inline-defs-container')\n    defsHost.setAttribute('aria-hidden', 'true')\n    defsHost.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden')\n    element.insertBefore(defsHost, element.firstChild || null)\n  }\n  let localDefs = defsHost.querySelector('defs') || null\n\n  // 4) Resolver externos; nunca tomar fuentes que ya estén dentro de 'element'\n  const findGlobalById = (id) => {\n    if (!id) return null\n    if (globalExistingIds.has(id)) return null // ya local en root\n    const esc = cssEscape(id)\n\n    const tryFind = (sel) => {\n      const el = searchRoot.querySelector(sel)\n      // si la fuente ya está dentro del contenedor root, no es \"externa\"\n      return el && !element.contains(el) ? el : null\n    }\n\n    return (\n      tryFind(`svg defs > *#${esc}`) ||\n      tryFind(`svg > symbol#${esc}`) ||\n      tryFind(`*#${esc}`)\n    )\n  }\n\n  // 5) Si no hay matches globales, igual mantenemos el contenedor vacío (cumple test final)\n  if (!neededIds.size) return\n\n  const queued = new Set(neededIds)\n  const inlined = new Set()\n\n  while (queued.size) {\n    const id = queued.values().next().value\n    queued.delete(id)\n\n    if (!id || globalExistingIds.has(id) || inlined.has(id)) continue\n\n    const source = findGlobalById(id)\n    if (!source) { // no existe externo o ya local en root\n      inlined.add(id)\n      continue\n    }\n\n    // Crear <defs> on-demand (solo si de verdad vamos a insertar algo)\n    if (!localDefs) {\n      localDefs = doc.createElementNS('http://www.w3.org/2000/svg', 'defs')\n      defsHost.appendChild(localDefs)\n    }\n\n    const clone = source.cloneNode(true)\n    if (!clone.id) clone.setAttribute('id', id)\n    localDefs.appendChild(clone)\n    inlined.add(id)\n    globalExistingIds.add(id)\n\n    // Seguir dependencias internas del clon (recursivo, dedupe global)\n    const walk = [clone, ...clone.querySelectorAll('*')]\n    for (const node of walk) {\n      const href = getHrefAttr(node)\n      if (href && href.startsWith('#')) {\n        const ref = href.slice(1).trim()\n        if (ref && !globalExistingIds.has(ref) && !inlined.has(ref)) {\n          queued.add(ref)\n        }\n      }\n\n      const style = node.getAttribute?.('style') || ''\n      if (style) addUrlIdsFromValue(style, queued)\n\n      for (const a of URL_ATTRS) {\n        const v = node.getAttribute?.(a)\n        if (v) addUrlIdsFromValue(v, queued)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/utils/browser.js",
    "content": "/**\n * Creates a promise that resolves after the specified delay\n * @param {number} [ms=0] - Milliseconds to delay\n * @returns {Promise<void>} Promise that resolves after the delay\n */\n\nexport function idle(fn, { fast = false } = {}) {\n  if (fast) return fn()\n  if ('requestIdleCallback' in window) {\n    requestIdleCallback(fn, { timeout: 50 })\n  } else {\n    setTimeout(fn, 1)\n  }\n}\n\nexport function isIOS() {\n  if (typeof navigator === 'undefined') return false\n  if (navigator.userAgentData) {\n    return navigator.userAgentData.platform === 'iOS'\n  }\n\n  // Usually iOS comes up with iPad/iPod/iPhone as USA\n  const ua = navigator.userAgent || ''\n  const isAppleMobile = /iPhone|iPad|iPod/.test(ua)\n  // Check if touch is enabled\n  const isIPadOS = navigator.maxTouchPoints > 2 && /Macintosh/.test(ua)\n  return isAppleMobile || isIPadOS\n}\n\nexport function isSafari() {\n  if (typeof navigator === 'undefined') return false\n  const ua = navigator.userAgent || ''\n  const uaLower = ua.toLowerCase()\n\n  // Safari desktop/mobile UA, excluding Chrome iOS, Firefox iOS and Android browsers\n  const isSafariUA =\n    uaLower.includes('safari') &&\n    !uaLower.includes('chrome') &&\n    !uaLower.includes('crios') &&   // Chrome on iOS\n    !uaLower.includes('fxios') &&   // Firefox on iOS\n    !uaLower.includes('android')\n\n  // Generic WebKit-based engines (UIWebView / WKWebView)\n  const isWebKit = /applewebkit/i.test(ua)\n  const isMobile = /mobile/i.test(ua)\n  const missingSafariToken = !/safari/i.test(ua)\n\n  // iOS UIWebView or WKWebView inside apps (in-app browsers)\n  const isUIWebView = isWebKit && isMobile && missingSafariToken\n\n  // WeChat / WeCom embedded browsers on iOS\n  const isWeChatUA =\n    /(micromessenger|wxwork|wecom|windowswechat|macwechat)/i.test(ua)\n\n  // Baidu app browsers on iOS (BaiduBoxApp, BaiduBrowser, etc.)\n  const isBaiduUA =\n    /(baiduboxapp|baidubrowser|baidusearch|baiduboxlite)/i.test(uaLower)\n\n  // On iOS, all browsers use WebKit as the rendering engine (WKWebView)\n  // If the device is iOS and uses WebKit, treat it as Safari-equivalent\n  const isIOSWebKit =\n    /ipad|iphone|ipod/.test(uaLower) && isWebKit\n\n  return isSafariUA || isUIWebView || isWeChatUA || isBaiduUA || isIOSWebKit\n}\n\nexport function isFirefox() {\n  if (typeof navigator === 'undefined') return false\n  const ua = (navigator.userAgent || '').toLowerCase()\n  return ua.includes('firefox') || ua.includes('fxios')\n}\n"
  },
  {
    "path": "src/utils/capture.helpers.js",
    "content": "/**\n * Helper utilities for DOM capture operations\n * @module utils/capture.helpers\n */\n\nimport { debugWarn } from './index.js'\n\n/**\n * Strip shadow-like visuals on the CLONE ROOT ONLY (box/text-shadow, outline, blur()/drop-shadow()).\n * Children remain intact.\n * @param {Element} originalEl\n * @param {HTMLElement} cloneRoot\n * @param {Object} [opts] - optional { debug } for verbose logging\n */\nexport function stripRootShadows(originalEl, cloneRoot, opts = {}) {\n  if (!originalEl || !cloneRoot || !cloneRoot.style) return\n  const cs = getComputedStyle(originalEl)\n  try { cloneRoot.style.boxShadow = 'none' } catch (e) { debugWarn(opts, 'stripRootShadows boxShadow', e) }\n  try { cloneRoot.style.textShadow = 'none' } catch (e) { debugWarn(opts, 'stripRootShadows textShadow', e) }\n  try { cloneRoot.style.outline = 'none' } catch (e) { debugWarn(opts, 'stripRootShadows outline', e) }\n  const f = cs.filter || ''\n  const cleaned = f\n    .replace(/\\bblur\\([^()]*\\)\\s*/gi, '')\n    .replace(/\\bdrop-shadow\\([^()]*\\)\\s*/gi, '')\n    .trim()\n    .replace(/\\s+/g, ' ')\n  try { cloneRoot.style.filter = cleaned.length ? cleaned : 'none' } catch (e) {\n    debugWarn(opts, 'stripRootShadows filter', e)\n  }\n}\n\n/** Remove all HTML comments (prevents invalid XML like \"--\") */\nexport function removeAllComments(root) {\n  const it = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT)\n  const toRemove = []\n  while (it.nextNode()) toRemove.push(it.currentNode)\n  for (const n of toRemove) n.remove()\n}\n\n/**\n * Sanitize attributes to produce valid XHTML inside foreignObject.\n * - Drop \"@\", unknown \":\" prefixes\n * - Drop common framework directives (x-*, v-*, :*, on:*, bind:*, let:*, class:*)\n */\nexport function sanitizeAttributesForXHTML(root, opts = {}) {\n  const { stripFrameworkDirectives = true } = opts\n  const ALLOWED_PREFIXES = new Set(['xml', 'xlink'])\n\n  const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)\n  while (walker.nextNode()) {\n    const el = walker.currentNode\n    // Copy first—NamedNodeMap is live\n    for (const attr of Array.from(el.attributes)) {\n      const name = attr.name\n\n      // \"@\": never valid in XML attribute names\n      if (name.includes('@')) { el.removeAttribute(name); continue }\n\n      // \":\" requires a declared namespace (xml:, xlink:)\n      if (name.includes(':')) {\n        const prefix = name.split(':', 1)[0]\n        if (!ALLOWED_PREFIXES.has(prefix)) { el.removeAttribute(name); continue }\n      }\n\n      if (!stripFrameworkDirectives) continue\n\n      // Common framework directives that break XHTML\n      if (\n        name.startsWith('x-') ||     // Alpine\n        name.startsWith('v-') ||     // Vue\n        name.startsWith(':') ||      // Vue/Alpine shorthand\n        name.startsWith('on:') ||    // Svelte\n        name.startsWith('bind:') ||  // Svelte\n        name.startsWith('let:') ||   // Svelte\n        name.startsWith('class:')    // Svelte\n      ) {\n        el.removeAttribute(name)\n        continue\n      }\n    }\n  }\n}\n\nexport function sanitizeCloneForXHTML(root, opts = {}) {\n  if (!root) return\n  sanitizeAttributesForXHTML(root, opts)\n  removeAllComments(root)\n}\n\n/**\n * Returns true if the author explicitly set any size inline on the source element.\n * We avoid overriding author-intended sizing.\n * @param {Element} el\n */\nfunction authorHasExplicitSize(el) {\n  try {\n    const s = el.getAttribute?.('style') || ''\n    return /\\b(height|width|block-size|inline-size)\\s*:/.test(s)\n  } catch { return false }\n}\n\n/**\n * Replaced elements (img, canvas, video, iframe, svg, object, embed) have intrinsic sizing;\n * we do not auto-shrink them here.\n * @param {Element} el\n */\nfunction isReplacedElement(el) {\n  return el instanceof HTMLImageElement ||\n    el instanceof HTMLCanvasElement ||\n    el instanceof HTMLVideoElement ||\n    el instanceof HTMLIFrameElement ||\n    el instanceof SVGElement ||\n    el instanceof HTMLObjectElement ||\n    el instanceof HTMLEmbedElement\n}\n\n/**\n * Minimal heuristic: shrink only \"normal flow\" boxes without explicit author sizing,\n * avoiding fragile layouts (flex/grid/absolute/fixed/sticky/transformed).\n * @param {Element} srcEl\n * @param {CSSStyleDeclaration} cs\n */\nfunction shouldShrinkBox(srcEl, cs) {\n  if (!(srcEl instanceof Element)) return false\n  if (authorHasExplicitSize(srcEl)) return false\n  if (isReplacedElement(srcEl)) return false\n\n  const pos = cs.position\n  if (pos === 'absolute' || pos === 'fixed' || pos === 'sticky') return false\n\n  const disp = cs.display || ''\n  if (disp.includes('flex') || disp.includes('grid') || disp.startsWith('table')) return false\n\n  if (cs.transform && cs.transform !== 'none') return false\n\n  return true\n}\n\n/**\n * Post-clone \"shrink pass\": for parents that lost children due to excludeMode:\"remove\",\n * override snapshot sizes so they can collapse naturally.\n *\n * It writes inline overrides on the CLONE (never the real DOM):\n *   - height/width: auto\n *   - remove logical sizes (block-size/inline-size)\n *   - relax min/max to allow collapse\n *\n * @param {Element} sourceRoot - original subtree root (for reading computed styles)\n * @param {HTMLElement} cloneRoot - cloned subtree root (to write overrides)\n * @param {Map<Element, CSSStyleDeclaration>} styleCache - optional cache you already build\n */\nexport function shrinkAutoSizeBoxes(sourceRoot, cloneRoot, styleCache = new Map()) {\n  /**\n   * @param {Element} src\n   * @param {Element} cln\n   */\n  function walk(src, cln) {\n    if (!(src instanceof Element) || !(cln instanceof Element)) return\n\n    // If the clone lost children relative to the source, it's a good candidate to shrink.\n    const lostKids = src.childElementCount > cln.childElementCount\n\n    const cs = styleCache.get(src) || getComputedStyle(src)\n    if (!styleCache.has(src)) styleCache.set(src, cs)\n\n    if (lostKids && shouldShrinkBox(src, cs)) {\n      // Inline overrides beat generated classes -> safe, local to the clone.\n      if (!cln.style.height) cln.style.height = 'auto'\n      if (!cln.style.width) cln.style.width = 'auto'\n\n      cln.style.removeProperty('block-size')\n      cln.style.removeProperty('inline-size')\n\n      if (!cln.style.minHeight) cln.style.minHeight = '0'\n      if (!cln.style.minWidth) cln.style.minWidth = '0'\n      if (!cln.style.maxHeight) cln.style.maxHeight = 'none'\n      if (!cln.style.maxWidth) cln.style.maxWidth = 'none'\n\n      // Ensure the box can actually reveal its new size\n      // (only when author didn't lock overflow intentionally)\n      const oy = cs.overflowY || cs.overflowBlock || 'visible'\n      const ox = cs.overflowX || cs.overflowInline || 'visible'\n      if (oy !== 'visible' || ox !== 'visible') {\n        cln.style.overflow = 'visible'\n      }\n    }\n\n    // Walk element children in order (pseudo wrappers are already inlined elsewhere)\n    const sKids = Array.from(src.children)\n    const cKids = Array.from(cln.children)\n    for (let i = 0; i < Math.min(sKids.length, cKids.length); i++) {\n      walk(sKids[i], cKids[i])\n    }\n  }\n\n  walk(sourceRoot, cloneRoot)\n}\n\n/**\n * True if the element contributes to its parent's height (block, float, sticky, etc.).\n * Excludes only position absolute/fixed and display:none.\n * @param {Element} el\n */\nfunction contributesToParentHeight(el) {\n  const cs = getComputedStyle(el)\n  if (cs.display === 'none') return false\n  if (cs.position === 'absolute' || cs.position === 'fixed') return false\n  return true\n}\n\n/**\n * Mirrors the removal logic used later so we can know what remains.\n * Extend to honor filterMode:\"remove\" if needed.\n * @param {Element} el\n * @param {any} options\n */\nfunction willBeExcluded(el, options) {\n  if (!(el instanceof Element)) return false\n  if (el.getAttribute('data-capture') === 'exclude' && options?.excludeMode === 'remove') return true\n  if (Array.isArray(options?.exclude)) {\n    for (const sel of options.exclude) {\n    try { if (el.matches(sel)) return options.excludeMode === 'remove' } catch (e) {\n      debugWarn(options, 'exclude selector match failed', e)\n    }\n  }\n  }\n  return false\n}\n\n/**\n * Compute the kept-children vertical span inside container's content box.\n * We take the min(top) and max(bottom) of included, in-flow children,\n * then add container paddings and borders to rebuild total height.\n * This avoids double-counting collapsed margins.\n * @param {Element} container\n * @param {any} options\n * @returns {number} estimated outerHeight (border+padding+content)\n */\nexport function estimateKeptHeight(container, options) {\n  const csC = getComputedStyle(container)\n  const rC = container.getBoundingClientRect()\n\n  let minTop = Infinity\n  let maxBottom = -Infinity\n  let found = false\n\n  // Consider only direct children; incluir floats (contribuyen a la altura del contenedor)\n  const kids = Array.from(container.children)\n  for (const k of kids) {\n    if (willBeExcluded(k, options)) continue\n    if (!contributesToParentHeight(k)) continue\n    const rk = k.getBoundingClientRect()\n    // usar coordenadas relativas al contenedor\n    const top = rk.top - rC.top\n    const bottom = rk.bottom - rC.top\n    if (bottom <= top) continue\n    if (top < minTop) minTop = top\n    if (bottom > maxBottom) maxBottom = bottom\n    found = true\n  }\n\n  // content span de lo que queda\n  const contentSpan = found ? Math.max(0, maxBottom - minTop) : 0\n\n  // reconstruir altura outer: border + padding + contenido\n  const bt = parseFloat(csC.borderTopWidth) || 0\n  const bb = parseFloat(csC.borderBottomWidth) || 0\n  const pt = parseFloat(csC.paddingTop) || 0\n  const pb = parseFloat(csC.paddingBottom) || 0\n\n  return bt + bb + pt + pb + contentSpan\n}\n\nexport const limitDecimals = (v, n = 3) =>\n  Number.isFinite(v) ? Math.round(v * 10 ** n) / 10 ** n : v\n\n/** Match ::-webkit-scrollbar and related pseudos (#334) */\nconst SCROLLBAR_PSEUDO = /::-webkit-scrollbar(-[a-z]+)?\\b/i\n\n/**\n * Recursively collect CSS rules that contain ::-webkit-scrollbar selectors.\n * Fixes #334: custom scrollbar styles now apply in capture.\n * @param {CSSRuleList} rules\n * @param {Set<string>} seen - dedupe by cssText\n * @returns {string}\n */\nfunction collectScrollbarRulesFromRules(rules, seen = new Set()) {\n  let out = ''\n  if (!rules) return out\n  for (let i = 0; i < rules.length; i++) {\n    const rule = rules[i]\n    try {\n      if (rule.type === CSSRule.IMPORT_RULE && rule.styleSheet) {\n        out += collectScrollbarRulesFromRules(rule.styleSheet.cssRules, seen)\n        continue\n      }\n      if (rule.type === CSSRule.MEDIA_RULE && rule.cssRules) {\n        const inner = collectScrollbarRulesFromRules(rule.cssRules, seen)\n        if (inner) out += `@media ${rule.conditionText}{${inner}}`\n        continue\n      }\n      if (rule.type === CSSRule.STYLE_RULE) {\n        const sel = rule.selectorText || ''\n        if (SCROLLBAR_PSEUDO.test(sel)) {\n          const text = rule.cssText\n          if (text && !seen.has(text)) {\n            seen.add(text)\n            out += text\n          }\n        }\n      }\n    } catch {\n      // CORS or invalid rule; skip\n    }\n  }\n  return out\n}\n\n/**\n * Extract ::-webkit-scrollbar rules from the document's stylesheets.\n * Used so custom scrollbar styling appears in capture (#334).\n * @param {Document} doc\n * @returns {string}\n */\nexport function collectScrollbarCSS(doc) {\n  if (!doc || !doc.styleSheets) return ''\n  const seen = new Set()\n  let out = ''\n  for (const sheet of Array.from(doc.styleSheets)) {\n    try {\n      const rules = sheet.cssRules\n      if (rules) out += collectScrollbarRulesFromRules(rules, seen)\n    } catch {\n      // Cross-origin stylesheet; cannot read cssRules\n    }\n  }\n  return out\n}\n"
  },
  {
    "path": "src/utils/clone.helpers.js",
    "content": "/**\n * Helper utilities for DOM cloning operations\n * @module utils/clone.helpers\n */\n\nimport { idle, debugWarn } from './index.js'\nimport { cache, EvictingMap } from '../core/cache.js'\nimport { snapFetch } from '../modules/snapFetch.js'\nimport { inlineAllStyles } from '../modules/styles.js'\n\n/**\n * Schedule work across idle slices without relying on IdleDeadline constructor.\n * Falls back to setTimeout on browsers without requestIdleCallback.\n * @param {Node[]} childList\n * @param {(child: Node, done: () => void) => void} callback\n * @param {boolean} fast\n * @returns {Promise<(Node|null)[]>}\n */\nexport function idleCallback(childList, callback, fast) {\n  return Promise.all(childList.map((child) => {\n    return new Promise((resolve) => {\n      function deal() {\n        idle((deadline) => {\n          // Safari iOS doesn't expose IdleDeadline constructor; duck-type it instead\n          const hasIdleBudget = deadline && typeof deadline.timeRemaining === 'function'\n            ? deadline.timeRemaining() > 0\n            : true // setTimeout path or unknown object\n\n          if (hasIdleBudget) {\n            callback(child, resolve)\n          } else {\n            deal()\n          }\n        }, { fast })\n      }\n      deal()\n    })\n  }))\n}\n\n/**\n * Add :not([data-sd-slotted]) at the rightmost compound of a selector.\n * Very safe approximation: append at the end.\n */\nfunction addNotSlottedRightmost(sel) {\n  sel = sel.trim()\n  if (!sel) return sel\n  // Evitar duplicar si ya está\n  if (/:not\\(\\s*\\[data-sd-slotted\\]\\s*\\)\\s*$/.test(sel)) return sel\n  return `${sel}:not([data-sd-slotted])`\n}\n\n/**\n * Wrap a selector list with :where(scope ...), lowering specificity to 0.\n * Optionally excludes slotted elements on the rightmost selector.\n */\nfunction wrapWithScope(selectorList, scopeSelector, excludeSlotted = true) {\n  return selectorList\n    .split(',')\n    .map(s => s.trim())\n    .filter(Boolean)\n    .map(s => {\n      // Si ya fue reescrito como :where(...), no lo toques\n      if (s.startsWith(':where(')) return s\n\n      // No toques @rules aquí (esto se hace en el caller)\n      if (s.startsWith('@')) return s\n\n      const body = excludeSlotted ? addNotSlottedRightmost(s) : s\n      // Especificidad 0 para todo el selector:\n      return `:where(${scopeSelector} ${body})`\n    })\n    .join(', ')\n}\n\n/**\n * Rewrite Shadow DOM selectors to a flat, host-scoped form with specificity 0.\n * - :host(.foo)           => :where([data-sd=\"sN\"]:is(.foo))\n * - :host                 => :where([data-sd=\"sN\"])\n * - ::slotted(X)          => :where([data-sd=\"sN\"] X)              (no excluye sloteados)\n * - (resto, p.ej. .button)=> :where([data-sd=\"sN\"] .button:not([data-sd-slotted]))\n * - :host-context(Y)      => :where(:where(Y) [data-sd=\"sN\"])      (aprox)\n */\nexport function rewriteShadowCSS(cssText, scopeSelector) {\n  if (!cssText) return ''\n\n  // 1) :host(.foo) y :host\n  cssText = cssText.replace(/:host\\(([^)]+)\\)/g, (_, sel) => {\n    return `:where(${scopeSelector}:is(${sel.trim()}))`\n  })\n  cssText = cssText.replace(/:host\\b/g, `:where(${scopeSelector})`)\n\n  // 2) :host-context(Y)\n  cssText = cssText.replace(/:host-context\\(([^)]+)\\)/g, (_, sel) => {\n    return `:where(:where(${sel.trim()}) ${scopeSelector})`\n  })\n\n  // 3) ::slotted(X) → descendiente dentro del scope, sin excluir sloteados\n  cssText = cssText.replace(/::slotted\\(([^)]+)\\)/g, (_, sel) => {\n    return `:where(${scopeSelector} ${sel.trim()})`\n  })\n\n  // 4) Por cada bloque de selectores \"suelto\", envolver con :where(scope …)\n  //    y excluir sloteados en el rightmost (:not([data-sd-slotted])).\n  cssText = cssText.replace(/(^|})(\\s*)([^@}{]+){/g, (_, brace, ws, selectorList) => {\n    const wrapped = wrapWithScope(selectorList, scopeSelector, /*excludeSlotted*/ true)\n    return `${brace}${ws}${wrapped}{`\n  })\n\n  return cssText\n}\n\n/**\n * Generate a unique shadow scope id for this session.\n * @param {{shadowScopeSeq?: number}} sessionCache\n * @returns {string} like \"s1\", \"s2\", ...\n */\nexport function nextShadowScopeId(sessionCache) {\n  sessionCache.shadowScopeSeq = (sessionCache.shadowScopeSeq || 0) + 1\n  return `s${sessionCache.shadowScopeSeq}`\n}\n\n/**\n * Extract CSS text from a ShadowRoot: inline <style> plus adoptedStyleSheets (if readable).\n * @param {ShadowRoot} sr\n * @returns {string}\n */\nexport function extractShadowCSS(sr) {\n  let css = ''\n  try {\n    sr.querySelectorAll('style').forEach(s => { css += (s.textContent || '') + '\\n' })\n    // adoptedStyleSheets (may throw cross-origin; guard)\n    const sheets = sr.adoptedStyleSheets || []\n    for (const sh of sheets) {\n      try {\n        if (sh && sh.cssRules) {\n          for (const rule of sh.cssRules) css += rule.cssText + '\\n'\n        }\n      } catch { /* ignore */ }\n    }\n  } catch { /* ignore */ }\n  return css\n}\n\n/**\n * Inject a <style> as the first child of `hostClone` with rewritten CSS.\n * @param {Element} hostClone\n * @param {string} cssText\n * @param {string} scopeId like s1\n */\nexport function injectScopedStyle(hostClone, cssText, scopeId) {\n  if (!cssText) return\n  const style = document.createElement('style')\n  style.setAttribute('data-sd', scopeId)\n  style.textContent = cssText\n  // prepend to ensure it wins over later subtree\n  hostClone.insertBefore(style, hostClone.firstChild || null)\n}\n\n/**\n * Freeze the responsive selection of an <img> that has srcset/sizes.\n * Copies a concrete URL into `src` and removes `srcset`/`sizes` so the clone\n * doesn't need layout to resolve a candidate.\n * Works with <picture> because currentSrc reflects the chosen source.\n * @param {HTMLImageElement} original - Image in the live DOM.\n * @param {HTMLImageElement} cloned - Just-created cloned <img>.\n */\nexport function freezeImgSrcset(original, cloned) {\n  try {\n    const chosen = original.currentSrc || original.src || ''\n    if (!chosen) return\n    cloned.setAttribute('src', chosen)\n    cloned.removeAttribute('srcset')\n    cloned.removeAttribute('sizes')\n    // Hint deterministic decode/load for capture\n    cloned.loading = 'eager'\n    cloned.decoding = 'sync'\n  } catch { }\n}\n\n/**\n * Collect all custom properties referenced via var(--foo) in a CSS string.\n * @param {string} cssText\n * @returns {Set<string>} e.g. new Set(['--o-fill','--o-gray-light'])\n */\nexport function collectCustomPropsFromCSS(cssText) {\n  const out = new Set()\n  if (!cssText) return out\n  const re = /var\\(\\s*(--[A-Za-z0-9_-]+)\\b/g\n  let m\n  while ((m = re.exec(cssText))) out.add(m[1])\n  return out\n}\n\n/**\n * Resolve the cascaded value of a custom prop for an element.\n * Falls back to documentElement if empty.\n * @param {Element} el\n * @param {string} name like \"--o-fill\"\n * @returns {string} resolved token string or empty if unavailable\n */\nfunction resolveCustomProp(el, name) {\n  try {\n    const cs = getComputedStyle(el)\n    let v = cs.getPropertyValue(name).trim()\n    if (v) return v\n  } catch { }\n  try {\n    const rootCS = getComputedStyle(document.documentElement)\n    let v = rootCS.getPropertyValue(name).trim()\n    if (v) return v\n  } catch { }\n  return ''\n}\n\n/**\n * Build a seed rule that initializes given custom props on the scope.\n * Placed before the rewritten shadow CSS so later rules (e.g. :hover) can override.\n * @param {Element} hostEl\n * @param {Iterable<string>} names\n * @param {string} scopeSelector e.g. [data-sd=\"s3\"]\n * @returns {string} CSS rule text (or \"\" if nothing to seed)\n */\nexport function buildSeedCustomPropsRule(hostEl, names, scopeSelector) {\n  const decls = []\n  for (const name of names) {\n    const val = resolveCustomProp(hostEl, name)\n    if (val) decls.push(`${name}: ${val};`)\n  }\n  if (!decls.length) return ''\n  return `${scopeSelector}{${decls.join('')}}\\n`\n}\n\n/**\n * Mark slotted subtree with data-sd-slotted attribute\n * @param {Node} root\n */\nexport function markSlottedSubtree(root) {\n  if (!root) return\n  if (root.nodeType === Node.ELEMENT_NODE) {\n    root.setAttribute('data-sd-slotted', '')\n  }\n  // Marcar todos los descendientes elemento\n  if (root.querySelectorAll) {\n    root.querySelectorAll('*').forEach(el => el.setAttribute('data-sd-slotted', ''))\n  }\n}\n\n/**\n * Wait for an accessible same-origin Document for a given <iframe>.\n * @param {HTMLIFrameElement} iframe\n * @param {number} [attempts=3]\n * @returns {Promise<Document|null>}\n */\nexport async function getAccessibleIframeDocument(iframe, attempts = 3) {\n  const probe = () => {\n    try { return iframe.contentDocument || iframe.contentWindow?.document || null } catch { return null }\n  }\n  let doc = probe()\n  let i = 0\n  while (i < attempts && (!doc || (!doc.body && !doc.documentElement))) {\n    await new Promise(r => setTimeout(r, 0))\n    doc = probe()\n    i++\n  }\n  return doc && (doc.body || doc.documentElement) ? doc : null\n}\n\n/**\n * Compute the content-box size of an element (client rect minus borders).\n * @param {Element} el\n * @returns {{contentWidth:number, contentHeight:number, rect:DOMRect}}\n */\nfunction measureContentBox(el) {\n  const rect = el.getBoundingClientRect()\n  let bl = 0, br = 0, bt = 0, bb = 0\n  try {\n    const cs = getComputedStyle(el)\n    bl = parseFloat(cs.borderLeftWidth) || 0\n    br = parseFloat(cs.borderRightWidth) || 0\n    bt = parseFloat(cs.borderTopWidth) || 0\n    bb = parseFloat(cs.borderBottomWidth) || 0\n  } catch { }\n  const contentWidth = Math.max(0, Math.round(rect.width - (bl + br)))\n  const contentHeight = Math.max(0, Math.round(rect.height - (bt + bb)))\n  return { contentWidth, contentHeight, rect }\n}\n\n/**\n * Get the unscaled dimensions of an element (pre-transform layout dimensions).\n * This function returns dimensions that do NOT include ancestor CSS transforms,\n * avoiding the double-scale bug where getBoundingClientRect() returns already-scaled\n * dimensions that then get scaled again by inherited transforms.\n *\n * Priority fallback chain:\n * 1. offsetWidth/offsetHeight (pre-transform layout dimensions)\n * 2. getComputedStyle() width/height\n * 3. getAttribute() width/height\n * 4. Intrinsic dimensions (naturalWidth/naturalHeight for images)\n *\n * @param {Element} el - The element to measure\n * @returns {{width: number, height: number}} Unscaled dimensions in pixels\n */\nexport function getUnscaledDimensions(el) {\n  let width = 0\n  let height = 0\n\n  // Priority 1: offsetWidth/offsetHeight (pre-transform layout dimensions)\n  if (el.offsetWidth > 0) width = el.offsetWidth\n  if (el.offsetHeight > 0) height = el.offsetHeight\n\n  // Priority 2: getComputedStyle() if offset dimensions not available\n  if (width === 0 || height === 0) {\n    try {\n      const cs = getComputedStyle(el)\n      if (width === 0) {\n        const w = parseFloat(cs.width)\n        if (!isNaN(w) && w > 0) width = w\n      }\n      if (height === 0) {\n        const h = parseFloat(cs.height)\n        if (!isNaN(h) && h > 0) height = h\n      }\n    } catch { }\n  }\n\n  // Priority 3: getAttribute() for hardcoded dimensions\n  if (width === 0 || height === 0) {\n    try {\n      if (width === 0) {\n        const w = parseFloat(el.getAttribute('width'))\n        if (!isNaN(w) && w > 0) width = w\n      }\n      if (height === 0) {\n        const h = parseFloat(el.getAttribute('height'))\n        if (!isNaN(h) && h > 0) height = h\n      }\n    } catch { }\n  }\n\n  // Priority 4: Intrinsic dimensions (for images)\n  if ((width === 0 || height === 0) && (el.naturalWidth || el.naturalHeight)) {\n    try {\n      if (width === 0 && el.naturalWidth > 0) width = el.naturalWidth\n      if (height === 0 && el.naturalHeight > 0) height = el.naturalHeight\n    } catch { }\n  }\n\n  return { width, height }\n}\n\n/**\n * Temporarily pin the iframe's internal viewport to (w, h) CSS px.\n * Injects a <style> into the iframe doc and returns a cleanup function.\n * @param {Document} doc\n * @param {number} w\n * @param {number} h\n * @returns {() => void}\n */\nfunction pinIframeViewport(doc, w, h) {\n  const style = doc.createElement('style')\n  style.setAttribute('data-sd-iframe-pin', '')\n  style.textContent = `html, body {margin: 0 !important;padding: 0 !important;width: ${w}px !important;height: ${h}px !important;min-width: ${w}px !important;min-height: ${h}px !important;box-sizing: border-box !important;overflow: hidden !important;background-clip: border-box !important;}`;\n  (doc.head || doc.documentElement).appendChild(style)\n  return () => { try { style.remove() } catch { } }\n}\n\n/**\n * Rasterize a same-origin iframe exactly at its content-box size, as the user requested:\n * - Capture iframe.contentDocument.documentElement\n * - Force a bitmap (toPng) sized to the iframe viewport (not the content height)\n * - Wrap with a styled container that mimics the <iframe> box (borders, radius, etc.)\n *\n * @param {HTMLIFrameElement} iframe\n * @param {object} sessionCache\n * @param {object} options\n * @returns {Promise<HTMLElement>}\n */\nexport async function rasterizeIframe(iframe, sessionCache, options) {\n  const doc = await getAccessibleIframeDocument(iframe, 3)\n  if (!doc) throw new Error('iframe document not accessible/ready')\n\n  const { contentWidth, contentHeight, rect } = measureContentBox(iframe)\n\n  // Prefer options.snap (set by main()); fallback to window.snapdom (IIFE build)\n  let snap = options?.snap\n  if (!snap && typeof window !== 'undefined' && window.snapdom) {\n    snap = window.snapdom\n  }\n  if (!snap || typeof snap.toPng !== 'function') {\n    throw new Error(\n      '[snapdom] iframe capture requires snapdom.toPng. Use snapdom(el) or pass options.snap. ' +\n      'With ESM, assign window.snapdom = snapdom after import if using iframes.'\n    )\n  }\n\n  // Avoid double scaling; parent capture decides final scale\n  const nested = { ...options, scale: 1 }\n\n  // Pin viewport so body background fills exactly content box (fixes 400x110 → 400x150)\n  const unpin = pinIframeViewport(doc, contentWidth, contentHeight)\n  let imgEl\n  try {\n    imgEl = await snap.toPng(doc.documentElement, nested)\n  } finally {\n    unpin()\n  }\n\n  // Build <img> (bitmap) sized to content box\n  imgEl.style.display = 'block'\n  imgEl.style.width = `${contentWidth}px`\n  imgEl.style.height = `${contentHeight}px`\n\n  // Wrapper that preserves the iframe box (border, radius...) and clips\n  const wrapper = document.createElement('div')\n  sessionCache.nodeMap.set(wrapper, iframe)\n  inlineAllStyles(iframe, wrapper, sessionCache, options)\n  wrapper.style.overflow = 'hidden'\n  wrapper.style.display = 'block'\n  if (!wrapper.style.width) wrapper.style.width = `${Math.round(rect.width)}px`\n  if (!wrapper.style.height) wrapper.style.height = `${Math.round(rect.height)}px`\n\n  wrapper.appendChild(imgEl)\n  return wrapper\n}\n\n// ========== Checkbox/Radio replacement (Firefox fix) ==========\n\n/**\n * Creates a visual replacement for checkbox/radio inputs using inline SVG.\n * Firefox does not render native form controls inside SVG foreignObject; SVG-based\n * representation avoids CSS class conflicts and renders consistently.\n * @param {HTMLInputElement} node - Source input\n * @returns {{ el: HTMLDivElement, applyVisual: () => void }}\n */\nexport function createCheckboxRadioReplacement(node) {\n  const { width: unscaledW, height: unscaledH } = getUnscaledDimensions(node)\n  const rect = node.getBoundingClientRect()\n  let cs\n  try { cs = window.getComputedStyle(node) } catch { }\n  const parsedW = cs ? parseFloat(cs.width) : NaN\n  const parsedH = cs ? parseFloat(cs.height) : NaN\n  const rw = Math.round(unscaledW || rect.width || 0)\n  const rh = Math.round(unscaledH || rect.height || 0)\n  let w = Number.isFinite(parsedW) && parsedW > 0 ? Math.round(parsedW) : Math.max(12, rw || 16)\n  let h = Number.isFinite(parsedH) && parsedH > 0 ? Math.round(parsedH) : Math.max(12, rh || 16)\n  const isCheckbox = (node.type || 'text').toLowerCase() === 'checkbox'\n  const checked = !!node.checked\n  const indeterminate = !!node.indeterminate\n\n  const size = Math.max(Math.min(w, h), 12)\n  const s = size\n\n  const box = document.createElement('div')\n  box.setAttribute('data-snapdom-input-replacement', node.type || 'checkbox')\n  box.style.cssText = `display:inline-block;width:${s}px;height:${s}px;vertical-align:middle;flex-shrink:0;line-height:0;`\n  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')\n  svg.setAttribute('width', String(s))\n  svg.setAttribute('height', String(s))\n  svg.setAttribute('viewBox', `0 0 ${s} ${s}`)\n  box.appendChild(svg)\n\n  function applyVisual() {\n    let color = '#0a6ed1'\n    try {\n      if (cs) color = cs.accentColor || cs.color || color\n    } catch { }\n    const stroke = 2\n    const pad = stroke / 2\n    const inner = s - stroke\n    svg.innerHTML = ''\n    if (isCheckbox) {\n      const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect')\n      rectEl.setAttribute('x', String(pad))\n      rectEl.setAttribute('y', String(pad))\n      rectEl.setAttribute('width', String(inner))\n      rectEl.setAttribute('height', String(inner))\n      rectEl.setAttribute('rx', '2')\n      rectEl.setAttribute('ry', '2')\n      rectEl.setAttribute('fill', checked ? color : 'none')\n      rectEl.setAttribute('stroke', color)\n      rectEl.setAttribute('stroke-width', String(stroke))\n      svg.appendChild(rectEl)\n      if (checked) {\n        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')\n        path.setAttribute('d', `M ${pad + 2} ${s / 2} L ${s / 2 - 1} ${s - pad - 2} L ${s - pad - 2} ${pad + 2}`)\n        path.setAttribute('stroke', 'white')\n        path.setAttribute('stroke-width', String(Math.max(1.5, stroke)))\n        path.setAttribute('fill', 'none')\n        path.setAttribute('stroke-linecap', 'round')\n        path.setAttribute('stroke-linejoin', 'round')\n        svg.appendChild(path)\n      } else if (indeterminate) {\n        const dash = document.createElementNS('http://www.w3.org/2000/svg', 'rect')\n        const dw = Math.max(6, inner - 4)\n        dash.setAttribute('x', String((s - dw) / 2))\n        dash.setAttribute('y', String((s - stroke) / 2))\n        dash.setAttribute('width', String(dw))\n        dash.setAttribute('height', String(stroke))\n        dash.setAttribute('fill', color)\n        dash.setAttribute('rx', '1')\n        svg.appendChild(dash)\n      }\n    } else {\n      const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')\n      circle.setAttribute('cx', String(s / 2))\n      circle.setAttribute('cy', String(s / 2))\n      circle.setAttribute('r', String((s - stroke) / 2))\n      circle.setAttribute('fill', checked ? color : 'none')\n      circle.setAttribute('stroke', color)\n      circle.setAttribute('stroke-width', String(stroke))\n      svg.appendChild(circle)\n      if (checked) {\n        const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')\n        const r = Math.max(2, (s - stroke * 2) * 0.35)\n        dot.setAttribute('cx', String(s / 2))\n        dot.setAttribute('cy', String(s / 2))\n        dot.setAttribute('r', String(r))\n        dot.setAttribute('fill', 'white')\n        svg.appendChild(dot)\n      }\n    }\n    // Force dimensions (inlineAllStyles may copy width:0 from native input)\n    box.style.setProperty('width', `${s}px`, 'important')\n    box.style.setProperty('height', `${s}px`, 'important')\n    box.style.setProperty('min-width', `${s}px`, 'important')\n    box.style.setProperty('min-height', `${s}px`, 'important')\n  }\n  applyVisual()\n  return { el: box, applyVisual }\n}\n\n// ========== Blob URL Helpers ==========\n\nvar _blobToDataUrlCache = new EvictingMap(80)\n\n/**\n * Read a blob: URL and return its data URL, with memoization + shared cache.\n * - Usa snapFetch(as:'dataURL') para convertir directo.\n * - Dedupea inflight guardando la promesa en el Map.\n * - Escribe también en cache.resource para reuso cross-módulo.\n * @param {string} blobUrl\n * @returns {Promise<string>} data URL\n */\nexport async function blobUrlToDataUrl(blobUrl) {\n  // 1) Hit en cache global compartido\n  if (cache.resource?.has(blobUrl)) return cache.resource.get(blobUrl)\n\n  // 2) Hit en memo local (puede ser promesa o string resuelto)\n  if (_blobToDataUrlCache.has(blobUrl)) return _blobToDataUrlCache.get(blobUrl)\n\n  // 3) Crear promesa inflight y guardarla para dedupe\n  const p = (async () => {\n    const r = await snapFetch(blobUrl, { as: 'dataURL', silent: true })\n    if (!r.ok || typeof r.data !== 'string') {\n      throw new Error(`[snapDOM] Failed to read blob URL: ${blobUrl}`)\n    }\n    cache.resource?.set(blobUrl, r.data)   // cache compartido\n    return r.data\n  })()\n\n  _blobToDataUrlCache.set(blobUrl, p)\n  try {\n    const data = await p\n    // Opcional: reemplazar promesa por string ya resuelto (menos retenciones)\n    _blobToDataUrlCache.set(blobUrl, data)\n    return data\n  } catch (e) {\n    // Si falla, limpiamos para permitir reintentos futuros\n    _blobToDataUrlCache.delete(blobUrl)\n    throw e\n  }\n}\n\nvar BLOB_URL_RE = /\\bblob:[^)\"'\\s]+/g\n\nasync function replaceBlobUrlsInCssText(cssText) {\n  if (!cssText || cssText.indexOf('blob:') === -1) return cssText\n  const uniques = Array.from(new Set(cssText.match(BLOB_URL_RE) || []))\n  if (uniques.length === 0) return cssText\n  let out = cssText\n  for (const u of uniques) {\n    try {\n      const d = await blobUrlToDataUrl(u)\n      out = out.split(u).join(d)\n    } catch { }\n  }\n  return out\n}\n\nfunction isBlobUrl(u) {\n  return typeof u === 'string' && u.startsWith('blob:')\n}\n\nfunction parseSrcset(srcset) {\n  return (srcset || '')\n    .split(',')\n    .map((s) => s.trim())\n    .filter(Boolean)\n    .map((item) => {\n      const m = item.match(/^(\\S+)(\\s+.+)?$/)\n      return m ? { url: m[1], desc: m[2] || '' } : null\n    })\n    .filter(Boolean)\n}\n\nfunction stringifySrcset(parts) {\n  return parts.map((p) => (p.desc ? `${p.url} ${p.desc.trim()}` : p.url)).join(', ')\n}\n\nexport async function resolveBlobUrlsInTree(root, sessionCache = null) {\n  if (!root) return\n  const ctx = sessionCache\n\n  const imgs = root.querySelectorAll ? root.querySelectorAll('img') : []\n  for (const img of imgs) {\n    try {\n      const srcAttr = img.getAttribute('src')\n      const effective = srcAttr || img.currentSrc || ''\n      if (isBlobUrl(effective)) {\n        const data = await blobUrlToDataUrl(effective)\n        img.setAttribute('src', data)\n      }\n      const srcset = img.getAttribute('srcset')\n      if (srcset && srcset.includes('blob:')) {\n        const parts = parseSrcset(srcset)\n        let changed = false\n        for (const p of parts) {\n          if (isBlobUrl(p.url)) {\n            try {\n              p.url = await blobUrlToDataUrl(p.url)\n              changed = true\n            } catch (e) {\n              debugWarn(ctx, 'blobUrlToDataUrl for srcset item failed', e)\n            }\n          }\n        }\n        if (changed) img.setAttribute('srcset', stringifySrcset(parts))\n      }\n    } catch (e) {\n      debugWarn(ctx, 'resolveBlobUrls for img failed', e)\n    }\n  }\n\n  const svgImages = root.querySelectorAll ? root.querySelectorAll('image') : []\n  for (const node of svgImages) {\n    try {\n      const XLINK_NS = 'http://www.w3.org/1999/xlink'\n      const href = node.getAttribute('href') || node.getAttributeNS?.(XLINK_NS, 'href')\n      if (isBlobUrl(href)) {\n        const d = await blobUrlToDataUrl(href)\n        node.setAttribute('href', d)\n        node.removeAttributeNS?.(XLINK_NS, 'href')\n      }\n    } catch (e) {\n      debugWarn(ctx, 'resolveBlobUrls for SVG image href failed', e)\n    }\n  }\n\n  const styled = root.querySelectorAll ? root.querySelectorAll(\"[style*='blob:']\") : []\n  for (const el of styled) {\n    try {\n      const styleText = el.getAttribute('style')\n      if (styleText && styleText.includes('blob:')) {\n        const replaced = await replaceBlobUrlsInCssText(styleText)\n        el.setAttribute('style', replaced)\n      }\n    } catch (e) {\n      debugWarn(ctx, 'replaceBlobUrls in inline style failed', e)\n    }\n  }\n\n  const styleTags = root.querySelectorAll ? root.querySelectorAll('style') : []\n  for (const s of styleTags) {\n    try {\n      const css = s.textContent || ''\n      if (css.includes('blob:')) {\n        s.textContent = await replaceBlobUrlsInCssText(css)\n      }\n    } catch (e) {\n      debugWarn(ctx, 'replaceBlobUrls in style tag failed', e)\n    }\n  }\n\n  const urlAttrs = ['poster']\n  for (const attr of urlAttrs) {\n    const nodes = root.querySelectorAll ? root.querySelectorAll(`[${attr}^='blob:']`) : []\n    for (const n of nodes) {\n      try {\n        const u = n.getAttribute(attr)\n        if (isBlobUrl(u)) {\n          n.setAttribute(attr, await blobUrlToDataUrl(u))\n        }\n      } catch (e) {\n        debugWarn(ctx, `resolveBlobUrls for ${attr} failed`, e)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/utils/css.js",
    "content": "// -----------------------------------------------------------------------------\n// Central single-source-of-truth sets\n// -----------------------------------------------------------------------------\n\n/** Tags that Snapdom must never capture (skip node + subtree). */\nexport const NO_CAPTURE_TAGS = new Set([\n  'meta', 'script', 'noscript', 'title', 'link', 'template'\n])\n\n/** Tags that must not generate default styles nor auto-classes. */\nexport const NO_DEFAULTS_TAGS = new Set([\n  // non-painting / head stuff\n  'meta', 'link', 'style', 'title', 'noscript', 'script', 'template',\n  // SVG whole namespace (safe for LeaderLine/presentation attrs)\n  'g', 'defs', 'use', 'marker', 'mask', 'clipPath', 'pattern',\n  'path', 'polygon', 'polyline', 'line', 'circle', 'ellipse', 'rect',\n  'filter', 'lineargradient', 'radialgradient', 'stop'\n])\n\nimport { cache } from '../core/cache'\n\nconst commonTags = [\n  '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', 'table', 'thead', 'tbody', 'tr', 'td', 'th'\n]\n\n// -----------------------------------------------------------------------------\n// 1) precacheCommonTags → salta NO_CAPTURE y NO_DEFAULTS (no calienta basura)\n// -----------------------------------------------------------------------------\nexport function precacheCommonTags() {\n  for (let tag of commonTags) {\n    const t = String(tag).toLowerCase()\n    if (NO_CAPTURE_TAGS.has(t)) continue\n    if (NO_DEFAULTS_TAGS.has(t)) continue // evita precache de SVG/body/etc.\n    getDefaultStyleForTag(t)\n  }\n}\n\n// -----------------------------------------------------------------------------\n// 2) getDefaultStyleForTag → gate único por NO_DEFAULTS_TAGS + sandbox marcado\n// -----------------------------------------------------------------------------\n/*\n * Retrieves default CSS property values from a temporary element.\n * @param {string} tagName\n * @returns {Object}\n */\nexport function getDefaultStyleForTag(tagName) {\n  tagName = String(tagName).toLowerCase()\n\n  if (NO_DEFAULTS_TAGS.has(tagName)) {\n    const empty = {}\n    cache.defaultStyle.set(tagName, empty)\n    return empty\n  }\n\n  if (cache.defaultStyle.has(tagName)) {\n    return cache.defaultStyle.get(tagName)\n  }\n\n  let sandbox = document.getElementById('snapdom-sandbox')\n  if (!sandbox) {\n    sandbox = document.createElement('div')\n    sandbox.id = 'snapdom-sandbox'\n    sandbox.setAttribute('data-snapdom-sandbox', 'true')\n    sandbox.setAttribute('aria-hidden', 'true')\n    sandbox.style.position = 'absolute'\n    sandbox.style.left = '-9999px'\n    sandbox.style.top = '-9999px'\n    sandbox.style.width = '0px'\n    sandbox.style.height = '0px'\n    sandbox.style.overflow = 'hidden'\n    document.body.appendChild(sandbox)\n  }\n\n  const el = document.createElement(tagName)\n  el.style.all = 'initial'\n  sandbox.appendChild(el)\n\n  const styles = getComputedStyle(el)\n  const defaults = {}\n  for (let prop of styles) {\n    // ⬇️ Nuevo: filtramos ruido que no pinta y props dependientes de layout\n    if (shouldIgnoreProp(prop)) continue\n    const value = styles.getPropertyValue(prop)\n    defaults[prop] = value\n  }\n\n  sandbox.removeChild(el)\n  cache.defaultStyle.set(tagName, defaults)\n  return defaults\n}\n\n/** Tokens \"animation\"/\"transition\" anywhere in the name (dash-bounded). */\nconst NO_PAINT_TOKEN = /(?:^|-)(animation|transition)(?:-|$)/i\n\n/** Prefixes that never affect the static pixel of the frame. */\nconst NO_PAINT_PREFIX = /^(--.+|view-timeline|scroll-timeline|animation-trigger|offset-|position-try|app-region|interactivity|overlay|view-transition|-webkit-locale|-webkit-user-(?:drag|modify)|-webkit-tap-highlight-color|-webkit-text-security)$/i\n\n/** Exact properties that do not render pixels (control/interaction/UA hints). */\nconst NO_PAINT_EXACT = new Set([\n  // Interaction hints\n  'cursor',\n  'pointer-events',\n  'touch-action',\n  'user-select',\n  // Printing/speech/reading-mode hints\n  'print-color-adjust',\n  'speak',\n  'reading-flow',\n  'reading-order',\n  // Anchoring/container/timeline scopes (metadata for layout queries)\n  'anchor-name',\n  'anchor-scope',\n  'container-name',\n  'container-type',\n  'timeline-scope',\n])\n\n/**\n * Returns true if a CSS property should be ignored because it does not affect\n * the static pixel output of a frame capture.\n * - Matches prefixes (fast path) and tokens (e.g., “...-animation-...”).\n * - Skips a curated set of exact property names.\n * @param {string} prop\n * @returns {boolean}\n */\nexport function shouldIgnoreProp(prop /*, tag */) {\n  const p = String(prop).toLowerCase()\n  if (NO_PAINT_EXACT.has(p)) return true\n  if (NO_PAINT_PREFIX.test(p)) return true // --*, view/scroll-timeline*, offset-*, position-try*, etc.\n  if (NO_PAINT_TOKEN.test(p)) return true  // …-animation…, …-transition… (incluye caret/trigger)\n  return false\n}\n\n// -----------------------------------------------------------------------------\n// 3) getStyleKey → si NO_DEFAULTS_TAGS: \"\", así no hay clase auto\n// -----------------------------------------------------------------------------\n/**\n * Builds a style key from a snapshot; returns \"\" for tags in NO_DEFAULTS_TAGS.\n * @param {Record<string,string>} snapshot\n * @param {string} tagName\n */\nexport function getStyleKey(snapshot, tagName) {\n  tagName = String(tagName || '').toLowerCase()\n  if (NO_DEFAULTS_TAGS.has(tagName)) {\n    return '' // no key => no class\n  }\n\n  const entries = []\n  const defaults = getDefaultStyleForTag(tagName)\n  const display = (snapshot.display || '').toLowerCase()\n  const isInline = display === 'inline'\n  // Tags that size to text content; grid/flex blockify them but we should not constrain\n  // width (causes wrap when font-weight makes text wider than captured width, e.g. \"Timestamp demo\")\n  const INLINE_SIZED_TAGS = new Set(['span', 'small', 'em', 'strong', 'b', 'i', 'u', 's', 'code', 'cite', 'mark', 'sub', 'sup'])\n  const skipWidth = isInline || INLINE_SIZED_TAGS.has(tagName)\n  for (let [prop, value] of Object.entries(snapshot)) {\n    if (shouldIgnoreProp(prop)) continue\n    if (skipWidth && (prop === 'width' || prop === 'min-width' || prop === 'max-width')) continue\n    const def = defaults[prop]\n    if (value && value !== def) entries.push(`${prop}:${value}`)\n  }\n  entries.sort()\n  return entries.join(';')\n}\n\n/**\n * Collects all unique tag names used in the DOM tree rooted at the given node.\n *\n * @param {Node} root - The root node to search\n * @returns {string[]} Array of unique tag names\n */\nexport function collectUsedTagNames(root) {\n  const tagSet = new Set()\n  if (root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {\n    return []\n  }\n  if (root.tagName) {\n    tagSet.add(root.tagName.toLowerCase())\n  }\n  if (typeof root.querySelectorAll === 'function') {\n    root.querySelectorAll('*').forEach(el => tagSet.add(el.tagName.toLowerCase()))\n  }\n  return Array.from(tagSet)\n}\n\n// -----------------------------------------------------------------------------\n// 5) generateDedupedBaseCSS → salta keys vacías (sin reglas basura)\n// -----------------------------------------------------------------------------\n/**\n * Generates deduplicated base CSS for the given tag names.\n *\n * @param {string[]} usedTagNames - Array of tag names\n * @returns {string} CSS string\n */\nexport function generateDedupedBaseCSS(usedTagNames) {\n  const groups = new Map()\n\n  for (let tagName of usedTagNames) {\n    const styles = cache.defaultStyle.get(tagName)\n    if (!styles) continue\n\n    // Creamos la \"firma\" del bloque CSS para comparar\n    const key = Object.entries(styles)\n      .map(([k, v]) => `${k}:${v};`)\n      .sort()\n      .join('')\n\n    if (!key) continue // <- evita reglas vacías (NO_DEFAULTS_TAGS produce {})\n\n    // Agrupamos por firma\n    if (!groups.has(key)) {\n      groups.set(key, [])\n    }\n    groups.get(key).push(tagName)\n  }\n\n  // Ahora generamos el CSS optimizado\n  let css = ''\n  for (let [styleBlock, tagList] of groups.entries()) {\n    css += `${tagList.join(',')} { ${styleBlock} }\\n`\n  }\n\n  return css\n}\n\n// -----------------------------------------------------------------------------\n// 4) generateCSSClasses → ignora keys vacías (defensivo)\n// -----------------------------------------------------------------------------\n/**\n * Generates CSS classes from a style map.\n *\n * @returns {Map} Map of style keys to class names\n */\nexport function generateCSSClasses(styleMap) {\n  const keys = Array.from(new Set(styleMap.values()))\n    .filter(Boolean)\n    .sort()                 // ← orden estable\n  const classMap = new Map()\n  let i = 1\n  for (const k of keys) classMap.set(k, `c${i++}`)\n  return classMap\n}\n\n/**\n * Gets the Window to use for getComputedStyle. Uses the element's document when\n * the element is from an iframe, so computed styles reflect the iframe's cascade.\n * Fixes #371 (pseudos in iframe body not rendering when capturing body only).\n *\n * @param {Element} el\n * @returns {Window|null}\n */\nfunction getWindowForElement(el) {\n  try {\n    const doc = el?.ownerDocument\n    if (!doc) return typeof window !== 'undefined' ? window : null\n    let win = doc.defaultView\n    if (win && typeof win.getComputedStyle === 'function') return win\n    // In some environments (e.g. srcdoc iframe before fully ready) defaultView can be null.\n    // Find the frame whose document is this document so we use the iframe's window.\n    if (typeof window !== 'undefined' && window.frames) {\n      for (let i = 0; i < window.frames.length; i++) {\n        try {\n          if (window.frames[i]?.document === doc) return window.frames[i]\n        } catch { /* cross-origin */ }\n      }\n    }\n  } catch { /* cross-origin etc */ }\n  return typeof window !== 'undefined' ? window : null\n}\n\n/**\n * Gets the computed style for an element or pseudo-element, with caching.\n *\n * @param {Element} el - The element\n * @param {string|null} [pseudo=null] - The pseudo-element\n * @returns {CSSStyleDeclaration} The computed style\n */\nexport function getStyle(el, pseudo = null) {\n  /**\n   * Minimal safe fallback CSSStyleDeclaration-like object.\n   * Ensures callers can read properties and iterate length without crashing.\n   */\n  const emptyStyle = () => {\n    const base = {\n      length: 0,\n      getPropertyValue: () => '',\n      item: () => '',\n    }\n    // Make it iterable: for (let prop of style) { ... }\n    base[Symbol.iterator] = function* () { /* empty */ }\n    return /** @type {any} */ (base)\n  }\n\n  if (!(el instanceof Element)) {\n    const win = typeof window !== 'undefined' ? window : null\n    if (win && typeof win.getComputedStyle === 'function') {\n      try {\n        return win.getComputedStyle(/** @type {any} */ (el), pseudo) || emptyStyle()\n      } catch {\n        return emptyStyle()\n      }\n    }\n    return emptyStyle()\n  }\n  let map = cache.computedStyle.get(el)\n  if (!map) {\n    map = new Map()\n    cache.computedStyle.set(el, map)\n  }\n\n  let style = map.get(pseudo)\n\n  if (!style) {\n    const win = getWindowForElement(el)\n    let st = null\n    try {\n      st = win && typeof win.getComputedStyle === 'function'\n        ? win.getComputedStyle(el, pseudo)\n        : null\n    } catch { /* ignore */ }\n\n    if (!st && typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {\n      try {\n        // Only use global window when the element belongs to the same document (e.g. avoid iframe cross-document call).\n        if (el.ownerDocument === document) {\n          st = window.getComputedStyle(el, pseudo)\n        }\n      } catch {\n        // ignore; handled below\n      }\n    }\n\n    style = st || emptyStyle()\n    map.set(pseudo, style)\n  }\n\n  return style\n}\n\n/**\n * Parses the CSS content property value, handling unicode escapes.\n *\n * @param {string} content - The CSS content value\n * @returns {string} The parsed content\n */\nexport function parseContent(content) {\n  let clean = content.replace(/^['\"]|['\"]$/g, '')\n  if (clean.startsWith('\\\\')) {\n    try {\n      return String.fromCharCode(parseInt(clean.replace('\\\\', ''), 16))\n    } catch {\n      return clean\n    }\n  }\n  return clean\n}\n\n/**\n * @export\n * @param {CSSStyleDeclaration} style\n * @return {Record<string,string>}\n */\nexport function snapshotComputedStyle(style) {\n  const snap = {}\n  for (let prop of style) {\n    snap[prop] = style.getPropertyValue(prop)\n  }\n  return snap\n}\n\n/**\n * @export\n * @param {string} bg\n * @return {string[]}\n */\nexport function splitBackgroundImage(bg) {\n  const parts = []\n  let depth = 0\n  let lastIndex = 0\n  for (let i = 0; i < bg.length; i++) {\n    const char = bg[i]\n    if (char === '(') depth++\n    if (char === ')') depth--\n    if (char === ',' && depth === 0) {\n      parts.push(bg.slice(lastIndex, i).trim())\n      lastIndex = i + 1\n    }\n  }\n  parts.push(bg.slice(lastIndex).trim())\n  return parts\n}\n"
  },
  {
    "path": "src/utils/debug.js",
    "content": "/**\n * Debug logging when options.debug is true.\n * Keeps production quiet; opt-in for troubleshooting.\n * @module utils/debug\n */\n\n/**\n * Log a warning when debug mode is enabled.\n * @param {Object|undefined} ctx - Context with options: { options }, or { options: { debug } }, or sessionCache with options\n * @param {string} msg - Short description\n * @param {unknown} [err] - Optional error/exception\n */\nexport function debugWarn(ctx, msg, err) {\n  const opts = ctx && typeof ctx === 'object' && (ctx.options || ctx)\n  if (opts && opts.debug) {\n    if (err !== undefined) {\n      console.warn('[snapdom]', msg, err)\n    } else {\n      console.warn('[snapdom]', msg)\n    }\n  }\n}\n"
  },
  {
    "path": "src/utils/helpers.js",
    "content": "/**\n * Extracts a URL from a CSS value like background-image.\n *\n * @param {string} value - The CSS value\n * @returns {string|null} The extracted URL or null\n */\n\nexport function extractURL(value) {\n  const match = value.match(/url\\((['\"]?)(.*?)(\\1)\\)/)\n  if (!match) return null\n\n  const url = match[2].trim()\n  if (url.startsWith('#')) return null\n  return url\n}\n\n/**\n * Determines if a font family or URL is an icon font.\n *\n * @param {string} familyOrUrl - The font family or URL\n * @returns {boolean} True if it is an icon font\n */\nexport function isIconFont(familyOrUrl) {\n  const iconFontPatterns = [\n    /font\\s*awesome/i,\n    /material\\s*icons/i,\n    /ionicons/i,\n    /glyphicons/i,\n    /feather/i,\n    /bootstrap\\s*icons/i,\n    /remix\\s*icons/i,\n    /heroicons/i,\n    /layui/i,\n    /lucide/i\n  ]\n  return iconFontPatterns.some(rx => rx.test(familyOrUrl))\n}\n\nexport function stripTranslate(transform) {\n  if (!transform || transform === 'none') return ''\n\n  let cleaned = transform.replace(/translate[XY]?\\([^)]*\\)/g, '')\n\n  cleaned = cleaned.replace(/matrix\\(([^)]+)\\)/g, (_, values) => {\n    const parts = values.split(',').map(s => s.trim())\n    if (parts.length !== 6) return `matrix(${values})`\n    parts[4] = '0'\n    parts[5] = '0'\n    return `matrix(${parts.join(', ')})`\n  })\n\n  cleaned = cleaned.replace(/matrix3d\\(([^)]+)\\)/g, (_, values) => {\n    const parts = values.split(',').map(s => s.trim())\n    if (parts.length !== 16) return `matrix3d(${values})`\n    parts[12] = '0'\n    parts[13] = '0'\n    return `matrix3d(${parts.join(', ')})`\n  })\n\n  return cleaned.trim().replace(/\\s{2,}/g, ' ')\n}\n\nexport function safeEncodeURI(uri) {\n  if (/%[0-9A-Fa-f]{2}/.test(uri)) return uri // prevent reencode\n  try { return encodeURI(uri) } catch { return uri }\n}\n\n/**\n * Resolves a possibly relative URL to an absolute URL.\n * @param {string} url - URL (relative or absolute)\n * @param {string} [base] - Base URL (defaults to document.baseURI or location.href)\n * @returns {string} Absolute URL\n */\nexport function resolveURL(url, base) {\n  if (!url || /^(data|blob|about|#)/i.test(url.trim())) return url\n  try {\n    const b = base || (typeof document !== 'undefined' && (document.baseURI || document.location?.href)) || 'http://localhost/'\n    return new URL(url, b).href\n  } catch {\n    return url\n  }\n}\n"
  },
  {
    "path": "src/utils/image.js",
    "content": "import { cache } from '../core/cache'\nimport { extractURL, safeEncodeURI, resolveURL } from './helpers'\nimport { snapFetch } from '../modules/snapFetch'\n\n/**\n * Inline a single background-image entry (one layer) robustly.\n * - If it's a URL() and fetching fails, degrade to \"none\" instead of throwing.\n * - Gradients and \"none\" are returned untouched.\n * - Uses cache.background to avoid repeated work.\n *\n * @param {string} entry - A single background layer (e.g., 'url(\"...\")', 'linear-gradient(...)', 'none')\n * @param {{ useProxy?: string }} [options]\n * @returns {Promise<string|undefined>} Inlined CSS value for this layer (e.g., `url(\"data:...\")`), original entry, or \"none\".\n */\nexport async function inlineSingleBackgroundEntry(entry, options = {}) {\n  // Quick checks for non-URL values\n  const isGradient = /^((repeating-)?(linear|radial|conic)-gradient)\\(/i.test(entry)\n  if (isGradient || entry.trim() === 'none') {\n    return entry // leave as is\n  }\n  // Extract raw URL from url(\"...\") (your existing helper)\n  const rawUrl = extractURL(entry)\n  if (!rawUrl) {\n    // Not a URL(...) we recognize → keep original as a safe fallback\n    return entry\n  }\n  // Resolve relative URLs to absolute (fixes #343: background url() missing when relative)\n  const absoluteUrl = resolveURL(rawUrl)\n  // Normalize / encode the URL string for cache key & fetch\n  const encodedUrl = safeEncodeURI(absoluteUrl)\n  // Fast path: cached success\n  if (cache.background.has(encodedUrl)) {\n    const dataUrl = cache.background.get(encodedUrl)\n    return dataUrl ? `url(\"${dataUrl}\")` : 'none'\n  }\n  // Try to inline; never throw — degrade to \"none\" on failure\n  try {\n    const dataUrl = await snapFetch(encodedUrl, { as: 'dataURL', useProxy: options.useProxy })\n    // Guard: ensure it actually looks like an image data URL\n    if (dataUrl.ok) {\n      cache.background.set(encodedUrl, dataUrl.data)\n      return `url(\"${dataUrl.data}\")`\n    }\n    // Unexpected format → degrade safely\n    cache.background.set(encodedUrl, null)\n    return 'none'\n  } catch {\n    // On any error (404/CORS/timeout/tainted/etc.), don't break the capture\n    cache.background.set(encodedUrl, null) // remember failure to avoid loops\n    return 'none'\n  }\n}\n"
  },
  {
    "path": "src/utils/index.js",
    "content": "export { inlineSingleBackgroundEntry } from './image.js'\nexport { precacheCommonTags, getDefaultStyleForTag, getStyleKey, collectUsedTagNames, generateDedupedBaseCSS, generateCSSClasses, getStyle, parseContent, snapshotComputedStyle, splitBackgroundImage, NO_CAPTURE_TAGS, NO_DEFAULTS_TAGS, shouldIgnoreProp } from './css.js'\nexport { idle, isIOS, isSafari } from './browser.js'\nexport { safeEncodeURI, stripTranslate, isIconFont, extractURL, resolveURL } from './helpers.js'\nexport { debugWarn } from './debug.js'\n"
  },
  {
    "path": "src/utils/prepare.helpers.js",
    "content": "/**\n * Helper utilities for preparing DOM clones\n * @module utils/prepare.helpers\n */\n\n/**\n * Stabilize layout by adding transparent border if element has outline but no border\n * @param {Element} element\n */\nexport function stabilizeLayout(element) {\n  const style = getComputedStyle(element)\n  const outlineStyle = style.outlineStyle\n  const outlineWidth = style.outlineWidth\n  const borderStyle = style.borderStyle\n  const borderWidth = style.borderWidth\n\n  const outlineVisible = outlineStyle !== 'none' && parseFloat(outlineWidth) > 0\n  const borderAbsent = (borderStyle === 'none' || parseFloat(borderWidth) === 0)\n\n  if (outlineVisible && borderAbsent) {\n    element.style.border = `${outlineWidth} solid transparent`\n  }\n}\n"
  },
  {
    "path": "src/utils/transforms.helpers.js",
    "content": "/**\n * Helper utilities for transform and geometry calculations\n * @module utils/transforms.helpers\n */\n\nimport { limitDecimals } from './capture.helpers.js'\n\n/**\n * Parse box-shadow and calculate bleed dimensions\n * @param {CSSStyleDeclaration} cs\n * @returns {{top: number, right: number, bottom: number, left: number}}\n */\nexport function parseBoxShadow(cs) {\n  const v = cs.boxShadow || ''\n  if (!v || v === 'none') return { top: 0, right: 0, bottom: 0, left: 0 }\n  const parts = v.split(/\\),(?=(?:[^()]*\\([^()]*\\))*[^()]*$)/).map((s) => s.trim())\n  let t = 0, r = 0, b2 = 0, l = 0\n  for (const part of parts) {\n    const nums = part.match(/-?\\d+(\\.\\d+)?px/g)?.map((n) => parseFloat(n)) || []\n    if (nums.length < 2) continue\n    const [ox2, oy2, blur = 0, spread = 0] = nums\n    const extX = Math.abs(ox2) + blur + spread\n    const extY = Math.abs(oy2) + blur + spread\n    r = Math.max(r, extX + Math.max(ox2, 0))\n    l = Math.max(l, extX + Math.max(-ox2, 0))\n    b2 = Math.max(b2, extY + Math.max(oy2, 0))\n    t = Math.max(t, extY + Math.max(-oy2, 0))\n  }\n  return { top: Math.ceil(t), right: Math.ceil(r), bottom: Math.ceil(b2), left: Math.ceil(l) }\n}\n\n/**\n * Parse filter blur and calculate bleed\n * @param {CSSStyleDeclaration} cs\n * @returns {{top: number, right: number, bottom: number, left: number}}\n */\nexport function parseFilterBlur(cs) {\n  const m = (cs.filter || '').match(/blur\\(\\s*([0-9.]+)px\\s*\\)/)\n  const b2 = m ? Math.ceil(parseFloat(m[1]) || 0) : 0\n  return { top: b2, right: b2, bottom: b2, left: b2 }\n}\n\n/**\n * Parse outline and calculate bleed\n * @param {CSSStyleDeclaration} cs\n * @returns {{top: number, right: number, bottom: number, left: number}}\n */\nexport function parseOutline(cs) {\n  if ((cs.outlineStyle || 'none') === 'none') return { top: 0, right: 0, bottom: 0, left: 0 }\n  const w2 = Math.ceil(parseFloat(cs.outlineWidth || '0') || 0)\n  return { top: w2, right: w2, bottom: w2, left: w2 }\n}\n\n/**\n * Parse filter drop-shadow and calculate bleed\n * @param {CSSStyleDeclaration} cs\n * @returns {{bleed: {top: number, right: number, bottom: number, left: number}, has: boolean}}\n */\nexport function parseFilterDropShadows(cs) {\n  const raw = `${cs.filter || ''} ${cs.webkitFilter || ''}`.trim()\n  if (!raw || raw === 'none') {\n    return { bleed: { top: 0, right: 0, bottom: 0, left: 0 }, has: false }\n  }\n  const tokens = raw.match(/drop-shadow\\((?:[^()]|\\([^()]*\\))*\\)/gi) || []\n  let t = 0, r = 0, b = 0, l = 0\n  let found = false\n  for (const tok of tokens) {\n    found = true\n    const nums = tok.match(/-?\\d+(?:\\.\\d+)?px/gi)?.map(v => parseFloat(v)) || []\n    const [ox = 0, oy = 0, blur = 0] = nums\n    const extX = Math.abs(ox) + blur\n    const extY = Math.abs(oy) + blur\n    r = Math.max(r, extX + Math.max(ox, 0))\n    l = Math.max(l, extX + Math.max(-ox, 0))\n    b = Math.max(b, extY + Math.max(oy, 0))\n    t = Math.max(t, extY + Math.max(-oy, 0))\n  }\n  return {\n    bleed: {\n      top: limitDecimals(t),\n      right: limitDecimals(r),\n      bottom: limitDecimals(b),\n      left: limitDecimals(l)\n    },\n    has: found\n  }\n}\n\n/**\n * Remove only translate/rotate from CLONE ROOT transform, keeping scale/skew.\n * Also forces transformOrigin to 0 0 to avoid negative offsets.\n * Returns the applied 2D matrix components so the caller can expand the viewBox accordingly.\n *\n * @param {Element} originalEl\n * @param {HTMLElement} cloneRoot\n * @returns {{a:number,b:number,c:number,d:number}|null} The 2D matrix (without translation) or null if not applicable.\n */\nexport function normalizeRootTransforms(originalEl, cloneRoot) {\n  if (!originalEl || !cloneRoot || !cloneRoot.style) return null\n  const cs = getComputedStyle(originalEl)\n\n  // Always anchor at top-left so scale/skew doesn't push content into negative coords\n  try { cloneRoot.style.transformOrigin = '0 0' } catch { }\n\n  // Try individual properties first (no-op safe)\n  try {\n    if ('translate' in cloneRoot.style) cloneRoot.style.translate = 'none'\n    if ('rotate' in cloneRoot.style) cloneRoot.style.rotate = 'none'\n    // do NOT touch 'scale'\n  } catch { }\n\n  const tr = cs.transform || 'none'\n  if (!tr || tr === 'none') {\n    // May still have individual scale; let computed matrix capture it\n    try {\n      const M = matrixFromComputed(originalEl)\n      // If identity, nothing to apply\n      if ((M.a === 1 && M.b === 0 && M.c === 0 && M.d === 1)) {\n        cloneRoot.style.transform = 'none'\n        return { a: 1, b: 0, c: 0, d: 1 }\n      }\n    } catch { }\n  }\n\n  // Composite path: decompose 2D; keep scale/skew, drop translate (e,f) and rotation\n  const m2d = tr.match(/^matrix\\(\\s*([^)]+)\\)$/i)\n  if (m2d) {\n    const nums = m2d[1].split(',').map(v => parseFloat(v.trim()))\n    if (nums.length === 6 && nums.every(Number.isFinite)) {\n      const [a, b, c, d] = nums // ignore e,f\n      // Decompose to isolate scale + shear, remove rotation:\n      const scaleX = Math.sqrt(a * a + b * b) || 0\n      let a1 = 0, b1 = 0, shear = 0, c2 = 0, d2 = 0, scaleY = 0\n      if (scaleX > 0) {\n        a1 = a / scaleX\n        b1 = b / scaleX\n        shear = a1 * c + b1 * d\n        c2 = c - a1 * shear\n        d2 = d - b1 * shear\n        scaleY = Math.sqrt(c2 * c2 + d2 * d2) || 0\n        if (scaleY > 0) shear = shear / scaleY\n        else shear = 0\n      }\n      const aP = scaleX\n      const bP = 0                 // rotation removed\n      const cP = shear * scaleY    // 2D shear component\n      const dP = scaleY\n      try { cloneRoot.style.transform = `matrix(${aP}, ${bP}, ${cP}, ${dP}, 0, 0)` } catch { }\n      return { a: aP, b: bP, c: cP, d: dP }\n    }\n  }\n\n  // 3D or unknown: best-effort — neutralize move/rotate at the end\n  try {\n    const legacy = String(tr).trim()\n    cloneRoot.style.transform = legacy + ' translate(0px, 0px) rotate(0deg)'\n    // We cannot reliably derive pure 2D here; return null to skip bbox expansion\n    return null\n  } catch {\n    return null\n  }\n}\n\n/**\n * Calculate bounding box with transform origin\n * @param {number} w2\n * @param {number} h2\n * @param {DOMMatrix} M\n * @param {number} ox2\n * @param {number} oy2\n * @returns {{minX: number, minY: number, maxX: number, maxY: number, width: number, height: number}}\n */\nexport function bboxWithOriginFull(w2, h2, M, ox2, oy2) {\n  const a2 = M.a, b2 = M.b, c2 = M.c, d2 = M.d, e2 = M.e || 0, f2 = M.f || 0\n  function pt(x, y) {\n    let X = x - ox2, Y = y - oy2\n    let X2 = a2 * X + c2 * Y, Y2 = b2 * X + d2 * Y\n    X2 += ox2 + e2\n    Y2 += oy2 + f2\n    return [X2, Y2]\n  }\n  const P = [pt(0, 0), pt(w2, 0), pt(0, h2), pt(w2, h2)]\n  let minX2 = Infinity, minY2 = Infinity, maxX2 = -Infinity, maxY2 = -Infinity\n  for (const [X, Y] of P) {\n    if (X < minX2) minX2 = X\n    if (Y < minY2) minY2 = Y\n    if (X > maxX2) maxX2 = X\n    if (Y > maxY2) maxY2 = Y\n  }\n  return { minX: minX2, minY: minY2, maxX: maxX2, maxY: maxY2, width: maxX2 - minX2, height: maxY2 - minY2 }\n}\n\n/**\n * Parses transform-origin supporting keywords (left/center/right, top/center/bottom).\n * Returns pixel offsets.\n * @param {CSSStyleDeclaration} cs\n * @param {number} w\n * @param {number} h\n */\nexport function parseTransformOriginPx(cs, w, h) {\n  const raw = (cs.transformOrigin || '0 0').trim().split(/\\s+/)\n  const [oxRaw, oyRaw] = [raw[0] || '0', raw[1] || '0']\n\n  const toPx = (token, size) => {\n    const t = token.toLowerCase()\n    if (t === 'left' || t === 'top') return 0\n    if (t === 'center') return size / 2\n    if (t === 'right') return size\n    if (t === 'bottom') return size\n    if (t.endsWith('px')) return parseFloat(t) || 0\n    if (t.endsWith('%')) return (parseFloat(t) || 0) * size / 100\n    // number without unit => px\n    if (/^-?\\d+(\\.\\d+)?$/.test(t)) return parseFloat(t) || 0\n    return 0\n  }\n\n  return {\n    ox: toPx(oxRaw, w),\n    oy: toPx(oyRaw, h),\n  }\n}\n\n/**\n * Returns a robust snapshot of individual transform-like properties.\n * Supports CSS Typed OM (CSSScale/CSSRotate/CSSTranslate) and legacy strings.\n * @param {Element} el\n * @returns {{ rotate:string, scale:string|null, translate:string|null }}\n */\nexport function readIndividualTransforms(el) {\n  const out = { rotate: '0deg', scale: null, translate: null }\n\n  const map = (typeof el.computedStyleMap === 'function') ? el.computedStyleMap() : null\n  if (map) {\n    const safeGet = (prop) => {\n      try {\n        if (typeof map.has === 'function' && !map.has(prop)) return null\n        if (typeof map.get !== 'function') return null\n        return map.get(prop)\n      } catch { return null }\n    }\n\n    // ROTATE\n    const rot = safeGet('rotate')\n    if (rot) {\n      // CSSRotate or CSSUnitValue(angle)\n      if (rot.angle) {\n        const ang = rot.angle // CSSUnitValue\n        out.rotate = (ang.unit === 'rad')\n          ? (ang.value * 180 / Math.PI) + 'deg'\n          : (ang.value + ang.unit)\n      } else if (rot.unit) {\n        // CSSUnitValue\n        out.rotate = rot.unit === 'rad'\n          ? (rot.value * 180 / Math.PI) + 'deg'\n          : (rot.value + rot.unit)\n      } else {\n        out.rotate = String(rot)\n      }\n    } else {\n      // Legacy fallback\n      const cs = getComputedStyle(el)\n      out.rotate = (cs.rotate && cs.rotate !== 'none') ? cs.rotate : '0deg'\n    }\n\n    // SCALE\n    const sc = safeGet('scale')\n    if (sc) {\n      // Chrome: CSSScale { x: CSSUnitValue, y: CSSUnitValue, z? }\n      // Safari TP / spec variants can differ; be permissive:\n      const sx = ('x' in sc && sc.x?.value != null) ? sc.x.value : (Array.isArray(sc) ? sc[0]?.value : Number(sc) || 1)\n      const sy = ('y' in sc && sc.y?.value != null) ? sc.y.value : (Array.isArray(sc) ? sc[1]?.value : sx)\n      out.scale = `${sx} ${sy}`\n    } else {\n      const cs = getComputedStyle(el)\n      out.scale = (cs.scale && cs.scale !== 'none') ? cs.scale : null\n    }\n\n    // TRANSLATE\n    const tr = safeGet('translate')\n    if (tr) {\n      // CSSTranslate: { x: CSSNumericValue, y: CSSNumericValue }\n      const tx = ('x' in tr && 'value' in tr.x) ? tr.x.value : (Array.isArray(tr) ? tr[0]?.value : 0)\n      const ty = ('y' in tr && 'value' in tr.y) ? tr.y.value : (Array.isArray(tr) ? tr[1]?.value : 0)\n      const ux = ('x' in tr && tr.x?.unit) ? tr.x.unit : 'px'\n      const uy = ('y' in tr && tr.y?.unit) ? tr.y.unit : 'px'\n      out.translate = `${tx}${ux} ${ty}${uy}`\n    } else {\n      const cs = getComputedStyle(el)\n      out.translate = (cs.translate && cs.translate !== 'none') ? cs.translate : null\n    }\n    return out\n  }\n\n  // Legacy path – no Typed OM\n  const cs = getComputedStyle(el)\n  out.rotate = (cs.rotate && cs.rotate !== 'none') ? cs.rotate : '0deg'\n  out.scale = (cs.scale && cs.scale !== 'none') ? cs.scale : null\n  out.translate = (cs.translate && cs.translate !== 'none') ? cs.translate : null\n  return out\n}\n\nvar __measureHost = null\n\nfunction getMeasureHost() {\n  if (__measureHost) return __measureHost\n  const n = document.createElement('div')\n  n.id = 'snapdom-measure-slot'\n  n.setAttribute('aria-hidden', 'true')\n  Object.assign(n.style, {\n    position: 'absolute',\n    left: '-99999px',\n    top: '0px',\n    width: '0px',\n    height: '0px',\n    overflow: 'hidden',\n    opacity: '0',\n    pointerEvents: 'none',\n    contain: 'size layout style'\n  })\n  document.documentElement.appendChild(n)\n  __measureHost = n\n  return n\n}\n\n/**\n * Read total transform matrix from combined transform properties\n * @param {object} t - Transform properties\n * @returns {DOMMatrix}\n */\nexport function readTotalTransformMatrix(t) {\n  const host = getMeasureHost()\n  const tmp = document.createElement('div')\n  tmp.style.transformOrigin = '0 0'\n  if (t.baseTransform) tmp.style.transform = t.baseTransform\n  if (t.rotate) tmp.style.rotate = t.rotate\n  if (t.scale) tmp.style.scale = t.scale\n  if (t.translate) tmp.style.translate = t.translate\n  host.appendChild(tmp)\n  const M = matrixFromComputed(tmp)\n  host.removeChild(tmp)\n  return M\n}\n\n/**\n * True if any transform (matrix or individual) can affect layout/bbox.\n * @param {Element} el\n */\nexport function hasBBoxAffectingTransform(el) {\n  const cs = getComputedStyle(el)\n  const t = cs.transform || 'none'\n\n  // Matrix identity or none => might still have individual transforms\n  const hasMatrix =\n    t !== 'none' &&\n    !/^matrix\\(\\s*1\\s*,\\s*0\\s*,\\s*0\\s*,\\s*1\\s*,\\s*0\\s*,\\s*0\\s*\\)$/i.test(t)\n\n  if (hasMatrix) return true\n\n  // Check individual transform-like properties\n  const r = cs.rotate && cs.rotate !== 'none' && cs.rotate !== '0deg'\n  const s = cs.scale && cs.scale !== 'none' && cs.scale !== '1'\n  const tr = cs.translate && cs.translate !== 'none' && cs.translate !== '0px 0px'\n\n  return Boolean(r || s || tr)\n}\n\n/**\n * Get matrix from computed style\n * @param {Element} el\n * @returns {DOMMatrix}\n */\nexport function matrixFromComputed(el) {\n  const tr = getComputedStyle(el).transform\n  if (!tr || tr === 'none') return new DOMMatrix()\n  try {\n    return new DOMMatrix(tr)\n  } catch {\n    return new WebKitCSSMatrix(tr)\n  }\n}\n"
  },
  {
    "path": "types/snapdom.d.ts",
    "content": "/**\n * snapDOM – ultra-fast DOM-to-image capture\n * TypeScript definitions (v1.9.14)\n *\n * Notes:\n * - Style compression is internal (no public option).\n * - Icon fonts are always embedded; `embedFonts` controls non-icon fonts only.\n * - This file preserves backward compatibility with earlier 1.9.x defs.\n */\n\n/* =========================\n * Basic MIME / type aliases\n * ========================= */\n\nexport type RasterMime = \"png\" | \"jpg\" | \"jpeg\" | \"webp\";\nexport type BlobType = \"svg\" | RasterMime;\n\nexport type IconFontMatcher = string | RegExp;\nexport type CachePolicy = \"disabled\" | \"full\" | \"auto\" | \"soft\";\n\n/* =========================\n * Font & proxy declarations\n * ========================= */\n\nexport interface LocalFont {\n  family: string;\n  src: string;            // URL or data: URL\n  weight?: string | number;\n  style?: string;\n}\n\nexport interface ExcludeFonts {\n  /** Case-insensitive family names to skip (non-icon only). */\n  families?: string[];\n  /** Host substrings to skip (e.g., \"fonts.gstatic.com\"). */\n  domains?: string[];\n  /** Unicode-range subset tags to skip (e.g., \"cyrillic-ext\"). */\n  subsets?: string[];\n}\n\n/* =========================\n * Capture options\n * ========================= */\n\nexport interface SnapdomOptions {\n  /** Fast path: skip small idle delays where safe. */\n  fast?: boolean;\n  /** Output scale multiplier. Takes precedence over width/height. */\n  scale?: number;\n  /** Device pixel ratio to use for rasterization (defaults to `devicePixelRatio`). */\n  dpr?: number;\n  /** Target width of the export (keeps aspect if only one dimension is provided). */\n  width?: number;\n  /** Target height of the export (keeps aspect if only one dimension is provided). */\n  height?: number;\n\n  /** Background fallback color (used esp. for JPEG). Default \"#fff\". */\n  backgroundColor?: string;\n  /** Quality for JPEG/WebP (0..1). Default 1. */\n  quality?: number;\n\n  /** Cross-origin proxy prefix (used as a fallback when CORS blocks). */\n  useProxy?: string;\n\n  /** Default Blob type for toBlob() when unspecified. */\n  type?: BlobType;\n\n  /** CSS selector list to filter nodes. */\n  exclude?: string[];\n  /** How to apply `exclude` (\"hide\" keeps layout via visibility:hidden; \"remove\" drops nodes). Default \"hide\". */\n  excludeMode?: \"hide\" | \"remove\";\n\n  /**\n   * Custom predicate: return true to keep node, false to exclude.\n   * Runs in document order; pairs with `filterMode`.\n   */\n  filter?: (el: Element) => boolean;\n  /** How to apply `filter` (\"hide\" or \"remove\"). Default \"hide\". */\n  filterMode?: \"hide\" | \"remove\";\n\n  /** outerTransforms the root: remove translate/rotate, keep scale/skew. */\n  outerTransforms?: boolean;\n  /**\n   * Do not expand root bbox for shadows/blur/outline; also strip shadows/outline\n   * from the cloned root to get a tight capture box.\n   */\n  outerShadows?: boolean;\n\n  /** Inline non-icon fonts actually used within the subtree. */\n  embedFonts?: boolean;\n  /** Provide fonts explicitly to avoid remote discovery. */\n  localFonts?: LocalFont[];\n  /** Additional matchers for icon font families (strings or regex). */\n  iconFonts?: IconFontMatcher | IconFontMatcher[];\n  /** Skip specific non-icon fonts (by family/domain/subset). */\n  excludeFonts?: ExcludeFonts;\n\n  /**\n   * Fallback image when <img> fails to load.\n   * Can be a fixed URL or a callback that receives measured dimensions.\n   */\n  fallbackURL?:\n    | string\n    | ((dims: { width?: number; height?: number }) => string);\n\n  /** Cache policy for resources and style maps. Default \"soft\". */\n  cache?: CachePolicy;\n\n  /** Show placeholders when resources are missing. Default true. */\n  placeholders?: boolean;\n\n  /** Arbitrary plugin configuration at call-site (see PluginUse). */\n  plugins?: PluginUse[];\n}\n\n/* =========================\n * Capture context (hook state)\n * ========================= */\n\nexport interface CaptureContext extends SnapdomOptions {\n  /** Input element being captured. */\n  element: Element;\n\n  /** Cloned root (detached), available after `beforeClone`/`afterClone`. */\n  clone?: HTMLElement | SVGElement | null;\n\n  /** Internal style/class caches (opaque to user). */\n  classCSS?: string;\n  styleCache?: unknown;\n  fontsCSS?: string;\n  baseCSS?: string;\n\n  /** Serialized artifacts, available after render. */\n  svgString?: string;\n  dataURL?: string;\n\n  /** Current export info during beforeExport/afterExport. */\n  export?: {\n    /** Export key (e.g., \"png\", \"jpeg\", \"svg\", or any custom key). */\n    type: string;\n    /** Options passed to the exporter. */\n    options?: any;\n    /** Canonical SVG data URL of this capture. */\n    url: string;\n  };\n}\n\n/* =========================\n * Exporter signatures\n * ========================= */\n\nexport type Exporter = (ctx: CaptureContext, opts?: any) => Promise<any>;\n\n/** Map returned by `defineExports`: keys are exposed on the result (e.g., `pdf` → `result.toPdf()` as well as `result['pdf']()`). */\nexport type ExportMap = Record<string, Exporter>;\n\n/* =========================\n * Plugin system\n * ========================= */\n\nexport interface SnapdomPlugin {\n  /** Unique name for de-dupe/overrides. */\n  name: string;\n\n  /** Hook order follows registration order. All hooks may be async. */\n  beforeSnap?(context: CaptureContext): void | Promise<void>;\n  beforeClone?(context: CaptureContext): void | Promise<void>;\n  afterClone?(context: CaptureContext): void | Promise<void>;\n  beforeRender?(context: CaptureContext): void | Promise<void>;\n  afterRender?(context: CaptureContext): void | Promise<void>;\n\n  /** Runs before EACH export. */\n  beforeExport?(context: CaptureContext): void | Promise<void>;\n  /**\n   * Runs after EACH export; returning a value will be chained to the next plugin\n   * (transform pipeline). If undefined is returned, the prior result is preserved.\n   */\n  afterExport?(context: CaptureContext, result: any): any | Promise<any>;\n\n  /**\n   * Provide custom exporters (e.g., { pdf: async (ctx, opts) => Blob }).\n   * Keys are exposed on the capture result as helpers (toPdf()) and as index access (result['pdf']()).\n   */\n  defineExports?(context: CaptureContext): ExportMap | Promise<ExportMap>;\n\n  /** Runs ONCE after the FIRST successful export of this capture (good for cleanup). */\n  afterSnap?(context: CaptureContext): void | Promise<void>;\n}\n\nexport type PluginFactory = (options?: any) => SnapdomPlugin;\n/** You can pass a plugin instance, a factory, or a tuple with options. */\nexport type PluginUse =\n  | SnapdomPlugin\n  | PluginFactory\n  | [PluginFactory, any]\n  | { plugin: PluginFactory; options?: any };\n\n/* =========================\n * Capture result API\n * ========================= */\n\nexport interface DownloadOptions {\n  filename?: string;\n  /** Override default blob type for this download. */\n  type?: BlobType;\n  /** Quality hint for raster formats. */\n  quality?: number;\n  /** Target width/height for this export. */\n  width?: number;\n  height?: number;\n}\n\nexport interface BlobOptions {\n  type?: BlobType;\n  quality?: number;\n  width?: number;\n  height?: number;\n}\n\nexport interface CaptureResult {\n  /** Canonical data URL of the SVG snapshot (when available). */\n  url: string;\n\n  /**\n   * @deprecated Use `toSvg()` for an <img> that renders the SVG snapshot.\n   * Historical alias kept for compatibility.\n   */\n  toImg(): Promise<HTMLImageElement>;\n\n  /** Returns an HTMLImageElement that renders the SVG snapshot. */\n  toSvg(options?: Partial<SnapdomOptions>): Promise<HTMLImageElement>;\n\n  /** Returns a Canvas with the rasterized snapshot. */\n  toCanvas(options?: Partial<SnapdomOptions>): Promise<HTMLCanvasElement>;\n\n  /** Returns a Blob of the chosen type (svg/png/jpeg/webp). */\n  toBlob(options?: BlobOptions & Partial<SnapdomOptions>): Promise<Blob>;\n\n  /** Convenience raster exports returning an HTMLImageElement. */\n  toPng(options?: Partial<SnapdomOptions>): Promise<HTMLImageElement>;\n  toJpeg(options?: Partial<SnapdomOptions>): Promise<HTMLImageElement>;\n  /** Alias for `toJpeg()`. */\n  toJpg(options?: Partial<SnapdomOptions>): Promise<HTMLImageElement>;\n  toWebp(options?: Partial<SnapdomOptions>): Promise<HTMLImageElement>;\n\n  /** Trigger a client-side download of the snapshot using current/default settings. */\n  download(options?: DownloadOptions & Partial<SnapdomOptions>): Promise<void>;\n\n  /**\n   * Custom exporters exposed by plugins:\n   * - As helpers: a plugin returning { pdf: (...) => ... } also enables result.toPdf(...)\n   * - As index access: result[\"pdf\"](...)\n   *\n   * Since keys are not known ahead of time, we allow index access.\n   */\n  [key: string]: any;\n}\n\n/* =========================\n * Main callable & static helpers\n * ========================= */\n\n/** Overload: main callable returns a reusable exporter object for the element. */\nexport declare function snapdom(\n  element: Element,\n  options?: SnapdomOptions\n): Promise<CaptureResult>;\n\n/**\n * Global plugin registration (chainable).\n * - De-duplicates by `name`.\n * - Execution order = registration order.\n * - Per-capture plugins run before globals and override by `name`.\n */\nexport declare namespace snapdom {\n  function plugins(...defs: PluginUse[]): typeof snapdom;\n\n  /** Shortcut helpers that run a one-off capture+export. */\n\n  /** @deprecated Returns an SVG <img>; prefer `toSvg`. */\n  function toImg(\n    element: Element,\n    options?: SnapdomOptions\n  ): Promise<HTMLImageElement>;\n\n  function toSvg(\n    element: Element,\n    options?: SnapdomOptions\n  ): Promise<HTMLImageElement>;\n\n  function toCanvas(\n    element: Element,\n    options?: SnapdomOptions\n  ): Promise<HTMLCanvasElement>;\n\n  function toBlob(\n    element: Element,\n    options?: SnapdomOptions & BlobOptions\n  ): Promise<Blob>;\n\n  function toPng(\n    element: Element,\n    options?: SnapdomOptions\n  ): Promise<HTMLImageElement>;\n\n  function toJpeg(\n    element: Element,\n    options?: SnapdomOptions\n  ): Promise<HTMLImageElement>;\n\n  /** Alias for `toJpeg`. */\n  function toJpg(\n    element: Element,\n    options?: SnapdomOptions\n  ): Promise<HTMLImageElement>;\n\n  function toWebp(\n    element: Element,\n    options?: SnapdomOptions\n  ): Promise<HTMLImageElement>;\n\n  function download(\n    element: Element,\n    options?: SnapdomOptions & DownloadOptions\n  ): Promise<void>;\n}\n\n/* =========================\n * preCache helper\n * ========================= */\n\nexport interface PreCacheOptions {\n  /** Root to scan (defaults to `document`). */\n  root?: Element | Document;\n  /** Try to embed non-icon fonts used under root (see also localFonts). */\n  embedFonts?: boolean;\n  /** Provide fonts explicitly to avoid remote discovery. */\n  localFonts?: LocalFont[];\n  /** Additional matchers for icon fonts (strings or regex). */\n  iconFonts?: IconFontMatcher | IconFontMatcher[];\n  /** Cross-origin proxy prefix (as in SnapdomOptions.useProxy). */\n  useProxy?: string;\n  /** Cache policy for this preload operation. */\n  cache?: CachePolicy;\n\n  /** Back-compat fields (no-ops if present) */\n  /**\n   * @deprecated Use `cache` instead.\n   */\n  cacheOpt?: CachePolicy;\n}\n\n/**\n * Preload external resources for a subtree to avoid first-capture stalls.\n * Uses the same discovery heuristics as the main capture path.\n */\nexport declare function preCache(\n  root?: Element | Document,\n  options?: PreCacheOptions\n): Promise<void>;\n\nexport {};\n"
  },
  {
    "path": "vitest.config.js",
    "content": "import { defineConfig } from 'vitest/config'\n\nexport default defineConfig({\n  test: {\n    browser: {\n      enabled: true,\n      provider: 'playwright',\n      // https://vitest.dev/guide/browser/playwright\n      instances: [\n        { browser: 'chromium' },\n      ],\n    },\n    coverage: {\n      provider: 'v8', // o 'istanbul'\n      include: [\n        'src/**/*.js',      // Solo archivos JS dentro de src/\n      ],\n    },\n  },\n})\n"
  }
]