Repository: zumerlab/snapdom Branch: main Commit: 1ba928d9d1c7 Files: 121 Total size: 830.3 KB Directory structure: gitextract_5bp_18n4/ ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ ├── labels.yml │ ├── scripts/ │ │ ├── classify-issues.js │ │ └── update-contributors.js │ └── workflows/ │ ├── issue-triage.yml │ ├── label-sync.yml │ └── update-contributors.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_CN.md ├── __tests__/ │ ├── api.preCache.more.test.js │ ├── api.preCache.test.js │ ├── api.snapdom.more.test.js │ ├── api.snapdom.test.js │ ├── core.cache.test.js │ ├── core.capture.more.test.js │ ├── core.capture.test.js │ ├── core.clone.more.test.js │ ├── core.clone.test.js │ ├── core.context.test.js │ ├── core.exporters.test.js │ ├── core.prepare.test.js │ ├── cssTools.utils.test.js │ ├── exporter.download.test.js │ ├── exporter.toCanvas.more.test.js │ ├── exporter.toCanvas.test.js │ ├── exporter.toImg.test.js │ ├── exporters.jpg-png-svg-webp.test.js │ ├── index.browser.test.js │ ├── module.background.test.js │ ├── module.changeCSS.test.js │ ├── module.counter.test.js │ ├── module.fonts.katex.test.js │ ├── module.fonts.more.more.test.js │ ├── module.fonts.more.test.js │ ├── module.fonts.test.js │ ├── module.iconFonts.more.test.js │ ├── module.iconFonts.test.js │ ├── module.lineClamp.test.js │ ├── module.pseudo.test.js │ ├── module.snapFetch.test.js │ ├── module.styles.test.js │ ├── module.svg.test.js │ ├── modules.images.test.js │ ├── snapdom.attributes.test.js │ ├── snapdom.backgroundColor.test.js │ ├── snapdom.benchmark.js │ ├── snapdom.complex.benchmark.js │ ├── snapdom.delete.test.js │ ├── snapdom.precache.perf.test.js │ ├── snapdom.test.js │ ├── snapdom.vs.htm2canvas.outputfilesize.test.js │ ├── snapdom.vs.modernscreenshot.outputfilesize.test.js │ ├── three-shake.test.js │ ├── utils.browser.more.test.js │ ├── utils.browser.test.js │ ├── utils.capture.helpers.test.js │ ├── utils.css.test.js │ ├── utils.helpers.test.js │ ├── utils.image.test.js │ └── utils.transforms.helpers.test.js ├── docs/ │ ├── CNAME │ ├── assets/ │ │ └── favicon/ │ │ └── site.webmanifest │ ├── index.html │ └── labs.html ├── esbuild.config.mjs ├── eslint.config.cjs ├── package.json ├── plugins/ │ └── html-in-canvas.js ├── src/ │ ├── api/ │ │ ├── preCache.js │ │ └── snapdom.js │ ├── core/ │ │ ├── cache.js │ │ ├── capture.js │ │ ├── clone.js │ │ ├── context.js │ │ ├── exporters.js │ │ ├── plugins.js │ │ └── prepare.js │ ├── exporters/ │ │ ├── download.js │ │ ├── toBlob.js │ │ ├── toCanvas.js │ │ ├── toImg.js │ │ ├── toJpg.js │ │ ├── toPng.js │ │ ├── toSvg.js │ │ └── toWebp.js │ ├── index.browser.js │ ├── index.js │ ├── modules/ │ │ ├── CSSVar.js │ │ ├── background.js │ │ ├── changeCSS.js │ │ ├── counter.js │ │ ├── fonts.js │ │ ├── iconFonts.js │ │ ├── images.js │ │ ├── lineClamp.js │ │ ├── pseudo.js │ │ ├── rasterize.js │ │ ├── snapFetch.js │ │ ├── styles.js │ │ └── svgDefs.js │ └── utils/ │ ├── browser.js │ ├── capture.helpers.js │ ├── clone.helpers.js │ ├── css.js │ ├── debug.js │ ├── helpers.js │ ├── image.js │ ├── index.js │ ├── prepare.helpers.js │ └── transforms.helpers.js ├── types/ │ └── snapdom.d.ts └── vitest.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at martinmuda@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: .github/CONTRIBUTING.md ================================================ ## 🤝 Contributing Guide Thank 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. --- ### 📝 **Issues** * 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.). * If you have a **feature request** or an idea for improvement: * You can [open an issue](https://github.com/zumerlab/snapdom/issues/new). * Or, start a [discussion](https://github.com/zumerlab/snapdom/discussions/new) to explore the idea with the community. Even small things matter — if you spot a **typo** in the code, docs, or comments, please let us know or submit a fix! --- ### 💡 **Examples** If you build something cool using **Snapdom**, we’d love to see it! Consider starting a **discussion** to share ideas and get feedback. --- ### 💬 **Discussions** You’re welcome to use [GitHub Discussions](https://github.com/zumerlab/snapdom/discussions) for: * Asking questions. * Sharing feedback or ideas. * Connecting with other users and contributors. Let’s keep the tone friendly and constructive! --- ### 🚀 **Pull Requests (PRs)** * Before starting, it’s a good idea to **discuss large changes** in an issue or discussion. * Fork the repo, create a branch (e.g. `fix-xyz`, `feature-abc`), and submit your PR. * Please make sure: * Your code follows the project’s style (we can help review). * You add tests or documentation if relevant. * You link the related issue (if any) in the PR description. All contributions are reviewed and merged after approval. Don’t worry if you’re new — we’re happy to help! --- ### ⚡ Quick links * [Open an issue](https://github.com/zumerlab/snapdom/issues/new) * [Start a discussion](https://github.com/zumerlab/snapdom/discussions/new) * [Check open PRs](https://github.com/zumerlab/snapdom/pulls) ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: tinchox5 patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: 'bug' assignees: '' --- ### Describe it simply: ### Quick demo to reproduce ### Anything else we should know? ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: 'enhancement' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. ================================================ FILE: .github/labels.yml ================================================ # Priority labels - name: "priority: critical" color: "B60205" description: "Core functionality broken, causes crashes or data loss, affects most users" - name: "priority: high" color: "D93F0B" description: "Important feature broken or significantly degraded, no practical workaround" - name: "priority: medium" color: "E4E669" description: "Feature partially broken or degraded, workaround exists" - name: "priority: low" color: "0E8A16" description: "Minor issue, cosmetic problem, or question" # Type labels - name: "bug" color: "D73A4A" description: "Something isn't working" - name: "enhancement" color: "A2EEEF" description: "New feature or request" - name: "question" color: "D876E3" description: "Further information is requested" - name: "documentation" color: "0075CA" description: "Improvements or additions to documentation" # Status labels - name: "🔧 in development" color: "FBCA04" description: "This issue is being actively worked on" - name: "need repro" color: "E4E669" description: "A reproducible test case is needed to investigate" - name: "duplicate" color: "CFD3D7" description: "This issue or pull request already exists" - name: "wontfix" color: "FFFFFF" description: "This will not be worked on" - name: "good first issue" color: "7057FF" description: "Good for newcomers" # Browser/environment labels - name: "safari-hates-me" color: "0052CC" description: "Issue specific to Safari browser" - name: "Fails on Firefox" color: "FF6B35" description: "Issue specific to Firefox browser" # Feature area labels - name: "plugin idea" color: "BFD4F2" description: "Could be implemented as a plugin" - name: "experimental" color: "F9D0C4" description: "Experimental feature or idea" ================================================ FILE: .github/scripts/classify-issues.js ================================================ // .github/scripts/classify-issues.js // Classifies all open issues by importance and applies priority labels. // Usage: GITHUB_TOKEN= node .github/scripts/classify-issues.js // Dry-run (no changes): GITHUB_TOKEN= node .github/scripts/classify-issues.js --dry-run import https from 'https'; const repo = 'zumerlab/snapdom'; const [owner, repoName] = repo.split('/'); const token = process.env.GITHUB_TOKEN; const dryRun = process.argv.includes('--dry-run'); if (!token) { console.error('Error: GITHUB_TOKEN environment variable is required.'); process.exit(1); } // ─── Classification rules ──────────────────────────────────────────────────── // Keywords for each priority tier const CRITICAL_KEYWORDS = [ 'the source image cannot be decoded', 'crash', 'data loss', '报错', // Chinese: "reports an error" ]; const HIGH_PRIORITY_KEYWORDS = [ 'pseudo-element', 'pseudo element', 'iframe', 'font', '字体', 'background-image', 'background image', 'cross-origin', 'cross origin', 'checkbox', 'radio button', 'radio ', 'webkit-text-stroke', 'text-stroke', 'transform', 'matrix', 'katex', 'scrollbar', 'scroll position', 'border style', 'svg image', 'video', ]; const LOW_KEYWORDS = [ 'why ', 'how can', 'how do', 'is it possible', 'does it support', 'configuration option', 'timeout', 'cors', 'wicg', 'experiment', 'announcement', 'inactivity', 'plugin idea', ]; const SAFARI_KEYWORDS = ['safari', 'ios', 'webkit', 'iphone', 'ipad', 'apple']; const FIREFOX_KEYWORDS = ['firefox', 'mozilla', 'gecko']; /** * Determines the priority label for a given issue based on its title and body. * Returns one of: 'priority: critical', 'priority: high', 'priority: medium', 'priority: low' */ function classifyPriority(issue) { const title = (issue.title || '').toLowerCase(); const body = (issue.body || '').toLowerCase(); const text = title + ' ' + body; if (CRITICAL_KEYWORDS.some(k => text.includes(k))) { return 'priority: critical'; } if (LOW_KEYWORDS.some(k => text.includes(k))) { return 'priority: low'; } if (HIGH_PRIORITY_KEYWORDS.some(k => text.includes(k))) { return 'priority: high'; } return 'priority: medium'; } /** * Additional type/browser labels to add based on content. */ function classifyExtra(issue) { const title = (issue.title || '').toLowerCase(); const body = (issue.body || '').toLowerCase(); const text = title + ' ' + body; const extra = []; if (SAFARI_KEYWORDS.some(k => text.includes(k))) extra.push('safari-hates-me'); if (FIREFOX_KEYWORDS.some(k => text.includes(k))) extra.push('Fails on Firefox'); return extra; } // ─── GitHub API helpers ─────────────────────────────────────────────────────── function request(method, path, body) { return new Promise((resolve, reject) => { const data = body ? JSON.stringify(body) : null; const options = { hostname: 'api.github.com', path, method, headers: { 'User-Agent': 'classify-issues-script', 'Accept': 'application/vnd.github.v3+json', 'Authorization': `token ${token}`, ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}) } }; const req = https.request(options, (res) => { let buf = ''; res.on('data', c => buf += c); res.on('end', () => { if (res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}: ${buf}`)); } else { resolve(buf ? JSON.parse(buf) : null); } }); }); req.on('error', reject); if (data) req.write(data); req.end(); }); } async function fetchOpenIssues() { const issues = []; let page = 1; while (true) { const batch = await request('GET', `/repos/${owner}/${repoName}/issues?state=open&per_page=100&page=${page}`); if (!batch || batch.length === 0) break; // Exclude pull requests (GitHub includes PRs in /issues endpoint) issues.push(...batch.filter(i => !i.pull_request)); if (batch.length < 100) break; page++; } return issues; } async function addLabels(issueNumber, labels) { await request('POST', `/repos/${owner}/${repoName}/issues/${issueNumber}/labels`, { labels }); } // ─── Main ───────────────────────────────────────────────────────────────────── (async () => { console.log(`Fetching open issues for ${repo}…`); const issues = await fetchOpenIssues(); console.log(`Found ${issues.length} open issues.\n`); if (dryRun) { console.log('DRY RUN — no labels will be applied.\n'); } const summary = { critical: [], high: [], medium: [], low: [] }; for (const issue of issues) { const existingLabels = issue.labels.map(l => l.name); const hasPriority = existingLabels.some(l => l.startsWith('priority:')); const priorityLabel = classifyPriority(issue); const extraLabels = classifyExtra(issue).filter(l => !existingLabels.includes(l)); const newLabels = [ ...(hasPriority ? [] : [priorityLabel]), ...extraLabels ]; const level = priorityLabel.replace('priority: ', ''); summary[level].push(`#${issue.number}: ${issue.title}`); if (newLabels.length === 0) { console.log(`#${issue.number} — no new labels needed (existing: ${existingLabels.join(', ') || 'none'})`); continue; } console.log(`#${issue.number} [${priorityLabel}] — adding: ${newLabels.join(', ')}`); if (!dryRun) { try { await addLabels(issue.number, newLabels); } catch (err) { console.error(` ✗ Failed to label #${issue.number}: ${err.message}`); } } } console.log('\n── Classification Summary ──────────────────────────'); for (const [level, list] of Object.entries(summary)) { console.log(`\n${level.toUpperCase()} (${list.length}):`); list.forEach(t => console.log(` ${t}`)); } })(); ================================================ FILE: .github/scripts/update-contributors.js ================================================ // .github/scripts/update-contributors.js import { writeFileSync, readFileSync } from 'fs'; import https from 'https'; const repo = 'zumerlab/snapdom'; const readmePaths = ['README.md', 'README_CN.md']; function fetchContributors() { const options = { hostname: 'api.github.com', path: `/repos/${repo}/contributors`, headers: { 'User-Agent': 'GitHub Action', 'Accept': 'application/vnd.github.v3+json' } }; return new Promise((resolve, reject) => { https .get(options, (res) => { let body = ''; res.on('data', (chunk) => (body += chunk)); res.on('end', () => { if (res.statusCode === 200) { resolve(JSON.parse(body)); } else { reject(new Error(`GitHub API error: ${res.statusCode}`)); } }); }) .on('error', reject); }); } function buildHTML(contributors) { return ( '\n

\n' + contributors .map((c) => { const avatar = `${c.login}`; return avatar; }) .join('\n') + '\n

\n' ); } function updateReadmes(contributorHTML) { for (const path of readmePaths) { //try { const content = readFileSync(path, 'utf8'); const updated = content.replace( /([\s\S]*?)/, `${contributorHTML}` ); writeFileSync(path, updated); //} catch () { //} } } fetchContributors() .then((contributors) => { const filtered = contributors.filter( (c) => c.type !== 'Bot' && c.login !== 'github-actions[bot]' ); const html = buildHTML(filtered); updateReadmes(html); }) .catch((err) => { console.error('Error fetching contributors:', err); process.exit(1); }); ================================================ FILE: .github/workflows/issue-triage.yml ================================================ name: Auto-label Issues on: issues: types: [opened, edited] jobs: triage: runs-on: ubuntu-latest permissions: issues: write steps: - name: Label bug reports uses: actions/github-script@v7 with: script: | const issue = context.payload.issue; const title = (issue.title || '').toLowerCase(); const body = (issue.body || '').toLowerCase(); const text = title + ' ' + body; const labelsToAdd = []; const existingLabels = issue.labels.map(l => l.name); // ── Keyword patterns ────────────────────────────────────────────── const BUG_KEYWORDS = /bug|error|broken|crash|fail|not work|doesn't work|cannot|can't|unable|wrong|incorrect|missing|disappear|blank|empty|distort/; const FEATURE_KEYWORDS = /feature request|suggestion|enhancement|would like|wish|please add/; const QUESTION_KEYWORDS = /^(why|how|what|can you|is it possible|does it|do you)|\bquestion\b|\bhelp\b/; const SAFARI_KEYWORDS = /safari|ios|webkit|iphone|ipad|apple/; const FIREFOX_KEYWORDS = /firefox|mozilla|gecko/; const CRITICAL_KEYWORDS = /crash|data loss|cannot capture|completely broken|decode|source image/; const HIGH_KEYWORDS = /pseudo.?element|iframe|font|background.?image|cross.?origin|checkbox|radio|text.?stroke|transform|matrix/; const LOW_BUG_KEYWORDS = /configuration option|timeout setting|cors support/; // ── Detect issue type ───────────────────────────────────────────── const isBug = BUG_KEYWORDS.test(text); const isFeature = FEATURE_KEYWORDS.test(text) && !isBug; const isQuestion = QUESTION_KEYWORDS.test(text) && !isBug; // ── Apply type labels ───────────────────────────────────────────── if (isBug && !existingLabels.includes('bug')) labelsToAdd.push('bug'); if (isFeature && !existingLabels.includes('enhancement')) labelsToAdd.push('enhancement'); if (isQuestion && !existingLabels.includes('question')) labelsToAdd.push('question'); // ── Apply browser labels ────────────────────────────────────────── if (SAFARI_KEYWORDS.test(text) && !existingLabels.includes('safari-hates-me')) labelsToAdd.push('safari-hates-me'); if (FIREFOX_KEYWORDS.test(text) && !existingLabels.includes('Fails on Firefox')) labelsToAdd.push('Fails on Firefox'); // ── Assign priority for bugs (skip if already labelled) ─────────── const hasPriority = existingLabels.some(l => l.startsWith('priority:')); if (isBug && !hasPriority) { if (CRITICAL_KEYWORDS.test(text)) { labelsToAdd.push('priority: critical'); } else if (HIGH_KEYWORDS.test(text)) { labelsToAdd.push('priority: high'); } else if (LOW_BUG_KEYWORDS.test(text)) { labelsToAdd.push('priority: low'); } else { labelsToAdd.push('priority: medium'); } } if (labelsToAdd.length > 0) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, labels: labelsToAdd }); console.log(`Added labels: ${labelsToAdd.join(', ')} to issue #${issue.number}`); } ================================================ FILE: .github/workflows/label-sync.yml ================================================ name: Sync Labels on: push: branches: - main paths: - '.github/labels.yml' workflow_dispatch: jobs: sync-labels: runs-on: ubuntu-latest permissions: issues: write contents: read steps: - name: Checkout repo uses: actions/checkout@v4 - name: Sync labels uses: EndBug/label-sync@v2 with: config-file: .github/labels.yml delete-other-labels: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/update-contributors.yml ================================================ name: Update Contributors in READMES on: push: branches: - main schedule: - cron: '0 0 * * 0' workflow_dispatch: jobs: update-contributors: runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Generate contributors list run: node .github/scripts/update-contributors.js - name: Commit and push if changed run: | git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' if ! git diff --quiet; then git add README.md README_CN.md git commit -m "chore: update contributors list" git push fi ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # vitepress build output **/.vitepress/dist # vitepress cache directory **/.vitepress/cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* package-lock.json drafts/ demos/ __tests__/__screenshots__ ================================================ FILE: .vscode/settings.json ================================================ { "liveServer.settings.port": 5501 } ================================================ FILE: CHANGELOG.md ================================================ ### Changelog All notable changes to this project will be documented in this file. #### [v2.5.0](https://github.com/zumerlab/snapdom/compare/v2.1.0...v2.5.0) > 17 March 2026 - 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) - fix: enable image download on iOS via Web Share API [`#384`](https://github.com/zumerlab/snapdom/pull/384) - feat(scrollbar): implement custom scrollbar style collection for capture, ensuring styles are applied correctly. Closes #334 [`#334`](https://github.com/zumerlab/snapdom/issues/334) - feat(styles): normalize Tailwind border styles in capture and inlineAllStyles to ensure consistent output. Closes #362 [`#362`](https://github.com/zumerlab/snapdom/issues/362) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - fix: enable image download on iOS via Web Share API [`#383`](https://github.com/zumerlab/snapdom/issues/383) - 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) - 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) - fix: Firefox checkbox radio replacement [`b97e553`](https://github.com/zumerlab/snapdom/commit/b97e5539849d08dc871b9a2e486c9505bdfc081e) - refactor(cache): implement EvictingMap for cache management to limit memory usage and improve performance [`212cd4f`](https://github.com/zumerlab/snapdom/commit/212cd4f0471613b68a47427a1e23f7d076d303de) - refactor(styles): improve height handling for transparent wrappers to support margin collapsing and enhance layout stability [`c50ccef`](https://github.com/zumerlab/snapdom/commit/c50ccefe931e4809d12c8993dde6aba3f9b46a54) - fix(styles): prevent overriding border styles when using border-image, and improve getStyle fallback handling [`a5857c5`](https://github.com/zumerlab/snapdom/commit/a5857c5c49febe3d39729f214ca29914928af34a) - feat(images): add support for inlining SVG <image> elements as data URLs, addressing #341. [`f7d4616`](https://github.com/zumerlab/snapdom/commit/f7d46160913bcf5dad26be0103ef01dc7d29243c) - 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) - test(getStyle): add tests to ensure getStyle never returns undefined for elements and pseudo-elements [`83c3854`](https://github.com/zumerlab/snapdom/commit/83c3854dc520a0e944a6cf1d23dbc5cdaf8e520a) - refactor(snapdom): streamline plugin exports by consolidating export functions into a loop for improved maintainability [`ca35387`](https://github.com/zumerlab/snapdom/commit/ca353871b2b696252ddd21572ae31106b39d3c76) - 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) - fix(css): enhance getWindowForElement and getStyle functions to handle cross-document scenarios and improve fallback logic [`b0fbc8d`](https://github.com/zumerlab/snapdom/commit/b0fbc8d44745811a7c5627e34ef764e02ff188f7) - refactor(context): remove inline cache policy normalization and import from cache module for improved code organization [`0492756`](https://github.com/zumerlab/snapdom/commit/04927566faf919c7a3df07287f799b152e5d4c8f) - feat(safari): add `safariWarmupAttempts` option to optimize font and image decoding for improved capture performance [`2474c05`](https://github.com/zumerlab/snapdom/commit/2474c0528051940e503c08ca52861042e57cc880) - fix(styles): prevent width constraints on inline and specific tags to avoid text wrapping issues [`674ef27`](https://github.com/zumerlab/snapdom/commit/674ef276bcad8e4107c57fee3e976083590a5dd0) - chore: update contributors list [`b30de75`](https://github.com/zumerlab/snapdom/commit/b30de75ab66cac137334a0ef68ca2bf9133f88c4) - docs: update README files to replace NPM version badge with weekly downloads badge [`8e12d01`](https://github.com/zumerlab/snapdom/commit/8e12d01fbeb861989eb1d60c534596c55ce9207a) - chore(.gitignore): add 'demos/' directory to .gitignore to exclude demo files from version control [`fa34905`](https://github.com/zumerlab/snapdom/commit/fa34905450d889e48b9b943c941f29386627acc5) - refactor(prepare): simplify deepClone call by removing redundant element argument for cleaner code [`399bfaf`](https://github.com/zumerlab/snapdom/commit/399bfaf127bb4416200068334dc069a5bfcef2ab) - fix(styles): adjust inline style for timestamp demo to prevent text wrapping [`b88e8d7`](https://github.com/zumerlab/snapdom/commit/b88e8d74f67fe67e3ac610972c53ff49752f6b74) - fix(snapdom): remove redundant safariWarmup reset to improve iteration logic [`72d3fb7`](https://github.com/zumerlab/snapdom/commit/72d3fb72968d136f1c71d234d57272ce0f3fb6e1) - Merge PR #384: enable image download on iOS via Web Share API [`05bc67c`](https://github.com/zumerlab/snapdom/commit/05bc67c76dbe6cc02946773de8413d48e314b3d9) - Merge main into dev (2.1.0) [`5f5ab34`](https://github.com/zumerlab/snapdom/commit/5f5ab345194832225e311421d177963ce3c4c59e) #### [v2.1.0](https://github.com/zumerlab/snapdom/compare/v2.0.2...v2.1.0) > 10 March 2026 - fix(background): inline background-image inside shadow DOM hosts [`#379`](https://github.com/zumerlab/snapdom/pull/379) - Update URL handling to use location.origin in fonts.js [`#380`](https://github.com/zumerlab/snapdom/pull/380) - fix: use nodeMap for source-clone alignment in inlinePseudoElements [`#381`](https://github.com/zumerlab/snapdom/pull/381) - fix(background): properly inline background-image inside shadow DOM hosts [`#318`](https://github.com/zumerlab/snapdom/issues/318) - update demo site [`4de1850`](https://github.com/zumerlab/snapdom/commit/4de1850d84d1698e8574fe405007fb47d6a677ea) - feat: classify open issues by importance with priority labels and triage workflows [`246a4c4`](https://github.com/zumerlab/snapdom/commit/246a4c43eef13fe8b654e297f52a639a7ad670b1) - fix: Enhanced font embedding functionality for dynamically injected stylesheets [`3d4985a`](https://github.com/zumerlab/snapdom/commit/3d4985a6d40963c30ff188207a62ac1e287709ba) - fix: resolve CSS transform double-scale bug (issue #321) [`d41504b`](https://github.com/zumerlab/snapdom/commit/d41504b8dcf94454a331337c49d74928d533f49a) - fix: improve demo capture functionality with Safari support and locking mechanism [`2cb3856`](https://github.com/zumerlab/snapdom/commit/2cb3856e55f0f1d8e0d2ac050d549705203fc6ae) - refactor: update build configuration for legacy and ESM outputs, removing module structure and adding subpath exports [`94f6289`](https://github.com/zumerlab/snapdom/commit/94f62897054f37923c5cd3e4e2d3a57a0fde8db4) - docs: update README to reflect changes in SnapDOM ESM build structure and usage instructions [`8e5a710`](https://github.com/zumerlab/snapdom/commit/8e5a7103b45ae2d326a93218c977a371407d8cec) - fix: only change to location.origin when treating inline styles in font.js [`94c91c6`](https://github.com/zumerlab/snapdom/commit/94c91c61d57b79a083c75d27aa3025beb1dcb535) - fix: validate fallback image data before setting source [`2c754fe`](https://github.com/zumerlab/snapdom/commit/2c754fec37e3ce18c512ff3ee61e386fcf780589) - fix: ensure image is only appended if data URL is valid [`46957c5`](https://github.com/zumerlab/snapdom/commit/46957c51c49d766db5ad60357f5664a7cf500049) - fix: ensure valid CSS text is fetched for font links [`201209f`](https://github.com/zumerlab/snapdom/commit/201209faad857dd21a01ebc2ae5dc740a33819ce) - Merge pull request #301 from Amyuan23/fix/svg-root-font-size [`5bd53ba`](https://github.com/zumerlab/snapdom/commit/5bd53ba4887dba4664589b51651747809a89f8ab) - Merge pull request #350 from ZiuChen/fix/remote-katex-font [`3cbbd57`](https://github.com/zumerlab/snapdom/commit/3cbbd577eae0c751fbac41cd3b3ff0aeb251ca0e) - Merge pull request #378 from FlavioLimaMindera/fix-scale-image-issue-321 [`e53f2f8`](https://github.com/zumerlab/snapdom/commit/e53f2f8c4a83617a14c168c0c2615bde283d4696) - Fix: Inherit root font-size in SVG output [`3bdf300`](https://github.com/zumerlab/snapdom/commit/3bdf300ce417d56b928ea3c1b7258103a35f2445) - Merge pull request #374 from kohaiy/patch-1 [`0b21142`](https://github.com/zumerlab/snapdom/commit/0b21142b87d1874aaaa88bc7fc9630eb506ab958) #### [v2.0.2](https://github.com/zumerlab/snapdom/compare/v2.0.1...v2.0.2) > 20 January 2026 - Fix bug when captured element is SVG. Closes #324 [`#324`](https://github.com/zumerlab/snapdom/issues/324) - Improve docs for blob [`#352`](https://github.com/zumerlab/snapdom/issues/352) #### [v2.0.1](https://github.com/zumerlab/snapdom/compare/v2.0.0...v2.0.1) > 26 November 2025 - Fix spaces. Closes #326 [`#326`](https://github.com/zumerlab/snapdom/issues/326) - Fix download options. Closes #323 [`#323`](https://github.com/zumerlab/snapdom/issues/323) - Fix Safari Image Issue. See #330 [`ba80b6f`](https://github.com/zumerlab/snapdom/commit/ba80b6f66393886298cdb2fc7ce134d1824184f7) - Minify mjs version [`9155d4f`](https://github.com/zumerlab/snapdom/commit/9155d4fa2e2fc4bb6a0e3d7a991fc75818644084) - Add a re-export for preCache. See #332 [`acc5b79`](https://github.com/zumerlab/snapdom/commit/acc5b79a0c8d38de9f6dea5b98bf1179b54ef671) ### [v2.0.0](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.4...v2.0.0) > 18 November 2025 - V2 release!! [`#319`](https://github.com/zumerlab/snapdom/pull/319) #### [v2.0.0-dev.4](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.3...v2.0.0-dev.4) > 18 November 2025 - Feature enable tree-shakeable code [`ebb7b6a`](https://github.com/zumerlab/snapdom/commit/ebb7b6add3b47c75a450e0264d628837303def5d) - Fix bug when img has height with % units. Closes #268 [`#268`](https://github.com/zumerlab/snapdom/issues/268) - Fix regression to process MathJax [`7ef116c`](https://github.com/zumerlab/snapdom/commit/7ef116cdbbfcba0793cd918b2ba8ad94e063f74f) - Fix bug See #316 [`7efbede`](https://github.com/zumerlab/snapdom/commit/7efbede5970ed09982c06f8cfa3fe45d990d8fdf) #### [v2.0.0-dev.3](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.2...v2.0.0-dev.3) > 11 November 2025 - Reorganice helper functions [`d1fd982`](https://github.com/zumerlab/snapdom/commit/d1fd98240459f07897311a42e09c1ad3e3a48c62) - Perf improvement [`daf0eca`](https://github.com/zumerlab/snapdom/commit/daf0eca47c0d11828e1a702b6d64d8ab7450581d) - Fix placeholder dimensions when image loading fails [`e44b9d9`](https://github.com/zumerlab/snapdom/commit/e44b9d947efaf28fec8976dc13b398788d461d52) #### [v2.0.0-dev.2](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.1...v2.0.0-dev.2) > 9 November 2025 - Integrate createBackground into toCanvas. Closes #297 [`#297`](https://github.com/zumerlab/snapdom/issues/297) - Add Chinese translation of README.md (readme_cn.md) [`#298`](https://github.com/zumerlab/snapdom/issues/298) - Adjust final dimensions when excludeMode: remove. See #294 [`a860827`](https://github.com/zumerlab/snapdom/commit/a8608271ffd9b891ec815fa7e3130c0e1be45307) - Improve material icon / symbols. See #304 [`526c4c8`](https://github.com/zumerlab/snapdom/commit/526c4c8e6874b2dab6110c8ac3361a29b1dc91de) - Add XHTML sanitize. See #282 [`0039301`](https://github.com/zumerlab/snapdom/commit/003930196ed6e11c5dd54fce75d814046a36637d) - Add detection to Baidu on iOS. Also detect other apps/browsers on iOS. See #295 [`97e6dff`](https://github.com/zumerlab/snapdom/commit/97e6dffa34440d157b0b2b1afc5267d7b15c1d7b) - add support for text-underline-offset. See #303 [`fb603bc`](https://github.com/zumerlab/snapdom/commit/fb603bca971a10577215c436b73ba5468a4255ae) - add support for text-underline-offset. See#303 [`0b93c0d`](https://github.com/zumerlab/snapdom/commit/0b93c0de7a720a7c6708a50a153de86ba6f2684e) - fix: improve CSS src property parsing in font faces [`dbe52a1`](https://github.com/zumerlab/snapdom/commit/dbe52a121a7d68441570c02ae32c607907e188dc) #### [v2.0.0-dev.1](https://github.com/zumerlab/snapdom/compare/v2.0.0-dev.0...v2.0.0-dev.1) > 23 October 2025 - Add basic support for icons with ligature such as material-icons. Closes #275 [`#275`](https://github.com/zumerlab/snapdom/issues/275) - Replace straighten with outerTransforms, and noShadows with outerShadows [`902f032`](https://github.com/zumerlab/snapdom/commit/902f032a43ed4919701765d89818c7782902b403) - Fix straighten regression [`60c6569`](https://github.com/zumerlab/snapdom/commit/60c6569ebcfbc9ea562c9bddfe9f01c6ea4136db) #### [v2.0.0-dev.0](https://github.com/zumerlab/snapdom/compare/v1.9.14...v2.0.0-dev.0) > 14 October 2025 - Document plugin system [`d87ac01`](https://github.com/zumerlab/snapdom/commit/d87ac01fcf0beb8779afbe2be709aa1b35cf7113) - First plugin and exporter draft [`5da0948`](https://github.com/zumerlab/snapdom/commit/5da09483b82e18ca0fc6873393b9d6830632fcfc) - Fix subpixel bug. See #261 [`465950b`](https://github.com/zumerlab/snapdom/commit/465950b18e68ce0faf94b229bd65511128cd18a7) - Update demos with plugins [`50e6c4f`](https://github.com/zumerlab/snapdom/commit/50e6c4f85d74bb769fcd7f753f92a3c5be4536c6) - Update plugin system [`4e21b47`](https://github.com/zumerlab/snapdom/commit/4e21b475fdc42c6eade9cb4dada5bb5ffeb71978) - Enhance external SVG defs. See #262 [`cd4a7fb`](https://github.com/zumerlab/snapdom/commit/cd4a7fbf0e2d9fc1651c06d5c5f5a3a8f0f54329) - First plugin system draft [`9c6b91b`](https://github.com/zumerlab/snapdom/commit/9c6b91bc4352de3d323c0746a4e3040058dad519) - Fix complex canvas render on Safari. See #263 [`2697207`](https://github.com/zumerlab/snapdom/commit/2697207c98373867bcbb91d4afb73d3820c878d3) - Enhance CSS vars detection. See #262 [`6655303`](https://github.com/zumerlab/snapdom/commit/665530377bceff05ff9c1570301019bd95370a9c) - Fix counter CSS reset and bug when exist background-image. See #265 [`3c29997`](https://github.com/zumerlab/snapdom/commit/3c299978e2a4cd4c2daf07d64de5772b28e880b8) - FIx excludeFonts defs. See #260 [`84b1770`](https://github.com/zumerlab/snapdom/commit/84b1770dd2e6e487e22afcd04c2d34cd0490528c) - Fix local register [`9c4508e`](https://github.com/zumerlab/snapdom/commit/9c4508e0cdeb445b76babd71483fe154e0e2ee3e) - Enable use built-in exporters in custom exporter [`5c6fe36`](https://github.com/zumerlab/snapdom/commit/5c6fe367101d8dfec710372d2b0a7362da3597a3) - Fix background-repeat. See #259 [`0ad5fa4`](https://github.com/zumerlab/snapdom/commit/0ad5fa4ee56e417016ce495ea299cf4a3c586f6a) - Fix export name format jpg -> jpeg [`47d532a`](https://github.com/zumerlab/snapdom/commit/47d532a98be096829b592295527c1a6428d6a5d8) - Update roadmap [`dff59b9`](https://github.com/zumerlab/snapdom/commit/dff59b9ea62fed09a9146fd1e9d897dace30a37b) - Fix local plugin registration [`0997618`](https://github.com/zumerlab/snapdom/commit/0997618fb2e716ead23bfbe4824bb8365987ccd4) #### [v1.9.14](https://github.com/zumerlab/snapdom/compare/v1.9.13...v1.9.14) > 5 October 2025 - Recompile builds [`fca9e00`](https://github.com/zumerlab/snapdom/commit/fca9e00d49bb675d6b6103ba2de3f898c85c5578) #### [v1.9.13](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.4...v1.9.13) > 5 October 2025 - Improve CSS vars detection. Closes #255 [`#255`](https://github.com/zumerlab/snapdom/issues/255) - Fix toImg() dimensions when scale==1. Closes #254 [`#254`](https://github.com/zumerlab/snapdom/issues/254) - Enhance web fonts detection on deph relative paths. See #253 [`e2a8c45`](https://github.com/zumerlab/snapdom/commit/e2a8c454fbab7590f39f77da544193f8e5af13ab) - Add two new options to control transforms and shadows on root element [`8c9a75f`](https://github.com/zumerlab/snapdom/commit/8c9a75f77940221107a6005e458514cca981b2eb) - Add toSvg() in replacement of toImg() [`10e2043`](https://github.com/zumerlab/snapdom/commit/10e2043182672b42dfbda4268fb17f75bc3b561a), [`122317e`](https://github.com/zumerlab/snapdom/commit/122317eb0301f8375cc23ea8f3fd2361d1b759a4) - Improve relative path detection. See #253 [`34158e0`](https://github.com/zumerlab/snapdom/commit/34158e0f5cf8f797cd49416f090d06d2d233542d) - Lint code [`c754981`](https://github.com/zumerlab/snapdom/commit/c7549812a018d28809e0e2b314c973abe0cd542c) - Lint tests [`ee974ed`](https://github.com/zumerlab/snapdom/commit/ee974ede694f324f674b688aa7cce16f7ec30a90) #### [v1.9.12-dev.4](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.3...v1.9.12-dev.4) > 30 September 2025 - Add basic support to sticky elements. See #232 [`02893e6`](https://github.com/zumerlab/snapdom/commit/02893e60e7f3244c1274d64232e7dbf338a283ec) - Update types [`19317e6`](https://github.com/zumerlab/snapdom/commit/19317e6ff34599028e627f891ae5a2d28036bac1) - fix formating [`58ca761`](https://github.com/zumerlab/snapdom/commit/58ca7616e3e82ec81122804264466f33d79c45c2) - Enhance browser detection. See #251 [`cfe753c`](https://github.com/zumerlab/snapdom/commit/cfe753c280ab0c0cda8aca88978a550257caf23f) - Fix pseudo capture. See #252 [`e85678e`](https://github.com/zumerlab/snapdom/commit/e85678e54fac6736c3dac8a0eaac8f1607dcbb6a) - Merge pull request #249 from K1ender/dev [`e497bba`](https://github.com/zumerlab/snapdom/commit/e497bbae30e2c539c53715f9dd0bed585d10cf9a) #### [v1.9.12-dev.3](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.2...v1.9.12-dev.3) > 26 September 2025 - Captures CSS shadows [`050365f`](https://github.com/zumerlab/snapdom/commit/050365f8ab2087a912c71c468b4b1c234b21dd6a) - Improve counter simulation [`4c9e21d`](https://github.com/zumerlab/snapdom/commit/4c9e21d6bc0135614b882e1940ce31b64c5e40b2) - Fix scale, width, height options [`8ef48cb`](https://github.com/zumerlab/snapdom/commit/8ef48cb2e038c286eea9b7ce2baafac30c062a5e) - Just run safariWarmup if it is needed [`a32846d`](https://github.com/zumerlab/snapdom/commit/a32846d6f48ab8c583b1f26c41d19f3cff67ab55) - Safari, in case of scale, width or height options use png to ensure fidelity [`0711a77`](https://github.com/zumerlab/snapdom/commit/0711a7774f6f545cd05e0cce86f3354fe377b02d) - Sanitize container [`a6ba396`](https://github.com/zumerlab/snapdom/commit/a6ba396c52406bf9c5dd0ffd27f9460cd34b028e) #### [v1.9.12-dev.2](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.1...v1.9.12-dev.2) > 22 September 2025 - Fix margin collapsing in some cases. See #243 [`7fe0a3f`](https://github.com/zumerlab/snapdom/commit/7fe0a3ffe6e9827b276f0c0337c60c8c02c4129c) #### [v1.9.12-dev.1](https://github.com/zumerlab/snapdom/compare/v1.9.12-dev.0...v1.9.12-dev.1) > 20 September 2025 - Modularize counters [`024a7f9`](https://github.com/zumerlab/snapdom/commit/024a7f9b2d1b4805c7b12b5cbb62f0200295cc8f) - Improve CSS counter() and counters() handling. See #120, see #235 [`8fb0385`](https://github.com/zumerlab/snapdom/commit/8fb03859301075ea9b096197ec5b4dbca4223a95) - Feat lineClamp. See #241 [`4082cd6`](https://github.com/zumerlab/snapdom/commit/4082cd6c39ff43bcb842a81768e11b858c5e8559) - Fix width/height options [`7cd2111`](https://github.com/zumerlab/snapdom/commit/7cd21114d1337e48b5d743cb94989feb1a1a0d20) - Fix bug that hangs snapDOM on some browsers. See #236 [`bc3c400`](https://github.com/zumerlab/snapdom/commit/bc3c400356a6f32f8d1d8a986c9a427e6dd13c7f) - Fix bug that overrides options.width/heigth. See #241 [`49fbb63`](https://github.com/zumerlab/snapdom/commit/49fbb63ac6c28a66a8e6d9076618bee7b6beab62) - Improve webFonts render. See #229 [`3082a3a`](https://github.com/zumerlab/snapdom/commit/3082a3ae019f424e127f3c065cbcfa7bad59bb39) - Feat. detect wechat browser. See #223 [`e7c4723`](https://github.com/zumerlab/snapdom/commit/e7c4723a24ad3a9c52da5a2e021c115b354bcf66) - Ensure donwload file measure. See #241 [`eed1995`](https://github.com/zumerlab/snapdom/commit/eed1995a56664c0ace5d94422ba6bb8b5ef82324) - Add iframe support [`ec59e4b`](https://github.com/zumerlab/snapdom/commit/ec59e4bad3dbb639cde37aed929dccb42b54e6b5) #### [v1.9.12-dev.0](https://github.com/zumerlab/snapdom/compare/v1.9.11...v1.9.12-dev.0) > 11 September 2025 - Try fix fallback images [`67bedd3`](https://github.com/zumerlab/snapdom/commit/67bedd3c7cb6bda3e2291fe494805a58263e6dce) - Two separate mode: filterMode and excludeMode [`394e7f4`](https://github.com/zumerlab/snapdom/commit/394e7f4ca2171fb1028eb382b2331d4718f6a350) - Workaround Safari See #231 [`593ad59`](https://github.com/zumerlab/snapdom/commit/593ad59383d0b3adbcb139f6892ae321a08c60d5) - Try new approach for solve Safari fonts/images decoding [`5b77738`](https://github.com/zumerlab/snapdom/commit/5b7773847f02809826a9ed459321100cfbd50518) #### [v1.9.11](https://github.com/zumerlab/snapdom/compare/v1.9.10...v1.9.11) > 9 September 2025 - Fix Safari bug that prevents capture [`6a43e59`](https://github.com/zumerlab/snapdom/commit/6a43e59d1c311452c7d16e1adc9bb12bb89132b4) #### [v1.9.10](https://github.com/zumerlab/snapdom/compare/v1.9.10-dev.2...v1.9.10) > 9 September 2025 - Merge dev branch [`#225`](https://github.com/zumerlab/snapdom/pull/225) - Strip dev comments [`e02066b`](https://github.com/zumerlab/snapdom/commit/e02066b6ef8e6c2b1d50b86096500f914ac025af) - increase test coverage [`0c59fa0`](https://github.com/zumerlab/snapdom/commit/0c59fa0d276b7899cf2b721d32e7934e701df76b) - Update types defs [`648f4a9`](https://github.com/zumerlab/snapdom/commit/648f4a965106c58c6f63d384bf8db600a661146c) - Improves mask handling [`f3915ea`](https://github.com/zumerlab/snapdom/commit/f3915ea919b927b5f2ff0a9f3c865beb7b08b231) - fix backgroundColor regression [`21a6a39`](https://github.com/zumerlab/snapdom/commit/21a6a3923d81d2f733d6bc61136a9d4eda8f1a62) #### [v1.9.10-dev.2](https://github.com/zumerlab/snapdom/compare/v1.9.10-dev.1...v1.9.10-dev.2) > 8 September 2025 - Fix cache disabled bug. Closes #221 [`#221`](https://github.com/zumerlab/snapdom/issues/221) - Add extra margin when element has transform [`6688eee`](https://github.com/zumerlab/snapdom/commit/6688eee661de2d91249f9db28948629f421b14b4) - Feat. handkles css trasnforms and scale rotate new props. Ref #216 [`d151da1`](https://github.com/zumerlab/snapdom/commit/d151da1993f1374d2bae16ed1a076a05f9ff8d45) - Add same-origin iframe support .See #222 [`f50720f`](https://github.com/zumerlab/snapdom/commit/f50720fe76d8d114c1de31ffe802ade1edd7060e) - Fix regression that doesnt reset origial translate [`800c427`](https://github.com/zumerlab/snapdom/commit/800c427d327f41fbcc9703dfa5a3e99b9b7c789f) - Fix duplicated values on textArea [`0915f8d`](https://github.com/zumerlab/snapdom/commit/0915f8d4f1b9e58800407ba28ad86e16b6cc4621) - 增强图像处理功能,添加图像加载失败时的后备图像源支持,并记录原始图像尺寸以便于使用。更新类型定义以包含新选项。 [`011620a`](https://github.com/zumerlab/snapdom/commit/011620a3c8dbabed9c2e509766c4516205fbad66) - ✨ feat: [`e3a4556`](https://github.com/zumerlab/snapdom/commit/e3a4556a4085c0968bcce9f54d7d0fb9bbcfc6a7) - remove iframe limitation [`77abf8f`](https://github.com/zumerlab/snapdom/commit/77abf8f617fd1942565ee141406757c3704f45ae) - Merge pull request #220 from Jarvis2018/main [`adb6455`](https://github.com/zumerlab/snapdom/commit/adb6455fd6ff9db128dda5a59ac7556a16c851fa) - Merge pull request #215 from xiaobai-web715/dev [`37be327`](https://github.com/zumerlab/snapdom/commit/37be327f7c55ae20ee65001a8469f59284dbe12f) #### [v1.9.10-dev.1](https://github.com/zumerlab/snapdom/compare/v1.9.10-dev.0...v1.9.10-dev.1) > 3 September 2025 - Fix flickering on Safari. Closes #197 [`#197`](https://github.com/zumerlab/snapdom/issues/197) - Prevents default svg values overwrite custom ones. Closes #217 [`#217`](https://github.com/zumerlab/snapdom/issues/217) - Feature: add placeholders option to disable rendered placeholder for iframes and fallback images. Closes #137 [`#137`](https://github.com/zumerlab/snapdom/issues/137) - FIx textarea styles. Closes #212 [`#212`](https://github.com/zumerlab/snapdom/issues/212) #### [v1.9.10-dev.0](https://github.com/zumerlab/snapdom/compare/v1.9.9...v1.9.10-dev.0) > 29 August 2025 - Code refactor, cache improve, options centralized [`3bd7182`](https://github.com/zumerlab/snapdom/commit/3bd71822cf72614b3bb5993039482fdb05833ceb) - Improve performance and cache [`8882025`](https://github.com/zumerlab/snapdom/commit/88820259d4000fd36dbf4f59bb4940a6e12e6611) - Enhance font handling [`cb1e04a`](https://github.com/zumerlab/snapdom/commit/cb1e04af0551f097b44eeb84ba64a97e869b6f60) - Improve capture fidelity [`e05f027`](https://github.com/zumerlab/snapdom/commit/e05f027b8eb3b5b90e22a9d8ce7a7279f7b1614b) - fix font fetching [`70dd092`](https://github.com/zumerlab/snapdom/commit/70dd092adb14899031f0596d83ec354c9ab023b9) - Set compress as default [`7e5ab00`](https://github.com/zumerlab/snapdom/commit/7e5ab007f653f995b09ecbbf7711d69937fdef4a) - optimice code [`df52437`](https://github.com/zumerlab/snapdom/commit/df524379288fcb08dd19b8957d627a24b898685e) - Core update: increase X3 speed capture compared 1.9.9 [`94bc57d`](https://github.com/zumerlab/snapdom/commit/94bc57dc53cb63d82532467b4a041ab26da7481a) - Ensure custom fonts are capured [`8125689`](https://github.com/zumerlab/snapdom/commit/81256893249b2313edfebacb9d68ec6ed4fa9ed2) - Fix first custom font bug on Safari [`971d976`](https://github.com/zumerlab/snapdom/commit/971d9762dd73263689057e16fb47c00d2e0eba1b) - Fix bug that affects overall capture fidelity [`35539a5`](https://github.com/zumerlab/snapdom/commit/35539a50da67c30e28c39272a4c1efefbf24a2e2) - update to avoid vitest issues [`51ef80d`](https://github.com/zumerlab/snapdom/commit/51ef80d24b693ceee3e33d75e2b64ba7037e49ea) #### [v1.9.9](https://github.com/zumerlab/snapdom/compare/v1.9.8...v1.9.9) > 14 August 2025 - 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) - Handles srcset. Closes #190 [`#190`](https://github.com/zumerlab/snapdom/issues/190) - Fix speed regression. [`542ed00`](https://github.com/zumerlab/snapdom/commit/542ed003e3f35c19bd51fd5317f5572c12ba1ac8) - Handles Blob scr [`fe27239`](https://github.com/zumerlab/snapdom/commit/fe27239ac3efef9a0e9e7f0feeb223dd74fd9086). Closes [`#169`](https://github.com/zumerlab/snapdom/issues/169) #### [v1.9.8](https://github.com/zumerlab/snapdom/compare/v1.9.7...v1.9.8) > 10 August 2025 - fix(types): update `preCache` [`#166`](https://github.com/zumerlab/snapdom/pull/166) - 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) - Fix icontFont alignment and rendered size. Closes #176 [`#176`](https://github.com/zumerlab/snapdom/issues/176) - Ensure skip empty pseudo elements. Closes #168 [`#168`](https://github.com/zumerlab/snapdom/issues/168) - Add basic border-image support. Closes #159 [`#159`](https://github.com/zumerlab/snapdom/issues/159) - Fix input css styles. Closes #144, closes #147 [`#144`](https://github.com/zumerlab/snapdom/issues/144) [`#147`](https://github.com/zumerlab/snapdom/issues/147) #### [v1.9.7](https://github.com/zumerlab/snapdom/compare/v1.9.6...v1.9.7) > 27 July 2025 - Fix input css styles. Closes #144, closes #147 [`#144`](https://github.com/zumerlab/snapdom/issues/144) [`#147`](https://github.com/zumerlab/snapdom/issues/147) - Fix Safari scale. Closes #133 [`#133`](https://github.com/zumerlab/snapdom/issues/133) - Fix @font-face. Closes #145 [`#145`](https://github.com/zumerlab/snapdom/issues/145) - Fix edge case that generates blank images on Safari. Closes #129 [`#129`](https://github.com/zumerlab/snapdom/issues/129) - Improve pseudo elements detection. Closes # 143 [`539e488`](https://github.com/zumerlab/snapdom/commit/539e488c018a1bf7be05e0d9d969e350c9ed4291) - Remove default backgroundColor on download(). Ref dissussion #142 [`a875fe3`](https://github.com/zumerlab/snapdom/commit/a875fe31c1c1fc9a1d0d59ef2934b5930a6b7c88) - Update docs, thanks @kohaiy[`e38d67b`](https://github.com/zumerlab/snapdom/commit/e38d67b0102edf75a9f6e742bd45eacc43be51c1) #### [v1.9.6](https://github.com/zumerlab/snapdom/compare/v1.9.5...v1.9.6) > 20 July 2025 - Add options argument to toBlob function. Thanks @rbbydotdev [`#118`](https://github.com/zumerlab/snapdom/pull/118) - Keep canvas CSS style. Fixes #121. [`#121`](https://github.com/zumerlab/snapdom/issues/121) - Improve: handles local() source font. See #114 [`c088aa0`](https://github.com/zumerlab/snapdom/commit/c088aa01422bf6ea6c1be70a88d09d540eae5038) - Improve webcomponent clone [`a0f37a5`](https://github.com/zumerlab/snapdom/commit/a0f37a57079057548e97a973bc6c734b4141769d) - Perf: unifies cache [`fe3a368`](https://github.com/zumerlab/snapdom/commit/fe3a3680ddef2736fc0176dcbc210fc760149038) - Improve cache handling. [`183ae2f`](https://github.com/zumerlab/snapdom/commit/183ae2f90debfb472164df01616f2558136a9f8f) - Adjust cache reset [`ff33ed3`](https://github.com/zumerlab/snapdom/commit/ff33ed374dcc02272225ca0154a84c304e6fc19a) - Improve regex [`1b4d5ad`](https://github.com/zumerlab/snapdom/commit/1b4d5ada356bce55caecd008df746f891d379c48) - Add primitive support to css counter. See #120 [`160bc2e`](https://github.com/zumerlab/snapdom/commit/160bc2eaf984051e23031664040ea91166bca061) - Fix bug background-color on export formats. See #90 [`47a34a9`](https://github.com/zumerlab/snapdom/commit/47a34a971cc875ec4d9eab772266df81a94438e7) - Fix regression textArea duplication [`1759fd0`](https://github.com/zumerlab/snapdom/commit/1759fd0ae1681e17df946d507ae4d704efff1b18) - Prevent process local ids. See #128 [`659e862`](https://github.com/zumerlab/snapdom/commit/659e8627bb6545f7843de1bcf808dc6bfb4dff3e) - Add node version and improve docs. See #123. Thanks @miusuncle [`e457bf6`](https://github.com/zumerlab/snapdom/commit/e457bf64ca2b4294aaf237c165f865ea50cc0c14) #### [v1.9.5](https://github.com/zumerlab/snapdom/compare/v1.9.3...v1.9.5) > 14 July 2025 Fix: add type def for `SnapOptions`. Thanks @simon1uo [`#111`](https://github.com/zumerlab/snapdom/pull/111) - Add `checkbox.indeterminate`. Thanks @titoBouzout [`#104`](https://github.com/zumerlab/snapdom/pull/104) - Add mask-image CSS detection (closes #106) [`#106`](https://github.com/zumerlab/snapdom/issues/106) - Add slot detection (closes # 97) / Fix textarea content duplication (closes #110) [`#110`](https://github.com/zumerlab/snapdom/issues/110) - Add html-to-image to benchmark. Closes #103 [`#103`](https://github.com/zumerlab/snapdom/issues/103) #### [v1.8.0](https://github.com/zumerlab/snapdom/compare/v1.7.1...v1.8.0) > 30 June 2025 - fix: encode same uri multiple times [`#65`](https://github.com/zumerlab/snapdom/pull/65) - Add Lucide to icon font detection [`#50`](https://github.com/zumerlab/snapdom/pull/50) - Avoid background-image logic duplication, closes #66 [`#66`](https://github.com/zumerlab/snapdom/issues/66) - 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) - Fix: canvas style props, closes #63 [`#63`](https://github.com/zumerlab/snapdom/issues/63) - Feat: handling @import and optimice cache, closes #61 [`#61`](https://github.com/zumerlab/snapdom/issues/61) - Compile .js to es2015, closes #58 [`#58`](https://github.com/zumerlab/snapdom/issues/58) - Fix background image handling, closes #57 [`#57`](https://github.com/zumerlab/snapdom/issues/57) - Improve inlinePseudoElements() to handle decorative properties, closes #55 [`#55`](https://github.com/zumerlab/snapdom/issues/55) - Add ::first-letter detection, closes #52 [`#52`](https://github.com/zumerlab/snapdom/issues/52) - test: increases coverage [`7ebc871`](https://github.com/zumerlab/snapdom/commit/7ebc87143101a9e5c8573f5ae76ede2884b59eb8) - Increase test coverage [`0c63478`](https://github.com/zumerlab/snapdom/commit/0c634785157ca9f611973976000b2f25ba7c9549) - Improve split multiple backgrounds [`0e67a9b`](https://github.com/zumerlab/snapdom/commit/0e67a9b72fb1ea7ea4a625d5f6dc2eb40438d7cd) - chore: update contributors list [`da22404`](https://github.com/zumerlab/snapdom/commit/da2240490b46ff4a0747f7db741b822dbc6ba3c4) - chore: update contributors list [`ec7c275`](https://github.com/zumerlab/snapdom/commit/ec7c27590318df95e7aa903ec7cbd92112b6c2e8) - Add check [`bf9a888`](https://github.com/zumerlab/snapdom/commit/bf9a888525e99dd663c17455755bb1478f1cb9d7) - Create update-contributors.js [`453dff0`](https://github.com/zumerlab/snapdom/commit/453dff07d0fd8f333627ed22a6e8a64373dbd62d) - Document width and height options [`0f7fb7a`](https://github.com/zumerlab/snapdom/commit/0f7fb7a02d9159a831a1dfc4cfbd9f6f3420bca7) - Create update-contributors.yml [`b48e334`](https://github.com/zumerlab/snapdom/commit/b48e334043e2a18212620df413b8742d72959468) - Update [`7da2892`](https://github.com/zumerlab/snapdom/commit/7da2892e69d04903111bbd24421a574e0034a83b) - Bumped version [`f322f51`](https://github.com/zumerlab/snapdom/commit/f322f51e2369bfdbbc1218c15e8375b0858bf73d) - Update README.md [`99c51a8`](https://github.com/zumerlab/snapdom/commit/99c51a89e2b486bbfc2810d94350428cbc9595f2) - Update update-contributors.js [`46a868b`](https://github.com/zumerlab/snapdom/commit/46a868baa45bd068be687b19bbd50cb06ceb9cf0) - Update update-contributors.js [`2a77e4c`](https://github.com/zumerlab/snapdom/commit/2a77e4c82dab36803b005cc6bceab06689a0e52c) - chore: update contributors list [`020eff8`](https://github.com/zumerlab/snapdom/commit/020eff873c18fc601c145f957bdc566403e18649) - Update update-contributors.js [`b4cf877`](https://github.com/zumerlab/snapdom/commit/b4cf87709f632be2e03f40b0af5661393f8f8793) - chore: update contributors list [`a2d28d9`](https://github.com/zumerlab/snapdom/commit/a2d28d952b18787d8e7aabf1b6d12cd8e45fa436) - Update doc [`7cf19de`](https://github.com/zumerlab/snapdom/commit/7cf19de5df40735b17958359251c481c1b517d8c) - Update update-contributors.js [`962c7c6`](https://github.com/zumerlab/snapdom/commit/962c7c6a4e23d5b8a0ef4c72111a617ffac3add4) - Update README.md [`183de8c`](https://github.com/zumerlab/snapdom/commit/183de8ce06c8df35fcbc3a32bb4204c14a657310) - Update README.md [`4352ae7`](https://github.com/zumerlab/snapdom/commit/4352ae75fb445857c14c64eb9a2ea7dbe82733c3) - Update update-contributors.js [`7dca4a1`](https://github.com/zumerlab/snapdom/commit/7dca4a1d3bbaacc14295f0e891095db1b39a76d0) - Update README.md [`ebb7f32`](https://github.com/zumerlab/snapdom/commit/ebb7f3204892d0d42713e7a2a14d261177a24d31) - Clean transform RootElement prop [`f293e5b`](https://github.com/zumerlab/snapdom/commit/f293e5be0e3ca6a97d43467976d80175d988916d) - Check if getStyle is iterable [`24dfe05`](https://github.com/zumerlab/snapdom/commit/24dfe056f6d35fe56ab39325b9c4492f84e64cd5) - chore: update contributors list [`8ac4aa1`](https://github.com/zumerlab/snapdom/commit/8ac4aa1f5a21373e73f86b22d0cdad8def37a8ea) - chore: update contributors list [`9987328`](https://github.com/zumerlab/snapdom/commit/9987328a796bb3eb70eb45cda4485dc8f5906688) - Update README.md [`4b52b87`](https://github.com/zumerlab/snapdom/commit/4b52b87e33b6a2f5d34460b2bff13e47ad011a73) #### [v1.7.1](https://github.com/zumerlab/snapdom/compare/v1.3.0...v1.7.1) > 19 June 2025 - Improve inlineBackgroundImages to support multiple background-image values. [`#46`](https://github.com/zumerlab/snapdom/pull/46) - Add @font-face / FontFace() deteccion, closes #43 [`#43`](https://github.com/zumerlab/snapdom/issues/43) - update [`7c5441e`](https://github.com/zumerlab/snapdom/commit/7c5441ed4b2c602bcee60b314162f10412b260c5) - Add benchmark against html2canvas [`f196afe`](https://github.com/zumerlab/snapdom/commit/f196afeb43b23624680a77e52a80222a476f055d) - Add description [`bcae4af`](https://github.com/zumerlab/snapdom/commit/bcae4af3ce9e0953fea8410303e6d77fe3e01e3e) - Update issue templates [`352dba3`](https://github.com/zumerlab/snapdom/commit/352dba3e53452f09fb5d056a0c5fb9216701a0f4) - add options.crossOrigin [`49f8ac6`](https://github.com/zumerlab/snapdom/commit/49f8ac6524e3f54e67505d048a4ad34c529ab6c9) - Update issue templates [`d832dbd`](https://github.com/zumerlab/snapdom/commit/d832dbd14df70f07d3f3ec9b62016dc4d19d8c9a) - Create CONTRIBUTING.md [`9a7be15`](https://github.com/zumerlab/snapdom/commit/9a7be151f6b36abd5a582aebbcaacfe759716c3a) - handle multiple background image in inlineBackgroundImages function [`95a5490`](https://github.com/zumerlab/snapdom/commit/95a5490f2de5a139f39c0286111eb4e84990fd00) - Update issue templates [`b69b5a4`](https://github.com/zumerlab/snapdom/commit/b69b5a4cb72e3bd0ca5f8ae5b43448c8aab95752) - update [`57d6b15`](https://github.com/zumerlab/snapdom/commit/57d6b1529c56e890a43cc427f817c731784f6ca0) - Update index.html [`f002bca`](https://github.com/zumerlab/snapdom/commit/f002bca6ee6330ae9d6f2550d36ce59414de29b0) - Update issue templates [`24d478f`](https://github.com/zumerlab/snapdom/commit/24d478f32795b42b13f70b4319b5e2cd0ba3fa70) - Bumped version [`d109fd7`](https://github.com/zumerlab/snapdom/commit/d109fd739197bbc37089f5acfe65ed10a6f48050) - Add files via upload [`0aecf4e`](https://github.com/zumerlab/snapdom/commit/0aecf4e46093743ca854397509a8be91e08cb666) - update [`e444762`](https://github.com/zumerlab/snapdom/commit/e444762ddb173d283b761e13a1e5e16c8853e325) - Update index.html [`997dab3`](https://github.com/zumerlab/snapdom/commit/997dab3293df81dc906116acbf7b4f388a270b39) - update [`0355286`](https://github.com/zumerlab/snapdom/commit/035528627f957213d35f1c63d9f73528deb972cf) - Prevent erasing non url background [`0d626cb`](https://github.com/zumerlab/snapdom/commit/0d626cb32b8958afd7e7fd6f96d5a71c6795113b) - docs: add @jhbae200 as contributor for PR #46 [`afe3094`](https://github.com/zumerlab/snapdom/commit/afe3094360f14712a55c1be134ab993c094a670b) - Merge pull request #44 from elliots/support-use-credentials-on-images [`005f23e`](https://github.com/zumerlab/snapdom/commit/005f23e529962d73e7550f9f20e92bdc7c8eb8ab) - Update index.html [`25d970f`](https://github.com/zumerlab/snapdom/commit/25d970fb1142c07bf10c8d9eba491ecdb3bf3e37) - update [`ea624c3`](https://github.com/zumerlab/snapdom/commit/ea624c362acb7c0f953f3c202dd78f83c84742ce) - update [`1cf93b7`](https://github.com/zumerlab/snapdom/commit/1cf93b7e25eaa39878f2334e9c240e98ed98f847) - update [`3a547df`](https://github.com/zumerlab/snapdom/commit/3a547dfccc46e835ac585057d80b03ef5b324e7b) - update [`2d4380b`](https://github.com/zumerlab/snapdom/commit/2d4380b4c900d3230a44a5af0380d149e55caca9) - Update index.html [`bedf815`](https://github.com/zumerlab/snapdom/commit/bedf815299c421e3fe810a480f32bb291aae40b1) - Update issue templates [`48a56fb`](https://github.com/zumerlab/snapdom/commit/48a56fb7f5006b20e64de6a592ec38c7a59b3cd8) - Create config.yml [`51700c4`](https://github.com/zumerlab/snapdom/commit/51700c4457abb070df34520887991508ce32ad7f) - update image [`0ec788c`](https://github.com/zumerlab/snapdom/commit/0ec788c562011990a008edb2b8f9b0cf18da8940) #### [v1.3.0](https://github.com/zumerlab/snapdom/compare/v1.2.5...v1.3.0) > 14 June 2025 - fix: double scaled images [`#38`](https://github.com/zumerlab/snapdom/pull/38) - Fix: background img & img base64 in pseudo elements, closes #36 [`#36`](https://github.com/zumerlab/snapdom/issues/36) - Feat: captures input values, closes #35 [`#35`](https://github.com/zumerlab/snapdom/issues/35) - Improve: Device Pixel Ratio handling, thanks @jswhisperer [`1a14f69`](https://github.com/zumerlab/snapdom/commit/1a14f69d340e935126b5388febe5d711c4b94e14) - Bumped version [`489be08`](https://github.com/zumerlab/snapdom/commit/489be081e6c7e50f1e4ba08d932d79c0ae242d45) - Update description [`4db784b`](https://github.com/zumerlab/snapdom/commit/4db784b4250b6eac6da8932e651872147fbc8bc1) #### [v1.2.5](https://github.com/zumerlab/snapdom/compare/v1.2.2...v1.2.5) > 9 June 2025 - Fix duplicated font-icon when embedFonts is true, closes #30 [`#30`](https://github.com/zumerlab/snapdom/issues/30) - Fix url with encode url, closes #29 [`#29`](https://github.com/zumerlab/snapdom/issues/29) - Fix .toCanvas scale [`fb47284`](https://github.com/zumerlab/snapdom/commit/fb4728463a65620bd4f4f8f50cd8b2263ba7bbe7) - Bumped version [`75b917a`](https://github.com/zumerlab/snapdom/commit/75b917a4fefc5fa9b55da3c028c43955f0656087) - Update cdn [`37533a2`](https://github.com/zumerlab/snapdom/commit/37533a2c2a858000e93d8d33009241a4be5f8726) - add homepage [`aa85c5d`](https://github.com/zumerlab/snapdom/commit/aa85c5d9f1777c437b07e624d874f7f1a0fac6a9) #### [v1.2.2](https://github.com/zumerlab/snapdom/compare/v1.2.1...v1.2.2) > 4 June 2025 - Patch: type script definitions, closes #23 [`#23`](https://github.com/zumerlab/snapdom/issues/23) - Bumped version [`548d7f3`](https://github.com/zumerlab/snapdom/commit/548d7f30a889d34ae4dac28a8dedc43262089325) #### [v1.2.1](https://github.com/zumerlab/snapdom/compare/v1.1.0...v1.2.1) > 31 May 2025 - feat(embedFonts): also embed icon fonts when embedFonts is true [`#18`](https://github.com/zumerlab/snapdom/issues/18) - Fix expose snapdom and preCache on browser compilation, closes #26 [`#26`](https://github.com/zumerlab/snapdom/issues/26) - Improve icon-font conversion [`7bac4ee`](https://github.com/zumerlab/snapdom/commit/7bac4ee3b152d6364c218aaa6d2bed4ad9997943) - Fix compress mode [`652cfe9`](https://github.com/zumerlab/snapdom/commit/652cfe9a8947029e31db6b089829fe8da87c0b42) - Bumped version [`0cd7973`](https://github.com/zumerlab/snapdom/commit/0cd797320b92310d86df0ce6296706d1f7f0ad5d) - Remove some logs [`4348b39`](https://github.com/zumerlab/snapdom/commit/4348b390ab8bb88c59ba9b0d24adbe58051b277a) - Chore: delete old comments [`ff81a40`](https://github.com/zumerlab/snapdom/commit/ff81a40e8a1b4baa8bacca2ed2ec59124df40b6e) - Chore: add dry bump script [`5c421c7`](https://github.com/zumerlab/snapdom/commit/5c421c75a1775a3b8c1fbd6a688fcfe409f676af) #### [v1.1.0](https://github.com/zumerlab/snapdom/compare/v1.0.0...v1.1.0) > 28 May 2025 - Add typescript declaration, closes #23 [`#23`](https://github.com/zumerlab/snapdom/issues/23) - Feat. support scrolling state, closes #20 [`#20`](https://github.com/zumerlab/snapdom/issues/20) - Fix bug by removing trim spaces, closes #21 [`#21`](https://github.com/zumerlab/snapdom/issues/21) - fix margin on mobile [`36297c8`](https://github.com/zumerlab/snapdom/commit/36297c89c085f605922f88ac5113f2f176c6a1a9) - mobile friendly [`42dada8`](https://github.com/zumerlab/snapdom/commit/42dada88bdbe886033890071e8e76499358a6b91) - Update index.html [`1bf3bc1`](https://github.com/zumerlab/snapdom/commit/1bf3bc1b15f4d28b50363c73410cea25ad589cda) - Create FUNDING.yml [`ddf914c`](https://github.com/zumerlab/snapdom/commit/ddf914c96727b3a82bbea4694d19dc0eb2b518e3) - Bumped version [`7a9f3d8`](https://github.com/zumerlab/snapdom/commit/7a9f3d8662099d15bcc2046ba88043eb3d3b1bfb) - add ga [`6d8a73f`](https://github.com/zumerlab/snapdom/commit/6d8a73fd52997e9e1a91944bd9a46d95c8c8507c) - Update index.html [`46e4b41`](https://github.com/zumerlab/snapdom/commit/46e4b41209c44766425e96fb9be94e1d1c08b6ae) - Update index.html [`1a2a04c`](https://github.com/zumerlab/snapdom/commit/1a2a04cbb3f4e81e2713d823d5b8dcdeb508591d) - Ignore generated screenshots tests [`cce8ead`](https://github.com/zumerlab/snapdom/commit/cce8ead47c470280761a34f7c98f9a2fd0796a34) - Update index.html [`5dd6749`](https://github.com/zumerlab/snapdom/commit/5dd67495df0a5cd48eda168565a81969d5639f40) - FIx bug that prevent scale on png format [`77a5265`](https://github.com/zumerlab/snapdom/commit/77a52651bd0ea8ccb451f199bd3d8f9e2478bf84) - Update README.md [`d8440f3`](https://github.com/zumerlab/snapdom/commit/d8440f3864931509f1b369d7e301d6ecccb63b14) - update [`ffa3a9a`](https://github.com/zumerlab/snapdom/commit/ffa3a9ad942987a5b52a7c9080914bed912db558) - Update README.md [`9c79e6e`](https://github.com/zumerlab/snapdom/commit/9c79e6e406ff9cb4df1539480e057b6828ef1788) - Update index.html [`7585674`](https://github.com/zumerlab/snapdom/commit/7585674ed21bb7009b84d1f948ceed2d5ed5ae69) - Update index.html [`8f4fb95`](https://github.com/zumerlab/snapdom/commit/8f4fb95a8f839159bd00c3338c7c3dc9fb23071c) - Update index.html [`eebc2bc`](https://github.com/zumerlab/snapdom/commit/eebc2bc01a6581f25995d5a9e946aa6bde08dfdc) ### [v1.0.0](https://github.com/zumerlab/snapdom/compare/v1.0.0-pre.1747581859131...v1.0.0) > 19 May 2025 - format code [`146fd95`](https://github.com/zumerlab/snapdom/commit/146fd95ec93d6b842acb28272aad43f787dc954a) - new demo gallery [`b8b2b6e`](https://github.com/zumerlab/snapdom/commit/b8b2b6eb4373999af5e67fc87418d6c6ab96199f) - Update code documentation [`6f933bc`](https://github.com/zumerlab/snapdom/commit/6f933bca3f1e9a9054f2e0e63807dfd52dda6270) - Add benchmarks section [`6becbb1`](https://github.com/zumerlab/snapdom/commit/6becbb12014d3cf33ec49264ca088486f08a5ce1) - Update documentation - add precache() [`a689566`](https://github.com/zumerlab/snapdom/commit/a6895665858f9eb574b0195dc918cef680c1651b) - Bumped version [`50d48c0`](https://github.com/zumerlab/snapdom/commit/50d48c05458e971a16375ca89da08eedad049e0c) - chore [`9f76e0c`](https://github.com/zumerlab/snapdom/commit/9f76e0cb1e7761604693588092ac8b1796cc892e) - update [`d84d395`](https://github.com/zumerlab/snapdom/commit/d84d39599abbd8fbd31727ff3a6650278ec0e28c) #### [v1.0.0-pre.1747581859131](https://github.com/zumerlab/snapdom/compare/v0.9.9...v1.0.0-pre.1747581859131) > 18 May 2025 - Fix retina and scale bug, closes #15 [`#15`](https://github.com/zumerlab/snapdom/issues/15) - Improve public API, closes #16 [`#16`](https://github.com/zumerlab/snapdom/issues/16) - Fix bug to render canvas with precache compress mode, closes #13 [`#13`](https://github.com/zumerlab/snapdom/issues/13) - Update to reflect new public API [`b6024cb`](https://github.com/zumerlab/snapdom/commit/b6024cb800b848103411d4e8f4be9a7ffdb84f48) - Update tests and benckmarks [`f06a0f8`](https://github.com/zumerlab/snapdom/commit/f06a0f835e42036a19761152cf5bf941b53d2f27) - Add helper to check Safari [`6c9ee04`](https://github.com/zumerlab/snapdom/commit/6c9ee0484c598dd56d52e62f3de37499024ad5e5) - Remove preWarm [`d3bd582`](https://github.com/zumerlab/snapdom/commit/d3bd582c144775617fc6221c4504466eb4cd6bef) - Bumped version [`fb0855d`](https://github.com/zumerlab/snapdom/commit/fb0855d55eafdbcae79f537b7e1a51e2cd4d1dfc) #### [v0.9.9](https://github.com/zumerlab/snapdom/compare/v0.9.8...v0.9.9) > 14 May 2025 - Bumped version [`676b00d`](https://github.com/zumerlab/snapdom/commit/676b00d71b5b51ea3a90c1aa95776a9b226378a3) - Fix bug on collectUsedTagNames() [`d627f18`](https://github.com/zumerlab/snapdom/commit/d627f18b6c0512545ab695bfae660cac8f64a9f0) - update [`c0e64d0`](https://github.com/zumerlab/snapdom/commit/c0e64d00905898660db68f054f5f5598c3fb9581) - Fix menu options [`8e87681`](https://github.com/zumerlab/snapdom/commit/8e876810c721fa0306c0f7d1b427ba6b111f8afe) #### [v0.9.8](https://github.com/zumerlab/snapdom/compare/v0.9.7...v0.9.8) > 14 May 2025 - Add font example [`26c59c8`](https://github.com/zumerlab/snapdom/commit/26c59c864aeaae80b54c22ace32e96396cb9eae6) - Bumped version [`0819d89`](https://github.com/zumerlab/snapdom/commit/0819d89bc52af1417e31b54e695af8491b709969) - update tests [`3cd5b70`](https://github.com/zumerlab/snapdom/commit/3cd5b70427613d7d595dd15736cb545db6411d88) - Fix capture output format [`2afa36a`](https://github.com/zumerlab/snapdom/commit/2afa36a1c41ff798ded5b7f8ecef1632e08ab716) - Update index.html [`0345fb1`](https://github.com/zumerlab/snapdom/commit/0345fb1f177297db0e17141c5737f9b3b510e6ca) - Add demo site [`88d0faa`](https://github.com/zumerlab/snapdom/commit/88d0faa1b27db0d305e8b78c7280c8a5e83384a5) - Disable user zoom [`3813580`](https://github.com/zumerlab/snapdom/commit/381358028159c51b9ed0da11e25928da490170fb) #### [v0.9.7](https://github.com/zumerlab/snapdom/compare/v0.9.2...v0.9.7) > 14 May 2025 - Update Dev branch [`#11`](https://github.com/zumerlab/snapdom/pull/11) - Delete functions [`c5040d9`](https://github.com/zumerlab/snapdom/commit/c5040d90b6276daa04e919ca4b0ecdf205f73af9) - improve cache handling [`27d7b19`](https://github.com/zumerlab/snapdom/commit/27d7b19cfafeed83f4b30a824638ee7edd63e10b) - add some examples [`3ce9dd2`](https://github.com/zumerlab/snapdom/commit/3ce9dd2807c8b84ed927c186621850a2518dfd2a) - Reorganice and add helpers [`c4f4182`](https://github.com/zumerlab/snapdom/commit/c4f4182a3e9ce636a2a263a05d75e64b33b25d7b) - Add tests [`455e7f2`](https://github.com/zumerlab/snapdom/commit/455e7f20e8a72f6a646a7d1e900f41fb22a18666) - Check if element to capture exists [`dfa96f2`](https://github.com/zumerlab/snapdom/commit/dfa96f2f720238fdff5df6e24b4572691ad6198f) - Improve capture logic [`79ab1b9`](https://github.com/zumerlab/snapdom/commit/79ab1b9e165dd08a34338fe0d837b0330be48539) - Update readme [`fdc2877`](https://github.com/zumerlab/snapdom/commit/fdc2877fd9e6fb73bc5d7bc9cf1f4a405f088be0) - Add preCache [`48bd910`](https://github.com/zumerlab/snapdom/commit/48bd910743a638ae8ce35ab7d617ad05a75d29a2) - Optimice [`cc638e7`](https://github.com/zumerlab/snapdom/commit/cc638e7f0f2e63a24eeee65ab4d87755e7207dec) - Bumped version [`3b26632`](https://github.com/zumerlab/snapdom/commit/3b266324c747c4bc139b99e4978493df79a5555c) - update [`111fdb4`](https://github.com/zumerlab/snapdom/commit/111fdb444b3c6d61dcb0e6bb2e21c871f5e73587) - Add cache Maps [`091484c`](https://github.com/zumerlab/snapdom/commit/091484c00941822684afc9148a59cb23e4b34627) - update [`bdbba7a`](https://github.com/zumerlab/snapdom/commit/bdbba7aeff458a60d5a83b5ead2d4f9402492fd3) - Update README.md [`c1756a9`](https://github.com/zumerlab/snapdom/commit/c1756a9192f8e3af90fd66da7e19c5fb883dbe0a) - Expose preCache [`1e96db1`](https://github.com/zumerlab/snapdom/commit/1e96db14c6c4e697361ceed2fb6f9c618801a138) - Chore [`38c08c0`](https://github.com/zumerlab/snapdom/commit/38c08c0c5a9eda486619855b9df47f33f490a921) - fix url [`bebec7f`](https://github.com/zumerlab/snapdom/commit/bebec7fd70141b3a82d41a5f6cc0849dcfb0c715) - Update README.md [`fb0ab3a`](https://github.com/zumerlab/snapdom/commit/fb0ab3ae528d4b37223e4eef03135e9be6a62b0b) - Update README.md [`1a76186`](https://github.com/zumerlab/snapdom/commit/1a76186d938a7a776a33c0e42ecc6813e86a9262) - Update README.md [`90d18a1`](https://github.com/zumerlab/snapdom/commit/90d18a165725ca3369fb5ebf48c281e0dd1377ae) #### [v0.9.2](https://github.com/zumerlab/snapdom/compare/v0.9.2-pre.1746130901718...v0.9.2) > 1 May 2025 - chore [`2f788af`](https://github.com/zumerlab/snapdom/commit/2f788afd3b25ae6391af6a41086e0b5c3595a701) #### [v0.9.2-pre.1746130901718](https://github.com/zumerlab/snapdom/compare/v0.9.1...v0.9.2-pre.1746130901718) > 1 May 2025 - 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) - Add as draft new default approach - not implemented [`6f4ec41`](https://github.com/zumerlab/snapdom/commit/6f4ec41c7146525c9db5cfce103e131bb3f19616) - Add tests [`bdd5a7f`](https://github.com/zumerlab/snapdom/commit/bdd5a7f491561966cd04bf72ca74185dc8e5a766) - Feat: captures icon fonts [`7b39e5f`](https://github.com/zumerlab/snapdom/commit/7b39e5fb964bc023f6d6fad555b357de5ab113f0) - Omit process default styles - temporary [`2953196`](https://github.com/zumerlab/snapdom/commit/2953196e00aa6bf9d026df95089d3fc81812f24d) - update to v.0.9.2 [`e0179a1`](https://github.com/zumerlab/snapdom/commit/e0179a160e361a1e7d58ee5e83747f385cacb887) - Add options as Object and allow bgColor on jpg and webp [`e5abaa7`](https://github.com/zumerlab/snapdom/commit/e5abaa72de77f75ebe6901935c5f539cda253db2) - Update commented docs [`cfd2272`](https://github.com/zumerlab/snapdom/commit/cfd2272b065e8c11fff1a729c6cbec1f14000668) - Update README.md [`fef6751`](https://github.com/zumerlab/snapdom/commit/fef6751ffa90d379c8d829998277825daddc27b8) - update [`26ff7ea`](https://github.com/zumerlab/snapdom/commit/26ff7ea0528d569820bed8748520a7d02c6506cd) - Update README.md [`3fda999`](https://github.com/zumerlab/snapdom/commit/3fda999bbdefb5aa32186bb07c59249f9a86e7e9) - Update README.md [`8ee616b`](https://github.com/zumerlab/snapdom/commit/8ee616baf0059eefcf7e83e7930f5ab8f3850eb5) - Omit delay function - temporary [`0f04721`](https://github.com/zumerlab/snapdom/commit/0f04721c458ba921694ee38117b8e0b8231a8c1a) - update unpkg url [`13ce66b`](https://github.com/zumerlab/snapdom/commit/13ce66bfee83802c32edfd9019959540d260cf84) - Bumped version [`9e4f518`](https://github.com/zumerlab/snapdom/commit/9e4f51885bddebd5364fb4ca96647233304e0dc7) - Update README.md [`3733476`](https://github.com/zumerlab/snapdom/commit/373347665ca89249244038eaf48731f6d7ee37b8) - Update README.md [`00c74b0`](https://github.com/zumerlab/snapdom/commit/00c74b07881373275d8c0e5144696d594b031e7e) - Update README.md [`dd2c9c5`](https://github.com/zumerlab/snapdom/commit/dd2c9c5dd507a432e4dc75e67c5d2d311073e791) - Update README.md [`d271cf7`](https://github.com/zumerlab/snapdom/commit/d271cf77f5747ee69df07785fef34e8c5e63649e) - Update README.md [`02bf650`](https://github.com/zumerlab/snapdom/commit/02bf6506ae7e3cf03507e10d8f76983c07f39c66) #### [v0.9.1](https://github.com/zumerlab/snapdom/compare/v0.9.0...v0.9.1) > 27 April 2025 - update [`d90fcb9`](https://github.com/zumerlab/snapdom/commit/d90fcb97bdeb75a2adaaa14b25bd6ebced4a70e2) - Bumped version [`99c286a`](https://github.com/zumerlab/snapdom/commit/99c286a8883ede66ff93aa96a62d008411e4ded0) - fix change files prop [`548adbe`](https://github.com/zumerlab/snapdom/commit/548adbe9490b0ed4fd7e9fb77e7d6e69a6dc28c9) - update [`f70a917`](https://github.com/zumerlab/snapdom/commit/f70a9173c7b11d659e6bf80c6ef60b9f71e652b7) #### v0.9.0 > 27 April 2025 - first public version [`aac1d99`](https://github.com/zumerlab/snapdom/commit/aac1d997836362dd008d6372173c9dd84a76197f) - Initial commit [`fb1c063`](https://github.com/zumerlab/snapdom/commit/fb1c06307b4b822bb898477beca46f88109ac196) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 ZumerLab Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

NPM version NPM weekly downloads GitHub contributors GitHub stars GitHub forks Sponsor tinchox5 License

English | 简体中文

# SnapDOM **SnapDOM** is a next-generation **DOM Capture Engine** — ultra-fast, modular, and extensible. It 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. * Full DOM capture * Embedded styles, pseudo-elements, and fonts * Export to SVG, PNG, JPG, WebP, `canvas`, or Blob * ⚡ Ultra fast, no dependencies * 100% based on standard Web APIs * Support same-origin `ìframe` * Support CSS counter() and CSS counters() * Support `...` line-clamp ## Demo [https://snapdom.dev](https://snapdom.dev) ## Quick Start **Capture any DOM element to PNG in one line:** ```js import { snapdom } from '@zumer/snapdom'; const img = await snapdom.toPng(document.querySelector('#card')); document.body.appendChild(img); ``` **Reusable capture** (one clone, multiple exports): ```js const result = await snapdom(document.querySelector('#card')); await result.toPng(); // → HTMLImageElement await result.toSvg(); // → SVG as Image await result.download({ format: 'jpg', filename: 'card.jpg' }); ``` --- ## Capture Flow SnapDOM transforms your DOM element through these stages: ``` DOM Element ↓ Clone ↓ Styles & Pseudo ↓ Images & Backgrounds ↓ Fonts ↓ SVG foreignObject ↓ data:image/svg+xml ↓ toPng / toSvg / toBlob / download ``` | Stage | What happens | |-------|--------------| | **Clone** | Deep clone with styles, Shadow DOM, iframes. Exclude/filter nodes. | | **Styles & Pseudo** | Inline `::before`/`::after` as elements, resolve `counter()`/`counters()`. | | **Images & Backgrounds** | Fetch and inline external images/backgrounds as data URLs. | | **Fonts** | Embed `@font-face` (optional) and icon fonts. | | **SVG** | Wrap clone in ``, serialize to `data:image/svg+xml`. | | **Export** | Convert SVG to PNG/JPG/WebP/Blob or trigger download. | Plugin hooks: `beforeSnap` → `beforeClone` → `afterClone` → `beforeRender` → `afterRender` → `beforeExport` → `afterExport`. ## Table of Contents - [Quick Start](#quick-start) - [Capture Flow](#capture-flow) - [Installation](#installation) - [NPM / Yarn (stable)](#npm--yarn-stable) - [NPM / Yarn (dev builds)](#npm--yarn-dev-builds) - [CDN (stable)](#cdn-stable) - [CDN (dev builds)](#cdn-dev-builds) - [Build Outputs](#build-outputs--tree-shaking) - [Usage](#usage) - [Reusable capture](#reusable-capture) - [One-step shortcuts](#one-step-shortcuts) - [API](#api) - [snapdom(el, options?)](#snapdomel-options) - [Shortcut methods](#shortcut-methods) - [Options](#options) - [debug](#debug) - [Fallback image on `` load failure](#fallback-image-on-img-load-failure) - [Dimensions (`scale`, `width`, `height`)](#dimensions-scale-width-height) - [Cross-Origin Images & Fonts (`useProxy`)](#cross-origin-images--fonts-useproxy) - [Fonts](#fonts) - [embedFonts](#embedfonts) - [localFonts](#localfonts) - [iconFonts](#iconfonts) - [excludeFonts](#excludefonts) - [Filtering nodes: `exclude` vs `filter`](#filtering-nodes-exclude-vs-filter) - [outerTransforms](#outerTransforms) - [outerShadows](#no-shadows) - [Cache control](#cache-control) - [preCache](#precache--optional-helper) - [Plugins (BETA)](#plugins-beta) - [Registering Plugins](#registering-plugins) - [Plugin Lifecycle Hooks](#plugin-lifecycle-hooks) - [Context Object](#context-object) - [Custom Exports via Plugins](#custom-exports-via-plugins) - [Example: Overlay Filter Plugin](#example-overlay-filter-plugin) - [Full Plugin Template](#full-plugin-template) - [Limitations](#limitations) - [⚡ Performance Benchmarks (Chromium)](#performance-benchmarks) - [Simple elements](#simple-elements) - [Complex elements](#complex-elements) - [Run the benchmarks](#run-the-benchmarks) - [Roadmap](#roadmap) - [Development](#development) - [Contributors 🙌](#contributors) - [💖 Sponsors](#sponsors) - [Star History](#star-history) - [License](#license) ## Installation ### NPM / Yarn (stable) ```bash npm i @zumer/snapdom yarn add @zumer/snapdom ``` ### NPM / Yarn (dev builds) For early access to new features and fixes: ```bash npm i @zumer/snapdom@dev yarn add @zumer/snapdom@dev ``` ⚠️ The `@dev` tag usually includes improvements before they reach production, but may be less stable. ### CDN (stable) ```html ``` ### CDN (dev builds) ```html ``` ## Build Outputs | Variant | File | Use case | |---------|------|----------| | **ESM** (tree-shakeable) | `dist/snapdom.mjs` | Bundlers (Vite, webpack), `import` | | **IIFE** (global) | `dist/snapdom.js` | Script tag, legacy `require` | **Bundler (npm):** ```js import { snapdom } from '@zumer/snapdom'; // → dist/snapdom.mjs ``` **Script tag (CDN):** ```html ``` **Subpath imports** (lighter bundle if you only need one): ```js import { preCache } from '@zumer/snapdom/preCache'; import { plugins } from '@zumer/snapdom/plugins'; ``` ## Usage | Pattern | When to use | |---------|-------------| | **Reusable** `snapdom(el)` | One clone → many exports (PNG + JPG + download). | | **Shortcuts** `snapdom.toPng(el)` | Single export, less code. | ### Reusable capture Capture once, export many times (no re-clone): ```js const el = document.querySelector('#target'); const result = await snapdom(el); const img = await result.toPng(); document.body.appendChild(img); await result.download({ format: 'jpg', filename: 'my-capture.jpg' }); ``` ### One-step shortcuts Direct export when you need a single format: ```js const png = await snapdom.toPng(el); const blob = await snapdom.toBlob(el); document.body.appendChild(png); ``` ## API ### `snapdom(el, options?)` Returns an object with reusable export methods: ```js { url: string; toRaw(): string; toImg(): Promise; // deprecated toSvg(): Promise; toCanvas(): Promise; toBlob(options?): Promise; toPng(options?): Promise; toJpg(options?): Promise; toWebp(options?): Promise; download(options?): Promise; } ``` ### Shortcut methods | Method | Description | | ------------------------------ | --------------------------------- | | `snapdom.toImg(el, options?)` | Returns an SVG `HTMLImageElement` (deprecated) | | `snapdom.toSvg(el, options?)` | Returns an SVG `HTMLImageElement` | | `snapdom.toCanvas(el, options?)` | Returns a `Canvas` | | `snapdom.toBlob(el, options?)` | Returns an SVG or raster `Blob` | | `snapdom.toPng(el, options?)` | Returns a PNG image | | `snapdom.toJpg(el, options?)` | Returns a JPG image | | `snapdom.toWebp(el, options?)` | Returns a WebP image | | `snapdom.download(el, options?)` | Triggers a download | ### Exporter-specific options Some exporters accept a small set of **export-only options** in addition to the global capture options. #### `download()` | Option | Type | Default | Description | | --- | --- | --- | --- | | `filename` | `string` | `snapdom` | Download name. | | `format` | `"png" \| "jpeg" \| "jpg" \| "webp" \| "svg"` | `"png"` | Output format for the downloaded file. | **Example:** ```js await result.download({ format: 'jpg', quality: 0.92, filename: 'my-capture' }); ``` #### `toBlob()` | Option | Type | Default | Description | | --- | --- | --- | --- | | `type` | `"svg" \| "png" \| "jpeg" \| "jpg" \| "webp"` | `"svg"` | Blob type to generate. | **Example:** ```js const blob = await result.toBlob({ type: 'jpeg', quality: 0.92 }); ``` ## Options All capture methods accept an `options` object: | Option | Type | Default | Description | | ----------------- | -------- | -------- | ----------------------------------------------- | | `debug` | boolean | `false` | When `true`, logs suppressed errors to `console.warn` for troubleshooting | | `fast` | boolean | `true` | Skips small idle delays for faster results | | `embedFonts` | boolean | `false` | Inlines non-icon fonts (icon fonts always on) | | `localFonts` | array | `[]` | Local fonts `{ family, src, weight?, style? }` | | `iconFonts` | string\|RegExp\|Array | `[]` | Extra icon font matchers | | `excludeFonts` | object | `{}` | Exclude families/domains/subsets during embedding | | `scale` | number | `1` | Output scale multiplier | | `dpr` | number | `devicePixelRatio` | Device pixel ratio | | `width` | number | - | Output width | | `height` | number | - | Output height | | `backgroundColor` | string | `"#fff"` | Fallback color for JPG/WebP | | `quality` | number | `1` | Quality for JPG/WebP (0 to 1) | | `useProxy` | string | `''` | Proxy base for CORS fallbacks | | `exclude` | string[] | - | CSS selectors to exclude | | `excludeMode` | `"hide"`\|`"remove"` | `"hide"` | How `exclude` is applied | | `filter` | function | - | Custom predicate `(el) => boolean` | | `filterMode` | `"hide"`\|`"remove"` | `"hide"` | How `filter` is applied | | `cache` | string | `"soft"` | `disabled` \| `soft` \| `auto` \| `full` | | `placeholders` | boolean | `true` | Show placeholders for images/CORS iframes | | `fallbackURL` | string \| function | - | Fallback image for `` load failure | | `outerTransforms` | boolean | `true` | When `false` removes `translate/rotate` but preserves `scale/skew`, producing a flat, reusable capture | | `outerShadows` | boolean | `false` | Do not expand the root’s bounding box for shadows/blur/outline, and strip those visual effects from the cloned root | | `safariWarmupAttempts` | number | `3` | Safari only: iterations to prime font/decode (WebKit #219770). Use `1` if 3 causes lag | ### debug When `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. ```js await snapdom.toPng(el, { debug: true }); ``` ### Fallback image on `` load failure Provide a default image for failed `` loads. You can pass a fixed URL or a callback that receives measured dimensions and returns a URL (handy to generate dynamic placeholders). ```js // 1) Fixed URL fallback await snapdom.toSvg(element, { fallbackURL: '/images/fallback.png' }); // 2) Dynamic placeholder via callback await snapdom.toSvg(element, { fallbackURL: ({ width: 300, height: 150 }) => `https://placehold.co/${width}x${height}` }); // 3) With proxy (if your fallback host has no CORS) await snapdom.toSvg(element, { fallbackURL: ({ width = 300, height = 150 }) => `https://dummyimage.com/${width}x${height}/cccccc/666.png&text=img`, useProxy: 'https://proxy.corsfix.com/?' }); ``` Notes: - If the fallback image also fails to load, snapDOM replaces the `` with a placeholder block preserving width/height. - Width/height used by the callback are gathered from the original element (dataset, style/attrs, etc.) when available. ### Dimensions (`scale`, `width`, `height`) * If `scale` is provided, it **takes precedence** over `width`/`height`. * If only `width` is provided, height scales proportionally (and vice versa). * Providing both `width` and `height` forces an exact size (may distort). ### Cross-Origin Images & Fonts (`useProxy`) By 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`: ```js await snapdom.toPng(el, { useProxy: 'https://proxy.corsfix.com/?' // Note: Any cors proxy could be used 'https://proxy.corsfix.com/?' }); ``` * The proxy is only used as a **fallback**; same-origin and CORS-enabled assets skip it. ### Fonts #### `embedFonts` When `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**. #### `localFonts` If you serve fonts yourself or have data URLs, you can declare them here to avoid extra CSS discovery: ```js await snapdom.toPng(el, { embedFonts: true, localFonts: [ { family: 'Inter', src: '/fonts/Inter-Variable.woff2', weight: 400, style: 'normal' }, { family: 'Inter', src: '/fonts/Inter-Italic.woff2', style: 'italic' } ] }); ``` #### `iconFonts` Add custom icon families (names or regex matchers). Useful for private icon sets: ```js await snapdom.toPng(el, { iconFonts: ['MyIcons', /^(Remix|Feather) Icons?$/i] }); ``` #### `excludeFonts` Skip specific non-icon fonts to speed up capture or avoid unnecessary downloads. ```js await snapdom.toPng(el, { embedFonts: true, excludeFonts: { families: ['Noto Serif', 'SomeHeavyFont'], // skip by family name domains: ['fonts.gstatic.com', 'cdn.example'], // skip by source host subsets: ['cyrillic-ext'] // skip by unicode-range subset tag } }); ``` *Notes* - `excludeFonts` only applies to **non-icon** fonts. Icon fonts are always embedded. - Matching is case-insensitive for `families`. Hosts are matched by substring against the resolved URL. #### Filtering nodes: `exclude` vs `filter` * `exclude`: remove by **selector**. * `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. * `filter`: advanced predicate per element (return `false` to drop). * `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. **Example: filter out elements with `display:none`:** ```js /** * Example filter: skip elements with display:none * @param {Element} el * @returns {boolean} true = keep, false = exclude */ function filterHidden(el) { const cs = window.getComputedStyle(el); if (cs.display === 'none') return false; return true; } await snapdom.toPng(document.body, { filter: filterHidden }); ``` **Example with `exclude`:** remove banners or tooltips by selector ```js await snapdom.toPng(el, { exclude: ['.cookie-banner', '.tooltip', '[data-test="debug"]'] }); ``` ### outerTransforms When 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. - **`outerTransforms: true (default)`** **Keeps the original `transforms` and `rotate`**. ### outerShadows - **`outerShadows: false (default)`** 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. > 💡 **Tip:** Using both (`outerTransforms: false` + `outerShadows: false`) produces a strict, minimal bounding box with no visual bleed. **Example** ```js // outerTransforms and remove shadow bleed await snapdom.toSvg(el, { outerTransforms: true, outerShadows: true }); ``` ## Cache control SnapDOM maintains internal caches for images, backgrounds, resources, styles, and fonts. You can control how they are cleared between captures using the `cache` option: | Mode | Description | | ----------- | --------------------------------------------------------------------------- | | `"disabled"`| No cache | | `"soft"` | Clears session caches (`styleMap`, `nodeMap`, `styleCache`) _(default)_ | | `"auto"` | Minimal cleanup: only clears transient maps | | `"full"` | Keeps all caches (nothing is cleared, maximum performance) | **Examples:** ```js // Use minimal but fast cache await snapdom.toPng(el, { cache: 'auto' }); // Keep everything in memory between captures await snapdom.toPng(el, { cache: 'full' }); // Force a full cleanup on every capture await snapdom.toPng(el, { cache: 'disabled' }); ``` ## `preCache()` – Optional helper Preloads external resources to avoid first-capture stalls (helpful for big/complex trees). ```js import { preCache } from '@zumer/snapdom'; await preCache({ root: document.body, embedFonts: true, localFonts: [{ family: 'Inter', src: '/fonts/Inter.woff2', weight: 400 }], useProxy: 'https://proxy.corsfix.com/?' }); ``` ## Plugins (BETA) SnapDOM 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. A plugin is a simple object with a unique `name` and one or more lifecycle **hooks**. Hooks can be synchronous or `async`, and they receive a shared **`context`** object. ### Registering Plugins **Global registration** (applies to all captures): ```js import { snapdom } from '@zumer/snapdom'; // You can register instances, factories, or [factory, options] snapdom.plugins( myPluginInstance, [myPluginFactory, { optionA: true }], { plugin: anotherFactory, options: { level: 2 } } ); ``` **Per-capture registration** (only for that specific call): ```js const out = await snapdom(element, { plugins: [ [overlayFilterPlugin, { color: 'rgba(0,0,0,0.25)' }], [myFullPlugin, { providePdf: true }] ] }); ``` * **Execution order = registration order** (first registered, first executed). * **Per-capture plugins** run **before** global ones. * Duplicates are automatically skipped by `name`; a per-capture plugin with the same `name` overrides its global version. ### Plugin Lifecycle Hooks Hooks run in capture order (see [Capture Flow](#capture-flow)): | Hook | Stage | Purpose | |------|-------|---------| | `beforeSnap` | Start | Adjust options before any work. | | `beforeClone` | Pre-clone | Before DOM clone (modify live DOM carefully). | | `afterClone` | Post-clone | Modify cloned tree safely (e.g. inject overlay). | | `beforeRender` | Pre-serialize | Right before SVG → data URL. | | `afterRender` | Post-serialize | Inspect `context.svgString` / `context.dataURL`. | | `beforeExport` | Per export | Before each `toPng`, `toSvg`, etc. | | `afterExport` | Per export | Transform returned result. | | `afterSnap` | Once | After first export; cleanup. | | `defineExports` | Setup | Add custom exporters (e.g. `toPdf`). | > Returned values from `afterExport` are chained to the next plugin (transform pipeline). ### Context Object Every hook receives a single `context` object that contains normalized capture state: * **Input & options:** `element`, `debug`, `fast`, `scale`, `dpr`, `width`, `height`, `backgroundColor`, `quality`, `useProxy`, `cache`, `outerTransforms`, `outerShadows`, `safariWarmupAttempts`, `embedFonts`, `localFonts`, `iconFonts`, `excludeFonts`, `exclude`, `excludeMode`, `filter`, `filterMode`, `fallbackURL`. * **Intermediate values (depending on stage):** `clone`, `classCSS`, `styleCache`, `fontsCSS`, `baseCSS`, `svgString`, `dataURL`. * **During export:** `context.export = { type, options, url }` where `type` is the exporter name (`"png"`, `"jpeg"`, `"svg"`, `"blob"`, etc.), and `url` is the serialized SVG base. > 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. ## Custom Exports via Plugins Plugins can add new exports using `defineExports(context)`. For each export key you return (e.g., `"pdf"`), SnapDOM automatically exposes a helper method named **`toPdf()`** on the capture result. **Register the plugin (global or per capture):** ```js import { snapdom } from '@zumer/snapdom'; // global snapdom.plugins(pdfExportPlugin()); // or per capture const out = await snapdom(element, { plugins: [pdfExportPlugin()] }); ``` **Call the custom export:** ```js const out = await snapdom(document.querySelector('#report')); // because the plugin returns { pdf: async (ctx, opts) => ... } const pdfBlob = await out.toPdf({ // exporter-specific options (width, height, quality, filename, etc.) }); ``` ### Example: Overlay Filter Plugin Adds a translucent overlay or color filter **only** to the captured clone (not your live DOM). Useful for highlighting or dimming sections before export. ```js /** * Ultra-simple overlay filter for SnapDOM (HTML-only). * Inserts a full-size
overlay on the cloned root. * * @param {{ color?: string; blur?: number }} [options] * color: overlay color (rgba/hex/hsl). Default: 'rgba(0,0,0,0.25)' * blur: optional blur in px (default: 0) */ export function overlayFilterPlugin(options = {}) { const color = options.color ?? 'rgba(0,0,0,0.25)'; const blur = Math.max(0, options.blur ?? 0); return { name: 'overlay-filter', /** * Add a full-coverage overlay to the cloned HTML root. * @param {any} context */ async afterClone(context) { const root = context.clone; if (!(root instanceof HTMLElement)) return; // HTML-only // Ensure containing block so absolute overlay anchors to the root if (getComputedStyle(root).position === 'static') { root.style.position = 'relative'; } const overlay = document.createElement('div'); overlay.style.position = 'absolute'; overlay.style.left = '0'; overlay.style.top = '0'; overlay.style.right = '0'; overlay.style.bottom = '0'; overlay.style.background = color; overlay.style.pointerEvents = 'none'; if (blur) overlay.style.filter = `blur(${blur}px)`; root.appendChild(overlay); } }; } ``` **Usage:** ```js import { snapdom } from '@zumer/snapdom'; // Global registration snapdom.plugins([overlayFilterPlugin, { color: 'rgba(0,0,0,0.3)', blur: 2 }]); // Per-capture const out = await snapdom(document.querySelector('#card'), { plugins: [[overlayFilterPlugin, { color: 'rgba(255,200,0,0.15)' }]] }); const png = await out.toPng(); document.body.appendChild(png); ``` > The overlay is injected **only in the cloned tree**, never in your live DOM, ensuring perfect fidelity and zero flicker. ### Full Plugin Template Use this as a starting point for custom logic or exporters. ```js export function myPlugin(options = {}) { return { /** Unique name used for de-duplication/overrides */ name: 'my-plugin', /** Early adjustments before any clone/style work. */ async beforeSnap(context) {}, /** Before subtree cloning (use sparingly if touching the live DOM). */ async beforeClone(context) {}, /** After subtree cloning (safe to modify the cloned tree). */ async afterClone(context) {}, /** Right before serialization (SVG/dataURL). */ async beforeRender(context) {}, /** After serialization; inspect context.svgString/context.dataURL if needed. */ async afterRender(context) {}, /** Before EACH export call (toPng/toSvg/toBlob/...). */ async beforeExport(context) {}, /** * After EACH export call. * If you return a value, it becomes the result for the next plugin (chaining). */ async afterExport(context, result) { return result; }, /** * Define custom exporters (auto-added as helpers like out.toPdf()). * Return a map { [key: string]: (ctx:any, opts:any) => Promise }. */ async defineExports(context) { return {}; }, /** Runs ONCE after the FIRST export finishes (cleanup). */ async afterSnap(context) {} }; } ``` **Quick recap:** * Plugins can modify capture behavior (`beforeSnap`, `afterClone`, etc.). * You can inject visuals or transformations safely into the cloned tree. * New exporters defined in `defineExports()` automatically become helpers like `out.toPdf()`. * All hooks can be asynchronous, run in order, and share the same `context`. ## Limitations * External images should be CORS-accessible (use `useProxy` option for handling CORS denied) * When WebP format is used on Safari, it will fallback to PNG rendering. * `@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) * **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). * **Custom scrollbar styles** (`::-webkit-scrollbar`): Applied only when the element has *not* been scrolled. When scrolled, the viewport content is captured without the scrollbar. ## Performance Benchmarks **Setup.** Vitest benchmarks on Chromium, repo tests. Hardware may affect results. Values are **average capture time (ms)** → lower is better. ### Simple elements | Scenario | SnapDOM current | SnapDOM v1.9.9 | html2canvas | html-to-image | | ------------------------ | --------------- | -------------- | ----------- | ------------- | | Small (200×100) | **0.5 ms** | 0.8 ms | 67.7 ms | 3.1 ms | | Modal (400×300) | **0.5 ms** | 0.8 ms | 75.5 ms | 3.6 ms | | Page View (1200×800) | **0.5 ms** | 0.8 ms | 114.2 ms | 3.3 ms | | Large Scroll (2000×1500) | **0.5 ms** | 0.8 ms | 186.3 ms | 3.2 ms | | Very Large (4000×2000) | **0.5 ms** | 0.9 ms | 425.9 ms | 3.3 ms | ### Complex elements | Scenario | SnapDOM current | SnapDOM v1.9.9 | html2canvas | html-to-image | | ------------------------ | --------------- | -------------- | ----------- | ------------- | | Small (200×100) | **1.6 ms** | 3.3 ms | 68.0 ms | 14.3 ms | | Modal (400×300) | **2.9 ms** | 6.8 ms | 87.5 ms | 34.8 ms | | Page View (1200×800) | **17.5 ms** | 50.2 ms | 178.0 ms | 429.0 ms | | Large Scroll (2000×1500) | **54.0 ms** | 201.8 ms | 735.2 ms | 984.2 ms | | Very Large (4000×2000) | **171.4 ms** | 453.7 ms | 1,800.4 ms | 2,611.9 ms | ### Run the benchmarks ```sh git clone https://github.com/zumerlab/snapdom.git cd snapdom npm install npm run test:benchmark ``` ## Roadmap Planned improvements for future versions of SnapDOM: * [X] **Implement plugin system** SnapDOM will support external plugins to extend or override internal behavior (e.g. custom node transformers, exporters, or filters). * [ ] **Refactor to modular architecture** Internal logic will be split into smaller, focused modules to improve maintainability and code reuse. * [X] **Decouple internal logic from global options** 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) * [X] **Expose cache control** Users will be able to manually clear image and font caches or configure their own caching strategies. * [X] **Auto font preloading** Required fonts will be automatically detected and preloaded before capture, reducing the need for manual `preCache()` calls. * [X] **Document plugin development** A full guide will be provided for creating and registering custom SnapDOM plugins. * [ ] **Make export utilities tree-shakeable** Export functions like `toPng`, `toJpg`, `toBlob`, etc. will be restructured into independent modules to support tree shaking and minimal builds. Have ideas or feature requests? Feel free to share suggestions or feedback in [GitHub Discussions](https://github.com/zumerlab/snapdom/discussions). ## Development **Source layout:** - `src/api/` – Public API (`snapdom`, `preCache`) - `src/core/` – Capture pipeline, clone, prepare, plugins - `src/modules/` – Images, fonts, pseudo-elements, backgrounds, SVG - `src/exporters/` – toPng, toSvg, toBlob, etc. - `dist/` – Build output (`snapdom.js`, `snapdom.mjs`, `preCache.mjs`, `plugins.mjs`) **Build:** ```sh git clone https://github.com/zumerlab/snapdom.git cd snapdom git checkout dev npm install npm run compile ``` **Test:** ```sh npx playwright install # Required for browser tests npm test npm run test:benchmark ``` For detailed guidelines, see [CONTRIBUTING](https://github.com/zumerlab/snapdom/blob/main/CONTRIBUTING.md). ## Contributors

tinchox5 Jarvis2018 tarwin Amyuan23 airamhr9 FlavioLimaMindera jswhisperer K1ender kohaiy 17biubiu av01d CHOYSEN pedrocateexte domialex elliots stypr mon-jai sharuzzaman simon1uo titoBouzout ZiuChen harshasiddartha karasHou jhbae200 xiaobai-web715 miusuncle rbbydotdev zhanghaotian2018

## Sponsors Special 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! If you'd like to support this project too, you can [become a sponsor](https://github.com/sponsors/tinchox5). ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=zumerlab/snapdom&type=Date)](https://www.star-history.com/#zumerlab/snapdom&Date) ## License MIT © Zumerlab ================================================ FILE: README_CN.md ================================================

NPM version NPM weekly downloads GitHub contributors GitHub stars GitHub forks Sponsor tinchox5 License

English | 简体中文

# snapDOM **SnapDOM** 是新一代的 **DOM 捕获引擎(DOM Capture Engine)**——超高速、模块化、可扩展。 它可以将任意 DOM 子树转换为自包含的结构,并导出为 SVG、PNG、JPG、WebP、Canvas、Blob,或通过插件系统生成 **任何自定义格式**。 SnapDOM 会保留样式、字体、背景图像、伪元素、Shadow DOM 等所有视觉特性,并通过可扩展的架构实现强大的灵活性和最高级别的捕获质量。 * 完整的 DOM 捕获 * 内嵌样式、伪元素和字体 * 导出为 SVG、PNG、JPG、WebP、`canvas` 或 Blob * ⚡ 超快速度,无依赖 * 100% 基于标准 Web API * 支持同源 `iframe` * 支持 CSS counter() 和 CSS counters() * 支持 `...` 文本截断(line-clamp) ## 演示 [https://snapdom.dev](https://snapdom.dev) ## 快速开始 **一行代码将任意 DOM 元素导出为 PNG:** ```js import { snapdom } from '@zumer/snapdom'; const img = await snapdom.toPng(document.querySelector('#card')); document.body.appendChild(img); ``` **可复用捕获**(一次克隆,多次导出): ```js const result = await snapdom(document.querySelector('#card')); await result.toPng(); // → HTMLImageElement await result.toSvg(); // → SVG 图片 await result.download({ format: 'jpg', filename: 'card.jpg' }); ``` --- ## 捕获流程 SnapDOM 将 DOM 元素按以下阶段转换: ``` DOM Element ↓ Clone ↓ Styles & Pseudo ↓ Images & Backgrounds ↓ Fonts ↓ SVG foreignObject ↓ data:image/svg+xml ↓ toPng / toSvg / toBlob / download ``` | 阶段 | 说明 | |------|------| | **Clone** | 深度克隆,含样式、Shadow DOM、iframe。排除/过滤节点。 | | **Styles & Pseudo** | 将 `::before`/`::after` 内联为元素,解析 `counter()`/`counters()`。 | | **Images & Backgrounds** | 拉取并内联外部图片/背景为 data URL。 | | **Fonts** | 嵌入 `@font-face`(可选)及图标字体。 | | **SVG** | 将克隆包裹在 `` 中,序列化为 `data:image/svg+xml`。 | | **Export** | 转换为 PNG/JPG/WebP/Blob 或触发下载。 | 插件钩子顺序:`beforeSnap` → `beforeClone` → `afterClone` → `beforeRender` → `afterRender` → `beforeExport` → `afterExport`。 ## 目录 - [快速开始](#快速开始) - [捕获流程](#捕获流程) - [安装](#安装) - [NPM / Yarn (稳定版)](#npm--yarn-稳定版) - [NPM / Yarn (开发版)](#npm--yarn-开发版) - [CDN (稳定版)](#cdn-稳定版) - [CDN (开发版)](#cdn-开发版) - [构建产物](#构建产物与摇树优化) - [用法](#基本用法) - [可复用的捕获](#可复用的捕获) - [一步式快捷方法](#一步式快捷方法) - [API](#api) - [snapdom(el, options?)](#snapdomel-options) - [快捷方法](#快捷方法) - [选项](#选项) - [debug](#debug) - [`` 加载失败时的备用图片](#img-加载失败时的备用图片) - [尺寸 (`scale`, `width`, `height`)](#尺寸-scale-width-height) - [跨域图片和字体 (`useProxy`)](#跨域图片和字体-useproxy) - [字体](#字体) - [embedFonts](#embedfonts) - [localFonts](#localfonts) - [iconFonts](#iconfonts) - [excludeFonts](#excludefonts) - [节点过滤:`exclude` vs `filter`](#节点过滤-exclude-vs-filter) - [outerTransforms](#outertransforms) - [outerShadows](#outerShadows) - [缓存控制](#缓存控制) - [preCache](#precache--可选辅助函数) - [插件(测试版)](#插件测试版) - [注册插件](#注册插件) - [插件生命周期钩子](#插件生命周期钩子) - [上下文对象](#上下文对象) - [通过插件自定义导出](#通过插件自定义导出) - [示例:叠加滤镜插件](#示例叠加滤镜插件) - [完整插件模板](#完整插件模板) - [限制](#限制) - [⚡ 性能基准测试(Chromium)](#性能基准测试chromium) - [简单元素](#简单元素) - [复杂元素](#复杂元素) - [运行基准测试](#运行基准测试) - [路线图](#路线图) - [开发](#开发) - [贡献者 🙌](#贡献者) - [💖 赞助者](#赞助者) - [Star 历史](#star-历史) - [许可证](#许可证) ## 安装 ### NPM / Yarn (稳定版) ```bash npm i @zumer/snapdom yarn add @zumer/snapdom ``` ### NPM / Yarn (开发版) 想要提前体验新功能和修复: ```bash npm i @zumer/snapdom@dev yarn add @zumer/snapdom@dev ``` ⚠️ `@dev` 标签通常包含在正式发布前的改进,但可能不够稳定。 ### CDN (稳定版) ```html ``` ### CDN (开发版) ```html ``` ## 构建产物 | 变体 | 文件 | 使用场景 | |------|------|----------| | **ESM**(可摇树) | `dist/snapdom.mjs` | 打包工具(Vite、webpack),`import` | | **IIFE**(全局) | `dist/snapdom.js` | script 标签、传统 `require` | **打包工具 (npm):** ```js import { snapdom } from '@zumer/snapdom'; // → dist/snapdom.mjs ``` **script 标签 (CDN):** ```html ``` **子路径导入**(仅需部分功能时可减小体积): ```js import { preCache } from '@zumer/snapdom/preCache'; import { plugins } from '@zumer/snapdom/plugins'; ``` ## 基本用法 | 模式 | 适用场景 | |------|----------| | **可复用** `snapdom(el)` | 一次克隆 → 多次导出(PNG + JPG + 下载)。 | | **快捷** `snapdom.toPng(el)` | 单次导出,代码更简洁。 | ### 可复用的捕获 一次捕获,多次导出(不会重新克隆): ```js const el = document.querySelector('#target'); const result = await snapdom(el); const img = await result.toPng(); document.body.appendChild(img); await result.download({ format: 'jpg', filename: 'my-capture.jpg' }); ``` ### 一步式快捷方法 直接导出单一格式: ```js const png = await snapdom.toPng(el); const blob = await snapdom.toBlob(el); document.body.appendChild(png); ``` ## API ### `snapdom(el, options?)` 返回一个包含可复用导出方法的对象: ```js { url: string; toRaw(): string; toImg(): Promise; // 已废弃 toSvg(): Promise; toCanvas(): Promise; toBlob(options?): Promise; toPng(options?): Promise; toJpg(options?): Promise; toWebp(options?): Promise; download(options?): Promise; } ``` ### 快捷方法 | 方法 | 描述 | | ------------------------------ | --------------------------------- | | `snapdom.toImg(el, options?)` | 返回一个 SVG `HTMLImageElement`(已废弃) | | `snapdom.toSvg(el, options?)` | 返回一个 SVG `HTMLImageElement` | | `snapdom.toCanvas(el, options?)` | 返回一个 `Canvas` | | `snapdom.toBlob(el, options?)` | 返回一个 SVG 或光栅 `Blob` | | `snapdom.toPng(el, options?)` | 返回一个 PNG 图片 | | `snapdom.toJpg(el, options?)` | 返回一个 JPG 图片 | | `snapdom.toWebp(el, options?)` | 返回一个 WebP 图片 | | `snapdom.download(el, options?)` | 触发下载 | ### 导出器专用选项 除了全局的捕获选项之外,部分导出器还支持一小组 **仅用于导出** 的选项。 #### `download()` | Option | Type | Default | Description | | ---------- | --------------------------------------------- | --------- | ----------- | | `filename` | `string` | `snapdom` | 下载文件名。 | | `format` | `"png" \| "jpeg" \| "jpg" \| "webp" \| "svg"` | `"png"` | 下载文件的输出格式。 | **示例:** ```js await result.download({ format: 'jpg', quality: 0.92, filename: 'my-capture' }); ``` #### `toBlob()` | Option | Type | Default | Description | | ------ | --------------------------------------------- | ------- | ------------ | | `type` | `"svg" \| "png" \| "jpeg" \| "jpg" \| "webp"` | `"svg"` | 生成的 Blob 类型。 | **示例:** ```js const blob = await result.toBlob({ type: 'jpeg', quality: 0.92 }); ``` ## 选项 所有捕获方法都接受一个 `options` 对象: | 选项 | 类型 | 默认值 | 描述 | | ----------------- | -------- | -------- | ----------------------------------------------- | | `debug` | boolean | `false` | 设为 `true` 时,将静默处理的错误输出到 `console.warn`,便于排查问题 | | `fast` | boolean | `true` | 跳过小的空闲延迟以获得更快的结果 | | `embedFonts` | boolean | `false` | 内嵌非图标字体(图标字体始终内嵌) | | `localFonts` | array | `[]` | 本地字体 `{ family, src, weight?, style? }` | | `iconFonts` | string\|RegExp\|Array | `[]` | 额外的图标字体匹配器 | | `excludeFonts` | object | `{}` | 在嵌入时排除字体族/域名/子集 | | `scale` | number | `1` | 输出缩放倍数 | | `dpr` | number | `devicePixelRatio` | 设备像素比 | | `width` | number | - | 输出宽度 | | `height` | number | - | 输出高度 | | `backgroundColor` | string | `"#fff"` | JPG/WebP 的备用颜色 | | `quality` | number | `1` | JPG/WebP 的质量(0 到 1) | | `useProxy` | string | `''` | CORS 备用代理基础 URL | | `exclude` | string[] | - | 要排除的 CSS 选择器 | | `excludeMode` | `"hide"`\|`"remove"` | `"hide"` | `exclude` 的应用方式 | | `filter` | function | - | 自定义谓词函数 `(el) => boolean` | | `filterMode` | `"hide"`\|`"remove"` | `"hide"` | `filter` 的应用方式 | | `cache` | string | `"soft"` | `disabled` \| `soft` \| `auto` \| `full` | | `placeholders` | boolean | `true` | 为图片/CORS iframe 显示占位符 | | `fallbackURL` | string \| function | - | `` 加载失败时的备用图片 | | `outerTransforms` | boolean | `true` | 当为 `false` 时移除 `translate/rotate` 但保留 `scale/skew`,产生扁平、可复用的捕获 | | `outerShadows` | boolean | `false` | 不为根元素的阴影/模糊/轮廓扩展边界框,并从克隆的根元素中移除这些视觉效果 | | `safariWarmupAttempts` | number | `3` | 仅 Safari:预热的迭代次数(WebKit #219770)。若 3 次过慢可设为 `1` | ### debug 当 `debug: true` 时,SnapDOM 会将通常静默处理的错误输出到 `console.warn`(带 `[snapdom]` 前缀)。便于排查捕获问题(如 canvas 失败、blob 解析、样式剥离等),而无需在生产环境中产生冗余输出。 ```js await snapdom.toPng(el, { debug: true }); ``` ### `` 加载失败时的备用图片 为失败的 `` 加载提供默认图片。您可以传递一个固定 URL 或一个接收测量尺寸并返回 URL 的回调函数(便于生成动态占位符)。 ```js // 1) 固定 URL 备用 await snapdom.toSvg(element, { fallbackURL: '/images/fallback.png' }); // 2) 通过回调生成动态占位符 await snapdom.toSvg(element, { fallbackURL: ({ width: 300, height: 150 }) => `https://placehold.co/${width}x${height}` }); // 3) 使用代理(如果您的备用图片主机没有 CORS) await snapdom.toSvg(element, { fallbackURL: ({ width = 300, height = 150 }) => `https://dummyimage.com/${width}x${height}/cccccc/666.png&text=img`, useProxy: 'https://proxy.corsfix.com/?' }); ``` 注意: - 如果备用图片也加载失败,snapDOM 会用保留宽度/高度的占位符块替换 ``。 - 回调使用的宽度/高度从原始元素(dataset、style/attrs 等)中收集(如果可用)。 ### 尺寸 (`scale`, `width`, `height`) * 如果提供了 `scale`,它将**优先于** `width`/`height`。 * 如果只提供 `width`,高度按比例缩放(反之亦然)。 * 同时提供 `width` 和 `height` 会强制使用精确尺寸(可能会失真)。 ### 跨域图片和字体 (`useProxy`) 默认情况下,snapDOM 尝试使用 `crossOrigin="anonymous"`(或同源时使用 `use-credentials`)。如果资源被 CORS 阻止,您可以将 `useProxy` 设置为转发实际 `src` 的前缀 URL: ```js await snapdom.toPng(el, { useProxy: 'https://proxy.corsfix.com/?' // 注意:可以使用任何 CORS 代理 'https://proxy.corsfix.com/?' }); ``` * 代理仅用作**备用**;同源和启用 CORS 的资源会跳过它。 ### 字体 #### `embedFonts` 当为 `true` 时,snapDOM 会嵌入在捕获子树中检测到使用的**非图标** `@font-face` 规则。图标字体(Font Awesome、Material Icons 等)**始终**被嵌入。 #### `localFonts` 如果您自己提供字体或拥有 data URL,可以在此处声明它们以避免额外的 CSS 发现: ```js await snapdom.toPng(el, { embedFonts: true, localFonts: [ { family: 'Inter', src: '/fonts/Inter-Variable.woff2', weight: 400, style: 'normal' }, { family: 'Inter', src: '/fonts/Inter-Italic.woff2', style: 'italic' } ] }); ``` #### `iconFonts` 添加自定义图标字体族(名称或正则表达式匹配器)。对私有图标集很有用: ```js await snapdom.toPng(el, { iconFonts: ['MyIcons', /^(Remix|Feather) Icons?$/i] }); ``` #### `excludeFonts` 跳过特定的非图标字体以加快捕获速度或避免不必要的下载。 ```js await snapdom.toPng(el, { embedFonts: true, excludeFonts: { families: ['Noto Serif', 'SomeHeavyFont'], // 按字体族名称跳过 domains: ['fonts.gstatic.com', 'cdn.example'], // 按源主机跳过 subsets: ['cyrillic-ext'] // 按 unicode-range 子集标签跳过 } }); ``` *注意* - `excludeFonts` 仅适用于**非图标**字体。图标字体始终被嵌入。 - `families` 的匹配不区分大小写。主机通过子字符串与解析后的 URL 进行匹配。 #### 节点过滤:`exclude` vs `filter` * `exclude`: 通过**选择器**移除。 * `excludeMode`: `hide` 对排除的节点应用 `visibility:hidden` CSS 规则,布局保持原样。`remove` 完全不克隆排除的节点。 * `filter`: 每个元素的高级谓词函数(返回 `false` 以丢弃)。 * `filterMode`: `hide` 对过滤的节点应用 `visibility:hidden` CSS 规则,布局保持原样。`remove` 完全不克隆过滤的节点。 **示例:过滤掉 `display:none` 的元素:** ```js /** * 示例过滤器:跳过 display:none 的元素 * @param {Element} el * @returns {boolean} true = 保留, false = 排除 */ function filterHidden(el) { const cs = window.getComputedStyle(el); if (cs.display === 'none') return false; return true; } await snapdom.toPng(document.body, { filter: filterHidden }); ``` **使用 `exclude` 的示例:** 通过选择器移除横幅或工具提示 ```js await snapdom.toPng(el, { exclude: ['.cookie-banner', '.tooltip', '[data-test="debug"]'] }); ``` ### outerTransforms 捕获旋转或平移的元素时,如果您想消除这些外部变换,可以使用 **outerTransforms: false** 选项。这样,输出是**扁平、直立且可直接**在其他地方使用的。 - **`outerTransforms: true (默认)`** **保留原始的 `transforms` 和 `rotate`**。 ### outerShadows - **`outerShadows: false (默认)`** 防止为根元素的阴影、模糊或轮廓扩展边界框,并从克隆的根元素中移除 `box-shadow`、`text-shadow`、`filter: blur()/drop-shadow()` 和 `outline`。 > 💡 **提示:** 同时使用两者(`outerTransforms: false` + `outerShadows: false`)会产生严格、最小化的边界框,没有视觉溢出。 **示例** ```js // outerTransforms 和移除阴影溢出 await snapdom.toSvg(el, { outerTransforms: true, outerShadows: true }); ``` ## 缓存控制 SnapDOM 为图片、背景、资源、样式和字体维护内部缓存。 您可以使用 `cache` 选项控制它们在捕获之间的清除方式: | 模式 | 描述 | | ----------- | --------------------------------------------------------------------------- | | `"disabled"`| 无缓存 | | `"soft"` | 清除会话缓存(`styleMap`、`nodeMap`、`styleCache`)_(默认)_ | | `"auto"` | 最小清理:仅清除临时映射 | | `"full"` | 保留所有缓存(不清除任何内容,最大性能) | **示例:** ```js // 使用最小但快速的缓存 await snapdom.toPng(el, { cache: 'auto' }); // 在捕获之间将所有内容保留在内存中 await snapdom.toPng(el, { cache: 'full' }); // 强制在每次捕获时完全清理 await snapdom.toPng(el, { cache: 'disabled' }); ``` ## `preCache()` – 可选辅助函数 预加载外部资源以避免首次捕获时的停顿(对大型/复杂树很有帮助)。 ```js import { preCache } from '@zumer/snapdom'; await preCache({ root: document.body, embedFonts: true, localFonts: [{ family: 'Inter', src: '/fonts/Inter.woff2', weight: 400 }], useProxy: 'https://proxy.corsfix.com/?' }); ``` ## 插件(测试版) SnapDOM 包含一个轻量级**插件系统**,允许您在捕获和导出过程的任何阶段扩展或覆盖行为——无需修改核心库。 插件是一个简单的对象,具有唯一的 `name` 和一个或多个生命周期**钩子**。 钩子可以是同步的或 `async`,它们接收一个共享的 **`context`** 对象。 ### 注册插件 **全局注册**(适用于所有捕获): ```js import { snapdom } from '@zumer/snapdom'; // 您可以注册实例、工厂函数或 [工厂函数, 选项] snapdom.plugins( myPluginInstance, [myPluginFactory, { optionA: true }], { plugin: anotherFactory, options: { level: 2 } } ); ``` **单次捕获注册**(仅适用于该特定调用): ```js const out = await snapdom(element, { plugins: [ [overlayFilterPlugin, { color: 'rgba(0,0,0,0.25)' }], [myFullPlugin, { providePdf: true }] ] }); ``` * **执行顺序 = 注册顺序**(先注册,先执行)。 * **单次捕获插件**在全局插件**之前**运行。 * 重复项通过 `name` 自动跳过;具有相同 `name` 的单次捕获插件会覆盖其全局版本。 ### 插件生命周期钩子 钩子按捕获顺序执行(见[捕获流程](#捕获流程)): | 钩子 | 阶段 | 目的 | |------|------|------| | `beforeSnap` | 开始 | 任何工作之前调整选项。 | | `beforeClone` | 克隆前 | DOM 克隆之前(谨慎修改实时 DOM)。 | | `afterClone` | 克隆后 | 安全修改克隆树(如注入叠加层)。 | | `beforeRender` | 序列化前 | SVG 转 data URL 之前。 | | `afterRender` | 序列化后 | 检查 `context.svgString` / `context.dataURL`。 | | `beforeExport` | 每次导出前 | 每次 `toPng`、`toSvg` 等之前。 | | `afterExport` | 每次导出后 | 转换返回结果。 | | `afterSnap` | 一次 | 第一次导出后;清理。 | | `defineExports` | 设置 | 添加自定义导出器(如 `toPdf`)。 | > `afterExport` 的返回值会链接到下一个插件(转换管道)。 ### 上下文对象 每个钩子都接收一个包含规范化捕获状态的 `context` 对象: * **输入和选项:** `element`, `debug`, `fast`, `scale`, `dpr`, `width`, `height`, `backgroundColor`, `quality`, `useProxy`, `cache`, `outerTransforms`, `outerShadows`, `safariWarmupAttempts`, `embedFonts`, `localFonts`, `iconFonts`, `excludeFonts`, `exclude`, `excludeMode`, `filter`, `filterMode`, `fallbackURL`。 * **中间值(取决于阶段):** `clone`, `classCSS`, `styleCache`, `fontsCSS`, `baseCSS`, `svgString`, `dataURL`。 * **导出期间:** `context.export = { type, options, url }` 其中 `type` 是导出器名称(`"png"`、`"jpeg"`、`"svg"`、`"blob"` 等),`url` 是序列化的 SVG 基础。 > 您可以安全地修改 `context`(例如,覆盖 `backgroundColor` 或 `quality`)——但要在早期(`beforeSnap`)进行以获得全局效果,或在 `beforeExport` 中进行以获得单次导出更改。 ## 通过插件自定义导出 插件可以使用 `defineExports(context)` 添加新的导出。 对于您返回的每个导出键(例如,`"pdf"`),SnapDOM 会在捕获结果上自动公开一个名为 **`toPdf()`** 的辅助方法。 **注册插件(全局或单次捕获):** ```js import { snapdom } from '@zumer/snapdom'; // 全局 snapdom.plugins(pdfExportPlugin()); // 或单次捕获 const out = await snapdom(element, { plugins: [pdfExportPlugin()] }); ``` **调用自定义导出:** ```js const out = await snapdom(document.querySelector('#report')); // 因为插件返回 { pdf: async (ctx, opts) => ... } const pdfBlob = await out.toPdf({ // 导出器特定选项(width, height, quality, filename 等) }); ``` ### 示例:叠加滤镜插件 仅在捕获的克隆中添加半透明叠加层或颜色滤镜(不在您的实时 DOM 中)。 在导出前用于高亮显示或变暗部分很有用。 ```js /** * SnapDOM 的超简单叠加滤镜(仅 HTML)。 * 在克隆的根元素上插入全尺寸
叠加层。 * * @param {{ color?: string; blur?: number }} [options] * color: 叠加颜色(rgba/hex/hsl)。默认: 'rgba(0,0,0,0.25)' * blur: 可选的模糊像素值(默认: 0) */ export function overlayFilterPlugin(options = {}) { const color = options.color ?? 'rgba(0,0,0,0.25)'; const blur = Math.max(0, options.blur ?? 0); return { name: 'overlay-filter', /** * 在克隆的 HTML 根元素上添加全覆盖叠加层。 * @param {any} context */ async afterClone(context) { const root = context.clone; if (!(root instanceof HTMLElement)) return; // 仅 HTML // 确保包含块,以便绝对定位的叠加层锚定到根元素 if (getComputedStyle(root).position === 'static') { root.style.position = 'relative'; } const overlay = document.createElement('div'); overlay.style.position = 'absolute'; overlay.style.left = '0'; overlay.style.top = '0'; overlay.style.right = '0'; overlay.style.bottom = '0'; overlay.style.background = color; overlay.style.pointerEvents = 'none'; if (blur) overlay.style.filter = `blur(${blur}px)`; root.appendChild(overlay); } }; } ``` **用法:** ```js import { snapdom } from '@zumer/snapdom'; // 全局注册 snapdom.plugins([overlayFilterPlugin, { color: 'rgba(0,0,0,0.3)', blur: 2 }]); // 单次捕获 const out = await snapdom(document.querySelector('#card'), { plugins: [[overlayFilterPlugin, { color: 'rgba(255,200,0,0.15)' }]] }); const png = await out.toPng(); document.body.appendChild(png); ``` > 叠加层仅注入到**克隆的树中**,永远不会注入到您的实时 DOM 中,确保完美保真度和零闪烁。 ### 完整插件模板 使用此模板作为自定义逻辑或导出器的起点。 ```js export function myPlugin(options = {}) { return { /** 用于去重/覆盖的唯一名称 */ name: 'my-plugin', /** 在任何克隆/样式工作之前的早期调整。 */ async beforeSnap(context) {}, /** 子树克隆之前(如果触及实时 DOM,请谨慎使用)。 */ async beforeClone(context) {}, /** 子树克隆之后(可以安全地修改克隆的树)。 */ async afterClone(context) {}, /** 序列化之前(SVG/dataURL)。 */ async beforeRender(context) {}, /** 序列化之后;如果需要,检查 context.svgString/context.dataURL。 */ async afterRender(context) {}, /** 每次导出调用之前(toPng/toSvg/toBlob/...)。 */ async beforeExport(context) {}, /** * 每次导出调用之后。 * 如果您返回一个值,它将成为下一个插件的结果(链式)。 */ async afterExport(context, result) { return result; }, /** * 定义自定义导出器(自动添加为辅助方法,如 out.toPdf())。 * 返回映射 { [key: string]: (ctx:any, opts:any) => Promise }。 */ async defineExports(context) { return {}; }, /** 在第一次导出完成后运行一次(清理)。 */ async afterSnap(context) {} }; } ``` **快速回顾:** * 插件可以修改捕获行为(`beforeSnap`、`afterClone` 等)。 * 您可以安全地将视觉效果或转换注入到克隆的树中。 * 在 `defineExports()` 中定义的新导出器会自动成为辅助方法,如 `out.toPdf()`。 * 所有钩子都可以是异步的,按顺序运行,并共享相同的 `context`。 ## 限制 * 外部图片应该是 CORS 可访问的(使用 `useProxy` 选项处理 CORS 拒绝) * 在 Safari 上使用 WebP 格式时,将回退到 PNG 渲染。 * `@font-face` CSS 规则得到良好支持,但如果需要使用 JS `FontFace()`,请参阅此解决方案 [`#43`](https://github.com/zumerlab/snapdom/issues/43) * **Safari**:启用 `embedFonts` 或包含背景/蒙版图片的捕获会较慢,因 [WebKit #219770](https://bugs.webkit.org/show_bug.cgi?id=219770)(字体解码时机)。SnapDOM 通过预捕获和 `drawImage` 预热管道;可通过 `safariWarmupAttempts` 调整(默认 3)。 * **自定义滚动条样式**(`::-webkit-scrollbar`):仅在元素*未滚动*时生效。若已滚动,将捕获视口内容且不显示滚动条。 ## ⚡ 性能基准测试(Chromium) **设置说明。** 在 Chromium 上使用 Vitest 基准测试,仓库测试。硬件可能影响结果。 数值为**平均捕获时间(毫秒)** → 越低越好。 ### 简单元素 | 场景 | SnapDOM 当前版本 | SnapDOM v1.9.9 | html2canvas | html-to-image | | ------------------------ | --------------- | -------------- | ----------- | ------------- | | 小尺寸 (200×100) | **0.5 ms** | 0.8 ms | 67.7 ms | 3.1 ms | | 模态框 (400×300) | **0.5 ms** | 0.8 ms | 75.5 ms | 3.6 ms | | 页面视图 (1200×800) | **0.5 ms** | 0.8 ms | 114.2 ms | 3.3 ms | | 大滚动 (2000×1500) | **0.5 ms** | 0.8 ms | 186.3 ms | 3.2 ms | | 超大尺寸 (4000×2000) | **0.5 ms** | 0.9 ms | 425.9 ms | 3.3 ms | ### 复杂元素 | 场景 | SnapDOM 当前版本 | SnapDOM v1.9.9 | html2canvas | html-to-image | | ------------------------ | --------------- | -------------- | ----------- | ------------- | | 小尺寸 (200×100) | **1.6 ms** | 3.3 ms | 68.0 ms | 14.3 ms | | 模态框 (400×300) | **2.9 ms** | 6.8 ms | 87.5 ms | 34.8 ms | | 页面视图 (1200×800) | **17.5 ms** | 50.2 ms | 178.0 ms | 429.0 ms | | 大滚动 (2000×1500) | **54.0 ms** | 201.8 ms | 735.2 ms | 984.2 ms | | 超大尺寸 (4000×2000) | **171.4 ms** | 453.7 ms | 1,800.4 ms | 2,611.9 ms | ### 运行基准测试 ```sh git clone https://github.com/zumerlab/snapdom.git cd snapdom npm install npm run test:benchmark ``` ## 路线图 SnapDOM 未来版本的计划改进: * [X] **实现插件系统** SnapDOM 将支持外部插件以扩展或覆盖内部行为(例如自定义节点转换器、导出器或过滤器)。 * [ ] **重构为模块化架构** 内部逻辑将被拆分为更小、更专注的模块,以提高可维护性和代码复用。 * [X] **将内部逻辑与全局选项解耦** 函数将重新设计以避免直接依赖 `options`。集中式捕获上下文将提高清晰度、自主性和可测试性。参见 [`next` 分支](https://github.com/zumerlab/snapdom/tree/main) * [X] **暴露缓存控制** 用户将能够手动清除图片和字体缓存或配置自己的缓存策略。 * [X] **自动字体预加载** 所需的字体将在捕获前自动检测和预加载,减少手动调用 `preCache()` 的需要。 * [X] **文档化插件开发** 将提供完整的指南,用于创建和注册自定义 SnapDOM 插件。 * [ ] **使导出工具支持 tree-shaking** `toPng`、`toJpg`、`toBlob` 等导出函数将被重构为独立模块,以支持 tree shaking 和最小化构建。 有想法或功能请求? 欢迎在 [GitHub Discussions](https://github.com/zumerlab/snapdom/discussions) 中分享建议或反馈。 ## 开发 **源码结构:** - `src/api/` – 公共 API(`snapdom`、`preCache`) - `src/core/` – 捕获流程、克隆、准备、插件 - `src/modules/` – 图片、字体、伪元素、背景、SVG - `src/exporters/` – toPng、toSvg、toBlob 等 - `dist/` – 构建产物(`snapdom.js`、`snapdom.mjs`、`preCache.mjs`、`plugins.mjs`) **构建:** ```sh git clone https://github.com/zumerlab/snapdom.git cd snapdom git checkout dev npm install npm run compile ``` **测试:** ```sh npx playwright install # 浏览器测试所需 npm test npm run test:benchmark ``` 详细指南请参阅 [CONTRIBUTING](https://github.com/zumerlab/snapdom/blob/main/CONTRIBUTING.md)。 ## 贡献者 🙌

tinchox5 Jarvis2018 tarwin Amyuan23 airamhr9 FlavioLimaMindera jswhisperer K1ender kohaiy 17biubiu av01d CHOYSEN pedrocateexte domialex elliots stypr mon-jai sharuzzaman simon1uo titoBouzout ZiuChen harshasiddartha karasHou jhbae200 xiaobai-web715 miusuncle rbbydotdev zhanghaotian2018

## 💖 赞助者 特别感谢 [@megaphonecolin](https://github.com/megaphonecolin)、[@sdraper69](https://github.com/sdraper69)、[@reynaldichernando](https://github.com/reynaldichernando) 和 [@gamma-app](https://github.com/gamma-app),感谢他们对本项目的支持! 如果您也想支持这个项目,您可以[成为赞助者](https://github.com/sponsors/tinchox5)。 ## Star 历史 [![Star History Chart](https://api.star-history.com/svg?repos=zumerlab/snapdom&type=Date)](https://www.star-history.com/#zumerlab/snapdom&Date) ## 许可证 MIT © Zumerlab ================================================ FILE: __tests__/api.preCache.more.test.js ================================================ // __tests__/api.preCache.more.test.js import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('../src/utils', async () => { const actual = await vi.importActual('../src/utils') return { ...actual, fetchImage: vi.fn(async () => 'data:image/png;base64,iVBORw0KGgo='), precacheCommonTags: vi.fn(), isSafari: vi.fn(() => false), // queda como vi.fn() invocable } }) // preCache importa inlineBackgroundImages desde ../modules/background.js // (si en tu código real lo seguís importando desde ../utils, cambiá esta ruta acá) vi.mock('../src/modules/background.js', () => ({ inlineBackgroundImages: vi.fn(async () => {}), })) vi.mock('../src/modules/fonts.js', () => ({ embedCustomFonts: vi.fn(async () => ''), collectUsedFontVariants: vi.fn(() => new Set(['Mansalva__700__italic__100'])), collectUsedCodepoints: vi.fn(() => new Set([65])), ensureFontsReady: vi.fn(async () => {}), })) // ⬇️ recién ahora importamos SUT + símbolos mocked import { preCache } from '../src/api/preCache.js' import { cache } from '../src/core/cache.js' import * as utils from '../src/utils' import { inlineBackgroundImages } from '../src/modules/background.js' import { embedCustomFonts, collectUsedFontVariants, collectUsedCodepoints, ensureFontsReady, } from '../src/modules/fonts.js' describe('preCache – líneas difíciles', () => { beforeEach(() => { vi.clearAllMocks() utils.isSafari.mockReset?.() utils.isSafari.mockReturnValue(false) if (!cache.session) cache.session = {} cache.session.styleCache = new WeakMap() }) it('pasa cache.session.styleCache a inlineBackgroundImages (líneas 49–50)', async () => { const root = document.createElement('div') const ref = cache.session.styleCache await expect(preCache(root, { embedFonts: false })).resolves.toBeUndefined() expect(inlineBackgroundImages).toHaveBeenCalledTimes(1) const args = inlineBackgroundImages.mock.calls[0] // [source, mirror, styleCache, options] expect(args[0]).toBe(root) expect(args[2]).toStrictEqual(ref) // MISMA referencia => cubre 49–50 expect(args[3]).toMatchObject({ useProxy: '' }) }) it('si inlineBackgroundImages falla, preCache resuelve igual (catch 56–65)', async () => { inlineBackgroundImages.mockRejectedValueOnce(new Error('boom')) const el = document.createElement('section') await expect(preCache(el, { embedFonts: false })).resolves.toBeUndefined() }) it('Safari warmup + embed de fuentes con params correctos (84–91)', async () => { utils.isSafari.mockReturnValue(true) const root = document.createElement('div') const excludeFonts = { subsets: ['latin'], domains: ['bad.example'] } const localFonts = [{ family: 'Foo', src: 'data:font/woff2;base64,AA==' }] await preCache(root, { embedFonts: true, useProxy: '/proxy/', excludeFonts, localFonts, }) expect(ensureFontsReady).toHaveBeenCalledTimes(1) const [families, reps] = ensureFontsReady.mock.calls[0] expect(families instanceof Set).toBe(true) expect(Array.from(families)).toContain('Mansalva') expect(reps).toBe(3) expect(embedCustomFonts).toHaveBeenCalledTimes(1) const call = embedCustomFonts.mock.calls[0][0] expect(call.required).toEqual(collectUsedFontVariants()) expect(call.usedCodepoints).toEqual(collectUsedCodepoints()) expect(call.exclude).toEqual(excludeFonts) expect(call.localFonts).toEqual(localFonts) expect(call.useProxy).toBe('/proxy/') }) it('crea styleCache si no existe y lo inyecta a inlineBackgroundImages (49–50)', async () => { // Aseguramos estado inicial sin styleCache cache.session = cache.session || {} delete cache.session.styleCache const root = document.createElement('main') await expect(preCache(root, { embedFonts: false })).resolves.toBeUndefined() // Se llamó una vez y con el WeakMap recién creado expect(inlineBackgroundImages).toHaveBeenCalledTimes(1) const args = inlineBackgroundImages.mock.calls[0] // [source, mirror, styleCache, options] expect(args[0]).toBe(root) // preCache debió crear y colgar el WeakMap en cache.session.styleCache expect(cache.session.styleCache).toBeInstanceOf(WeakMap) expect(args[2]).toBe(cache.session.styleCache) // MISMA referencia => cubre 49–50 }) it('si inlineBackgroundImages lanza (throw sync), preCache resuelve igual (56–65)', async () => { // Lanzar sincrónico para entrar al try/catch de preCache inlineBackgroundImages.mockImplementationOnce(() => { throw new Error('sync-boom') }) const el = document.createElement('section') await expect(preCache(el, { embedFonts: false })).resolves.toBeUndefined() // (Opcional) el resto del flujo no debe romperse expect(inlineBackgroundImages).toHaveBeenCalledTimes(1) }) }) ================================================ FILE: __tests__/api.preCache.test.js ================================================ import { describe, it, expect, beforeEach, vi } from 'vitest' import { preCache } from '../src/api/preCache.js' import { cache } from '../src/core/cache.js' import { safeEncodeURI } from '../src/utils/helpers.js' // ajustá el path si difiere beforeEach(() => { vi.restoreAllMocks() cache.image?.clear?.() cache.background?.clear?.() document.body.innerHTML = '' }) describe('preCache – extra coverage', () => { it('prefetches SVG background via proxy fallback and dedupes repeated URL', async () => { const PROXY = 'https://proxy.example.com/?u=' const DIRECT = 'https://cdn.example.com/icon.svg' const svg = '' globalThis.fetch = vi.fn((url) => { const u = String(url) if (u.startsWith(PROXY)) { return Promise.resolve({ ok: true, text: () => Promise.resolve(svg), blob: () => Promise.resolve(new Blob([svg], { type: 'image/svg+xml' })), }) } // En el contrato nuevo, no se debería llegar acá si useProxy está seteado return Promise.reject(new Error('network fail')) }) const root = document.createElement('div') const a = document.createElement('div') const b = document.createElement('div') a.style.backgroundImage = `url(${DIRECT})` b.style.backgroundImage = `url(${DIRECT})` root.appendChild(a) root.appendChild(b) document.body.appendChild(root) await preCache(root, { useProxy: PROXY }) const calls = vi.mocked(globalThis.fetch).mock.calls.map(([u]) => String(u)) const proxyCalls = calls.filter(u => u.startsWith(PROXY)) const directCalls = calls.filter(u => u === DIRECT) // (1) EXACTAMENTE una llamada proxied (in-flight + cache dedupe) expect(proxyCalls.length).toBe(1) // (2) Con proxy activo, NO hay intentos directos en el nuevo snapFetch expect(directCalls.length).toBe(0) // (3) Dedupe en cache.background: una sola entrada para ese URL const key = safeEncodeURI(DIRECT) expect(cache.background.has(key)).toBe(true) expect([...cache.background.keys()].filter(k => k === key).length).toBe(1) document.body.removeChild(root) }) it('handles mixed background layers (gradient + url) and only processes the URL layer', async () => { // No contamos fetch acá porque raster usa Image() y puede ser 0. globalThis.fetch = vi.fn() // por si algo intenta fetch (no debería) const URL = 'https://assets.test/a.svg' // usamos SVG para que sí pase por fetch en tu impl // Si querés testear raster, cambiá asserts a cache.image; con SVG comprobamos background. const svg = '' vi.mocked(globalThis.fetch).mockResolvedValue({ ok: true, text: () => Promise.resolve(svg), blob: () => Promise.resolve(new Blob([svg], { type: 'image/svg+xml' })), }) const el = document.createElement('div') el.style.backgroundImage = `linear-gradient(90deg, #000, #fff), url(${URL})` document.body.appendChild(el) await preCache(el) // Verificamos que SOLO la capa url(...) fue procesada y quedó cacheada const key = safeEncodeURI(URL) expect(cache.background.has(key)).toBe(true) // No exigimos conteo de fetch: puede ser 0 si fuese raster. // Si mantenés SVG como arriba, opcionalmente: // expect(globalThis.fetch).toHaveBeenCalledTimes(1); document.body.removeChild(el) }) it('walks the subtree and preloads child backgrounds', async () => { const svg = '' globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve(svg), blob: () => Promise.resolve(new Blob([svg], { type: 'image/svg+xml' })), }) const root = document.createElement('div') const child = document.createElement('span') const CHILD_URL = 'https://x.test/nested.svg' child.style.backgroundImage = `url(${CHILD_URL})` root.appendChild(child) document.body.appendChild(root) await preCache(root) // Comprobamos que el hijo fue visto y cacheado const key = safeEncodeURI(CHILD_URL) expect(cache.background.has(key)).toBe(true) document.body.removeChild(root) }) }) ================================================ FILE: __tests__/api.snapdom.more.test.js ================================================ // __tests__/api.snapdom.more.test.js – snapdom.js extra coverage import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { snapdom } from '../src/index.js' vi.mock('../src/utils/browser', { spy: true }) import * as browser from '../src/utils/browser' beforeEach(() => { document.body.innerHTML = '' vi.restoreAllMocks() vi.mocked(browser.isSafari).mockReturnValue(false) }) afterEach(() => { document.body.innerHTML = '' }) describe('snapdom – error handling', () => { it('throws when element is null', async () => { await expect(snapdom(null)).rejects.toThrow(/cannot be null/) }) it('throws when element is undefined', async () => { await expect(snapdom(undefined)).rejects.toThrow(/cannot be null/) }) }) describe('snapdom – result.to()', () => { it('throws for unknown export type', async () => { const el = document.createElement('div') el.textContent = 'x' document.body.appendChild(el) const result = await snapdom(el) await expect(result.to('unknownType')).rejects.toThrow(/Unknown export type/) }) }) describe('snapdom – result helpers', () => { it('result has all expected export methods', async () => { const el = document.createElement('div') el.style.width = '50px' el.style.height = '50px' el.textContent = 'x' document.body.appendChild(el) const result = await snapdom(el) expect(typeof result.toPng).toBe('function') expect(typeof result.toSvg).toBe('function') expect(typeof result.toCanvas).toBe('function') expect(typeof result.download).toBe('function') }) }) ================================================ FILE: __tests__/api.snapdom.test.js ================================================ import { describe, it, expect, vi } from 'vitest' import { snapdom } from '../src/api/snapdom.js' describe('snapdom API (direct)', () => { it('throws on null element', async () => { await expect(snapdom(null)).rejects.toThrow() }) it('snapdom returns export methods', async () => { const el = document.createElement('div') el.style.width = '100px' el.style.height = '50px' document.body.appendChild(el) const result = await snapdom(el) expect(result).toHaveProperty('toRaw') expect(result).toHaveProperty('toImg') expect(result).toHaveProperty('download') document.body.removeChild(el) }) it('snapdom.toRaw, toImg, toCanvas, toBlob, toPng, toJpg, toWebp, download', async () => { const el = document.createElement('div') el.style.width = '100px' el.style.height = '50px' document.body.appendChild(el) await snapdom.toRaw(el) await snapdom.toImg(el) await snapdom.toCanvas(el) await snapdom.toBlob(el) await snapdom.toPng(el) await snapdom.toJpg(el) await snapdom.toWebp(el) await snapdom.download(el, { format: 'png', filename: 'test' }) document.body.removeChild(el) }) it('cubre rama Safari en toImg', async () => { vi.resetModules() vi.mock('../utils', async () => { const actual = await vi.importActual('../utils') return { ...actual, isSafari: true } }) const { snapdom } = await import('../src/api/snapdom.js') const el = document.createElement('div') el.style.width = '10px' el.style.height = '10px' document.body.appendChild(el) // Forzar un SVG dataURL simple const img = new Image() img.width = 10 img.height = 10 img.decode = () => Promise.resolve() globalThis.Image = function() { return img } const res = await snapdom(el) await res.toImg() document.body.removeChild(el) vi.resetModules() }) it('cubre rama de download SVG', async () => { const el = document.createElement('div') el.style.width = '10px' el.style.height = '10px' document.body.appendChild(el) // Mock a.click y URL.createObjectURL const a = document.createElement('a') document.body.appendChild(a) const origCreate = URL.createObjectURL URL.createObjectURL = () => 'blob:url' const origClick = a.click a.click = () => {} HTMLAnchorElement.prototype.click = () => {} const { snapdom } = await import('../src/api/snapdom.js') await snapdom.download(el, { format: 'svg', filename: 'testsvg' }) URL.createObjectURL = origCreate a.click = origClick document.body.removeChild(a) document.body.removeChild(el) }) it('snapdom.toBlob supports type options ', async () => { const el = document.createElement('div') el.style.width = '50px' el.style.height = '30px' document.body.appendChild(el) const result = await snapdom(el) const pngBlob = await result.toBlob({ type: 'png' }) expect(pngBlob).toBeInstanceOf(Blob) expect(pngBlob.type).toBe('image/png') const jpgBlob = await result.toBlob({ type: 'jpeg', quality: 0.8 }) expect(jpgBlob).toBeInstanceOf(Blob) expect(jpgBlob.type).toBe('image/jpeg') const webpBlob = await result.toBlob({ type: 'webp', quality: 0.9 }) expect(webpBlob).toBeInstanceOf(Blob) expect(webpBlob.type).toBe('image/webp') // default fallback const svgBlob = await result.toBlob() expect(svgBlob).toBeInstanceOf(Blob) expect(svgBlob.type).toBe('image/svg+xml') document.body.removeChild(el) }) it('toPng, toJpg, toWebp return HTMLImageElement with URLs', async () => { const el = document.createElement('div') el.style.width = '60px' el.style.height = '40px' document.body.appendChild(el) const snap = await snapdom(el) const pngImg = await snap.toPng() expect(pngImg).toBeInstanceOf(HTMLImageElement) expect(typeof pngImg.src).toBe('string') expect(pngImg.src.startsWith('data:image/png')).toBe(true) const jpgImg = await snap.toJpg() expect(jpgImg).toBeInstanceOf(HTMLImageElement) expect(typeof jpgImg.src).toBe('string') expect(jpgImg.src.startsWith('data:image/jpeg')).toBe(true) const webpImg = await snap.toWebp() expect(webpImg).toBeInstanceOf(HTMLImageElement) expect(typeof webpImg.src).toBe('string') expect(webpImg.src.startsWith('data:image/webp')).toBe(true) document.body.removeChild(el) }) it('snapdom should support exclude option to filter out elements by CSS selectors', async () => { const el = document.createElement('div') el.innerHTML = `

Title

Should be excluded
Private data

This should remain

` document.body.appendChild(el) const result = await snapdom(el, { exclude: ['.exclude-me', '[data-private]'] }) const svg = result.toRaw() const decoded = decodeURIComponent(svg.split(',')[1]) expect(decoded).not.toContain('Should be excluded') expect(decoded).not.toContain('Private data') expect(decoded).toContain('Title') expect(decoded).toContain('This should remain') }) it('snapdom should support filter option to exclude elements with custom logic', async () => { const el = document.createElement('div') el.innerHTML = `
Level 1
Level 2
Level 3
` document.body.appendChild(el) const result = await snapdom(el, { filter: (element) => !element.classList.contains('level-3') }) const svg = result.toRaw() const decoded = decodeURIComponent(svg.split(',')[1]) expect(decoded).toContain('Level 1') expect(decoded).toContain('Level 2') expect(decoded).not.toContain('Level 3') }) it('snapdom should support combining exclude and filter options', async () => { const el = document.createElement('div') el.innerHTML = `
Exclude by selector
Exclude by filter
Keep this content
` document.body.appendChild(el) const result = await snapdom(el, { exclude: ['.exclude-by-selector'], filter: (element) => !element.classList.contains('exclude-by-filter') }) const svg = result.toRaw() const decoded = decodeURIComponent(svg.split(',')[1]) expect(decoded).not.toContain('Exclude by selector') expect(decoded).not.toContain('Exclude by filter') expect(decoded).toContain('Keep this content') }) }) ================================================ FILE: __tests__/core.cache.test.js ================================================ // __tests__/core.cache.test.js import { describe, it, expect, beforeEach } from 'vitest' import { cache, normalizeCachePolicy, applyCachePolicy } from '../src/core/cache.js' /** * Snapshot helpers to assert identity changes. */ function snapshotRefs() { return { image: cache.image, background: cache.background, resource: cache.resource, defaultStyle: cache.defaultStyle, baseStyle: cache.baseStyle, computedStyle: cache.computedStyle, font: cache.font, session_styleMap: cache.session.styleMap, session_styleCache: cache.session.styleCache, session_nodeMap: cache.session.nodeMap, } } function seedSomeData() { cache.image.set('k', 1) cache.background.set('b', 2) cache.resource.set('r', 3) cache.defaultStyle.set('d', 4) cache.baseStyle.set('bs', 5) cache.computedStyle.set({}, { c: 6 }) cache.font.add('Inter__400') cache.session.styleMap.set('x', 'y') cache.session.styleCache.set({}, { sc: 1 }) cache.session.nodeMap.set({}, document.createElement('div')) } describe('normalizeCachePolicy', () => { it('maps booleans and known strings, defaults to "soft"', () => { expect(normalizeCachePolicy(true)).toBe('soft') expect(normalizeCachePolicy(false)).toBe('disabled') expect(normalizeCachePolicy('auto')).toBe('auto') expect(normalizeCachePolicy('full')).toBe('full') expect(normalizeCachePolicy('soft')).toBe('soft') expect(normalizeCachePolicy('disabled')).toBe('disabled') // unknown → soft (default) expect(normalizeCachePolicy('weird')).toBe('soft') expect(normalizeCachePolicy(undefined)).toBe('soft') expect(normalizeCachePolicy(123)).toBe('soft') }) }) describe('applyCachePolicy', () => { beforeEach(() => { // Re-crear contenedores para que cada test sea independiente. cache.image = new Map() cache.background = new Map() cache.resource = new Map() cache.defaultStyle = new Map() cache.baseStyle = new Map() cache.computedStyle = new WeakMap() cache.font = new Set() cache.session.styleMap = new Map() cache.session.styleCache = new WeakMap() cache.session.nodeMap = new Map() }) it('auto: resets only session.styleMap and session.nodeMap', () => { seedSomeData() const before = snapshotRefs() applyCachePolicy('auto') const after = snapshotRefs() // Reemplaza solo estos dos expect(after.session_styleMap).not.toBe(before.session_styleMap) expect(after.session_nodeMap).not.toBe(before.session_nodeMap) // Mantiene styleCache y resto expect(after.session_styleCache).toBe(before.session_styleCache) expect(after.image).toBe(before.image) expect(after.background).toBe(before.background) expect(after.resource).toBe(before.resource) expect(after.defaultStyle).toBe(before.defaultStyle) expect(after.baseStyle).toBe(before.baseStyle) expect(after.computedStyle).toBe(before.computedStyle) expect(after.font).toBe(before.font) // Nuevos maps están vacíos expect(cache.session.styleMap.size).toBe(0) expect(cache.session.nodeMap.size).toBe(0) }) it('soft: resets toda la sesión (styleMap, nodeMap, styleCache) y deja globales', () => { seedSomeData() const before = snapshotRefs() applyCachePolicy('soft') const after = snapshotRefs() // Reemplaza los tres de sesión expect(after.session_styleMap).not.toBe(before.session_styleMap) expect(after.session_nodeMap).not.toBe(before.session_nodeMap) expect(after.session_styleCache).not.toBe(before.session_styleCache) // Globales se mantienen (misma identidad) expect(after.image).toBe(before.image) expect(after.background).toBe(before.background) expect(after.resource).toBe(before.resource) expect(after.defaultStyle).toBe(before.defaultStyle) expect(after.baseStyle).toBe(before.baseStyle) expect(after.computedStyle).toBe(before.computedStyle) expect(after.font).toBe(before.font) // Sesión está vacía expect(cache.session.styleMap.size).toBe(0) expect(cache.session.nodeMap.size).toBe(0) }) it('full: no limpia nada (mantiene identidades y contenidos)', () => { seedSomeData() const before = snapshotRefs() applyCachePolicy('full') const after = snapshotRefs() // Todo igual expect(after.image).toBe(before.image) expect(after.background).toBe(before.background) expect(after.resource).toBe(before.resource) expect(after.defaultStyle).toBe(before.defaultStyle) expect(after.baseStyle).toBe(before.baseStyle) expect(after.computedStyle).toBe(before.computedStyle) expect(after.font).toBe(before.font) expect(after.session_styleMap).toBe(before.session_styleMap) expect(after.session_styleCache).toBe(before.session_styleCache) expect(after.session_nodeMap).toBe(before.session_nodeMap) // Y siguen con datos expect(cache.image.size).toBeGreaterThan(0) expect(cache.session.styleMap.size).toBeGreaterThan(0) }) it('disabled: reinstancia TODO (global + sesión) y deja todo vacío', () => { seedSomeData() const before = snapshotRefs() applyCachePolicy('disabled') const after = snapshotRefs() // Todo debe ser nuevo expect(after.image).not.toBe(before.image) expect(after.background).not.toBe(before.background) expect(after.resource).not.toBe(before.resource) expect(after.defaultStyle).not.toBe(before.defaultStyle) expect(after.baseStyle).not.toBe(before.baseStyle) expect(after.computedStyle).not.toBe(before.computedStyle) expect(after.font).not.toBe(before.font) expect(after.session_styleMap).not.toBe(before.session_styleMap) expect(after.session_styleCache).not.toBe(before.session_styleCache) expect(after.session_nodeMap).not.toBe(before.session_nodeMap) // Vacíos expect(cache.image.size).toBe(0) expect(cache.background.size).toBe(0) expect(cache.resource.size).toBe(0) expect(cache.defaultStyle.size).toBe(0) expect(cache.baseStyle.size).toBe(0) expect(cache.font.size).toBe(0) expect(cache.session.styleMap.size).toBe(0) expect(cache.session.nodeMap.size).toBe(0) }) it('default (input desconocido): cae en soft', () => { seedSomeData() const before = snapshotRefs() // Política inexistente provoca rama default → soft applyCachePolicy('unknown-policy') const after = snapshotRefs() // Reemplaza los de sesión expect(after.session_styleMap).not.toBe(before.session_styleMap) expect(after.session_nodeMap).not.toBe(before.session_nodeMap) expect(after.session_styleCache).not.toBe(before.session_styleCache) // Mantiene globales expect(after.image).toBe(before.image) expect(after.baseStyle).toBe(before.baseStyle) expect(after.defaultStyle).toBe(before.defaultStyle) expect(after.computedStyle).toBe(before.computedStyle) expect(after.font).toBe(before.font) }) }) ================================================ FILE: __tests__/core.capture.more.test.js ================================================ import { describe, it, expect, vi, afterEach } from 'vitest' /** * Decode the SVG XML text from a data URL returned by captureDOM. * @param {string} dataUrl * @returns {string} */ function decodeSvg(dataUrl) { const [, encoded] = dataUrl.split(',', 2) return decodeURIComponent(encoded) } /** * Creates a stable DOMRect for BCR stubs. * @param {number} x * @param {number} y * @param {number} w * @param {number} h * @returns {DOMRect} */ function rect(x, y, w, h) { return new DOMRect(x, y, w, h) } afterEach(() => { vi.restoreAllMocks() }) // // ────────────────────────────────────────────────────────────────────────────── // Edge cases (los que ya tenías, sin cambios) // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM edge cases', () => { it('throws for unsupported element (unknown nodeType)', async () => { const { captureDOM } = await import('../src/core/capture.js') const fakeNode = { nodeType: 999 } await expect(captureDOM(fakeNode)).rejects.toThrow() }) it('throws if element is null', async () => { const { captureDOM } = await import('../src/core/capture.js') await expect(captureDOM(null)).rejects.toThrow() }) it('throws error if getBoundingClientRect fails', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockImplementation(() => { throw new Error('fail') }) const el = document.createElement('div') await expect(captureDOM(el, { fast: true })).rejects.toThrow(/fail/) }) }) // // ────────────────────────────────────────────────────────────────────────────── // Functional & overflow rules // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM functional', () => { it('returns a data:image/svg+xml and includes overflow visible rules', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue( rect(0, 0, 80, 40) ) const el = document.createElement('div') el.textContent = 'test' const url = await captureDOM(el, { fast: true, embedFonts: false }) expect(url.startsWith('data:image/svg+xml')).toBe(true) const svg = decodeSvg(url) expect(svg).toMatch(/svg\{overflow:visible;?\}/) expect(svg).toMatch(/foreignObject\{overflow:visible;?\}/) }) it('supports scale and width/height options (wrapper sizing behavior)', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue( rect(10, 20, 100, 50) // aspect = 2 ) const el = document.createElement('div') // scale → mantiene tamaño natural en , usa viewBox y no agrega transform: scale(...) const svg1 = decodeSvg(await captureDOM(el, { fast: true, scale: 2, embedFonts: false })) expect(svg1).toContain('width="100"') expect(svg1).toContain('height="50"') expect(svg1).toContain('viewBox="0 0 100 50"') expect(svg1).toMatch(/]*style="[^"]*width:\s*100px/) expect(svg1).toMatch(/]*style="[^"]*height:\s*50px/) expect(/transform:[^"]*scale\(/.test(svg1)).toBe(false) // width only → el adopta 200x100; el wrapper interno permanece 100x50 (natural) const svg2 = decodeSvg(await captureDOM(el, { fast: true, width: 200, embedFonts: false })) expect(svg2).toContain('width="200"') expect(svg2).toContain('height="100"') expect(svg2).toContain('viewBox="0 0 100 50"') expect(svg2).toMatch(/]*style="[^"]*width:\s*100px/) expect(svg2).toMatch(/]*style="[^"]*height:\s*50px/) // height only → el adopta 200x100; el wrapper permanece 100x50 (natural) const svg3 = decodeSvg(await captureDOM(el, { fast: true, height: 100, embedFonts: false })) expect(svg3).toContain('width="200"') expect(svg3).toContain('height="100"') expect(svg3).toContain('viewBox="0 0 100 50"') expect(svg3).toMatch(/]*style="[^"]*width:\s*100px/) expect(svg3).toMatch(/]*style="[^"]*height:\s*50px/) }) it('supports fast=false (idle scheduling path)', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 10, 10)) const el = document.createElement('div') const url = await captureDOM(el, { fast: false, embedFonts: false }) expect(url.startsWith('data:image/svg+xml')).toBe(true) }) }) // // ────────────────────────────────────────────────────────────────────────────── // BaseCSS presence (sin espiar ESM): solo verificamos reglas base // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM – baseCSS presence (no ESM spies)', () => { it('includes base overflow rules on repeated calls', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 120, 60)) const el1 = document.createElement('div') const el2 = document.createElement('div') const s1 = decodeSvg(await captureDOM(el1, { fast: true, embedFonts: false })) const s2 = decodeSvg(await captureDOM(el2, { fast: true, embedFonts: false })) expect(s1).toMatch(/svg\{overflow:visible;?\}/) expect(s1).toMatch(/foreignObject\{overflow:visible;?\}/) expect(s2).toMatch(/svg\{overflow:visible;?\}/) expect(s2).toMatch(/foreignObject\{overflow:visible;?\}/) }) }) // // ────────────────────────────────────────────────────────────────────────────── // Width/Height/Scale branches (precise, alineado a tu implementación actual) // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM – width/height/scale branches (precise)', () => { it('natural rect used when no width/height/scale given', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 50)) const el = document.createElement('div') const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) expect(svg).toContain('width="100"') expect(svg).toContain('height="50"') }) it('width only → SVG 200x100; wrapper queda 100x50; viewBox natural', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 50)) const el = document.createElement('div') const svg = decodeSvg(await captureDOM(el, { fast: true, width: 200, embedFonts: false })) expect(svg).toContain('width="200"') expect(svg).toContain('height="100"') expect(svg).toContain('viewBox="0 0 100 50"') expect(svg).toMatch(/]*style="[^"]*width:\s*100px/) expect(svg).toMatch(/]*style="[^"]*height:\s*50px/) }) it('height only → SVG 200x100; wrapper queda 100x50; viewBox natural', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 50)) const el = document.createElement('div') const svg = decodeSvg(await captureDOM(el, { fast: true, height: 100, embedFonts: false })) expect(svg).toContain('width="200"') expect(svg).toContain('height="100"') expect(svg).toContain('viewBox="0 0 100 50"') expect(svg).toMatch(/]*style="[^"]*width:\s*100px/) expect(svg).toMatch(/]*style="[^"]*height:\s*50px/) }) it('scale only → keeps natural width/height; uses viewBox; no transform', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 50)) const el = document.createElement('div') const svg = decodeSvg(await captureDOM(el, { fast: true, scale: 2, embedFonts: false })) expect(svg).toContain('width="100"') expect(svg).toContain('height="50"') expect(svg).toContain('viewBox="0 0 100 50"') expect(/transform:[^"]*scale\(/.test(svg)).toBe(false) }) }) // // ────────────────────────────────────────────────────────────────────────────── // Viewport path (sin tocar utils): solo afirmamos x/y presentes (0 o valores) // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM – viewport path sanity', () => { it('foreignObject has x/y attributes (tx/ty), even if 0', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(5, 7, 80, 30)) const el = document.createElement('div') const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) // No imponemos un cálculo exacto; validamos la presencia de x="" y y="" numéricos. expect(svg).toMatch(/]*\sx="[-\d]+"/) expect(svg).toMatch(/]*\sy="[-\d]+"/) }) }) // // ────────────────────────────────────────────────────────────────────────────── // #348: CSS vars excluded from snapshot – fidelity preserved (var() resolved) // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM – #348 CSS vars fidelity', () => { it('color: var(--x) resolves to computed value in output', async () => { const { captureDOM } = await import('../src/core/capture.js') const wrap = document.createElement('div') wrap.innerHTML = `
red text
` document.body.appendChild(wrap) const el = wrap.querySelector('.t348') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 80, 20)) const url = await captureDOM(el, { fast: true, embedFonts: false }) document.body.removeChild(wrap) const svg = decodeSvg(url) expect(svg).toMatch(/rgb\(255,\s*0,\s*0\)|#[fF]{2}0000/) }) }) // // ────────────────────────────────────────────────────────────────────────────── // #372: iframe CSS isolation – wrapper div must not inherit iframe cascade // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM – #372 iframe CSS isolation', () => { it('wrapper div has all:initial to block iframe cascade (e.g. div { border: 10px solid red })', async () => { const { captureDOM } = await import('../src/core/capture.js') const iframe = document.createElement('iframe') iframe.srcdoc = `
content
` iframe.style.width = '100px' iframe.style.height = '80px' document.body.appendChild(iframe) await new Promise((resolve) => { iframe.onload = resolve }) const doc = iframe.contentDocument const root = doc.documentElement const url = await captureDOM(root, { fast: true, embedFonts: false }) document.body.removeChild(iframe) const svg = decodeSvg(url) // Wrapper div (container) inside foreignObject must be isolated from iframe CSS (#372). // Browser expands all:initial to individual props (border: initial, position: initial, etc.) expect(svg).toContain('box-sizing: border-box') expect(svg).toMatch(/border:\s*initial|position:\s*initial/) }) }) // // ────────────────────────────────────────────────────────────────────────────── // #362: Tailwind * { border: 0 solid } – normalize to border: none in capture // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM – #362 canvas Tailwind border', () => { it('elements with border-width 0 get border:none in output (not border: 0 solid)', async () => { const { captureDOM } = await import('../src/core/capture.js') const wrap = document.createElement('div') wrap.innerHTML = ` ` document.body.appendChild(wrap) const canvas = wrap.querySelector('#c362') const ctx = canvas.getContext('2d') ctx.fillStyle = 'red' ctx.fillRect(0, 0, 80, 40) vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue( new DOMRect(0, 0, 80, 40) ) const url = await captureDOM(wrap, { fast: true, embedFonts: false }) document.body.removeChild(wrap) const svg = decodeSvg(url) // Canvas becomes img; snapshot should normalize border: 0 solid → border: none expect(svg).toMatch(/\bborder:\s*none\b/) }) }) // ────────────────────────────────────────────────────────────────────────────── // Transform handling (lenient, effect-only) // ────────────────────────────────────────────────────────────────────────────── describe('captureDOM – transform handling (lenient heuristic)', () => { it('when element has a rotate transform, output stays valid and exposes transform-related styles', async () => { const { captureDOM } = await import('../src/core/capture.js') const el = document.createElement('div') el.style.transform = 'rotate(30deg)' vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockReturnValue(new DOMRect(0, 0, 120, 60)) const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) // 1) SVG válido expect(svg.startsWith(' { it('inline style in output includes individual transform properties', async () => { const { captureDOM } = await import('../src/core/capture.js') const el = document.createElement('div') el.style.transform = 'rotate(10deg)' vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 100, 100)) const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) // Aceptamos presencia inline o, si la implementación normaliza, resets en CSS base. const hasInlineAny = /style="[^"]*(?:rotate|scale|translate):/.test(svg) const hasBaseResets = /\b(rotate|scale|translate):\s*none\b/.test(svg) expect(hasInlineAny || hasBaseResets).toBe(true) // transform-origin suele estar presente expect(svg).toMatch(/transform-origin:\s*[\d.]+px\s+[\d.]+px/) }) }) // // ────────────────────────────────────────────────────────────────────────────── // embedFonts branch: no espiamos ESM; solo verificamos que no rompa y que // potencialmente inserte CSS de fuentes si el pipeline interno lo decide. // (Sin red real, aceptamos ambas salidas.) // ────────────────────────────────────────────────────────────────────────────── // describe('captureDOM – embedFonts=true (no spies, effect-only)', () => { it('does not throw and may inject fonts CSS', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 50, 20)) const el = document.createElement('div') el.textContent = 'Hello' const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: true })) // No afirmamos siempre la presencia de CSS de fuentes (depende de IO/hints), // pero sí que sea SVG válido. expect(svg.startsWith(' { it('cleans up the offscreen sandbox', async () => { const sandbox = document.createElement('div') sandbox.id = 'snapdom-sandbox' sandbox.style.position = 'absolute' document.body.appendChild(sandbox) const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 10, 10)) const el = document.createElement('div') const url = await captureDOM(el, { fast: true }) expect(url.startsWith('data:image/svg+xml')).toBe(true) expect(document.getElementById('snapdom-sandbox')).toBeNull() }) }) // ────────────────────────────────────────────────────────────────────────────── // Width & Height together -> container may use non-uniform scale OR set wrapper size // ────────────────────────────────────────────────────────────────────────────── describe('captureDOM – width & height together apply size (scale or wrapper size)', () => { it('adopts requested SVG width/height; implementation may use non-uniform scale, wrapper sizing, or viewBox-only', async () => { const { captureDOM } = await import('../src/core/capture.js') // Natural rect = 100x50 (aspect 2) vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockReturnValue(new DOMRect(0, 0, 100, 50)) const el = document.createElement('div') // Ask for 150x120 const svg = decodeSvg(await captureDOM(el, { fast: true, width: 150, height: 120, embedFonts: false, })) // SVG header adopta el tamaño pedido expect(svg).toContain('width="150"') expect(svg).toContain('height="120"') // Implementación puede elegir: // A) non-uniform scale en container const hasScale = /transform:[^"]*scale\(\s*1\.5[0-9]*\s*,\s*2\.4[0-9]*\s*\)/.test(svg) // B) explicit wrapper sizing via style width/height const hasWrapperSize = /]*style="[^"]*width:\s*150px[^"]*height:\s*120px/.test(svg) || /]*style="[^"]*height:\s*120px[^"]*width:\s*150px/.test(svg) // C) solo viewBox natural con wrapper natural (lo que estás emitiendo) const usesViewBoxOnly = svg.includes('viewBox="0 0 100 50"') && /]*style="[^"]*width:\s*100px/.test(svg) && /]*style="[^"]*height:\s*50px/.test(svg) expect(hasScale || hasWrapperSize || usesViewBoxOnly).toBe(true) }) }) // ────────────────────────────────────────────────────────────────────────────── // Fractional viewport: tolerate ceil rounding and just require numeric x/y // ────────────────────────────────────────────────────────────────────────────── describe('captureDOM – fractional viewport sizes and tx/ty computation', () => { it('emits valid SVG with numeric width/height and numeric foreignObject x/y', async () => { const { captureDOM } = await import('../src/core/capture.js') // BCR con fracciones vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockReturnValue(new DOMRect(10.3, 20.6, 100.4, 50.6)) const el = document.createElement('div') const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) // Algunas implementaciones conservan fracciones; otras hacen ceil. // Aceptamos '100.4' o '101' y '50.6' o '51'. expect(/width="(100\.4|101)"/.test(svg)).toBe(true) expect(/height="(50\.6|51)"/.test(svg)).toBe(true) // x/y del foreignObject: solo exigimos que existan y sean numéricos (con o sin signo/decimales) expect(/]*\sx="[-\d.]+"/.test(svg)).toBe(true) expect(/]*\sy="[-\d.]+"/.test(svg)).toBe(true) }) }) // ────────────────────────────────────────────────────────────────────────────── // Cache policy path (no internal assert, but forces applyCachePolicy branch) // ────────────────────────────────────────────────────────────────────────────── describe('captureDOM – honors cache policy "none"', () => { it('works with cache: "none" and still returns a valid SVG data URL', async () => { const { captureDOM } = await import('../src/core/capture.js') vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockReturnValue(new DOMRect(0, 0, 64, 32)) const el = document.createElement('div') const url = await captureDOM(el, { fast: true, cache: 'none', embedFonts: false }) expect(url.startsWith('data:image/svg+xml')).toBe(true) const svg = decodeSvg(url) expect(svg.startsWith('deg), scale array, translate array, + fallback de strings. // ────────────────────────────────────────────────────────────────────────────── describe('captureDOM – Typed OM readIndividualTransforms', () => { it('reads rotate/scale/translate from computedStyleMap (Typed OM) and propagates to clone', async () => { const { captureDOM } = await import('../src/core/capture.js') // BCR estándar vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockReturnValue(new DOMRect(0, 0, 100, 50)) const el = document.createElement('div') // Stub Typed OM en el elemento el.computedStyleMap = () => ({ // rotate: ángulo en radianes → debe convertirse a deg get(prop) { if (prop === 'rotate') { return { angle: { value: Math.PI / 2, unit: 'rad' } } // 90deg } if (prop === 'scale') { // array-like con sx, sy return [{ value: 2 }, { value: 3 }] } if (prop === 'translate') { return [{ value: 4, unit: 'px' }, { value: 5, unit: 'px' }] } return null }, }) const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) // Si el pipeline mapea Typed OM, veremos los valores inline… const gotInlineRot = /style="[^"]*rotate:\s*90deg/.test(svg) const gotInlineScale = /style="[^"]*scale:\s*2\s+3/.test(svg) const gotInlineTrans = /style="[^"]*translate:\s*4px\s+5px/.test(svg) // …si no, aceptamos resets en CSS base. const hasBaseResets = /\brotate:\s*none\b/.test(svg) && /\bscale:\s*none\b/.test(svg) && /\btranslate:\s*none\b/.test(svg) expect((gotInlineRot && gotInlineScale && gotInlineTrans) || hasBaseResets).toBe(true) }) }) // ────────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────── // Strict path: ensure element is attached so computedStyle picks transforms // ────────────────────────────────────────────────────────────────────────────── describe('captureDOM – strict path uses measure host and matrix pipeline', () => { it('creates snapdom-measure-slot once and reuses it; output includes transform work', async () => { const { captureDOM } = await import('../src/core/capture.js') // Fuerza bbox-transform: matrix con rotación + translate (no es translate puro) const el = document.createElement('div') el.style.transform = 'matrix(0.9396926,0.3420201,-0.3420201,0.9396926,5,-7)' // ⚠️ Importante: anclar al DOM para que getComputedStyle refleje transform document.body.appendChild(el) vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockReturnValue(new DOMRect(0, 0, 120, 60)) // 1ª captura: debería crear el host de medición const svg1 = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) const host1 = document.getElementById('snapdom-measure-slot') expect(host1).toBeTruthy() // Debe haber algún transform aplicado en el container (cancel/scale/etc.) expect(/style="[^"]*transform:[^"]+/.test(svg1)).toBe(true) // 2ª captura: reutiliza el mismo host (no duplica nodos) const beforeCount = document.querySelectorAll('#snapdom-measure-slot').length const svg2 = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) const afterCount = document.querySelectorAll('#snapdom-measure-slot').length expect(afterCount).toBe(beforeCount) // Sigue habiendo transform en el container expect(/style="[^"]*transform:[^"]+/.test(svg2)).toBe(true) // Limpieza el.remove() }) }) // ────────────────────────────────────────────────────────────────────────────── // Pure translate NO afecta bbox: ejercita rama identity/pure-translate de // hasBBoxAffectingTransform (310–312) sin tocar exports internos. // ────────────────────────────────────────────────────────────────────────────── describe('captureDOM – pure translate does not trigger strict path', () => { it('keeps viewport path semantics for translate-only transforms (no extra cancel)', async () => { const { captureDOM } = await import('../src/core/capture.js') const el = document.createElement('div') // translate puro → should be treated as non-bbox-affecting el.style.transform = 'translate(8px, 9px)' vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockReturnValue(new DOMRect(10, 20, 100, 50)) const svg = decodeSvg(await captureDOM(el, { fast: true, embedFonts: false })) // Viewport path: el tamaño del refleja el rect (ceil), sin obligación de transform en container. expect(svg).toContain('width="100"') expect(svg).toContain('height="50"') // Aceptamos que el container NO tenga transform o solo tenga transform-origin. // Si por implementación hubiese un transform, igual no debería contener translate de "cancelación". const hasTransform = /style="[^"]*transform:[^"]+/.test(svg) if (hasTransform) { // No esperaríamos un translate(...) de cancelación (estrict path) en este caso. expect(/transform:[^"]*translate\(/.test(svg)).toBe(false) } }) }) ================================================ FILE: __tests__/core.capture.test.js ================================================ import { describe, it, expect, vi, afterEach } from 'vitest' import { captureDOM } from '../src/core/capture.js' afterEach(() => vi.restoreAllMocks()) describe('captureDOM edge cases', () => { it('throws for unsupported element (unknown nodeType)', async () => { const fakeNode = { nodeType: 999 } await expect(captureDOM(fakeNode)).rejects.toThrow() }) it('throws if element is null', async () => { await expect(captureDOM(null)).rejects.toThrow() }) it('throws error if getBoundingClientRect fails', async () => { vi.spyOn(Element.prototype, 'getBoundingClientRect') .mockImplementation(() => { throw new Error('fail') }) const el = document.createElement('div') await expect(captureDOM(el, { fast: true })).rejects.toThrow(/fail/) }) }) describe('captureDOM functional', () => { it('captures a simple div and returns an SVG dataURL', async () => { const el = document.createElement('div') el.textContent = 'test' const url = await captureDOM(el, { fast: true, embedFonts: false }) expect(url.startsWith('data:image/svg+xml')).toBe(true) }) it('supports scale and width/height options', async () => { const el = document.createElement('div') el.style.width = '100px' el.style.height = '50px' await captureDOM(el, { fast: true, scale: 2 }) await captureDOM(el, { fast: true, width: 200 }) await captureDOM(el, { fast: true, height: 100 }) }) it('supports fast=false', async () => { const el = document.createElement('div') await captureDOM(el, { fast: false, embedFonts: false }) }) it('supports embedFonts (stubbed)', async () => { // opcional: stub para que no haga IO real // const mod = await import('../src/modules/fonts.js'); // vi.spyOn(mod, 'embedCustomFonts').mockResolvedValue('/* inlined */'); const el = document.createElement('div') await captureDOM(el, { fast: true, embedFonts: true }) }) }) ================================================ FILE: __tests__/core.clone.more.test.js ================================================ // __tests__/core.clone.more.test.js import { describe, it, expect, vi, beforeEach } from 'vitest' import { deepClone } from '../src/core/clone.js' import { cache } from '../src/core/cache.js' import { NO_CAPTURE_TAGS } from '../src/utils/css.js' // fresh session cache each test const makeSession = () => ({ styleMap: cache.session.styleMap, styleCache: cache.session.styleCache, nodeMap: cache.session.nodeMap, }) describe('deepClone – extra coverage', () => { let session beforeEach(() => { // reset-ish session structures if available if (cache.session?.styleMap?.clear) cache.session.styleMap.clear() if (cache.session?.styleCache?.clear) cache.session.styleCache = new WeakMap() if (cache.session?.nodeMap?.clear) cache.session.nodeMap = new Map() session = makeSession() }) it('clones a Text node (TEXT_NODE path)', async () => { const t = document.createTextNode('hello') const c = await deepClone(t, session, {}) expect(c.nodeType).toBe(Node.TEXT_NODE) expect(c.nodeValue).toBe('hello') expect(c).not.toBe(t) }) it('freezes srcset using src (no currentSrc) and strips srcset/sizes', async () => { const img = document.createElement('img') // supply a concrete src so freeze picks it img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' img.setAttribute('srcset', 'a.png 1x, b.png 2x') img.setAttribute('sizes', '(max-width: 600px) 100vw, 600px') const clone = await deepClone(img, session, {}) expect(clone.tagName).toBe('IMG') // chosen copied to src expect(clone.getAttribute('src')).toContain('data:image/') // stripped by freezeImgSrcset expect(clone.hasAttribute('srcset')).toBe(false) expect(clone.hasAttribute('sizes')).toBe(false) // eager/sync hints applied expect(clone.loading).toBe('eager') expect(clone.decoding).toBe('sync') }) it('does not freeze when no chosen URL (keeps srcset/sizes)', async () => { const img = document.createElement('img') img.setAttribute('srcset', 'a.png 1x') img.setAttribute('sizes', '100vw') // leave src and currentSrc empty const clone = await deepClone(img, session, {}) // no src chosen => still has original responsive attributes expect(clone.hasAttribute('src')).toBe(false) expect(clone.getAttribute('srcset')).toBe('a.png 1x') expect(clone.getAttribute('sizes')).toBe('100vw') }) it('does not exclude when selector is invalid; only warns', async () => { const el = document.createElement('div') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const out = await deepClone(el, session, { exclude: ['::bad('] }) expect(out).toBeInstanceOf(HTMLElement) expect(out.tagName).toBe('DIV') // It should not return the hidden spacer for invalid selector expect(out.style.visibility).not.toBe('hidden') expect(warn).toHaveBeenCalled() warn.mockRestore() }) it('exclude by selector with excludeMode = "remove" skips element from clonning', async () => { const el = document.createElement('div') el.classList.add('exclude-me') const out = await deepClone(el, session, { exclude: ['.exclude-me'], excludeMode: 'remove' }) expect(out).not.toBeInstanceOf(HTMLElement) }) it('excludes by custom filter returning false; and handles filter error', async () => { // filter false -> spacer const a = document.createElement('p') const out1 = await deepClone(a, session, { filter: () => false, filterMode: 'hide' }) expect(out1).toBeInstanceOf(HTMLElement) expect(out1.style.visibility).toBe('hidden') // filter throws -> warn + spacer const b = document.createElement('p') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const out2 = await deepClone(b, session, { filter: () => { throw new Error('boom') } }) expect(out2).toBeInstanceOf(HTMLElement) expect(warn).toHaveBeenCalled() warn.mockRestore() }) it ('custom filter with filterMode = "remove" skips element from clonning', async () => { // filter false -> null const a = document.createElement('p') const out1 = await deepClone(a, session, { filter: () => false, filterMode: 'remove' }) expect(out1).not.toBeInstanceOf(HTMLElement) }) it('IFRAME fallback uses gradient style and element size', async () => { const frame = document.createElement('iframe') // JSDOM offset* are not layouted; provide getters Object.defineProperty(frame, 'offsetWidth', { configurable: true, get: () => 123 }) Object.defineProperty(frame, 'offsetHeight', { configurable: true, get: () => 45 }) const fallback = await deepClone(frame, session, { placeholders: true }) expect(fallback.tagName).toBe('DIV') expect(fallback.style.width).toBe('123px') expect(fallback.style.height).toBe('45px') expect(fallback.style.backgroundImage).toContain('repeating-linear-gradient') }) it('throws and logs when base clone (node.cloneNode) fails', async () => { const el = document.createElement('div') const err = new Error('fail') const spy = vi.spyOn(el, 'cloneNode').mockImplementation(() => { throw err }) const log = vi.spyOn(console, 'error').mockImplementation(() => {}) await expect(() => deepClone(el, session, {})).rejects.toThrow('fail') expect(log).toHaveBeenCalled() spy.mockRestore() log.mockRestore() }) it('textarea keeps value and explicit size via getBoundingClientRect', async () => { const ta = document.createElement('textarea') ta.value = 'hello' vi.spyOn(ta, 'getBoundingClientRect').mockReturnValue({ width: 80, height: 30 }) const clone = await deepClone(ta, session, {}) expect(clone.value).toBe('hello') expect(clone.style.width).toBe('80px') expect(clone.style.height).toBe('30px') }) it('input copies value/checked/attributes and select applies selected on options', async () => { // input const input = document.createElement('input') input.type = 'checkbox' input.checked = true input.value = 'abc' const c1 = await deepClone(input, session, {}) expect(c1.value).toBe('abc') expect(c1.checked).toBe(true) expect(c1.getAttribute('value')).toBe('abc') expect(c1.hasAttribute('checked')).toBe(true) // select const sel = document.createElement('select') const o1 = document.createElement('option'); o1.value = 'a'; sel.appendChild(o1) const o2 = document.createElement('option'); o2.value = 'b'; sel.appendChild(o2) sel.value = 'b' const c2 = await deepClone(sel, session, {}) expect(c2.value).toBe('b') expect([...c2.options].find(o => o.value === 'b')?.hasAttribute('selected')).toBe(true) expect([...c2.options].find(o => o.value === 'a')?.hasAttribute('selected')).toBe(false) }) it('ShadowRoot with only stores STYLE css into styleCache (no content clone)', async () => { const host = document.createElement('div') const sr = host.attachShadow({ mode: 'open' }) const style = document.createElement('style') style.textContent = '.x{color:red}' const slot = document.createElement('slot') sr.appendChild(style) sr.appendChild(slot) const clone = await deepClone(host, session, {}) // nuevo comportamiento: se inyecta un ' ) const canvas = await toCanvas(svgWithBoxShadow, {}) expect(canvas).toBeInstanceOf(HTMLCanvasElement) }) }) ================================================ FILE: __tests__/exporter.toCanvas.test.js ================================================ // __tests__/exporters.toCanvas.more.test.js import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // IMPORTANT: in Browser Mode we cannot spy on ESM exports directly. // Use { spy: true } so we can override implementations safely. vi.mock('../src/utils/browser', { spy: true }) import * as browser from '../src/utils/browser' import { toCanvas } from '../src/exporters/toCanvas.js' const ONE_BY_ONE_PNG = 'data:image/png;base64,' + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBCd4/7mEAAAAASUVORK5CYII=' beforeEach(() => { // clean up DOM between tests document.body.innerHTML = '' vi.restoreAllMocks() }) afterEach(() => { document.body.innerHTML = '' }) describe('toCanvas (Browser Mode)', () => { it('renders to canvas (non-Safari path) without appending the ', async () => { // Non-Safari path vi.mocked(browser.isSafari).mockReturnValue(false) // Make sure no IMG remains in the DOM after execution (should never append) const beforeImgs = document.querySelectorAll('img').length const canvas = await toCanvas(ONE_BY_ONE_PNG, { scale: 2, dpr: 1.5 }) expect(canvas).toBeInstanceOf(HTMLCanvasElement) // For a 1x1 image with scale=2 and dpr=1.5: // CSS size: 2x2, backing store: ceil(2 * 1.5) = 3 expect(canvas.style.width).toBe('2px') expect(canvas.style.height).toBe('2px') expect(canvas.width).toBe(3) expect(canvas.height).toBe(3) const afterImgs = document.querySelectorAll('img').length expect(afterImgs - beforeImgs).toBe(0) // nothing appended }) it('appends and removes and waits 100ms on Safari path', async () => { vi.mocked(browser.isSafari).mockReturnValue(true) // Spy setTimeout so the promise resolves immediately and we can assert the delay const origSetTimeout = globalThis.setTimeout const calls = [] const stoSpy = vi .spyOn(globalThis, 'setTimeout') .mockImplementation((cb, ms, ...args) => { calls.push(ms) // Trigger callback ASAP so the awaited promise resolves return origSetTimeout(cb, 0, ...args) }) // Spy on Element.prototype.remove to ensure the appended is removed const rmSpy = vi.spyOn(Element.prototype, 'remove') const imgCountBefore = document.querySelectorAll('img').length const canvas = await toCanvas(ONE_BY_ONE_PNG, { scale: 1, dpr: 2 }) expect(canvas).toBeInstanceOf(HTMLCanvasElement) const imgCountAfter = document.querySelectorAll('img').length expect(imgCountAfter).toBe(imgCountBefore) // no stray left in the DOM stoSpy.mockRestore() rmSpy.mockRestore() }) }) ================================================ FILE: __tests__/exporter.toImg.test.js ================================================ // __tests__/exporter.toImg.test.js – toImg.js coverage import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' vi.mock('../src/utils', async () => { const actual = await vi.importActual('../src/utils') return { ...actual, isSafari: vi.fn() } }) import { isSafari } from '../src/utils' import { toImg } from '../src/exporters/toImg.js' const DATA_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBCd4/7mEAAAAASUVORK5CYII=' const DATA_SVG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('') beforeEach(() => { vi.mocked(isSafari).mockReturnValue(false) }) afterEach(() => { vi.restoreAllMocks() }) describe('toImg', () => { it('uses width and height when both provided', async () => { const img = await toImg(DATA_PNG, { width: 100, height: 50 }) expect(img.style.width).toBe('100px') expect(img.style.height).toBe('50px') }) it('uses width only (scales height)', async () => { const img = await toImg(DATA_PNG, { width: 80 }) expect(img.style.width).toBe('80px') expect(img.style.height).toBeDefined() }) it('uses height only (scales width)', async () => { const img = await toImg(DATA_PNG, { height: 60 }) expect(img.style.height).toBe('60px') expect(img.style.width).toBeDefined() }) it('uses meta.w0/meta.h0 when provided', async () => { const img = await toImg(DATA_PNG, { width: 50, meta: { w0: 10, h0: 5 } }) expect(img.style.width).toBe('50px') expect(img.style.height).toBe('25px') }) it('uses scale for non-SVG', async () => { const img = await toImg(DATA_PNG, { scale: 2 }) expect(img.style.width).toBe('2px') expect(img.style.height).toBe('2px') }) it('patches SVG dimensions when scale !== 1', async () => { const img = await toImg(DATA_SVG, { scale: 2 }) expect(img.style.width).toBe('40px') expect(img.style.height).toBe('20px') expect(decodeURIComponent(img.src)).toContain('width="40"') }) it('uses rasterize path on Safari when wantsScale', async () => { vi.mocked(isSafari).mockReturnValue(true) const img = await toImg(DATA_PNG, { scale: 2 }) expect(img).toBeDefined() }) }) ================================================ FILE: __tests__/exporters.jpg-png-svg-webp.test.js ================================================ // __tests__/exporters.jpg-png-svg-webp.test.js – direct exporter calls (0% → covered) import { describe, it, expect } from 'vitest' const DATA_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBCd4/7mEAAAAASUVORK5CYII=' describe('toJpg (direct)', () => { it('returns rasterized output when given data URL (string path)', async () => { const { toJpg } = await import('../src/exporters/toJpg.js') const out = await toJpg(DATA_PNG, { scale: 1 }) expect(out).toBeDefined() expect(out instanceof HTMLImageElement || out instanceof HTMLCanvasElement || typeof out === 'string' || out instanceof Blob).toBe(true) }) }) describe('toPng (direct)', () => { it('returns rasterized output when given data URL', async () => { const { toPng } = await import('../src/exporters/toPng.js') const out = await toPng(DATA_PNG) expect(out).toBeDefined() }) }) describe('toSvg (direct)', () => { const DATA_SVG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('') it('re-exports toImg as toSvg and returns Image when given SVG data URL', async () => { const { toSvg } = await import('../src/exporters/toSvg.js') const out = await toSvg(DATA_SVG, { scale: 1 }) expect(out).toBeInstanceOf(HTMLImageElement) expect(out.src).toMatch(/^data:image\/svg\+xml/) }) }) describe('toWebp (direct)', () => { it('returns rasterized output when given data URL', async () => { const { toWebp } = await import('../src/exporters/toWebp.js') const out = await toWebp(DATA_PNG) expect(out).toBeDefined() }) }) ================================================ FILE: __tests__/index.browser.test.js ================================================ import { it, expect } from 'vitest' import * as snapdom from '../src/index.browser.js' it('should import the browser bundle without errors', () => { expect(snapdom).toBeDefined() }) ================================================ FILE: __tests__/module.background.test.js ================================================ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { inlineBackgroundImages } from '../src/modules/background.js' describe('inlineBackgroundImages', () => { let source, clone beforeEach(() => { source = document.createElement('div') clone = document.createElement('div') document.body.appendChild(source) document.body.appendChild(clone) }) afterEach(() => { document.body.removeChild(source) document.body.removeChild(clone) }) it('does not fail if there is no background-image', async () => { source.style.background = 'none' await expect(inlineBackgroundImages(source, clone, new WeakMap())).resolves.toBeUndefined() }) it('processes a valid background-image', async () => { source.style.backgroundImage = 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAn8B9p6Q2wAAAABJRU5ErkJggg==")' await expect(inlineBackgroundImages(source, clone, new WeakMap())).resolves.toBeUndefined() }) }) ================================================ FILE: __tests__/module.changeCSS.test.js ================================================ // __tests__/module.changeCSS.test.js – freezeSticky coverage import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { freezeSticky } from '../src/modules/changeCSS.js' beforeEach(() => { document.body.innerHTML = '' }) afterEach(() => { document.body.innerHTML = '' }) describe('freezeSticky', () => { it('returns early for null roots', () => { const clone = document.createElement('div') expect(() => freezeSticky(null, clone)).not.toThrow() expect(() => freezeSticky(document.createElement('div'), null)).not.toThrow() }) it('returns early when scrollTop is 0', () => { const orig = document.createElement('div') orig.style.height = '200px' orig.style.overflow = 'auto' const sticky = document.createElement('div') sticky.style.position = 'sticky' sticky.style.top = '0' orig.appendChild(sticky) document.body.appendChild(orig) const clone = orig.cloneNode(true) freezeSticky(orig, clone) expect(clone.querySelector('[data-snap-ph]')).toBeNull() }) it('freezes sticky elements when scrollTop > 0', async () => { const orig = document.createElement('div') orig.style.cssText = 'height:100px; overflow:auto; position:relative;' const sticky = document.createElement('div') sticky.style.cssText = 'position:sticky; top:0; height:24px; min-height:24px;' sticky.textContent = 'sticky' orig.appendChild(sticky) const filler = document.createElement('div') filler.style.height = '200px' filler.textContent = 'filler' orig.appendChild(filler) document.body.appendChild(orig) orig.scrollTop = 50 await new Promise(r => requestAnimationFrame(r)) const clone = orig.cloneNode(true) document.body.appendChild(clone) freezeSticky(orig, clone) const cloneSticky = Array.from(clone.children).find(c => !c.hasAttribute('data-snap-ph')) if (cloneSticky) { expect(cloneSticky.style.position).toBe('absolute') } expect(clone.querySelector('[data-snap-ph="1"]')).toBeTruthy() }) }) ================================================ FILE: __tests__/module.counter.test.js ================================================ // __tests__/module.counter.test.js – counter.js coverage import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { hasCounters, unquoteDoubleStrings, buildCounterContext, resolveCountersInContent, deriveCounterCtxForPseudo, resolvePseudoContent } from '../src/modules/counter.js' beforeEach(() => { document.body.innerHTML = '' }) afterEach(() => { document.body.innerHTML = '' }) describe('hasCounters', () => { it('detects counter()', () => { expect(hasCounters('content: counter(x)')).toBe(true) expect(hasCounters('counter(x)')).toBe(true) }) it('detects counters()', () => { expect(hasCounters('content: counters(x, ".")')).toBe(true) expect(hasCounters('counters(name, sep)')).toBe(true) }) it('returns false for non-counter content', () => { expect(hasCounters('content: "foo"')).toBe(false) expect(hasCounters('')).toBe(false) expect(hasCounters(null)).toBe(false) }) }) describe('unquoteDoubleStrings', () => { it('removes double quotes from strings', () => { expect(unquoteDoubleStrings('"hello"')).toBe('hello') expect(unquoteDoubleStrings('before "mid" after')).toBe('before mid after') }) it('handles null/empty', () => { expect(unquoteDoubleStrings(null)).toBe('') expect(unquoteDoubleStrings('')).toBe('') }) }) describe('buildCounterContext', () => { it('returns get and getStack for a node', () => { const root = document.createElement('div') root.innerHTML = '' document.body.appendChild(root) const ctx = buildCounterContext(root) expect(typeof ctx.get).toBe('function') expect(typeof ctx.getStack).toBe('function') expect(ctx.get(root.querySelector('span'), 'x')).toBe(0) expect(ctx.getStack(root.querySelector('span'), 'x')).toEqual([]) }) it('applies counter-reset and counter-increment', () => { const root = document.createElement('div') const child = document.createElement('span') child.style.counterReset = 'section 1' child.style.counterIncrement = 'section' root.appendChild(child) document.body.appendChild(root) const ctx = buildCounterContext(root) expect(ctx.get(child, 'section')).toBe(2) }) it('handles LI with value attribute via counter-reset', () => { const ol = document.createElement('ol') const li = document.createElement('li') li.setAttribute('value', '10') li.style.counterReset = 'list-item 9' li.style.counterIncrement = 'list-item' li.textContent = 'x' ol.appendChild(li) document.body.appendChild(ol) const ctx = buildCounterContext(ol) expect(ctx.get(li, 'list-item')).toBe(10) }) it('accepts Document as root', () => { const ctx = buildCounterContext(document) expect(ctx.get(document.documentElement, 'x')).toBe(0) }) }) describe('resolveCountersInContent', () => { it('resolves counter(name) with decimal style', () => { const root = document.createElement('div') const span = document.createElement('span') span.style.counterIncrement = 'step' root.appendChild(span) document.body.appendChild(root) const ctx = buildCounterContext(root) expect(resolveCountersInContent('counter(step)', span, ctx)).toBe('1') }) it('resolves counter with upper-alpha', () => { const root = document.createElement('div') const span = document.createElement('span') span.style.counterReset = 'a 3' span.style.counterIncrement = 'a' root.appendChild(span) document.body.appendChild(root) const ctx = buildCounterContext(root) expect(resolveCountersInContent('counter(a, upper-alpha)', span, ctx)).toBe('D') }) it('returns raw for none', () => { expect(resolveCountersInContent('none', null, null)).toBe('none') }) it('returns empty for empty', () => { expect(resolveCountersInContent('', null, null)).toBe('') }) it('resolves counters(name, sep)', () => { const root = document.createElement('div') const inner = document.createElement('span') inner.style.counterReset = 'x 1' inner.style.counterIncrement = 'x' root.appendChild(inner) document.body.appendChild(root) const ctx = buildCounterContext(root) expect(resolveCountersInContent('counters(x, ". ")', inner, ctx)).toBe('2') }) }) describe('deriveCounterCtxForPseudo', () => { it('applies pseudo counter-reset/increment', () => { const span = document.createElement('span') span.style.counterReset = 'item 0' document.body.appendChild(span) const baseCtx = buildCounterContext(span) const pseudoStyle = { counterReset: 'item 5', counterIncrement: 'item' } const derived = deriveCounterCtxForPseudo(span, pseudoStyle, baseCtx) expect(derived.get(span, 'item')).toBe(6) }) }) describe('resolvePseudoContent', () => { it('returns empty for none/normal', () => { const span = document.createElement('span') document.body.appendChild(span) const ctx = buildCounterContext(span) expect(resolvePseudoContent(span, '::before', ctx)).toBe('') }) }) ================================================ FILE: __tests__/module.fonts.katex.test.js ================================================ // __tests__/module.fonts.katex.test.js import { describe, it, expect, vi, beforeEach } from 'vitest' /** * Test for issue #344: KaTeX font embedding with dynamically injected stylesheets * Validates that the isLikelyFontStylesheet function recognizes KaTeX CDN URLs */ vi.mock('../src/utils/helpers', async () => { const actual = await vi.importActual('../src/utils/helpers') return { ...actual, extractURL: actual.extractURL, fetchResource: vi.fn(actual.fetchResource) } }) vi.mock('../src/modules/iconFonts.js', () => ({ isIconFont: vi.fn(() => false) })) vi.mock('../src/modules/snapFetch.js', () => ({ snapFetch: vi.fn(async (url, opts = {}) => { if (opts.as === 'text') { // Return minimal KaTeX CSS with @font-face return { ok: true, data: ` @font-face { font-family: 'KaTeX_Main'; font-style: normal; font-weight: 400; src: url(fonts/KaTeX_Main-Regular.woff2) format('woff2'); } `, status: 200, url, fromCache: false } } return { ok: true, data: 'data:font/woff2;base64,AA==', status: 200, url, fromCache: false, mime: 'font/woff2' } }) })) import { embedCustomFonts } from '../src/modules/fonts.js' import { cache } from '../src/core/cache.js' import { snapFetch } from '../src/modules/snapFetch.js' function addLink(href) { const link = document.createElement('link') link.rel = 'stylesheet' link.href = href document.head.appendChild(link) return link } const req = (...keys) => new Set(keys) const cps = (t) => new Set([...t].map((ch) => ch.codePointAt(0))) beforeEach(() => { if (typeof cache.reset === 'function') cache.reset() if (typeof cache.resetCache === 'function') cache.resetCache() cache.font?.clear?.() cache.resource?.clear?.() vi.clearAllMocks() document.querySelectorAll('style,link[rel="stylesheet"]').forEach((n) => n.remove()) }) describe('embedCustomFonts - KaTeX CDN support (issue #344)', () => { it('recognizes and processes KaTeX CSS from registry.npmmirror.com', async () => { const href = 'https://registry.npmmirror.com/katex/0.16.25/files/dist/katex.min.css' addLink(href) const required = req('KaTeX_Main__400__normal__100') const usedCodepoints = cps('abc123') const result = await embedCustomFonts({ required, usedCodepoints }) // Should have called snapFetch to fetch the stylesheet expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' })) // Should include the font-face in the result expect(result).toContain('@font-face') expect(result).toContain('KaTeX_Main') }) it('recognizes KaTeX CSS from unpkg.com', async () => { const href = 'https://unpkg.com/katex@0.16.8/dist/katex.min.css' addLink(href) const required = req('KaTeX_Main__400__normal__100') const usedCodepoints = cps('abc123') const result = await embedCustomFonts({ required, usedCodepoints }) expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' })) expect(result).toContain('@font-face') }) it('recognizes KaTeX CSS from cdn.jsdelivr.net', async () => { const href = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css' addLink(href) const required = req('KaTeX_Main__400__normal__100') const usedCodepoints = cps('abc123') const result = await embedCustomFonts({ required, usedCodepoints }) expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' })) expect(result).toContain('@font-face') }) it('recognizes cross-origin CSS when fontStylesheetDomains allows custom CDN (#309)', async () => { const href = 'https://cdn.example.com/styles.css' addLink(href) vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: ` @font-face { font-family: 'CustomFont'; src: url(./CustomFont.woff2) format('woff2'); } `, status: 200, url: href, fromCache: false }) const required = req('CustomFont__400__normal__100') const usedCodepoints = cps('abc') const result = await embedCustomFonts({ required, usedCodepoints, fontStylesheetDomains: ['cdn.example.com'] }) expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' })) expect(result).toContain('@font-face') expect(result).toContain('CustomFont') }) it('recognizes MathJax CSS from CDN', async () => { const href = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/output/chtml/fonts/woff-v2/mathjax.css' addLink(href) vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: ` @font-face { font-family: 'MJX'; src: url(MathJax_Main.woff2) format('woff2'); } `, status: 200, url: href, fromCache: false }) const required = req('MJX__400__normal__100') const usedCodepoints = cps('abc123') const result = await embedCustomFonts({ required, usedCodepoints }) expect(snapFetch).toHaveBeenCalledWith(href, expect.objectContaining({ as: 'text' })) expect(result).toContain('@font-face') }) }) ================================================ FILE: __tests__/module.fonts.more.more.test.js ================================================ // __tests__/module.fonts.more.more.test.js import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' // ====== Mocks HOISTED-SAFE ====== // helpers: dejamos extractURL real si querés, pero acá no dependemos de fetchResource. vi.mock('../src/utils/helpers', async () => { const actual = await vi.importActual('../src/utils/helpers') return { ...actual, extractURL: actual.extractURL ?? ((cssUrlFn) => { const m = String(cssUrlFn).match(/url\((["']?)([^"')]+)\1\)/i) return m ? m[2] : '' }), fetchResource: vi.fn(async () => ({ async blob () { return new Blob([new Uint8Array([0x77, 0x6F, 0x32])], { type: 'font/woff2' }) }, async text () { return '' } })), } }) // iconFonts siempre falso para no excluir vi.mock('../src/modules/iconFonts.js', () => ({ isIconFont: vi.fn(() => false), })) // 🔴 Nuevo: mock de snapFetch (API no-throw) vi.mock('../src/modules/snapFetch.js', () => ({ snapFetch: vi.fn(async (url, opts = {}) => { if (opts.as === 'text') { return { ok: true, data: '', status: 200, url, fromCache: false } } // default: devolvemos una dataURL mínima de woff2 return { ok: true, data: 'data:font/woff2;base64,AA==', status: 200, url, fromCache: false, mime: 'font/woff2', } }), })) // ====== SUT + deps ====== import { embedCustomFonts } from '../src/modules/fonts.js' import { cache } from '../src/core/cache.js' import * as helpers from '../src/utils/helpers' import { snapFetch } from '../src/modules/snapFetch.js' // ====== utilidades locales ====== function addStyle (css) { const s = document.createElement('style') s.setAttribute('data-test', 'fonts-extra') s.textContent = css document.head.appendChild(s) return s } function cleanInjectedStuff () { document.querySelectorAll('link[rel="stylesheet"]').forEach(n => n.remove()) document.querySelectorAll('link[data-snapdom="injected-import"]').forEach(n => n.remove()) document.querySelectorAll('style[data-test="fonts-extra"]').forEach(n => n.remove()) // Limpia styles con @import colgados de otras suites document.querySelectorAll('style').forEach(n => { if ((n.textContent || '').includes('@import')) n.remove() }) } /** Mock mínimo de document.fonts */ function setDocumentFonts (fontsArray = []) { const items = [...fontsArray] const iter = function * () { yield * items } const fakeSet = { [Symbol.iterator]: iter, values: iter, entries: function * () { for (const it of items) yield [it.family, it] }, forEach (cb, thisArg) { for (const it of items) cb.call(thisArg, it, it, fakeSet) }, has (ff) { return items.includes(ff) }, add (ff) { items.push(ff) }, delete (ff) { const i = items.indexOf(ff); if (i >= 0) items.splice(i, 1) }, clear () { items.length = 0 }, ready: Promise.resolve(), size: items.length } Object.defineProperty(document, 'fonts', { configurable: true, get () { return fakeSet }, set () {} }) return () => { delete document.fonts } } let restoreFonts = () => {} // ====== setup/teardown ====== beforeEach(() => { vi.restoreAllMocks() // helpers mocks ya están instalados; limpiamos counters helpers.fetchResource?.mockClear?.() // limpiar caches y DOM try { cache.resource?.clear?.() } catch {} try { cache.font?.clear?.() } catch {} cleanInjectedStuff() // document.fonts vacío por default restoreFonts?.() restoreFonts = setDocumentFonts([]) // reset de snapFetch mock por si algún test setea respuestas específicas vi.mocked(snapFetch).mockImplementation(async (url, opts = {}) => { if (opts.as === 'text') { return { ok: true, data: '', status: 200, url, fromCache: false } } return { ok: true, data: 'data:font/woff2;base64,AA==', status: 200, url, fromCache: false, mime: 'font/woff2', } }) }) afterEach(() => { restoreFonts?.() cleanInjectedStuff() }) /* ----------------- unicode-range & helpers ------------------ */ describe('embedCustomFonts – unicode-range & helpers', () => { it('incluye la face cuando usedCodepoints intersecta el unicode-range', async () => { const url = 'https://cdn.example.com/cyr.woff2' addStyle(` @font-face { font-family: 'CyrillicOnly'; font-style: normal; font-weight: 400; font-stretch: 100%; unicode-range: U+0400-04FF; src: url(${url}) format('woff2'); }`) // asegurar que el fetch de la fuente devuelva una dataURL conocida vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:font/woff2;base64,CCC=', status: 200, url, fromCache: false, mime: 'font/woff2' }) const css = await embedCustomFonts({ required: new Set(['CyrillicOnly__400__normal__100']), usedCodepoints: new Set([0x0410]) // 'А' cirílica ⇒ intersecta }) expect(css).toMatch(/font-family:\s*['"]?CyrillicOnly['"]?/) expect(css).toMatch(/url\(["']?data:/) }) }) /* ----------------- cache.resource short-circuit ------------------ */ describe('embedCustomFonts – cache.resource short-circuit', () => { it('usa cache.resource para inlining y evita snapFetch', async () => { const fontUrl = 'https://cdn.example.com/foo.woff2' const b64 = 'data:font/woff2;base64,AAA' cache.resource.set(fontUrl, b64) addStyle(` @font-face { font-family: 'Foo'; font-style: normal; font-weight: 400; font-stretch: 100%; src: url(${fontUrl}) format('woff2'); }`) const css = await embedCustomFonts({ required: new Set(['Foo__400__normal__100']), usedCodepoints: new Set([0x41]) }) expect(css).toMatch(/font-family:\s*['"]?Foo['"]?/) expect(css).toMatch(/url\(["']?data:font\/woff2;base64,AAA/) expect(snapFetch).not.toHaveBeenCalled() }) }) /* ----------------- document.fonts con _snapdomSrc ------------------ */ describe('document.fonts con _snapdomSrc', () => { it('descarga _snapdomSrc (no data:) y lo inyecta como @font-face', async () => { // simular un font cargado dinámico restoreFonts?.() restoreFonts = setDocumentFonts([ { family: 'DynFont', status: 'loaded', style: 'normal', weight: '400', _snapdomSrc: 'https://cdn.example.com/dyn.woff2' } ]) // se espera 1 fetch → dataURL vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:font/woff2;base64,DYN=', status: 200, url: 'https://cdn.example.com/dyn.woff2', fromCache: false, mime: 'font/woff2' }) const css = await embedCustomFonts({ required: new Set(['DynFont__400__normal__100']), usedCodepoints: new Set([0x41]) }) expect(css).toMatch(/font-family:\s*['"]?DynFont['"]?/) expect(css).toMatch(/url\(["']?data:/) expect(snapFetch).toHaveBeenCalledTimes(1) }) it('si _snapdomSrc ya es data:, no hace fetch', async () => { restoreFonts?.() restoreFonts = setDocumentFonts([ { family: 'DynData', status: 'loaded', style: 'normal', weight: '400', _snapdomSrc: 'data:font/woff2;base64,ZZ==' } ]) const css = await embedCustomFonts({ required: new Set(['DynData__400__normal__100']), usedCodepoints: new Set([0x41]) }) expect(css).toMatch(/DynData/) expect(css).toMatch(/url\(['"]?data:/) expect(snapFetch).not.toHaveBeenCalled() }) it('si snapFetch devuelve ok:false, continúa sin romper', async () => { restoreFonts?.() restoreFonts = setDocumentFonts([ { family: 'DynFail', status: 'loaded', style: 'normal', weight: '400', _snapdomSrc: 'https://cdn.example.com/fail.woff2' } ]) vi.mocked(snapFetch).mockResolvedValueOnce({ ok: false, data: null, status: 0, url: 'https://cdn.example.com/fail.woff2', fromCache: false, reason: 'network' }) const css = await embedCustomFonts({ required: new Set(['DynFail__400__normal__100']), usedCodepoints: new Set([0x41]) }) // Puede no incluir DynFail si no pudo inlinear — lo importante es que no explote y sea string. expect(typeof css).toBe('string') }) }) ================================================ FILE: __tests__/module.fonts.more.test.js ================================================ // __tests__/module.fonts.more.test.js import { describe, it, expect, vi, beforeEach } from 'vitest' /** * Mocks * - helpers: mantenemos extractURL real; fetchResource queda spied pero ya no lo usa fonts.js * - iconFonts: forzado a false (no excluir) * - snapFetch: API nueva (no-throw) → devolvemos {ok,data,...} */ vi.mock('../src/utils/helpers', async () => { const actual = await vi.importActual('../src/utils/helpers') return { ...actual, extractURL: actual.extractURL, fetchResource: vi.fn(actual.fetchResource), } }) vi.mock('../src/modules/iconFonts.js', () => ({ isIconFont: vi.fn(() => false), })) vi.mock('../src/modules/snapFetch.js', () => ({ snapFetch: vi.fn(async (url, opts = {}) => { if (opts.as === 'text') { return { ok: true, data: '', status: 200, url, fromCache: false } } return { ok: true, data: 'data:font/woff2;base64,AA==', status: 200, url, fromCache: false, mime: 'font/woff2', } }), })) import { embedCustomFonts, ensureFontsReady } from '../src/modules/fonts.js' import { cache } from '../src/core/cache.js' import { snapFetch } from '../src/modules/snapFetch.js' /* ----------------- helpers dom & utils ------------------ */ function addLink(href) { const link = document.createElement('link') link.rel = 'stylesheet' link.href = href document.head.appendChild(link) return link } function addStyle(css) { const s = document.createElement('style') s.textContent = css document.head.appendChild(s) return s } const req = (...keys) => new Set(keys) const cps = (t) => new Set([...t].map(ch => ch.codePointAt(0))) beforeEach(() => { if (typeof cache.reset === 'function') cache.reset() if (typeof cache.resetCache === 'function') cache.resetCache() cache.font?.clear?.() cache.resource?.clear?.() vi.clearAllMocks() document.querySelectorAll('style,link[rel="stylesheet"]').forEach(n => n.remove()) }) /* ----------------- External links & heuristics ------------------ */ describe('embedCustomFonts - external links & heuristics', () => { it('fetches cross-origin Google Fonts link and inlines @font-face', async () => { const href = 'https://fonts.googleapis.com/css2?family=Unbounded:wght@400' addLink(href) // 1) CSS del vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: ` @font-face { font-family: 'Unbounded'; font-style: normal; font-weight: 400; font-stretch: 100%; unicode-range: U+000-5FF; src: url(https://fonts.gstatic.com/s/unbounded/v1/a.woff2) format('woff2'); } `, status: 200, url: href, fromCache: false, }) // 2) Blob → DataURL vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:font/woff2;base64,ABC=', status: 200, url: 'https://fonts.gstatic.com/s/unbounded/v1/a.woff2', fromCache: false, mime: 'font/woff2', }) const css = await embedCustomFonts({ required: req('Unbounded__400__normal__100'), usedCodepoints: cps('A'), }) expect(css).toMatch(/font-family:\s*['"]?Unbounded['"]?/) expect(css).toMatch(/url\(["']?data:/) expect(snapFetch).toHaveBeenCalledTimes(2) }) it('allows cross-origin by family token in URL (e.g., family=) and inlines font blob', async () => { const href = 'https://cdn.example.com/css?family=My+Fancy+Font' addLink(href) addStyle(`@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'); }`) // 1) CSS del vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, 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\');}', status: 200, url: href, fromCache: false, }) // 2) Fuente → DataURL vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:font/woff2;base64,ABC=', status: 200, url: 'https://cdn.example.com/mff.woff2', fromCache: false, mime: 'font/woff2', }) const css = await embedCustomFonts({ required: new Set(['My Fancy Font__400__normal__100']), usedCodepoints: new Set(['B'.codePointAt(0)]), }) expect(css).toMatch(/My Fancy Font/) expect(css).toMatch(/url\(["']?data:/) expect(snapFetch).toHaveBeenCalledTimes(2) }) }) /* ----------------- Cache hits & fetch errors (CSSOM path, deterministic) ------------------ */ describe('embedCustomFonts - cache hits & fetch errors', () => { it('uses cache.resource for URL already in cache (no refetch)', async () => { addStyle(`@font-face{ font-family:'Foo'; font-style:normal;font-weight:400;font-stretch:100%; src:url(https://fonts.gstatic.com/foo.woff2) format('woff2'); }`) cache.resource.set('https://fonts.gstatic.com/foo.woff2', 'data:font/woff2;base64,AAA') const css = await embedCustomFonts({ required: req('Foo__400__normal__100'), usedCodepoints: cps('A'), }) expect(css).toMatch(/url\(["']?data:font\/woff2;base64,AAA/) expect(snapFetch).not.toHaveBeenCalled() }) it('continues when font fetch fails (ok:false)', async () => { addStyle(`@font-face{ font-family:'Bar'; font-style:normal;font-weight:400;font-stretch:100%; src:url(https://fonts.gstatic.com/bar.woff2) format('woff2'); }`) vi.mocked(snapFetch).mockResolvedValueOnce({ ok: false, data: null, status: 0, url: 'https://fonts.gstatic.com/bar.woff2', fromCache: false, reason: 'network' }) const css = await embedCustomFonts({ required: new Set(['Bar__400__normal__100']), usedCodepoints: new Set(['C'.codePointAt(0)]), }) expect(typeof css).toBe('string') expect(snapFetch).toHaveBeenCalled() }) }) /* ----------------- @import injection & dedupe ------------------ */ describe('embedCustomFonts - @import injection & dedupe', () => { it('injects for @import urls and does not duplicate', async () => { const imported = 'https://fonts.googleapis.com/css2?family=Inter:wght@400' addStyle(`@import url("${imported}");`) // 1) CSS importado vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, 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\');}', status: 200, url: imported, fromCache: false, }) // 2) Fuente → DataURL vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:font/woff2;base64,QQ==', status: 200, url: 'https://fonts.gstatic.com/s/inter/v1/a.woff2', fromCache: false, mime: 'font/woff2', }) const css = await embedCustomFonts({ required: req('Inter__400__normal__100'), usedCodepoints: cps('A'), }) const links = [...document.querySelectorAll(`link[rel="stylesheet"][href="${imported}"]`)] expect(links.length).toBe(1) expect(links[0].getAttribute('data-snapdom')).toBe('injected-import') expect(css).toMatch(/font-family:\s*['"]?Inter['"]?/) expect(css).toMatch(/url\(["']?data:/) // dedupe: segunda llamada no duplica faces const css2 = await embedCustomFonts({ required: req('Inter__400__normal__100'), usedCodepoints: cps('A'), }) const facesCount = (css2.match(/@font-face/g) || []).length expect(facesCount).toBe(1) }) }) /* ----------------- Relative URL inlining ------------------ */ describe('embedCustomFonts - relative URL inlining', () => { it('inlines relative url(...) using base from location.href', async () => { addStyle(`@font-face{ font-family:'RelFace'; font-style:normal;font-weight:400;font-stretch:100%; src:url(./rel.woff2) format('woff2'); }`) let seenUrl = '' vi.mocked(snapFetch).mockImplementation(async (url, opts = {}) => { if (opts.as === 'text') { return { ok: true, data: '', status: 200, url, fromCache: false } } seenUrl = String(url) return { ok: true, data: 'data:font/woff2;base64,QQ==', status: 200, url, fromCache: false, mime: 'font/woff2' } }) const css = await embedCustomFonts({ required: req('RelFace__400__normal__100'), usedCodepoints: cps('B'), }) expect(seenUrl).toContain(new URL('./rel.woff2', location.href).href) expect(css).toMatch(/RelFace/) expect(css).toMatch(/url\(["']?data:/) }) }) /* ----------------- Weight fallback & ranges ------------------ */ describe('embedCustomFonts - nearest weight fallback & ranges', () => { it('selects face 400 when required 700 (nearest fallback)', async () => { addStyle(`@font-face{ font-family:'Nearest'; font-style:normal;font-weight:400;font-stretch:100%; src:url(data:font/woff2;base64,AA==) format('woff2'); }`) const css = await embedCustomFonts({ required: req('Nearest__700__normal__100'), usedCodepoints: cps('A'), }) expect(css).toMatch(/font-family:\s*['"]?Nearest['"]?/) expect(css).toMatch(/font-weight:\s*400/) }) it('accepts faces that declare weight and stretch ranges', async () => { addStyle(`@font-face{ font-family:'Ranges'; font-style:normal;font-weight:100 700;font-stretch:75% 125%; src:url(data:font/woff2;base64,QQ==) format('woff2'); unicode-range: U+000-5FF; }`) const css = await embedCustomFonts({ required: req('Ranges__500__normal__100'), usedCodepoints: cps('Z'), }) expect(css).toMatch(/font-family:\s*['"]?Ranges['"]?/) expect(css).toMatch(/font-weight:\s*100 700|font-weight:\s*100\s+700/i) }) }) /* ----------------- Exclude knobs ------------------ */ describe('embedCustomFonts - exclude by families/domains/subsets', () => { it('excludes by family name', async () => { addStyle(`@font-face{ font-family:'Excluded'; font-weight:400;font-style:normal;font-stretch:100%; src:url(data:font/woff2;base64,AA==); }`) const css = await embedCustomFonts({ required: req('Excluded__400__normal__100'), usedCodepoints: cps('A'), exclude: { families: ['excluded'] }, }) expect(css).not.toMatch(/Excluded/) }) it('excludes by domain host', async () => { addStyle(`@font-face{ font-family:'DomainFace'; font-weight:400;font-style:normal;font-stretch:100%; src:url(https://blocked.example.org/font.woff2) format('woff2'); }`) const css = await embedCustomFonts({ required: req('DomainFace__400__normal__100'), usedCodepoints: cps('B'), exclude: { domains: ['blocked.example.org'] }, }) expect(css).not.toMatch(/DomainFace/) }) it('excludes by subset detection from unicode-range (e.g., cyrillic)', async () => { addStyle(`@font-face{ font-family:'Subsets'; font-weight:400;font-style:normal;font-stretch:100%; unicode-range: U+0400-04FF; src:url(data:font/woff2;base64,AA==); }`) const css = await embedCustomFonts({ required: req('Subsets__400__normal__100'), usedCodepoints: new Set([0x0410]), // 'А' exclude: { subsets: ['cyrillic'] }, }) expect(css).not.toMatch(/Subsets/) }) }) /* ----------------- Cache key hit ------------------ */ describe('embedCustomFonts - cache key hit', () => { it('returns cached CSS on second identical call (no extra fetch)', async () => { addStyle(`@font-face{ font-family:'CacheFace'; font-weight:400;font-style:normal;font-stretch:100%; src:url(https://cdn.example.com/cache.woff2) format('woff2'); }`) // Primera llamada: 1 fetch (dataURL) vi.mocked(snapFetch).mockResolvedValueOnce({ ok: true, data: 'data:font/woff2;base64,ABC=', status: 200, url: 'https://cdn.example.com/cache.woff2', fromCache: false, mime: 'font/woff2', }) const opts = { required: req('CacheFace__400__normal__100'), usedCodepoints: cps('A'), } const css1 = await embedCustomFonts(opts) expect(css1).toMatch(/url\(["']?data:/) expect(snapFetch).toHaveBeenCalledTimes(1) // Segunda llamada idéntica → sale del cache.resource por cacheKey vi.mocked(snapFetch).mockClear() const css2 = await embedCustomFonts(opts) expect(css2).toBe(css1) expect(snapFetch).not.toHaveBeenCalled() }) }) /* ----------------- ensureFontsReady (smoke) ------------------ */ describe('ensureFontsReady (smoke)', () => { it('awaits fonts.ready and cleans up warmup container', async () => { const items = [] const fakeSet = { [Symbol.iterator]: function* () { yield* items }, ready: Promise.resolve(), add(ff) { items.push(ff) } } Object.defineProperty(document, 'fonts', { configurable: true, get: () => fakeSet }) const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(performance.now()) return 1 }) await ensureFontsReady(['WarmupFam'], 1) expect(document.querySelector('[data-warmup]')).toBeFalsy() raf.mockRestore() delete document.fonts }) }) ================================================ FILE: __tests__/module.fonts.test.js ================================================ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { iconToImage, embedCustomFonts } from '../src/modules/fonts.js' import { cache } from '../src/core/cache.js' // === helpers locales === function cleanFontEnvironment() { document .querySelectorAll('style[data-test-font], link[data-test-font]') .forEach(el => el.remove()) } function addStyleTag(css) { const style = document.createElement('style') style.setAttribute('data-test-font', 'true') style.textContent = css document.head.appendChild(style) return style } // Helpers nuevos para la API smart function makeRequired(family, weight='400', style='normal', stretchPct=100) { const key = `${family}__${weight}__${style}__${stretchPct}` return new Set([key]) } function makeUsedCodepoints(text='A') { const s = new Set() for (const ch of text) s.add(ch.codePointAt(0)) return s } /** * Mock seguro de document.fonts (FontFaceSet "mínimo pero compatible") * @param {Array<{ family:string, status:string, weight?:string, style?:string, _snapdomSrc?:string }>} fontsArray */ function setDocumentFonts(fontsArray = []) { const items = [...fontsArray] // iterables y helpers típicos const iter = function* () { yield* items } const fakeSet = { // iterator por defecto [Symbol.iterator]: iter, // API parecida a FontFaceSet values: iter, entries: function* () { for (const it of items) yield [it.family, it] }, forEach(cb, thisArg) { for (const it of items) cb.call(thisArg, it, it, fakeSet) }, has(ff) { return items.includes(ff) }, add(ff) { items.push(ff) }, delete(ff) { const i = items.indexOf(ff); if (i >= 0) items.splice(i, 1) }, clear() { items.length = 0 }, ready: Promise.resolve(), // extra mínimo size: items.length } Object.defineProperty(document, 'fonts', { configurable: true, get() { return fakeSet }, set() {} }) return () => { delete document.fonts } } let restoreFonts = () => {} beforeEach(() => { // cache.reset() o resetCache() según exista if (typeof cache.reset === 'function') cache.reset() if (typeof cache.resetCache === 'function') cache.resetCache() if (cache.font?.clear) cache.font.clear?.() if (cache.resource?.clear) cache.resource.clear?.() cleanFontEnvironment() vi.restoreAllMocks() restoreFonts = setDocumentFonts([]) // mock vacío por defecto }) afterEach(() => { restoreFonts?.() }) // ========== iconToImage ========== describe('iconToImage', () => { let ctxSpy, toDataURLSpy afterEach(() => { ctxSpy?.mockRestore?.() toDataURLSpy?.mockRestore?.() }) it('devuelve un data URL válido con dimensiones > 0', async () => { ctxSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => ({ scale: vi.fn(), font: '', textBaseline: '', fillStyle: '', fillText: vi.fn(), })) toDataURLSpy = vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation(() => 'data:image/png;base64,TEST') const { dataUrl, width, height } = await iconToImage('A', 'Arial', '400', 16, '#000') expect(dataUrl).toMatch(/^data:image\/png;base64,/) expect(width).toBeGreaterThan(0) expect(height).toBeGreaterThan(0) }) }) // ========== embedCustomFonts ========== describe('embedCustomFonts', () => { it('conserva @font-face con solo local() en src', async () => { const style = addStyleTag(` @font-face { font-family: 'OnlyLocal'; src: local("Arial"); font-style: normal; font-weight: 400; } `) const css = await embedCustomFonts({ required: makeRequired('OnlyLocal', '400', 'normal', 100), usedCodepoints: makeUsedCodepoints('abc') }) expect(css).toMatch(/font-family:\s*['"]?OnlyLocal['"]?/) expect(css).toMatch(/src:\s*local\(["']Arial["']\)/) document.head.removeChild(style) }) it('filtra @font-face no utilizados segun document.fonts', async () => { restoreFonts?.() // reemplazamos con fuentes usadas restoreFonts = setDocumentFonts([ { family: 'UsedFont', status: 'loaded', weight: 'normal', style: 'normal' }, ]) addStyleTag(` @font-face { font-family: 'UsedFont'; src: url(data:font/woff;base64,AA==); } @font-face { font-family: 'UnusedFont'; src: url(data:font/woff;base64,BB==); } `) const css = await embedCustomFonts({ required: makeRequired('UsedFont', '400', 'normal', 100), usedCodepoints: makeUsedCodepoints('A') }) expect(css).toMatch(/UsedFont/) expect(css).not.toMatch(/UnusedFont/) }) it('embebe fuentes locales provistas', async () => { const css = await embedCustomFonts({ required: makeRequired('MyLocal', '400', 'normal', 100), usedCodepoints: makeUsedCodepoints('A'), localFonts: [{ family: 'MyLocal', src: 'data:font/woff;base64,AA==' }], }) expect(css).toMatch(/font-family:\s*['"]?MyLocal['"]?/) expect(css).toMatch(/AA==/) }) it('usa _snapdomSrc para nuevas fuentes', async () => { restoreFonts?.() restoreFonts = setDocumentFonts([ { family: 'DynFont', status: 'loaded', weight: 'normal', style: 'normal', _snapdomSrc: 'data:font/woff;base64,CC==' }, ]) const css = await embedCustomFonts({ required: makeRequired('DynFont', '400', 'normal', 100), usedCodepoints: makeUsedCodepoints('A'), }) expect(css).toMatch(/font-family:\s*['"]?DynFont['"]?/) expect(css).toMatch(/CC==/) }) it('conserva @font-face con local() y sin url()', async () => { const style = addStyleTag(` @font-face { font-family: LocalFont; src: local('MyFont'), local('FallbackFont'); font-style: italic; font-weight: bold; } `) const css = await embedCustomFonts({ required: makeRequired('LocalFont', '700', 'italic', 100), usedCodepoints: makeUsedCodepoints('Z') }) expect(css).toMatch(/font-family:\s*['"]?LocalFont['"]?/) expect(css).toMatch(/src:\s*local\(['"]MyFont['"]\),\s*local\(['"]FallbackFont['"]\)/) expect(css).toMatch(/font-style:\s*italic/) document.head.removeChild(style) }) }) /** * Ensures that when a family only publishes a single weight (e.g., 400), * and the required variant asks for 700, we still embed the available 400 face * (browser will synthesize bold). This covers families like "Mansalva". */ describe('embedCustomFonts - single weight fallback', () => { it('embeds the 400 @font-face when 700 is required (fallback)', async () => { // Prepare a minimal @font-face for "Mansalva" with only weight 400 const style = document.createElement('style') style.textContent = ` @font-face { font-family: 'Mansalva'; font-style: normal; font-weight: 400; font-stretch: 100%; unicode-range: U+000-5FF; src: local('Mansalva'), url(data:font/woff2;base64,AA==) format('woff2'); } ` document.head.appendChild(style) // Required variants: // ask for 700 normal stretch=100% (our faceMatchesRequired must accept nearest=400) const required = new Set(['Mansalva__700__normal__100']) // Used codepoints: any latin char; we keep within declared unicode-range const usedCodepoints = new Set([65]) // 'A' const css = await embedCustomFonts({ required, usedCodepoints, // no excludes, no proxy }) // Expectations: expect(css).toMatch(/font-family:\s*['"]?Mansalva['"]?/) // It must embed the available 400 face (we accept nearest) expect(css).toMatch(/font-weight:\s*400/) // And keep the inlined data URL we provided expect(css).toMatch(/data:font\/woff2;base64,AA==/) document.head.removeChild(style) }) }) ================================================ FILE: __tests__/module.iconFonts.more.test.js ================================================ // __tests__/module.iconFonts.more.test.js – extend iconFonts coverage (34% → higher) import { describe, it, expect, beforeEach, vi } from 'vitest' let mod beforeEach(async () => { vi.restoreAllMocks() vi.resetModules() mod = await import('../src/modules/iconFonts.js') }) describe('isMaterialFamily', () => { it('returns true for "Material Icons"', () => { expect(mod.isMaterialFamily('Material Icons')).toBe(true) }) it('returns true for "Material Symbols"', () => { expect(mod.isMaterialFamily('Material Symbols')).toBe(true) }) it('returns false for non-Material fonts', () => { expect(mod.isMaterialFamily('Arial')).toBe(false) expect(mod.isMaterialFamily('Font Awesome')).toBe(false) }) it('is case-insensitive', () => { expect(mod.isMaterialFamily('material icons')).toBe(true) expect(mod.isMaterialFamily('MATERIAL SYMBOLS')).toBe(true) }) }) describe('isIconFont heuristics', () => { it('matches icon, glyph, symbols, feather, fontawesome (fallback heuristics)', () => { const { isIconFont } = mod expect(isIconFont('My Icon Pack')).toBe(true) expect(isIconFont('Glyph Set')).toBe(true) expect(isIconFont('Symbols font')).toBe(true) expect(isIconFont('Feather icons')).toBe(true) expect(isIconFont('FontAwesome free')).toBe(true) }) }) describe('materialIconToImage', () => { it('returns object with dataUrl, width, height', async () => { const out = await mod.materialIconToImage('home', { fontSize: 24 }) expect(out).toBeDefined() expect(out.dataUrl).toMatch(/^data:image\//) expect(typeof out.width).toBe('number') expect(typeof out.height).toBe('number') }) }) ================================================ FILE: __tests__/module.iconFonts.test.js ================================================ // __tests__/module.iconFonts.test.js import { describe, it, expect, beforeEach, vi } from 'vitest' let mod // se setea en beforeEach para resetear estado del módulo beforeEach(async () => { vi.restoreAllMocks() vi.resetModules() // importante para resetear userIconFonts mod = await import('../src/modules/iconFonts.js') // ESM dynamic import }) describe('extendIconFonts', () => { it('acepta string y lo convierte a RegExp (case-insensitive)', () => { const { extendIconFonts, isIconFont } = mod // string -> RegExp extendIconFonts('acme-brand') expect(isIconFont('ACME-BRAND pack')).toBe(true) // control: no matchea algo que no contenga el patrón y tampoco cae en la heurística expect(isIconFont('qwerty')).toBe(false) }) it('acepta RegExp y lo agrega a la lista de usuarios', () => { const { extendIconFonts, isIconFont } = mod extendIconFonts(/brandx/i) expect(isIconFont('This is BrAnDx kit')).toBe(true) expect(isIconFont('no-match-here')).toBe(false) }) it('acepta arrays mezclando strings y RegExp', () => { const { extendIconFonts, isIconFont } = mod extendIconFonts(['foo-lib', /bar-pkg/i]) expect(isIconFont('FOO-LIB icons')).toBe(true) expect(isIconFont('BAR-PKG family')).toBe(true) expect(isIconFont('none')).toBe(false) }) it('ignora valores inválidos y hace console.warn', () => { const { extendIconFonts, isIconFont } = mod const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) extendIconFonts(123) // inválido extendIconFonts({ nope: true }) // inválido expect(warn).toHaveBeenCalled() // cubre rama del console.warn // No debe haber agregado nada que haga matchear "qwerty" expect(isIconFont('qwerty')).toBe(false) warn.mockRestore() }) }) describe('isIconFont (defaults)', () => { it('reconoce patrones por default (e.g., Font Awesome)', () => { const { isIconFont } = mod expect(isIconFont('Font Awesome 6 Pro')).toBe(true) // match por defaultIconFonts }) }) ================================================ FILE: __tests__/module.lineClamp.test.js ================================================ // __tests__/module.lineClamp.test.js – lineClamp coverage import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { lineClamp, lineClampTree } from '../src/modules/lineClamp.js' beforeEach(() => { document.body.innerHTML = '' }) afterEach(() => { document.body.innerHTML = '' }) describe('lineClamp', () => { it('returns no-op for null element', () => { const undo = lineClamp(null) expect(typeof undo).toBe('function') undo() }) it('returns no-op when no -webkit-line-clamp', () => { const div = document.createElement('div') div.textContent = 'Hello world' document.body.appendChild(div) lineClamp(div) expect(div.textContent).toBe('Hello world') }) it('returns no-op when not plain text container (has child elements)', () => { const div = document.createElement('div') div.style.webkitLineClamp = '2' div.appendChild(document.createElement('span')) div.appendChild(document.createTextNode('text')) document.body.appendChild(div) lineClamp(div) expect(div.childNodes.length).toBe(2) }) it('clamps text and returns undo when content overflows', () => { const div = document.createElement('div') div.style.webkitLineClamp = '2' div.style.lineHeight = '20px' div.style.fontSize = '16px' div.style.padding = '0' div.style.width = '200px' div.style.overflow = 'hidden' const longText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' div.textContent = longText document.body.appendChild(div) const undo = lineClamp(div) expect(div.textContent).toContain('…') undo() expect(div.textContent).toBe(longText) }) it('returns no-op when content fits in N lines', () => { const div = document.createElement('div') div.style.webkitLineClamp = '5' div.style.lineHeight = '20px' div.style.fontSize = '16px' div.textContent = 'Short' document.body.appendChild(div) lineClamp(div) expect(div.textContent).toBe('Short') }) }) describe('lineClampTree (#386)', () => { it('clamps nested element with -webkit-line-clamp', () => { const outer = document.createElement('div') outer.style.width = '200px' const inner = document.createElement('div') inner.style.webkitLineClamp = '2' inner.style.lineHeight = '20px' inner.style.fontSize = '16px' inner.style.padding = '0' inner.style.overflow = 'hidden' const longText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore.' inner.textContent = longText outer.appendChild(inner) document.body.appendChild(outer) const undo = lineClampTree(outer) expect(inner.textContent).toContain('…') undo() expect(inner.textContent).toBe(longText) }) it('returns no-op for null', () => { const undo = lineClampTree(null) expect(typeof undo).toBe('function') undo() }) }) ================================================ FILE: __tests__/module.pseudo.test.js ================================================ // __tests__/modules.pseudo.test.js import { describe, it, expect, vi, beforeEach } from 'vitest' import { inlinePseudoElements } from '../src/modules/pseudo.js' // Mock de utils y fonts con importActual para que Vitest Browser no rompa vi.mock('../src/utils', async () => { const actual = await vi.importActual('../src/utils') return { ...actual, fetchImage: vi.fn(), inlineSingleBackgroundEntry: vi.fn(), // agregado para cubrir casos extra } }) vi.mock('../src/modules/fonts.js', async () => { const actual = await vi.importActual('../src/modules/fonts.js') return { ...actual, iconToImage: vi.fn(), } }) import * as helpers from '../src/utils/index.js' import * as fonts from '../src/modules/fonts.js' const sessionCache = { styleMap: new Map(), styleCache: new WeakMap() } describe('inlinePseudoElements', () => { beforeEach(() => { vi.clearAllMocks() }) it('does not fail with simple elements', async () => { const el = document.createElement('div') const clone = document.createElement('div') await expect(inlinePseudoElements(el, clone, sessionCache, {})).resolves.toBeUndefined() }) it('handles ::before with text content', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'content' ? '"★"' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? 'transparent' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('handles ::before with icon font', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'content' ? '"★"' : prop === 'font-family' ? 'Font Awesome' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? 'transparent' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Font Awesome' } return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' } }) fonts.iconToImage.mockResolvedValue('data:image/png;base64,icon') await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('handles ::before with url content', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'content' ? 'url("https://test.com/img.png")' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'none' : prop === 'background-color' ? 'transparent' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' } }) helpers.fetchImage.mockResolvedValue('data:image/png;base64,img') await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('handles ::before with background-image (data url)', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'content' ? 'none' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'url("data:image/png;base64,abc")' : prop === 'background-color' ? 'transparent' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('handles ::before with background-image (fetch ok)', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'content' ? 'none' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'url("https://test.com/img.png")' : prop === 'background-color' ? 'transparent' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' } }) helpers.fetchImage.mockResolvedValue('data:image/png;base64,img') await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('handles ::before with background-image (fetch error)', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'content' ? 'none' : prop === 'font-family' ? 'Arial' : prop === 'font-size' ? '32' : prop === 'font-weight' ? '400' : prop === 'color' ? '#000' : prop === 'background-image' ? 'url("https://test.com/img.png")' : prop === 'background-color' ? 'transparent' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' } }) helpers.fetchImage.mockRejectedValue(new Error('fail')) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('cubre el catch de error en inlineSingleBackgroundEntry', async () => { helpers.inlineSingleBackgroundEntry.mockRejectedValue(new Error('fail')) const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'background-image' ? 'url("data:image/png;base64,abc")' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('cubre el inlining exitoso con inlineSingleBackgroundEntry', async () => { helpers.inlineSingleBackgroundEntry.mockResolvedValue('url("data:image/png;base64,abc")') const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'background-image' ? 'url("https://test.com/img.png")' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('handles ::before with no visible box', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: () => 'none' } return { getPropertyValue: () => '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('handles ::first-letter with no textNode', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ getPropertyValue: () => '' })) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('handles error in pseudo processing', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation(() => { throw new Error('fail') }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('ignores if source no es Element', async () => { const notElement = {} const clone = document.createElement('div') await expect(inlinePseudoElements(notElement, clone, sessionCache, {})).resolves.toBeUndefined() }) it('ignores if clone no es Element', async () => { const el = document.createElement('div') const notElement = {} await expect(inlinePseudoElements(el, notElement, {})).resolves.toBeUndefined() }) it('inserta pseudoEl como ::after', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::after') return { getPropertyValue: (prop) => prop === 'content' ? '"after"' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('inserta pseudoEl como ::before', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: (prop) => prop === 'content' ? '"before"' : '', color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial' } return { getPropertyValue: () => '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('maneja ::first-letter meaningful', async () => { const el = document.createElement('div') el.textContent = 'Test' const clone = document.createElement('div') clone.textContent = 'Test' vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::first-letter') return { getPropertyValue: (prop) => prop === 'color' ? '#f00' : '', color: '#f00', fontSize: '32px' } return { getPropertyValue: () => '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('inserta ambos pseudoEl ::before y ::after', async () => { const el = document.createElement('div') const clone = document.createElement('div') vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => { if (pseudo === '::before') return { getPropertyValue: () => '"before"' } if (pseudo === '::after') return { getPropertyValue: () => '"after"' } return { getPropertyValue: () => '' } }) await inlinePseudoElements(el, clone, sessionCache, {}) window.getComputedStyle.mockRestore() }) it('should inline ::first-letter when style is meaningful', async () => { const el = document.createElement('p') el.textContent = '¡Hola mundo!' el.style.setProperty('color', 'black') document.body.appendChild(el) const clone = el.cloneNode(true) const style = document.createElement('style') style.textContent = ` p::first-letter { color: red; font-size: 200%; } ` document.head.appendChild(style) await inlinePseudoElements(el, clone, sessionCache, {}) const firstLetterEl = clone.querySelector('[data-snapdom-pseudo="::first-letter"]') expect(firstLetterEl).toBeTruthy() expect(firstLetterEl.textContent.length).toBeGreaterThan(0) }) it('should inline background-image entries for pseudo-element', async () => { const el = document.createElement('div') document.body.appendChild(el) const style = document.createElement('style') style.textContent = ` div::after { content: " "; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='10' height='10' fill='blue'/%3E%3C/svg%3E"); display: inline-block; width: 10px; height: 10px; } ` document.head.appendChild(style) const clone = el.cloneNode(true) await inlinePseudoElements(el, clone, sessionCache, {}) const pseudoAfter = clone.querySelector('[data-snapdom-pseudo="::after"]') expect(pseudoAfter).toBeTruthy() expect(pseudoAfter.style.backgroundImage.startsWith('url("data:image/')).toBeTruthy() }) }) ================================================ FILE: __tests__/module.snapFetch.test.js ================================================ // __tests__/module.snapFetch.test.js import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' /** * Re-import the module with a clean state so internal singletons * like _inflight / _errorCache start fresh. * @returns {Promise<{snapFetch: (url: string, opts?: any)=>Promise}>} */ async function importFresh() { vi.resetModules() return import('../src/modules/snapFetch.js') } /** Stable origins/URLs for tests (no need to redefine window.location). */ const ORIGIN = globalThis.location?.origin || 'http://localhost' const SAME = `${ORIGIN}/assets/a.css` const CROSS = 'https://cdn.example/x.png' /** Mock `fetch` with a static Response. */ function mockFetchOnce(status = 200, body = 'ok', headers = {}) { globalThis.fetch = vi.fn(async (_input, _init) => new Response(body, { status, headers })) } /** Mock `fetch` that rejects like a network error. */ function mockFetchNetworkError() { globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch') }) } /** Mock `fetch` that rejects with AbortError when the signal aborts (timeout). */ function mockFetchTimeoutAware() { globalThis.fetch = vi.fn((input, init) => { return new Promise((_, reject) => { const err = Object.assign(new Error('timeout'), { name: 'AbortError' }) const signal = init?.signal if (signal?.aborted) return reject(err) signal?.addEventListener('abort', () => reject(err), { once: true }) }) }) } /** Create a deferred promise for fine-grained inflight control. */ function deferred() { const d = {} d.promise = new Promise((res, rej) => { d.resolve = res d.reject = rej }) // @ts-ignore return d } beforeEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) describe('snapFetch (happy paths)', () => { it('returns text when as:"text"', async () => { const { snapFetch } = await importFresh() mockFetchOnce(200, 'hello', { 'content-type': 'text/plain' }) const res = await snapFetch(`${ORIGIN}/hello.txt`, { as: 'text' }) expect(res.ok).toBe(true) expect(res.status).toBe(200) expect(res.data).toBe('hello') }) it('returns Blob when as:"blob"', async () => { const { snapFetch } = await importFresh() mockFetchOnce(200, 'BLOB!', { 'content-type': 'image/png' }) const res = await snapFetch(`${ORIGIN}/p.png`, { as: 'blob' }) expect(res.ok).toBe(true) expect(res.data).toBeInstanceOf(Blob) expect(res.mime).toMatch(/image\/png/i) }) it('returns DataURL when as:"dataURL"', async () => { const { snapFetch } = await importFresh() mockFetchOnce(200, 'PNGDATA', { 'content-type': 'image/png' }) const res = await snapFetch(`${ORIGIN}/p.png`, { as: 'dataURL' }) expect(res.ok).toBe(true) expect(typeof res.data).toBe('string') expect(res.data.startsWith('data:')).toBe(true) }) }) describe('snapFetch (errors are non-throwing)', () => { it('maps HTTP error to { ok:false, reason:"http_error" }', async () => { const { snapFetch } = await importFresh() mockFetchOnce(404, '', {}) const res = await snapFetch(`${ORIGIN}/miss.css`, { as: 'text' }) expect(res.ok).toBe(false) expect(res.status).toBe(404) expect(res.reason).toBe('http_error') }) it('maps network error to { ok:false, reason:"network" }', async () => { const { snapFetch } = await importFresh() mockFetchNetworkError() const res = await snapFetch(CROSS, { as: 'blob' }) expect(res.ok).toBe(false) expect(res.status).toBe(0) expect(res.reason).toBe('network') }) it('maps timeout to { ok:false, reason:"timeout" }', async () => { vi.useFakeTimers() const { snapFetch } = await importFresh() mockFetchTimeoutAware() const p = snapFetch(CROSS, { as: 'blob', timeout: 123 }) vi.advanceTimersByTime(123) const res = await p expect(res.ok).toBe(false) expect(res.reason).toBe('timeout') expect(res.status).toBe(0) }) }) describe('snapFetch (inflight dedup + error TTL)', () => { it('deduplicates inflight requests to same key', async () => { const { snapFetch } = await importFresh() // Controlled fetch that resolves later const d = deferred() globalThis.fetch = vi.fn((_input, _init) => d.promise) const reqA = snapFetch(`${ORIGIN}/a.png`, { as: 'blob', timeout: 999 }) const reqB = snapFetch(`${ORIGIN}/a.png`, { as: 'blob', timeout: 999 }) // Only one network call expect(globalThis.fetch).toHaveBeenCalledTimes(1) // Fulfill with a real Response d.resolve(new Response('IMG', { status: 200, headers: { 'content-type': 'image/png' } })) const [resA, resB] = await Promise.all([reqA, reqB]) expect(resA.ok && resB.ok).toBe(true) expect(resA.status).toBe(200) expect(resB.status).toBe(200) }) it('caches errors for errorTTL and does not re-fetch within TTL', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2020-01-01T00:00:00Z')) const { snapFetch } = await importFresh() // Un solo spy con dos respuestas const spy = vi.fn() .mockResolvedValueOnce(new Response('', { status: 500 })) .mockResolvedValueOnce(new Response('OK', { status: 200, headers: { 'content-type': 'image/png' } })) globalThis.fetch = spy // 1) Falla inicial → cachea error const r1 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 }) expect(r1.ok).toBe(false) expect(spy).toHaveBeenCalledTimes(1) // 2) Dentro del TTL → no re-fetch (desde cache) const r2 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 }) expect(r2.ok).toBe(false) expect(r2.fromCache).toBe(true) expect(spy).toHaveBeenCalledTimes(1) // 3) Pasado el TTL → re-fetch (segunda llamada real) vi.advanceTimersByTime(8001) vi.setSystemTime(new Date('2020-01-01T00:00:08.001Z')) const r3 = await snapFetch(`${ORIGIN}/fail.png`, { as: 'blob', errorTTL: 8000 }) expect(r3.ok).toBe(true) expect(spy).toHaveBeenCalledTimes(2) }) }) describe('snapFetch (proxy & credentials)', () => { it('same-origin → credentials: include, no proxy applied', async () => { const { snapFetch } = await importFresh() const spy = vi.fn(async (_input, _init) => new Response('ok', { status: 200 })) globalThis.fetch = spy const url = SAME await snapFetch(url, { as: 'text', useProxy: 'https://proxy.example/p?url={url}' }) // No proxy used expect(spy.mock.calls[0][0]).toBe(url) // Credentials should be 'include' for same-origin expect(spy.mock.calls[0][1]?.credentials).toBe('include') }) it('cross-origin + proxy template replaces {url}', async () => { const { snapFetch } = await importFresh() mockFetchOnce(200, 'ok', {}) const res = await snapFetch(CROSS, { as: 'blob', useProxy: 'https://proxy.example/p?url={url}' }) expect(res.ok).toBe(true) expect(res.url).toBe('https://proxy.example/p?url=' + encodeURIComponent(CROSS)) }) it('cross-origin + base proxy appends ?url=', async () => { const { snapFetch } = await importFresh() mockFetchOnce(200, 'ok', {}) const res = await snapFetch(CROSS, { as: 'text', useProxy: 'https://proxy.example/p?' }) expect(res.ok).toBe(true) expect(res.url).toBe('https://proxy.example/p?url=' + encodeURIComponent(CROSS)) }) }) describe('snapFetch (logging & silent mode)', () => { it('dedupes console messages; silent:true suppresses logs', async () => { const { snapFetch } = await importFresh() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) // First: HTTP error (warn once) mockFetchOnce(404, '', {}) const r1 = await snapFetch(`${ORIGIN}/missing.png`, { as: 'blob' }) expect(r1.ok).toBe(false) const warnCountAfter1 = warnSpy.mock.calls.length // Second same error within logger TTL: no new warn const r2 = await snapFetch(`${ORIGIN}/missing.png`, { as: 'blob' }) expect(r2.ok).toBe(false) expect(warnSpy.mock.calls.length).toBe(warnCountAfter1) const totalBefore = errSpy.mock.calls.length + warnSpy.mock.calls.length mockFetchNetworkError() const r3 = await snapFetch(CROSS, { as: 'blob' }) expect(r3.ok).toBe(false) const totalAfter = errSpy.mock.calls.length + warnSpy.mock.calls.length // Aceptamos 0 o 1 log nuevo (dedupe-friendly): expect([0, 1]).toContain(totalAfter - totalBefore) // Silent suppresses mockFetchNetworkError() const r4 = await snapFetch('https://cdn.example/b.png', { as: 'blob', silent: true }) expect(r4.ok).toBe(false) const afterSilent = errSpy.mock.calls.length + warnSpy.mock.calls.length expect(afterSilent).toBe(totalAfter) warnSpy.mockRestore() errSpy.mockRestore() }) }) ================================================ FILE: __tests__/module.styles.test.js ================================================ import { describe, it, expect, vi, beforeEach } from 'vitest' // Carga fresca del módulo para que __wired se reinicie en cada test que lo pida async function loadInlineAllStylesFresh() { await vi.resetModules() const mod = await import('../src/modules/styles.js') return mod.inlineAllStyles } // Stub minimal de MutationObserver para contar instancias class MOStub { static count = 0 constructor(/* cb */) { MOStub.count++ } observe() {} disconnect() {} takeRecords() { return [] } } function freshSession() { return { styleMap: new Map(), styleCache: new WeakMap(), nodeMap: new Map(), } } describe('inlineAllStyles – branches y firmas', () => { beforeEach(() => { MOStub.count = 0 globalThis.MutationObserver = MOStub }) it('early-return para

SnapDOM

Next-generation DOM capture engine — fast, modular, extensible.

zumerlab/snapdom

🏁 Benchmark: snapDOM vs html2canvas

Each library will capture the same DOM element to canvas 5 times. We'll calculate average speed and show the winner.

This is the benchmark test element to be captured by both libraries.
snapDOM
Waiting to start...
html2canvas
Waiting to start...

📦 Basic

Hello SnapDOM!

Transforms & Shadows

Transformed + Shadow

Capture it just with outerTransforms / outerShadows.

🅰️ ASCII Plugin

🕒 Timestamp Plugin

SnapDOM
Timestamp demo

🚀 Fun Transition

🕺💃

I'm dancing and changing color!

Orbit CSS toolkit - Go to repo

ORBIT

🔤 Google Fonts

Unique Typography!

Google Fonts with embedFonts: true.

🧱 Shadow DOM

🎨 Canvas

📁 Export Formats

📤 Export as
PNG, JPG & WebP.

✨ Pseudo Elements

This element has pseudo-elements.

✂️ Clip-Path Demo

This shape uses clip-path

🌀 Mix Blend Mode

CSS background-blend-mode: multiply — a gradient image (sky + grass) blended with a blue overlay. SnapDOM captures the final rendered result.

Gradient × blue overlay = tinted result

🧩 Iframe (same-origin)

⌨️ Inputs & Textarea

🎭 Masking Effects

CSS radial mask
PNG circle mask
SVG mask
Linear gradient mask

🌐 CORS Proxy (useProxy)

Image preview (background)
CORS proxy by Corsfix

🧾 Full Page Capture

================================================ FILE: docs/labs.html ================================================ snapDOM Labs — Plugins Playground & html-in-canvas Demo

SnapDOM Labs

Plugins playground & experimental demos

zumerlab/snapdom

Dog ID Card (Plugins Playground)

SnapDOM plugins let you modify the cloned element before render (text replacement, filters, overlays) and add custom exports like toPdf() or toAscii(). Create your own plugin →

OFFICIAL • Dog ID
🐶

FRIDA

Registered Dog
Breed
Border Collie (Dog)
Age
2 years (Dog adult)
Owner
John Peters
Microchip
ID-0xD0G-0001
Notes
Friendly Dog. Loves fetch, hates vacuums.

Compose plugins (toggle to enable)

WICG html-in-canvas (Issue #172)

Uses drawElementImage() to render the clone directly into canvas, bypassing SVG/foreignObject. Requires Chrome with chrome://flags/#canvas-draw-element enabled.

Target for html-in-canvas

This box is captured via both PNG and drawElementImage.

Default (SVG)
html-in-canvas
================================================ FILE: esbuild.config.mjs ================================================ import { build } from 'esbuild' import { readFileSync, rmSync } from 'node:fs' /** @type {import('esbuild').BuildOptions} */ const common = { bundle: true, sourcemap: false, logLevel: 'info', } const pkg = JSON.parse(readFileSync('./package.json', 'utf8')) const version = pkg.version || '0.0.0' const banner = { js: `/* * SnapDOM * v${version} * Author: Juan Martin Muda * License: MIT */`, } /** * 1. LEGACY IIFE (script tag / require) * Salida: dist/snapdom.js */ async function buildLegacy() { await build({ ...common, entryPoints: ['src/index.browser.js'], outfile: 'dist/snapdom.js', globalName: 'snapdom', platform: 'neutral', minify: true, target: ['es2020'], banner, }) } /** * 2. ESM MONOLÍTICO (tree-shakeable, bundlers + CDN) * Salida: dist/snapdom.mjs */ async function buildESM() { await build({ ...common, entryPoints: ['src/index.js'], outfile: 'dist/snapdom.mjs', format: 'esm', minify: true, splitting: false, banner, }) } /** * 3. SUBPATH EXPORTS (preCache, plugins) * Salida: dist/preCache.mjs, dist/plugins.mjs */ async function buildSubpaths() { await build({ ...common, entryPoints: { 'preCache': 'src/api/preCache.js', 'plugins': 'src/core/plugins.js', }, outdir: 'dist', outExtension: { '.js': '.mjs' }, format: 'esm', minify: true, splitting: false, banner, }) } async function main() { try { rmSync('dist/modules', { recursive: true, force: true }) } catch { /* ok */ } await Promise.all([ buildLegacy(), buildESM(), buildSubpaths(), ]) } main().catch((err) => { // eslint-disable-next-line console.error(err) // eslint-disable-next-line process.exit(1) }) ================================================ FILE: eslint.config.cjs ================================================ const js = require("@eslint/js") const globals = require("globals") module.exports = [ js.configs.recommended, { files: ["__tests__/**/*.js","src/**/*.js"], languageOptions: { ecmaVersion: "latest", sourceType: "module", globals: { ...globals.browser, WebKitCSSMatrix: "readonly" } }, rules: { "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }], "eol-last": ["error", "always"], "no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], "arrow-spacing": ["error", { before: true, after: true }], "no-trailing-spaces": "error", "quotes": ["error", "single", { avoidEscape: true }], "semi": ["error", "never"], "no-empty": ["error", { allowEmptyCatch: true }] } } ] ================================================ FILE: package.json ================================================ { "name": "@zumer/snapdom", "version": "2.5.0", "description": "snapDOM captures HTML elements to images with exceptional speed and accuracy.", "type": "module", "main": "./dist/snapdom.js", "module": "./dist/snapdom.mjs", "types": "./types/snapdom.d.ts", "sideEffects": false, "exports": { ".": { "types": "./types/snapdom.d.ts", "import": "./dist/snapdom.mjs", "require": "./dist/snapdom.js", "default": "./dist/snapdom.mjs" }, "./preCache": { "import": "./dist/preCache.mjs" }, "./plugins": { "import": "./dist/plugins.mjs" } }, "files": [ "dist/", "types/snapdom.d.ts", "README.md" ], "scripts": { "compile": "node esbuild.config.mjs", "lint": "eslint src __tests__ --ext .js", "lint:fix": "eslint src __tests__ --ext .js --fix", "test": "npm run lint:fix && npx vitest run --browser.headless --reporter=verbose", "test:coverage": "npx vitest run --browser.headless --coverage", "test:benchmark": "npx vitest bench --browser.headless --watch=false", "bump:dry": "npx @zumerbox/bump -d", "bump": "npx @zumerbox/bump && npx @zumerbox/changelog", "build": "npm run compile && npm pack", "prebuild": "git add CHANGELOG.md && git commit -m \"Bumped version\" && git push --follow-tags" }, "repository": { "type": "git", "url": "git+https://github.com/zumerlab/snapdom.git" }, "keywords": [ "zumerlab", "snapDOM", "screenshot", "engine", "html capture", "dom capture", "html to image", "dom to image", "html screenshot", "capture element", "html snapshot", "element screenshot", "web capture", "snapshot tool", "render html", "capture dom", "web snapshot", "html export", "dom snapshot", "html to png", "html to svg" ], "author": "Juan Martin Muda", "license": "MIT", "bugs": { "url": "https://github.com/zumerlab/snapdom/issues" }, "homepage": "https://zumerlab.github.io/snapdom/", "devDependencies": { "@eslint/js": "^9.36.0", "@vitest/browser": "^3.1.2", "@vitest/coverage-v8": "^3.1.2", "eslint": "^9.36.0", "globals": "^16.4.0", "playwright": "^1.52.0", "esbuild": "^0.24.0" } } ================================================ FILE: plugins/html-in-canvas.js ================================================ /** * Experimental plugin for WICG html-in-canvas (issue #172). * Uses drawElementImage() to render the snapdom clone directly into canvas, * bypassing SVG/foreignObject. * * Requires: Chrome with chrome://flags/#canvas-draw-element enabled. * @see https://github.com/WICG/html-in-canvas */ const PLUGIN_NAME = 'html-in-canvas' function isDrawElementImageAvailable() { try { const c = document.createElement('canvas') const ctx = c.getContext('2d') return ctx && typeof ctx.drawElementImage === 'function' } catch { return false } } /** * @returns {import('../src/core/plugins.js').Plugin} */ export function htmlInCanvasPlugin() { const available = isDrawElementImageAvailable() if (!available) { console.warn('[snapdom] html-in-canvas plugin: drawElementImage not available. Enable chrome://flags/#canvas-draw-element') } return { name: PLUGIN_NAME, beforeRender(state) { if (!available) return if (!state.clone || !state.element) return state.options.__htmlInCanvas = { clone: state.clone, baseCSS: state.baseCSS || '', fontsCSS: state.fontsCSS || '', classCSS: state.classCSS || '', element: state.element, w0: null, h0: null } }, afterRender(state) { if (!available) return const meta = state.options?.meta const stored = state.options?.__htmlInCanvas if (meta && stored) { stored.w0 = meta.w0 stored.h0 = meta.h0 } }, async defineExports(ctx) { if (!available) return {} const stored = ctx.__htmlInCanvas if (!stored) return {} return { htmlInCanvas: async (opts = {}) => { const { clone, baseCSS, fontsCSS, classCSS, element } = stored const w0 = stored.w0 ?? element?.offsetWidth const h0 = stored.h0 ?? element?.offsetHeight const scale = opts.scale ?? ctx.scale ?? 1 const dpr = opts.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1) const rect = element?.getBoundingClientRect?.() const width = w0 ?? rect?.width ?? 100 const height = h0 ?? rect?.height ?? 100 const outW = Math.round(width * scale * dpr) const outH = Math.round(height * scale * dpr) const canvas = document.createElement('canvas') canvas.width = outW canvas.height = outH canvas.setAttribute('layoutsubtree', '') const wrapper = document.createElement('div') wrapper.style.cssText = `width:${width}px;height:${height}px;overflow:visible;box-sizing:border-box;` wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml') const styleTag = document.createElement('style') styleTag.textContent = `${baseCSS}${fontsCSS}svg{overflow:visible;}${classCSS}` wrapper.appendChild(styleTag) const cloneCopy = clone.cloneNode(true) wrapper.appendChild(cloneCopy) canvas.appendChild(wrapper) const container = document.createElement('div') container.id = 'snapdom-html-in-canvas-temp' container.style.cssText = 'position:fixed;left:-9999px;top:0;visibility:hidden;' container.appendChild(canvas) document.body.appendChild(container) try { await new Promise(r => requestAnimationFrame(r)) const ctx2d = canvas.getContext('2d') if (!ctx2d || typeof ctx2d.drawElementImage !== 'function') { throw new Error('drawElementImage not available') } ctx2d.save() ctx2d.scale(dpr * scale, dpr * scale) ctx2d.drawElementImage(wrapper, 0, 0, width, height) ctx2d.restore() return canvas } finally { try { document.body.removeChild(container) } catch {} } } } } } } export default htmlInCanvasPlugin ================================================ FILE: src/api/preCache.js ================================================ // src/api/preCache.js import { getStyle, inlineSingleBackgroundEntry, precacheCommonTags, isSafari } from '../utils' import { embedCustomFonts, collectUsedFontVariants, collectUsedCodepoints, ensureFontsReady } from '../modules/fonts.js' import { snapFetch } from '../modules/snapFetch.js' import { cache, applyCachePolicy, EvictingMap } from '../core/cache.js' import { inlineBackgroundImages } from '../modules/background.js' /** * Preloads images, background images, and (optionally) fonts into cache before DOM capture. * @param {Element|Document} [root=document] * @param {Object} [options={}] * @param {boolean} [options.embedFonts=true] * @param {'full'|'soft'|'auto'|'disabled'} [options.cache='full'] * @param {string} [options.useProxy=""] * @param {{family:string,src:string,weight?:string|number,style?:string,stretchPct?:number}[]} [options.localFonts=[]] * @param {{families?:string[], domains?:string[], subsets?:string[]}} [options.excludeFonts] * @param {string[]} [options.fontStylesheetDomains] // extra domains to fetch cross-origin CSS from (#309) * @returns {Promise} */ export async function preCache(root = document, options = {}) { const { embedFonts = true, useProxy = '', } = options // Accept both `cache` (JSDoc) and legacy `cacheOpt` const cacheMode = options.cache ?? options.cacheOpt ?? 'full' applyCachePolicy(cacheMode) // Ensure font metrics are ready (non-throwing) try { await document.fonts?.ready } catch {} // Warm common tag/style caches (no-op if already done) try { precacheCommonTags() } catch {} // Ensure session caches cache.session = cache.session || {} if (!cache.session.styleCache) { cache.session.styleCache = new WeakMap() } cache.image = cache.image || new EvictingMap(100) cache.background = cache.background || new EvictingMap(100) // Pre-inline background images into cache (best-effort) try { await inlineBackgroundImages(root, /* mirror */ undefined, cache.session.styleCache, { useProxy }) } catch {} // Collect elements for prefetch let imgEls = [], allEls = [] try { // 🔸 Importante: incluir al root si es un Element if (root && root.nodeType === 1 /* ELEMENT_NODE */) { const descendants = root.querySelectorAll ? Array.from(root.querySelectorAll('*')) : [] allEls = [root, ...descendants] // Sólo imágenes dentro del subtree (el root también puede ser ) imgEls = [] if (root.tagName === 'IMG' && root.getAttribute('src')) imgEls.push(root) imgEls.push(...Array.from(root.querySelectorAll?.('img[src]') || [])) } else if (root?.querySelectorAll) { // Document o DocumentFragment imgEls = Array.from(root.querySelectorAll('img[src]')) allEls = Array.from(root.querySelectorAll('*')) } } catch {} const promises = [] // Prefetch sources to dataURL and cache for (const img of imgEls) { const src = img?.currentSrc || img?.src if (!src) continue if (!cache.image.has(src)) { const p = Promise.resolve() .then(async () => { const res = await snapFetch(src, { as: 'dataURL', useProxy }) if (res?.ok && typeof res.data === 'string') { cache.image.set(src, res.data) } }) .catch(() => {}) promises.push(p) } } // Prefetch background-image url(...) entries for (const el of allEls) { let bg = '' try { // Preferir estilo autor (estable en JSDOM/test); fallback a computado bg = el?.style?.backgroundImage || '' if (!bg || bg === 'none') { bg = getStyle(el).backgroundImage } } catch {} if (bg && bg !== 'none') { // Extraer SOLO capas url(...) (robusto ante comas de gradients) const urlEntries = bg.match(/url\((?:[^()"']+|"(?:[^"]*)"|'(?:[^']*)')\)/gi) || [] for (const entry of urlEntries) { const p = Promise.resolve() .then(() => inlineSingleBackgroundEntry(entry, { ...options, useProxy })) .catch(() => {}) promises.push(p) } // (quedó como compat opcional por si querés volver a splitBackgroundImage) // const parts = splitBackgroundImage(bg) // for (const entry of parts) { // if (entry.startsWith('url(')) { // const p = Promise.resolve() // .then(() => inlineSingleBackgroundEntry(entry, { ...options, useProxy })) // .catch(() => {}) // promises.push(p) // } // } } } // Optional: preload/embed fonts if (embedFonts) { try { const required = collectUsedFontVariants(root) const usedCodepoints = collectUsedCodepoints(root) // Safari warmup: ensure families are ready before embedding const safari = (typeof isSafari === 'function') ? isSafari() : !!isSafari if (safari) { const families = new Set( Array.from(required) .map(k => String(k).split('__')[0]) .filter(Boolean) ) await ensureFontsReady(families, 3) } await embedCustomFonts({ required, usedCodepoints, exclude: options.excludeFonts, localFonts: options.localFonts, useProxy: options.useProxy ?? useProxy, fontStylesheetDomains: options.fontStylesheetDomains, }) } catch {} } await Promise.allSettled(promises) } ================================================ FILE: src/api/snapdom.js ================================================ // src/api/snapdom.js import { captureDOM } from '../core/capture.js' import { extendIconFonts } from '../modules/iconFonts.js' import { createContext } from '../core/context.js' import { isSafari } from '../utils/browser.js' import { debugWarn } from '../utils/debug.js' import { registerPlugins, runHook, runAll, attachSessionPlugins } from '../core/plugins.js' import { collectUsedFontVariants, ensureFontsReady } from '../modules/fonts.js' export { preCache } from './preCache.js' // API pública (registro global de plugins) export function plugins(...defs) { registerPlugins(...defs); return snapdom } export const snapdom = Object.assign(main, { plugins }) // Token to prevent public use of snapdom.capture const INTERNAL_TOKEN = Symbol('snapdom.internal') // Token interno para llamadas de export "silenciosas" desde plugins (no hooks) const INTERNAL_EXPORT_TOKEN = Symbol('snapdom.internal.silent') let _safariWarmup = false /** * Main function that captures a DOM element and returns export utilities. * Local-first plugins: `options.plugins` override globals for this capture. * * @param {HTMLElement} element - The DOM element to capture. * @param {object} userOptions - Options for rendering/exporting. * @returns {Promise} Object with exporter methods: * - url: The raw data URL * - toRaw(): Gets raw data URL * - toImg(): Converts to Image element * - toSvg(): Converts to SVG Image element * - toCanvas(): Converts to HTMLCanvasElement * - toBlob(): Converts to Blob * - toPng(): Converts to PNG format * - toJpg(): Converts to JPEG format * - toWebp(): Converts to WebP format * - download(): Triggers file download */ async function main(element, userOptions) { if (!element) throw new Error('Element cannot be null or undefined') // Normalize options into a capture context const context = createContext(userOptions) // Attach per-capture plugins (local-first) without removing globals attachSessionPlugins(context, userOptions && userOptions.plugins) // Safari warm-up: WebKit Bug #219770 — SVG with embedded font triggers img.onload // before font is available. First canvas draw is blank; second+ works. We run // pre-captures + drawImage to prime the font/decode pipeline. Fidelity > speed. // See: https://bugs.webkit.org/show_bug.cgi?id=219770 if (isSafari() && (context.embedFonts === true || hasBackgroundOrMask(element))) { if (context.embedFonts) { try { const required = collectUsedFontVariants(element) const families = new Set([...required].map(k => String(k).split('__')[0]).filter(Boolean)) await ensureFontsReady(families, 1) } catch { /* non-blocking */ } } const attempts = context.safariWarmupAttempts ?? 3 for (let i = 0; i < attempts; i++) { try { await safariWarmup(element, userOptions) } catch { // swallow error } } } if (context.iconFonts && context.iconFonts.length > 0) extendIconFonts(context.iconFonts) if (!context.snap) { // Mantener compat: atajos disponibles en context.snap context.snap = { toPng: (el, opts) => snapdom.toPng(el, opts), toSvg: (el, opts) => snapdom.toSvg(el, opts), } } return snapdom.capture(element, context, INTERNAL_TOKEN) } /** * Internal capture method that returns helper methods for transformation/export. * Integrates export hooks: beforeExport → work() → afterExport → afterSnap(once per URL) * @private * @param {HTMLElement} el - The DOM element to capture. * @param {object} context - Normalized context options. * @param {symbol} _token - Internal security token. * @returns {Promise} Exporter functions. */ snapdom.capture = async (el, context, _token) => { if (_token !== INTERNAL_TOKEN) throw new Error('[snapdom.capture] is internal. Use snapdom(...) instead.') const url = await captureDOM(el, context) // ——— 1) Core exports por defecto (carga lazy en cada tipo) ——— // NOTA: no importamos estáticamente los exportadores aquí. const coreExports = { img: async (ctx, opts) => { const { toImg } = await import('../exporters/toImg.js') return toImg(url, { ...ctx, ...(opts || {}) }) }, svg: async (ctx, opts) => { const { toSvg } = await import('../exporters/toImg.js') return toSvg(url, { ...ctx, ...(opts || {}) }) }, canvas: async (ctx, opts) => { const { toCanvas } = await import('../exporters/toCanvas.js') return toCanvas(url, { ...ctx, ...(opts || {}) }) }, blob: async (ctx, opts) => { const { toBlob } = await import('../exporters/toBlob.js') return toBlob(url, { ...ctx, ...(opts || {}) }) }, png: async (ctx, opts) => { const { rasterize } = await import('../modules/rasterize.js') return rasterize(url, { ...ctx, ...(opts || {}), format: 'png' }) }, jpeg: async (ctx, opts) => { const { rasterize } = await import('../modules/rasterize.js') return rasterize(url, { ...ctx, ...(opts || {}), format: 'jpeg' }) }, webp: async (ctx, opts) => { const { rasterize } = await import('../modules/rasterize.js') return rasterize(url, { ...ctx, ...(opts || {}), format: 'webp' }) }, download: async (ctx, opts) => { const { download } = await import('../exporters/download.js') return download(url, { ...ctx, ...(opts || {}) }) }, } // ——— 2) Exports declarados por plugins ——— // Fachada reutilizable “silenciosa” (sin hooks) para uso en defineExports() const _pluginExports = {} for (const k of ['img', 'svg', 'canvas', 'blob', 'png', 'jpeg', 'webp']) { _pluginExports[k] = async (opts) => coreExports[k](context, { ...(opts || {}), [INTERNAL_EXPORT_TOKEN]: true }) } _pluginExports.jpg = _pluginExports.jpeg // Contexto extendido para defineExports (incluye URL y la fachada para reuso) const _defineCtx = { ...context, export: { url }, exports: _pluginExports } const providedMaps = await runAll('defineExports', _defineCtx) const provided = Object.assign({}, ...providedMaps.filter(x => x && typeof x === 'object')) // Merge (plugins pueden overridear core) const exportsMap = { ...coreExports, ...provided } // —— Alias: jpg → jpeg (para toJpg y to('jpg')) —— if (exportsMap.jpeg && !exportsMap.jpg) { exportsMap.jpg = (ctx, opts) => exportsMap.jpeg(ctx, opts) } // —— Normalizador para opciones por tipo (p.ej. JPEG: fondo blanco) —— function normalizeExportOptions(type, opts) { const next = { ...context, ...(opts || {}) } if (type === 'jpeg' || type === 'jpg') { const noBg = next.backgroundColor == null || next.backgroundColor === 'transparent' if (noBg) next.backgroundColor = '#ffffff' } return next } // —— Runner unificado con beforeExport/afterExport y cola por sesión —— let afterSnapFired = false let _exportQueue = Promise.resolve() async function runExport(type, opts) { const job = async () => { const work = exportsMap[type] if (!work) throw new Error(`[snapdom] Unknown export type: ${type}`) const nextOpts = normalizeExportOptions(type, opts) const ctx = { ...context, export: { type, options: nextOpts, url } } await runHook('beforeExport', ctx) const result2 = await work(ctx, nextOpts) await runHook('afterExport', ctx, result2) if (!afterSnapFired) { afterSnapFired = true await runHook('afterSnap', context) } return result2 } return _exportQueue = _exportQueue.then(job) } // —— Helpers esperados por los tests + API azúcar —— const result = { url, toRaw: () => url, to: (type, opts) => runExport(type, opts), // Métodos “clásicos” que los tests esperan: toImg: (opts) => runExport('img', opts), toSvg: (opts) => runExport('svg', opts), toCanvas: (opts) => runExport('canvas', opts), toBlob: (opts) => runExport('blob', opts), toPng: (opts) => runExport('png', opts), toJpg: (opts) => runExport('jpg', opts), // alias requerido por tests toWebp: (opts) => runExport('webp', opts), download: (opts) => runExport('download', opts) } // Azúcar dinámico por cada export registrado (plugins incluidos) for (const key of Object.keys(exportsMap)) { const helper = 'to' + key.charAt(0).toUpperCase() + key.slice(1) if (!result[helper]) { result[helper] = (opts) => runExport(key, opts) } } return result } /** * Returns the raw data URL from a captured element. * @param {HTMLElement} el - DOM element to capture. * @param {object} [options] - Rendering options. * @returns {Promise} Raw data URL. */ snapdom.toRaw = (el, options) => snapdom(el, options).then(result => result.toRaw()) /** * Returns an HTMLImageElement from a captured element. * @param {HTMLElement} el - DOM element to capture. * @param {object} [options] - Rendering options. * @returns {Promise} Loaded image element. */ snapdom.toImg = (el, options) => snapdom(el, options).then(result => result.toImg()) snapdom.toSvg = (el, options) => snapdom(el, options).then(result => result.toSvg()) /** * Returns a Canvas element from a captured element. * @param {HTMLElement} el - DOM element to capture. * @param {object} [options] - Rendering options. * @returns {Promise} Rendered canvas element. */ snapdom.toCanvas = (el, options) => snapdom(el, options).then(result => result.toCanvas()) /** * Returns a Blob from a captured element. * @param {HTMLElement} el - DOM element to capture. * @param {object} [options] - Rendering options. * @returns {Promise} Image blob. */ snapdom.toBlob = (el, options) => snapdom(el, options).then(result => result.toBlob()) /** * Returns a PNG image from a captured element. * @param {HTMLElement} el - DOM element to capture. * @param {object} [options] - Rendering options. * @returns {Promise} PNG image element. */ snapdom.toPng = (el, options) => snapdom(el, { ...options, format: 'png' }).then(result => result.toPng()) /** * Returns a JPEG image from a captured element. * @param {HTMLElement} el - DOM element to capture. * @param {object} [options] - Rendering options. * @returns {Promise} JPEG image element. */ snapdom.toJpg = (el, options) => snapdom(el, { ...options, format: 'jpeg' }).then(result => result.toJpg()) /** * Returns a WebP image from a captured element. * @param {HTMLElement} el - DOM element to capture. * @param {object} [options] - Rendering options. * @returns {Promise} WebP image element. */ snapdom.toWebp = (el, options) => snapdom(el, { ...options, format: 'webp' }).then(result => result.toWebp()) /** * Downloads the captured image in the specified format. * @param {HTMLElement} el - DOM element to capture. * @param {object} options - Download options including filename. * @param {string} options.filename - Name for the downloaded file. * @param {string} [options.format='png'] - Image format ('png', 'jpeg', 'webp', 'svg'). * @returns {Promise} */ snapdom.download = (el, options) => snapdom(el, options).then(result => result.download()) /** * Safari/WebKit warmup: primes font and image decode pipeline. * Workaround for WebKit #219770 (img.onload fires before embedded font ready). * - ensureFontsReady (when embedFonts) runs before first iteration * - Mini pre-capture (scale 0.2) → load as Image + decode * - drawImage to offscreen canvas (consumes "first draw blank" so real capture works) * - Double rAF for layout stabilization * - Poke canvas elements for Chart.js etc. * Skipped after first session warmup (_safariWarmup) to avoid repeated cost. */ async function safariWarmup(element, baseOptions) { if (_safariWarmup) return const preflight = { ...baseOptions, fast: true, embedFonts: true, scale: 0.2 } let url try { url = await captureDOM(element, preflight) } catch (e) { debugWarn(baseOptions, 'safariWarmup pre-capture failed', e) } // 1) estabiliza layout/paint en WebKit await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) if (url) { await new Promise((resolve) => { const img = new Image() try { img.decoding = 'sync'; img.loading = 'eager' } catch (e) { debugWarn(baseOptions, 'safariWarmup img hints failed', e) } img.style.cssText = 'position:fixed;left:0px;top:0px;width:10px;height:10px;opacity:0.01;pointer-events:none;' img.src = url document.body.appendChild(img) ;(async () => { try { if (typeof img.decode === 'function') await img.decode() } catch (e) { debugWarn(baseOptions, 'safariWarmup img.decode failed', e) } const start = performance.now() while (!(img.complete && img.naturalWidth > 0) && performance.now() - start < 900) { await new Promise(r => setTimeout(r, 200)) } await new Promise(r => requestAnimationFrame(r)) // Key: drawImage primes the canvas path. WebKit #219770 — first draw is blank, // second works. We consume the blank draw here so the real capture works. try { const c = document.createElement('canvas') c.width = Math.max(1, img.naturalWidth || 10) c.height = Math.max(1, img.naturalHeight || 10) const ctx = c.getContext('2d') if (ctx) ctx.drawImage(img, 0, 0) } catch { /* non-blocking */ } await new Promise(r => requestAnimationFrame(r)) try { img.remove() } catch (e) { debugWarn(baseOptions, 'safariWarmup img.remove failed', e) } resolve() })() }) } // 3) “poke” a los canvas del elemento (Chart.js, etc.) element.querySelectorAll('canvas').forEach(c => { try { const ctx = c.getContext('2d', { willReadFrequently: true }) if (ctx) { ctx.getImageData(0, 0, 1, 1) } } catch (e) { debugWarn(baseOptions, 'safariWarmup canvas poke failed', e) } }) _safariWarmup = true } /** * Checks if the element (or its descendants) use background or mask images. */ function hasBackgroundOrMask(el) { const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT) while (walker.nextNode()) { const node = /** @type {Element} */ (walker.currentNode) const cs = getComputedStyle(node) const bg = cs.backgroundImage && cs.backgroundImage !== 'none' const mask = (cs.maskImage && cs.maskImage !== 'none') || (cs.webkitMaskImage && cs.webkitMaskImage !== 'none') if (bg || mask) return true if (node.tagName === 'CANVAS') return true } return false } export default snapdom ================================================ FILE: src/core/cache.js ================================================ /** Max entries before evicting oldest (FIFO). Keeps lib lightweight, avoids memory leaks. */ const MAX_IMAGE = 100 const MAX_BACKGROUND = 100 const MAX_RESOURCE = 150 const MAX_BASE_STYLE = 50 const MAX_DEFAULT_STYLE = 30 /** * Map that evicts oldest entries when exceeding maxSize. FIFO order. * @extends Map */ class EvictingMap extends Map { constructor(maxSize = 100, ...args) { super(...args) this._maxSize = maxSize } set(key, value) { if (this.size >= this._maxSize && !this.has(key)) { const first = this.keys().next().value if (first !== undefined) this.delete(first) } return super.set(key, value) } } /** * Global caches for images, styles, and resources. * Persistent caches use EvictingMap to avoid unbounded memory growth. */ export const cache = { image: new EvictingMap(MAX_IMAGE), background: new EvictingMap(MAX_BACKGROUND), resource: new EvictingMap(MAX_RESOURCE), defaultStyle: new EvictingMap(MAX_DEFAULT_STYLE), baseStyle: new EvictingMap(MAX_BASE_STYLE), computedStyle: new WeakMap(), font: new Set(), session: { styleMap: new Map(), styleCache: new WeakMap(), nodeMap: new Map(), } } export { EvictingMap } /** * Normalizes shorthand values to canonical cache policies. * - true => "soft" * - false => "disabled" * - "auto" => "auto" * - "full" => "full" * @param {unknown} v * @returns {"soft"|"auto"|"full"|"disabled"} */ export function normalizeCachePolicy(v) { if (v === true) return 'soft' if (v === false) return 'disabled' if (typeof v === 'string') { const s = v.toLowerCase().trim() if (s === 'auto') return 'auto' if (s === 'full') return 'full' if (s === 'soft' || s === 'disabled') return s } return 'soft' // default } /** * Applies the cache policy. * @param {"soft"|"auto"|"full"|"disabled"} policy */ export function applyCachePolicy(policy = 'soft') { cache.session.__counterEpoch = (cache.session.__counterEpoch || 0) + 1 switch (policy) { case 'auto': { cache.session.styleMap = new Map() cache.session.nodeMap = new Map() return } case 'soft': { cache.session.styleMap = new Map() cache.session.nodeMap = new Map() cache.session.styleCache = new WeakMap() return } case 'full': { return } case 'disabled': { cache.session.styleMap = new Map() cache.session.nodeMap = new Map() cache.session.styleCache = new WeakMap() cache.computedStyle = new WeakMap() cache.baseStyle = new EvictingMap(MAX_BASE_STYLE) cache.defaultStyle = new EvictingMap(MAX_DEFAULT_STYLE) cache.image = new EvictingMap(MAX_IMAGE) cache.background = new EvictingMap(MAX_BACKGROUND) cache.resource = new EvictingMap(MAX_RESOURCE) cache.font = new Set() return } default: { // fallback → soft cache.session.styleMap = new Map() cache.session.nodeMap = new Map() cache.session.styleCache = new WeakMap() return } } } ================================================ FILE: src/core/capture.js ================================================ /** * Core logic for capturing DOM elements as SVG data URLs. * @module capture */ import { prepareClone } from './prepare.js' import { inlineImages } from '../modules/images.js' import { inlineBackgroundImages } from '../modules/background.js' import { ligatureIconToImage } from '../modules/iconFonts.js' import { idle, collectUsedTagNames, generateDedupedBaseCSS, isSafari, getStyle } from '../utils/index.js' import { embedCustomFonts, collectUsedFontVariants, collectUsedCodepoints, ensureFontsReady } from '../modules/fonts.js' import { cache, applyCachePolicy } from '../core/cache.js' import { lineClampTree } from '../modules/lineClamp.js' import { runHook } from './plugins.js' import { stripRootShadows, sanitizeCloneForXHTML, shrinkAutoSizeBoxes, estimateKeptHeight, limitDecimals, collectScrollbarCSS } from '../utils/capture.helpers.js' import { parseBoxShadow, parseFilterBlur, parseOutline, parseFilterDropShadows, normalizeRootTransforms, bboxWithOriginFull, parseTransformOriginPx, readIndividualTransforms, readTotalTransformMatrix, hasBBoxAffectingTransform, } from '../utils/transforms.helpers.js' /** * Captures an HTML element as an SVG data URL, inlining styles, images, backgrounds, and optionally fonts. * * @param {Element} element - DOM element to capture * @param {Object} [options={}] - Capture options * @param {boolean} [options.embedFonts=false] - Whether to embed custom fonts * @param {boolean} [options.fast=true] - Whether to skip idle delay for faster results * @param {number} [options.scale=1] - Output scale multiplier * @param {string[]} [options.exclude] - CSS selectors for elements to exclude * @param {Function} [options.filter] - Custom filter function * @param {boolean} [options.outerTransforms=false] - Normalize root by removing translate/rotate (keep scale/skew) * @param {boolean} [options.outerShadows=false] - Do not expand bleed for shadows/blur/outline on root (and strip root shadows visually) * @returns {Promise} Promise that resolves to an SVG data URL */ export async function captureDOM(element, options) { if (!element) throw new Error('Element cannot be null or undefined') applyCachePolicy(options.cache) const fast = options.fast const outerTransforms = options.outerTransforms !== false // default: true const outerShadows = !!options.outerShadows let state = { element, options, plugins: options.plugins } let clone, classCSS, styleCache let fontsCSS = '' let baseCSS = '' let dataURL let svgString // NEW: store root transform (scale/skew) when outerTransforms is on let rootTransform2D = null // BEFORESNAP await runHook('beforeSnap', state) // BEFORECLONE await runHook('beforeClone', state) const undoClamp = lineClampTree(state.element) try { ({ clone, classCSS, styleCache } = await prepareClone(state.element, state.options)) // state = {clone, classCSS, styleCache, ...state} if (!outerTransforms && clone) { rootTransform2D = normalizeRootTransforms(state.element, clone) // {a,b,c,d} or null } if (!outerShadows && clone) { stripRootShadows(state.element, clone, state.options) } } finally { undoClamp() } // AFTERCLONE state = { clone, classCSS, styleCache, ...state } await runHook('afterClone', state) sanitizeCloneForXHTML(state.clone) // Shrink pass ONLY when excludeMode === 'remove' if (state.options?.excludeMode === 'remove') { try { shrinkAutoSizeBoxes(state.element, state.clone, state.styleCache) } catch (e) { console.warn('[snapdom] shrink pass failed:', e) } } try { await ligatureIconToImage(state.clone, state.element) } catch { /* non-blocking */ } await new Promise((resolve) => { idle(async () => { await inlineImages(state.clone, state.options) resolve() }, { fast }) }) await new Promise((resolve) => { idle(async () => { await inlineBackgroundImages(state.element, state.clone, state.styleCache, state.options) resolve() }, { fast }) }) if (options.embedFonts) { await new Promise((resolve) => { idle(async () => { const required = collectUsedFontVariants(state.element) const usedCodepoints = collectUsedCodepoints(state.element) if (isSafari()) { const families = new Set( Array.from(required).map((k) => String(k).split('__')[0]).filter(Boolean) ) await ensureFontsReady(families, 1) } fontsCSS = await embedCustomFonts({ required, usedCodepoints, preCached: false, exclude: state.options.excludeFonts, useProxy: state.options.useProxy, fontStylesheetDomains: state.options.fontStylesheetDomains }) resolve() }, { fast }) }) } const usedTags = collectUsedTagNames(state.clone).sort() const tagKey = usedTags.join(',') if (cache.baseStyle.has(tagKey)) { baseCSS = cache.baseStyle.get(tagKey) } else { await new Promise((resolve) => { idle(() => { baseCSS = generateDedupedBaseCSS(usedTags) cache.baseStyle.set(tagKey, baseCSS) resolve() }, { fast }) }) } // #334: inject ::-webkit-scrollbar rules so custom scrollbar styles apply in capture const scrollbarCSS = collectScrollbarCSS(state.element?.ownerDocument || document) state = { fontsCSS, baseCSS, scrollbarCSS, ...state } await runHook('beforeRender', state) await new Promise((resolve) => { idle(() => { const csEl = getStyle(state.element) const rect = state.element.getBoundingClientRect() let w0 = Math.max(1, limitDecimals(state.element.offsetWidth || parseFloat(csEl.width) || rect.width || 1)) let h0 = Math.max(1, limitDecimals(state.element.offsetHeight || parseFloat(csEl.height) || rect.height || 1)) // body/documentElement: measure clone in-document to get true content height (Chrome clamps offset/scroll) // Use element's ownerDocument for iframe support (#371) const elDoc = state.element.ownerDocument || document const isRoot = state.element === elDoc.body || state.element === elDoc.documentElement if (isRoot) { const docH = Math.max( state.element.scrollHeight || 0, elDoc.documentElement?.scrollHeight || 0, elDoc.body?.scrollHeight || 0 ) const docW = Math.max( state.element.scrollWidth || 0, elDoc.documentElement?.scrollWidth || 0, elDoc.body?.scrollWidth || 0 ) if (docH > 0) h0 = Math.max(h0, limitDecimals(docH)) if (docW > 0) w0 = Math.max(w0, limitDecimals(docW)) // Also measure clone in a temp container with injected styles (clone may layout differently) try { const wrap = elDoc.createElement('div') wrap.style.cssText = 'position:absolute!important;left:-9999px!important;top:0!important;width:' + w0 + 'px!important;overflow:visible!important;visibility:hidden!important;' const styleNode = elDoc.createElement('style') styleNode.textContent = (state.scrollbarCSS || '') + state.baseCSS + state.fontsCSS + 'svg{overflow:visible;} foreignObject{overflow:visible;}' + state.classCSS wrap.appendChild(styleNode) wrap.appendChild(state.clone.cloneNode(true)) elDoc.body.appendChild(wrap) const csh = wrap.scrollHeight const csw = wrap.scrollWidth elDoc.body.removeChild(wrap) if (csh > 0) h0 = Math.max(h0, limitDecimals(csh)) if (csw > 0) w0 = Math.max(w0, limitDecimals(csw)) } catch { /* fallback: use doc dimensions above */ } } // === NEW: recompute height using the kept-children span (no offscreen) === if (state.options?.excludeMode === 'remove') { const hEst = estimateKeptHeight(state.element, state.options) // border+padding+contentSpan // Safety: nunca mayor al original, y con un epsilon para evitar recortes por redondeo const EPS = 1 // px if (Number.isFinite(hEst) && hEst > 0) { h0 = Math.max(1, Math.min(h0, limitDecimals(hEst + EPS))) } // En ancho casi nunca conviene ajustar; si lo necesitás, podés hacer análogo con estimateKeptWidth(...) } const coerceNum = (v, def = NaN) => { const n = typeof v === 'string' ? parseFloat(v) : v return Number.isFinite(n) ? n : def } const optW = coerceNum(state.options.width) const optH = coerceNum(state.options.height) let w = w0, h = h0 const hasW = Number.isFinite(optW) const hasH = Number.isFinite(optH) const aspect0 = h0 > 0 ? w0 / h0 : 1 if (hasW && hasH) { w = Math.max(1, limitDecimals(optW)) h = Math.max(1, limitDecimals(optH)) } else if (hasW) { w = Math.max(1, limitDecimals(optW)) h = Math.max(1, limitDecimals(w / (aspect0 || 1))) } else if (hasH) { h = Math.max(1, limitDecimals(optH)) w = Math.max(1, limitDecimals(h * (aspect0 || 1))) } else { w = w0 h = h0 } // ——— BBOX ——— let minX = 0, minY = 0, maxX = w0, maxY = h0 // NEW: if outerTransforms => expand bbox using the post-normalization 2D matrix if (!outerTransforms && rootTransform2D && Number.isFinite(rootTransform2D.a)) { const M2 = { a: rootTransform2D.a, b: rootTransform2D.b || 0, c: rootTransform2D.c || 0, d: rootTransform2D.d || 1, e: 0, f: 0 } const bb2 = bboxWithOriginFull(w0, h0, M2, 0, 0) minX = limitDecimals(bb2.minX) minY = limitDecimals(bb2.minY) maxX = limitDecimals(bb2.maxX) maxY = limitDecimals(bb2.maxY) } else { const useTFBBox = outerTransforms && hasTFBBox(state.element) if (useTFBBox) { const baseTransform2 = csEl.transform && csEl.transform !== 'none' ? csEl.transform : '' const ind2 = readIndividualTransforms(state.element) const TOTAL = readTotalTransformMatrix({ baseTransform: baseTransform2, rotate: ind2.rotate || '0deg', scale: ind2.scale, translate: ind2.translate }) const { ox: ox2, oy: oy2 } = parseTransformOriginPx(csEl, w0, h0) const M = TOTAL.is2D ? TOTAL : new DOMMatrix(TOTAL.toString()) const bb = bboxWithOriginFull(w0, h0, M, ox2, oy2) minX = limitDecimals(bb.minX) minY = limitDecimals(bb.minY) maxX = limitDecimals(bb.maxX) maxY = limitDecimals(bb.maxY) } } // ——— BLEED ——— const bleedShadow = parseBoxShadow(csEl) const bleedBlur = parseFilterBlur(csEl) const bleedOutline = parseOutline(csEl) const drop = parseFilterDropShadows(csEl) const bleed = (!outerShadows) ? { top: 0, right: 0, bottom: 0, left: 0 } : { top: limitDecimals(bleedShadow.top + bleedBlur.top + bleedOutline.top + drop.bleed.top), right: limitDecimals(bleedShadow.right + bleedBlur.right + bleedOutline.right + drop.bleed.right), bottom: limitDecimals(bleedShadow.bottom + bleedBlur.bottom + bleedOutline.bottom + drop.bleed.bottom), left: limitDecimals(bleedShadow.left + bleedBlur.left + bleedOutline.left + drop.bleed.left) } minX = limitDecimals(minX - bleed.left) minY = limitDecimals(minY - bleed.top) maxX = limitDecimals(maxX + bleed.right) maxY = limitDecimals(maxY + bleed.bottom) const vbW0 = Math.max(1, limitDecimals(maxX - minX)) const vbH0 = Math.max(1, limitDecimals(maxY - minY)) const scaleW = (hasW || hasH) ? limitDecimals(w / w0) : 1 const scaleH = (hasH || hasW) ? limitDecimals(h / h0) : 1 const outW = Math.max(1, limitDecimals(vbW0 * scaleW)) const outH = Math.max(1, limitDecimals(vbH0 * scaleH)) const svgNS = 'http://www.w3.org/2000/svg' // Safari workaround: pad only when root has bbox-affecting transforms (avoids edge clipping) const basePad = (isSafari() && hasTFBBox(state.element)) ? 1 : 0 const extraPad = !outerTransforms ? 1 : 0 const pad = limitDecimals(basePad + extraPad) const fo = document.createElementNS(svgNS, 'foreignObject') const vbMinX = limitDecimals(minX) const vbMinY = limitDecimals(minY) fo.setAttribute('x', String(limitDecimals(-(vbMinX - pad)))) fo.setAttribute('y', String(limitDecimals(-(vbMinY - pad)))) fo.setAttribute('width', String(limitDecimals(w0 + pad * 2))) fo.setAttribute('height', String(limitDecimals(h0 + pad * 2))) fo.style.overflow = 'visible' const styleTag = document.createElement('style') styleTag.textContent = (state.scrollbarCSS || '') + state.baseCSS + state.fontsCSS + 'svg{overflow:visible;} foreignObject{overflow:visible;}' + state.classCSS fo.appendChild(styleTag) const container = document.createElement('div') container.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml') // #372: isolate wrapper from iframe CSS cascade (e.g. div { border: 10px solid red }) container.style.cssText = 'all:initial;box-sizing:border-box;display:block;overflow:visible;' + `width:${limitDecimals(w0)}px;height:${limitDecimals(h0)}px` //state.clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml') container.appendChild(state.clone) fo.appendChild(container) const serializer = new XMLSerializer() const foString = serializer.serializeToString(fo) const vbW = limitDecimals(vbW0 + pad * 2) const vbH = limitDecimals(vbH0 + pad * 2) const wantsSize = hasW || hasH options.meta = { w0, h0, vbW, vbH, targetW: w, targetH: h } const svgOutW = (isSafari() && wantsSize) ? vbW : limitDecimals(outW + pad * 2) const svgOutH = (isSafari() && wantsSize) ? vbH : limitDecimals(outH + pad * 2) const rootFontSize = parseFloat(getStyle(elDoc.documentElement)?.fontSize) || 16 const svgHeader = `` const svgFooter = '' svgString = svgHeader + foString + svgFooter dataURL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}` state = { svgString, dataURL, ...state } resolve() }, { fast }) }) // afterRender(context) await runHook('afterRender', state) const sandbox = document.getElementById('snapdom-sandbox') if (sandbox && sandbox.style.position === 'absolute') sandbox.remove() return state.dataURL } function hasTFBBox(el) { return hasBBoxAffectingTransform(el) } ================================================ FILE: src/core/clone.js ================================================ /** * Deep cloning utilities for DOM elements, including styles and shadow DOM. * @module clone */ import { inlineAllStyles } from '../modules/styles.js' import { NO_CAPTURE_TAGS } from '../utils/css.js' import { resolveCSSVars } from '../modules/CSSVar.js' import { debugWarn } from '../utils/index.js' import { idleCallback, rewriteShadowCSS, nextShadowScopeId, extractShadowCSS, injectScopedStyle, freezeImgSrcset, collectCustomPropsFromCSS, buildSeedCustomPropsRule, markSlottedSubtree, rasterizeIframe, getUnscaledDimensions, createCheckboxRadioReplacement } from '../utils/clone.helpers.js' import { isFirefox } from '../utils/browser.js' // helper implementations moved to ../utils/clone.helpers.js export async function deepClone(node, sessionCache, options) { if (!node) throw new Error('Invalid node') const clonedAssignedNodes = new Set() let pendingSelectValue = null let pendingTextAreaValue = null if (node.nodeType === Node.ELEMENT_NODE) { const tag = (node.localName || node.tagName || '').toLowerCase() if (node.id === 'snapdom-sandbox' || node.hasAttribute('data-snapdom-sandbox')) { return null } if (NO_CAPTURE_TAGS.has(tag)) { return null } } if (node.nodeType === Node.TEXT_NODE) { return node.cloneNode(true) } if (node.nodeType !== Node.ELEMENT_NODE) { return node.cloneNode(true) } if (node.getAttribute('data-capture') === 'exclude') { if (options.excludeMode === 'hide') { const spacer = document.createElement('div') const { width, height } = getUnscaledDimensions(node) const w = width || node.getBoundingClientRect().width || 0 const h = height || node.getBoundingClientRect().height || 0 spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;` return spacer } else if (options.excludeMode === 'remove') { return null } } if (options.exclude && Array.isArray(options.exclude)) { for (const selector of options.exclude) { try { if (node.matches?.(selector)) { if (options.excludeMode === 'hide') { const spacer = document.createElement('div') const { width, height } = getUnscaledDimensions(node) const w = width || node.getBoundingClientRect().width || 0 const h = height || node.getBoundingClientRect().height || 0 spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;` return spacer } else if (options.excludeMode === 'remove') { return null } } } catch (err) { console.warn(`Invalid selector in exclude option: ${selector}`, err) } } } if (typeof options.filter === 'function') { try { if (!options.filter(node)) { if (options.filterMode === 'hide') { const spacer = document.createElement('div') const { width, height } = getUnscaledDimensions(node) const w = width || node.getBoundingClientRect().width || 0 const h = height || node.getBoundingClientRect().height || 0 spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;` return spacer } else if (options.filterMode === 'remove') { return null } } } catch (err) { console.warn('Error in filter function:', err) } } if (node.tagName === 'IFRAME') { let sameOrigin = false try { sameOrigin = !!(node.contentDocument || node.contentWindow?.document) } catch (e) { debugWarn(sessionCache, 'iframe same-origin probe failed', e) } if (sameOrigin) { try { const wrapper = await rasterizeIframe(node, sessionCache, options) return wrapper } catch (err) { console.warn('[SnapDOM] iframe rasterization failed, fallback:', err) // fall through } } // Fallback actual (placeholder o spacer) if (options.placeholders) { const { width, height } = getUnscaledDimensions(node) const fallback = document.createElement('div') fallback.style.cssText = `width:${width}px;height:${height}px;` + 'background-image:repeating-linear-gradient(45deg,#ddd,#ddd 5px,#f9f9f9 5px,#f9f9f9 10px);' + 'display:flex;align-items:center;justify-content:center;font-size:12px;color:#555;border:1px solid #aaa;' inlineAllStyles(node, fallback, sessionCache, options) return fallback } else { const { width, height } = getUnscaledDimensions(node) const spacer = document.createElement('div') spacer.style.cssText = `display:inline-block;width:${width}px;height:${height}px;visibility:hidden;` inlineAllStyles(node, spacer, sessionCache, options) return spacer } } if (node.getAttribute('data-capture') === 'placeholder') { const clone2 = node.cloneNode(false) sessionCache.nodeMap.set(clone2, node) inlineAllStyles(node, clone2, sessionCache, options) const placeholder = document.createElement('div') placeholder.textContent = node.getAttribute('data-placeholder-text') || '' placeholder.style.cssText = 'color:#666;font-size:12px;text-align:center;line-height:1.4;padding:0.5em;box-sizing:border-box;' clone2.appendChild(placeholder) return clone2 } if (node.tagName === 'CANVAS') { // Safari-safe snapshot: poke + rAF + retry + scratch fallback let url = '' try { const ctx = node.getContext('2d', { willReadFrequently: true }) try { ctx && ctx.getImageData(0, 0, 1, 1) } catch { } await new Promise(r => requestAnimationFrame(r)) // deja materializar el frame url = node.toDataURL('image/png') if (!url || url === 'data:,') { // reintento rápido try { ctx && ctx.getImageData(0, 0, 1, 1) } catch { } await new Promise(r => requestAnimationFrame(r)) url = node.toDataURL('image/png') // último recurso: copiar a un scratch-canvas y leer desde ahí if (!url || url === 'data:,') { const scratch = document.createElement('canvas') scratch.width = node.width scratch.height = node.height const sctx = scratch.getContext('2d') if (sctx) { sctx.drawImage(node, 0, 0) url = scratch.toDataURL('image/png') } } } } catch (e) { debugWarn(sessionCache, 'Canvas toDataURL failed, using empty/fallback', e) } const img = document.createElement('img') try { img.decoding = 'sync'; img.loading = 'eager' } catch (e) { debugWarn(sessionCache, 'img decoding/loading hints failed', e) } if (url) img.src = url // conservar dimensiones intrínsecas del bitmap img.width = node.width img.height = node.height // conservar caja CSS para no romper layout usando dimensiones pre-transform const { width, height } = getUnscaledDimensions(node) if (width > 0) img.style.width = `${width}px` if (height > 0) img.style.height = `${height}px` sessionCache.nodeMap.set(img, node) inlineAllStyles(node, img, sessionCache, options) return img } let clone try { clone = node.cloneNode(false) resolveCSSVars(node, clone) sessionCache.nodeMap.set(clone, node) if (node.tagName === 'IMG') { freezeImgSrcset(node, clone) // Record original image dimensions (pre-transform) for fallback usage when inlining fails try { const { width, height } = getUnscaledDimensions(node) const w = Math.round(width || 0) const h = Math.round(height || 0) if (w) clone.dataset.snapdomWidth = String(w) if (h) clone.dataset.snapdomHeight = String(h) } catch (e) { debugWarn(sessionCache, 'getUnscaledDimensions for IMG failed', e) } // Si el autor usó % o auto, o el alto/ ancho efectivos dan 0, // escribimos px en línea para evitar que el clon “pierda” la imagen. try { const authored = node.getAttribute('style') || '' const cs = window.getComputedStyle(node) const usesPercentOrAuto = (prop) => { const a = authored.match(new RegExp(`${prop}\\s*:\\s*([^;]+)`, 'i')) const v = a ? a[1].trim() : cs.getPropertyValue(prop) return /%|auto/i.test(String(v || '')) } const w = parseInt(clone.dataset.snapdomWidth || '0', 10) const h = parseInt(clone.dataset.snapdomHeight || '0', 10) const needFreezeW = usesPercentOrAuto('width') || !w const needFreezeH = usesPercentOrAuto('height') || !h if (needFreezeW && w) clone.style.width = `${w}px` if (needFreezeH && h) clone.style.height = `${h}px` // Blindaje extra: evita que una clase agregada luego anule el fix if (w) clone.style.minWidth = `${w}px` if (h) clone.style.minHeight = `${h}px` } catch (e) { debugWarn(sessionCache, 'IMG dimension freeze failed', e) } } } catch (err) { console.error('[Snapdom] Failed to clone node:', node, err) throw err } let applyInputVisual = null if (node instanceof HTMLTextAreaElement) { const { width, height } = getUnscaledDimensions(node) const w = width || node.getBoundingClientRect().width || 0 const h = height || node.getBoundingClientRect().height || 0 if (w) clone.style.width = `${w}px` if (h) clone.style.height = `${h}px` } if (node instanceof HTMLInputElement) { const type = (node.type || 'text').toLowerCase() const isCheckboxOrRadio = type === 'checkbox' || type === 'radio' if (isCheckboxOrRadio && isFirefox()) { const { el: replacement, applyVisual } = createCheckboxRadioReplacement(node) sessionCache.nodeMap.set(replacement, node) applyInputVisual = applyVisual clone = replacement } else { clone.value = node.value clone.setAttribute('value', node.value) if (node.checked !== void 0) { clone.checked = node.checked if (node.checked) clone.setAttribute('checked', '') if (node.indeterminate) clone.indeterminate = node.indeterminate } } } if (node instanceof HTMLSelectElement) { pendingSelectValue = node.value } if (node instanceof HTMLTextAreaElement) { pendingTextAreaValue = node.value } inlineAllStyles(node, clone, sessionCache, options) if (applyInputVisual) { applyInputVisual() } if (node.shadowRoot) { try { const slots = node.shadowRoot.querySelectorAll('slot') for (const s of slots) { let assigned = [] try { assigned = s.assignedNodes?.({ flatten: true }) || s.assignedNodes?.() || [] } catch { assigned = s.assignedNodes?.() || [] } for (const an of assigned) clonedAssignedNodes.add(an) } } catch { } const scopeId = nextShadowScopeId(sessionCache) const scopeSelector = `[data-sd="${scopeId}"]` try { clone.setAttribute('data-sd', scopeId) } catch { } const rawCSS = extractShadowCSS(node.shadowRoot) const rewritten = rewriteShadowCSS(rawCSS, scopeSelector) const neededVars = collectCustomPropsFromCSS(rawCSS) const seed = buildSeedCustomPropsRule(node, neededVars, scopeSelector) injectScopedStyle(clone, seed + rewritten, scopeId) const shadowFrag = document.createDocumentFragment() function callback(child, resolve) { if (child.nodeType === Node.ELEMENT_NODE && child.tagName === 'STYLE') { return resolve(null) } else { deepClone(child, sessionCache, options).then((clonedChild) => { resolve(clonedChild || null) }).catch(() => { resolve(null) }) } } const cloneList = await idleCallback(Array.from(node.shadowRoot.childNodes), callback, options.fast) shadowFrag.append(...cloneList.filter(clonedChild => !!clonedChild)) clone.appendChild(shadowFrag) } if (node.tagName === 'SLOT') { const assigned = node.assignedNodes?.({ flatten: true }) || [] const nodesToClone = assigned.length > 0 ? assigned : Array.from(node.childNodes) const fragment = document.createDocumentFragment() function callback(child, resolve) { deepClone(child, sessionCache, options).then((clonedChild) => { if (clonedChild) { markSlottedSubtree(clonedChild) } resolve(clonedChild || null) }).catch(() => { resolve(null) }) } const cloneList = await idleCallback(Array.from(nodesToClone), callback, options.fast) fragment.append(...cloneList.filter(clonedChild => !!clonedChild)) return fragment } function callback(child, resolve) { if (clonedAssignedNodes.has(child)) return resolve(null) deepClone(child, sessionCache, options).then((clonedChild) => { resolve(clonedChild || null) }).catch(() => { resolve(null) }) } const cloneList = await idleCallback(Array.from(node.childNodes), callback, options.fast) clone.append(...cloneList.filter(clonedChild => !!clonedChild)) // Adjust select value after children are cloned if (pendingSelectValue !== null && clone instanceof HTMLSelectElement) { clone.value = pendingSelectValue for (const opt of clone.options) { if (opt.value === pendingSelectValue) { opt.setAttribute('selected', '') } else { opt.removeAttribute('selected') } } } if (pendingTextAreaValue !== null && clone instanceof HTMLTextAreaElement) { clone.textContent = pendingTextAreaValue } return clone } ================================================ FILE: src/core/context.js ================================================ /** * @typedef {"disabled"|"full"|"auto"|"soft"} CachePolicy */ import { normalizeCachePolicy } from './cache.js' /** * Creates a normalized capture context for SnapDOM. * @param {Object} [options={}] * @param {boolean} [options.debug] * @param {boolean} [options.fast] * @param {number} [options.scale] * @param {Array} [options.exclude] * @param {string} [options.excludeMode] * @param {(node: Node)=>boolean} [options.filter] * @param {string} [options.filterMode] * @param {boolean} [options.embedFonts] * @param {string|string[]} [options.iconFonts] * @param {string[]} [options.localFonts] * @param {string[]|undefined} [options.excludeFonts] * @param {string[]} [options.fontStylesheetDomains] // extra domains to fetch cross-origin CSS from (#309) * @param {string|function} [options.fallbackURL] * @param {string} [options.useProxy] * @param {number|null} [options.width] * @param {number|null} [options.height] * @param {"png"|"jpg"|"jpeg"|"webp"|"svg"} [options.format] * @param {"svg"|"img"|"canvas"|"blob"} [options.type] * @param {number} [options.quality] * @param {number} [options.dpr] * @param {string|null} [options.backgroundColor] * @param {string} [options.filename] * @param {unknown} [options.cache] // "disabled"|"full"|"auto"|"soft" * @param {boolean} [options.outerTransforms] // NEW * @param {boolean} [options.outerShadows] // NEW * @param {RegExp|((prop: string) => boolean)} [options.excludeStyleProps] - Skip props when snapshotting (#348). e.g. /^--/ to exclude CSS vars * @returns {Object} */ export function createContext(options = {}) { let resolvedFormat = options.format ?? 'png' if (resolvedFormat === 'jpg') resolvedFormat = 'jpeg' /** @type {CachePolicy} */ const cachePolicy = normalizeCachePolicy(options.cache) return { // Debug & perf debug: options.debug ?? false, fast: options.fast ?? true, scale: options.scale ?? 1, // DOM filters exclude: options.exclude ?? [], excludeMode: options.excludeMode ?? 'hide', filter: options.filter ?? null, filterMode: options.filterMode ?? 'hide', // Placeholders placeholders: options.placeholders !== false, // default true // Fonts embedFonts: options.embedFonts ?? false, iconFonts: Array.isArray(options.iconFonts) ? options.iconFonts : (options.iconFonts ? [options.iconFonts] : []), localFonts: Array.isArray(options.localFonts) ? options.localFonts : [], excludeFonts: options.excludeFonts ?? undefined, fontStylesheetDomains: Array.isArray(options.fontStylesheetDomains) ? options.fontStylesheetDomains : [], fallbackURL: options.fallbackURL ?? undefined, /** @type {CachePolicy} */ cache: cachePolicy, // Network useProxy: typeof options.useProxy === 'string' ? options.useProxy : '', // Output width: options.width ?? null, height: options.height ?? null, format: resolvedFormat, type: options.type ?? 'svg', quality: options.quality ?? 0.92, dpr: options.dpr ?? (window.devicePixelRatio || 1), backgroundColor: options.backgroundColor ?? (['jpeg', 'webp'].includes(resolvedFormat) ? '#ffffff' : null), filename: options.filename ?? 'snapDOM', // NEW flags (user-friendly) outerTransforms: options.outerTransforms ?? true, outerShadows: options.outerShadows ?? false, // Safari warmup (WebKit #219770): iterations to prime font/decode pipeline. 1–3. safariWarmupAttempts: Math.min(3, Math.max(1, (options.safariWarmupAttempts ?? 3) | 0)), // #348: exclude style props from snapshot (reduces cost when :root has thousands of CSS vars) excludeStyleProps: options.excludeStyleProps ?? null, // Plugins (reservado) // plugins: normalizePlugins(...), } } ================================================ FILE: src/core/exporters.js ================================================ /** * Exporters registry (by format). * An exporter declares supported formats and an export() method: * * interface Exporter { * format: string | string[]; // e.g., 'png' or ['png','image/png'] * export(context: object, args: { format: string, options: object, url: string }): Promise | any; * } */ const __exporters = new Map() /** * Normalize an exporter def: Factory, [Factory, options], { exporter, options }, instance. * @param {any} spec * @returns {any|null} */ export function normalizeExporter(spec) { if (!spec) return null if (Array.isArray(spec)) { const [factory, options] = spec return typeof factory === 'function' ? factory(options) : factory } if (typeof spec === 'object' && 'exporter' in spec) { const { exporter, options } = spec return typeof exporter === 'function' ? exporter(options) : exporter } if (typeof spec === 'function') return spec() return spec } /** * Register one or many exporters. * Last one wins on format collision. * @param {...any} defs */ export function registerExporters(...defs) { const flat = defs.flat() for (const d of flat) { const inst = normalizeExporter(d) if (!inst) continue const formats = Array.isArray(inst.format) ? inst.format : [inst.format] for (const fmtRaw of formats) { const fmt = String(fmtRaw || '').toLowerCase().trim() if (!fmt) continue __exporters.set(fmt, inst) } } } /** * Resolve the exporter for a format (case-insensitive). * @param {string} format * @returns {any|null} */ export function getExporter(format) { if (!format) return null const key = String(format).toLowerCase().trim() return __exporters.get(key) || null } /** Utilities for tests */ export function _exportersMap() { return new Map(__exporters) } export function _clearExporters() { __exporters.clear() } /* -------------------------------------------------------------------------- */ /* 🧩 Export Hooks Integration (auto-once afterSnap) */ /* -------------------------------------------------------------------------- */ import { runHook } from './plugins.js' /** Keeps track of which captures have already triggered afterSnap */ const finished = new Set() /** * Runs export-related hooks around a given export task. * * Flow: * beforeExport → work() → afterExport → afterSnap(once per URL) * * @template T * @param {object} ctx - Capture context extended with { export:{ type, options, url } } * @param {() => Promise} work - Async exporter function * @returns {Promise} - The export result */ export async function runExportHooks(ctx, work) { await runHook('beforeExport', ctx) ctx.export.result = await work() await runHook('afterExport', ctx) const key = ctx.export?.url if (key && !finished.has(key)) { finished.add(key) await runHook('afterSnap', ctx) } return ctx.export.result } ================================================ FILE: src/core/plugins.js ================================================ /** * Plugin core for SnapDOM (minimalistic, local-first compatible). * * Public hooks: * - beforeSnap(context) * - beforeClone(context) * - afterClone(context) * - beforeRender(context) * - afterRender(context) * - beforeExport(context, { format, options }) * - afterExport(context, { format, options, result }) * - afterSnap(context) * * Hook signature: (context, payload?) => void | any | Promise * * Global plugins are registered via registerPlugins() * Local (per-capture) plugins can be attached using attachSessionPlugins(). */ const __plugins = [] /** * Normalize any plugin definition form into an instance. * Supports plain objects, [factory, options], { plugin, options }, or functions. * @param {any} spec * @returns {any|null} */ export function normalizePlugin(spec) { if (!spec) return null if (Array.isArray(spec)) { const [factory, options] = spec return typeof factory === 'function' ? factory(options) : factory } if (typeof spec === 'object' && 'plugin' in spec) { const { plugin, options } = spec return typeof plugin === 'function' ? plugin(options) : plugin } if (typeof spec === 'function') return spec() return spec } /** * Register global plugins (deduped by name, preserves order). * @param {...any} defs */ export function registerPlugins(...defs) { const flat = defs.flat() for (const d of flat) { const inst = normalizePlugin(d) if (!inst) continue // 🔒 de-dup por name if (!__plugins.some(p => p && p.name && inst.name && p.name === inst.name)) { __plugins.push(inst) } } } /** * INTERNAL: pick the plugin list for a given context. * If the context defines a per-capture plugin list, use that (local-first). * Otherwise, fall back to the global registry. * @param {any} context * @returns {readonly any[]} */ function getContextPlugins(context) { const arr = context && Array.isArray(context.plugins) ? context.plugins : __plugins return arr || __plugins } /** * Llama un hook y propaga un acumulador (compat con tu runHook actual). * Usa los plugins locales si existen, o los globales en fallback. * @param {string} name * @param {any} context * @param {any} payload */ export async function runHook(name, context, payload) { let acc = payload const list = getContextPlugins(context) for (const p of list) { const fn = p && typeof p[name] === 'function' ? p[name] : null if (!fn) continue const out = await fn(context, acc) if (typeof out !== 'undefined') acc = out } return acc } /** * NUEVO: recolecta los valores devueltos por TODOS los plugins para un hook. * Útil para `defineExports` (cada plugin devuelve un mapa propio). * Usa plugins locales si existen, o los globales en fallback. * @param {string} name * @param {any} context * @param {any} payload */ export async function runAll(name, context, payload) { const outs = [] const list = getContextPlugins(context) for (const p of list) { const fn = p && typeof p[name] === 'function' ? p[name] : null if (!fn) continue const out = await fn(context, payload) if (typeof out !== 'undefined') outs.push(out) } return outs } /** * Return a shallow copy of currently registered global plugins. * @returns {any[]} */ export function pluginsList() { return __plugins.slice() } /** Clear all globally registered plugins (mostly for tests). */ export function clearPlugins() { __plugins.length = 0 } /* ────────────────────────────────────────────────────────────────────────────── * NEW: Local-first per-capture support (without removing global APIs) * ────────────────────────────────────────────────────────────────────────────── */ /** * Merge local (per-capture) plugin defs with the global registry (local-first). * - Local plugins override globals by `name`. * - Accepts plain instances, factories ([factory, options]) and {plugin, options}. * - Returns a frozen array for immutability & GC safety. * @param {any[]|undefined} localDefs * @returns {ReadonlyArray} */ export function mergePlugins(localDefs) { /** @type {any[]} */ const out = [] // 1️⃣ Locals first (priority) if (Array.isArray(localDefs)) { for (const d of localDefs) { const inst = normalizePlugin(d) if (!inst || !inst.name) continue const i = out.findIndex(x => x && x.name === inst.name) if (i >= 0) out.splice(i, 1) out.push(inst) } } // 2️⃣ Then globals if not already present for (const g of __plugins) { if (g && g.name && !out.some(x => x.name === g.name)) { out.push(g) } } return Object.freeze(out) } /** * Attach a per-capture plugin list on the given context (local-first). * Idempotent: if `context.plugins` already exists, it remains unless `force` is true. * @param {any} context * @param {any[]|undefined} localDefs * @param {boolean} [force=false] * @returns {any} the same context (for chaining) */ export function attachSessionPlugins(context, localDefs, force = false) { if (!context || (context.plugins && !force)) return context context.plugins = mergePlugins(localDefs) return context } /** * Shallow copy of current global plugins (handy for tests or introspection). * @returns {any[]} */ export function getGlobalPlugins() { return __plugins.slice() } ================================================ FILE: src/core/prepare.js ================================================ /** * Prepares a deep clone of an element, inlining pseudo-elements and generating CSS classes. * @module prepare */ import { generateCSSClasses, stripTranslate, debugWarn, getStyle } from '../utils/index.js' import { deepClone } from './clone.js' import { inlinePseudoElements } from '../modules/pseudo.js' import { inlineExternalDefsAndSymbols } from '../modules/svgDefs.js' import { cache } from '../core/cache.js' import { freezeSticky } from '../modules/changeCSS.js' import { resolveBlobUrlsInTree } from '../utils/clone.helpers.js' import { stabilizeLayout } from '../utils/prepare.helpers.js' /** * Prepares a clone of an element for capture, inlining pseudo-elements and generating CSS classes. * * @param {Element} element - Element to clone * @param {boolean} [embedFonts=false] - Whether to embed custom fonts * @param {Object} [options={}] - Capture options * @param {string[]} [options.exclude] - CSS selectors for elements to exclude * @param {Function} [options.filter] - Custom filter function * @returns {Promise} Object containing the clone, generated CSS, and style cache */ export async function prepareClone(element, options = {}) { const sessionCache = { styleMap: cache.session.styleMap, styleCache: cache.session.styleCache, nodeMap: cache.session.nodeMap, options } let clone let classCSS = '' let shadowScopedCSS = '' stabilizeLayout(element) try { inlineExternalDefsAndSymbols(element) } catch (e) { console.warn('inlineExternal defs or symbol failed:', e) } try { clone = await deepClone(element, sessionCache, options) } catch (e) { console.warn('deepClone failed:', e) throw e } try { await inlinePseudoElements(element, clone, sessionCache, options) } catch (e) { console.warn('inlinePseudoElements failed:', e) } await resolveBlobUrlsInTree(clone, sessionCache) // --- Pull shadow-scoped CSS out of the clone (avoid visible CSS text) --- try { const styleNodes = clone.querySelectorAll('style[data-sd]') for (const s of styleNodes) { shadowScopedCSS += s.textContent || '' s.remove() // Do not leave svgText = svgText.replace(/]*>([\s\S]*?)<\/style>/gi, (m, css) => m.replace(css, rewriteCssBlock(css)) ) // 2) style="…" svgText = svgText.replace(/style=(['"])([\s\S]*?)\1/gi, (m, q, body) => `style=${q}${rewriteDeclList(body)}${q}` ) return svgText } function maybeConvertBoxShadowForSafari(url) { if (!isSafari() || !isSvgDataURL(url)) return url try { const svg = decodeSvgFromDataURL(url) const fixed = rewriteSvgBoxShadowToDropShadow(svg) return encodeSvgToDataURL(fixed) } catch { return url } } /** * Rasterize SVG (o data URL) en un canvas respetando width/height + scale. * Soporta aplanar un background color sin canvas intermedio. * @param {string} url * @param {{ * width?:number, * height?:number, * scale?:number, * dpr?:number, * meta?:object, * backgroundColor?: string // <- NUEVO: color opcional para aplanar fondo * }} options * @returns {Promise} */ export async function toCanvas(url, options) { let { width: optW, height: optH, scale = 1, dpr = 1, meta = {}, backgroundColor } = options url = maybeConvertBoxShadowForSafari(url) const img = new Image() img.loading = 'eager' img.decoding = 'sync' img.crossOrigin = 'anonymous' img.src = url await img.decode() const natW = img.naturalWidth const natH = img.naturalHeight const refW = Number.isFinite(meta.w0) ? meta.w0 : natW const refH = Number.isFinite(meta.h0) ? meta.h0 : natH let outW, outH const hasW = Number.isFinite(optW) const hasH = Number.isFinite(optH) if (hasW && hasH) { outW = Math.max(1, optW) outH = Math.max(1, optH) } else if (hasW) { const k = optW / Math.max(1, refW) outW = optW outH = refH * k } else if (hasH) { const k = optH / Math.max(1, refH) outH = optH outW = refW * k } else { outW = natW outH = natH } outW = outW * scale outH = outH * scale const canvas = document.createElement('canvas') canvas.width = outW * dpr canvas.height = outH * dpr canvas.style.width = `${outW}px` canvas.style.height = `${outH}px` const ctx = canvas.getContext('2d') if (dpr !== 1) ctx.scale(dpr, dpr) if (backgroundColor) { ctx.save() ctx.fillStyle = backgroundColor ctx.fillRect(0, 0, outW, outH) ctx.restore() } ctx.drawImage(img, 0, 0, outW, outH) return canvas } ================================================ FILE: src/exporters/toImg.js ================================================ // src/exporters/toImg.js import { isSafari, debugWarn } from '../utils' import { rasterize } from '../modules/rasterize' /** * Converts a data URL to an HTMLImageElement. * @param {string} url - The data URL of the image. * @param {object} options - Context options including scale. * @param {number} [options.scale=1] - Scale factor for the image dimensions. * @returns {Promise} Resolves with the loaded Image element. */ export async function toImg(url, options) { const { scale = 1, width, height, meta = {} } = options const hasW = Number.isFinite(width) const hasH = Number.isFinite(height) const wantsScale = (Number.isFinite(scale) && scale !== 1) || hasW || hasH if (isSafari() && wantsScale) { const pngUrl = await rasterize(url, {...options, format: 'png', quality: 1, meta}) return pngUrl } const img = new Image() img.decoding = 'sync' img.loading = 'eager' img.src = url await img.decode() if (hasW && hasH) { img.style.width = `${width}px` img.style.height = `${height}px` } else if (hasW) { const refW = Number.isFinite(meta.w0) ? meta.w0 : img.naturalWidth const refH = Number.isFinite(meta.h0) ? meta.h0 : img.naturalHeight const k = width / Math.max(1, refW) img.style.width = `${width}px` img.style.height = `${Math.round(refH * k)}px` } else if (hasH) { const refW = Number.isFinite(meta.w0) ? meta.w0 : img.naturalWidth const refH = Number.isFinite(meta.h0) ? meta.h0 : img.naturalHeight const k = height / Math.max(1, refH) img.style.height = `${height}px` img.style.width = `${Math.round(refW * k)}px` } else { const cssW = Math.round(img.naturalWidth * scale) const cssH = Math.round(img.naturalHeight * scale) img.style.width = `${cssW}px` img.style.height = `${cssH}px` if (typeof url === 'string' && url.startsWith('data:image/svg+xml')) { try { const decoded = decodeURIComponent(url.split(',')[1]) const patched = decoded .replace(/width="[^"]*"/, `width="${cssW}"`) .replace(/height="[^"]*"/, `height="${cssH}"`) url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(patched)}` img.src = url } catch (e) { debugWarn(options, 'SVG width/height patch in toImg failed', e) } } } return img } export { toImg as toSvg } ================================================ FILE: src/exporters/toJpg.js ================================================ import { rasterize } from '../modules/rasterize.js' import { captureDOM } from '../core/capture.js' export async function toJpg(elOrUrl, opts = {}) { // El normalizador de JPEG→fondo blanco ya corre en snapdom.capture(), // pero por si alguien llama directo al exporter: const next = { backgroundColor: '#ffffff', ...opts } const url = typeof elOrUrl === 'string' ? elOrUrl : await captureDOM(elOrUrl, next) return rasterize(url, { ...next, format: 'jpeg' }) } ================================================ FILE: src/exporters/toPng.js ================================================ // PNG via rasterize; acepta Element o dataURL (SVG) import { rasterize } from '../modules/rasterize.js' import { captureDOM } from '../core/capture.js' /** * @param {HTMLElement|string} elOrUrl * @param {object} opts * @returns {Promise} según tu contrato de `rasterize` */ export async function toPng(elOrUrl, opts = {}) { const url = typeof elOrUrl === 'string' ? elOrUrl : await captureDOM(elOrUrl, opts) return rasterize(url, { ...opts, format: 'png' }) } ================================================ FILE: src/exporters/toSvg.js ================================================ // Reexpone el toSvg que ya definís en toImg.js para habilitar el sub-path export { toSvg } from './toImg.js' ================================================ FILE: src/exporters/toWebp.js ================================================ import { rasterize } from '../modules/rasterize.js' import { captureDOM } from '../core/capture.js' export async function toWebp(elOrUrl, opts = {}) { const url = typeof elOrUrl === 'string' ? elOrUrl : await captureDOM(elOrUrl, opts) return rasterize(url, { ...opts, format: 'webp' }) } ================================================ FILE: src/index.browser.js ================================================ /** * Entry point for snapDOM library exports. * * @file index.browser.js */ import { snapdom } from './api/snapdom.js' import { preCache } from './api/preCache.js' if (typeof window !== 'undefined') { window.snapdom = snapdom window.preCache = preCache } ================================================ FILE: src/index.js ================================================ /** * Entry point for snapDOM library exports. * * @file index.js */ export { snapdom } from './api/snapdom.js' export { preCache } from './api/preCache.js' ================================================ FILE: src/modules/CSSVar.js ================================================ // src/utils/resolveCSSVars.js /** Props donde típicamente aparece var() y conviene “materializar” si difieren del baseline */ const KEY_PROPS = ['fill', 'stroke', 'color', 'background-color', 'stop-color'] /** Cache de estilos base por (namespaceURI + tagName) */ const __BASELINE_CACHE = new Map() /** Obtiene el estilo computado “base” (sin clase ni estilo) para un tag/namespace */ function getBaselineComputed(tagName, ns) { const key = ns + '::' + tagName.toLowerCase() let entry = __BASELINE_CACHE.get(key) if (entry) return entry // Crear elemento del mismo tipo fuera del flujo visual const doc = document const el = ns === 'http://www.w3.org/2000/svg' ? doc.createElementNS(ns, tagName) : doc.createElement(tagName) // Lo insertamos de forma que el UA pueda computar estilos, pero sin afectar layout // (un shadowRoot vacío temporal funciona bien) const holder = doc.createElement('div') holder.style.cssText = 'position:absolute;left:-99999px;top:-99999px;contain:strict;display:block;' holder.appendChild(el) doc.documentElement.appendChild(holder) const cs = getComputedStyle(el) const base = {} for (const p of KEY_PROPS) { base[p] = cs.getPropertyValue(p) || '' } holder.remove() __BASELINE_CACHE.set(key, base) return base } /** * General: resuelve var() en estilos inline/atributos. Además, si no hay var() * pero el valor computado de KEY_PROPS difiere del baseline, inlina ese valor. */ export function resolveCSSVars(sourceEl, cloneEl) { if (!(sourceEl instanceof Element) || !(cloneEl instanceof Element)) return // --- 0) Pre-chequeo ultra barato const styleAttr = sourceEl.getAttribute?.('style') let hasVar = !!(styleAttr && styleAttr.includes('var(')) if (!hasVar && sourceEl.attributes?.length) { const attrs = sourceEl.attributes for (let i = 0; i < attrs.length; i++) { const a = attrs[i] if (a && typeof a.value === 'string' && a.value.includes('var(')) { hasVar = true; break } } } // Leemos cs sólo si hace falta o si vamos a comparar con baseline let cs = null if (hasVar) { try { cs = getComputedStyle(sourceEl) } catch {} } // --- 1) Resolver var() en estilos inline if (hasVar) { const author = sourceEl.style if (author && author.length) { for (let i = 0; i < author.length; i++) { const prop = author[i] const val = author.getPropertyValue(prop) if (!val || !val.includes('var(')) continue const resolved = cs && cs.getPropertyValue(prop) if (resolved) { try { cloneEl.style.setProperty(prop, resolved.trim(), author.getPropertyPriority(prop)) } catch {} } } } } // --- 2) Resolver var() en atributos (genérico; si prop no existe en CSS, setProperty no hace nada) if (hasVar && sourceEl.attributes?.length) { const attrs = sourceEl.attributes for (let i = 0; i < attrs.length; i++) { const a = attrs[i] if (!a || typeof a.value !== 'string' || !a.value.includes('var(')) continue const propName = a.name const resolved = cs && cs.getPropertyValue(propName) if (resolved) { try { cloneEl.style.setProperty(propName, resolved.trim()) } catch {} } } } // --- 3) Fallback general: cubrir reglas de hoja (clases) SIN buscar en CSSOM // Si NO vimos var() inline/attrs, quizás la clase aplicó var(). En ese caso, // comparamos KEY_PROPS contra baseline del mismo tag/namespace y, si difiere, // inlinamos el valor computado. Esto materializa p.ej. `.css-var-fill { fill: var(--x) }` if (!hasVar) { // Leemos cs aquí sólo si lo vamos a usar if (!cs) { try { cs = getComputedStyle(sourceEl) } catch { cs = null } } if (!cs) return const ns = sourceEl.namespaceURI || 'html' const base = getBaselineComputed(sourceEl.tagName, ns) for (const prop of KEY_PROPS) { const v = cs.getPropertyValue(prop) || '' const b = base[prop] || '' if (v && v !== b) { // Es distinto al baseline => hay estilo de hoja afectando (posiblemente via var()). try { cloneEl.style.setProperty(prop, v.trim()) } catch {} } } } } ================================================ FILE: src/modules/background.js ================================================ /** * Utilities for inlining background images as data URLs. * @module background */ import { getStyle, inlineSingleBackgroundEntry, splitBackgroundImage } from '../utils' /** * Recursively inlines background-related images and masks from the source element to its clone. * * This function walks through the source DOM tree and its clone, copying inline styles for * background images, masks, and border images to ensure the clone retains all visual image * resources inline (e.g., data URLs), avoiding external dependencies. * * It also preserves the `background-color` property if it is not transparent. * * Special handling is done for `border-image` related properties: the * `border-image-slice`, `border-image-width`, `border-image-outset`, and `border-image-repeat` * are only copied if `border-image` or `border-image-source` are present and active. * * @param {HTMLElement} source The original source element from which styles are read. * @param {HTMLElement} clone The cloned element to which inline styles are applied. * @param {Object} [options={}] Optional parameters passed to image inlining functions. * @returns {Promise} Resolves when all inlining operations (including async image fetches) complete. */ /** * Inlines URL-bearing properties (background/mask/border-image) * and also preserves mask positioning longhands (position/size/repeat). * This fixes cases like `mask: url(...) center/60% 60% no-repeat`. */ export async function inlineBackgroundImages(source, clone, styleCache, options = {}) { const queue = [[source, clone]] /** Props that can contain url(...) and may need inlining */ const URL_PROPS = [ 'background-image', // Mask shorthands & images (both standard and WebKit) 'mask', 'mask-image', '-webkit-mask', '-webkit-mask-image', // Mask sources (rare, but keep) 'mask-source', 'mask-box-image-source', 'mask-border-source', '-webkit-mask-box-image-source', // Border image 'border-image', 'border-image-source', ] /** Mask longhands to preserve spatial layout (copy as-is) */ const MASK_LAYOUT_PROPS = [ 'mask-position', 'mask-size', 'mask-repeat', // WebKit variants '-webkit-mask-position', '-webkit-mask-size', '-webkit-mask-repeat', // Extra (optional but helpful across engines) 'mask-origin', 'mask-clip', '-webkit-mask-origin', '-webkit-mask-clip', // Some engines expose X/Y position separately: '-webkit-mask-position-x', '-webkit-mask-position-y', ] const BG_LAYOUT_PROPS = [ 'background-position', 'background-position-x', 'background-position-y', 'background-size', 'background-repeat', 'background-origin', 'background-clip', 'background-attachment', 'background-blend-mode' ] /** Border-image aux longhands (copy only when active) */ const BORDER_AUX_PROPS = [ 'border-image-slice', 'border-image-width', 'border-image-outset', 'border-image-repeat', ] while (queue.length) { const [srcNode, cloneNode] = queue.shift() if (!cloneNode) continue // Style cache const style = styleCache.get(srcNode) || getStyle(srcNode) if (!styleCache.has(srcNode)) styleCache.set(srcNode, style) // Border-image present? const hasBorderImage = (() => { const bi = style.getPropertyValue('border-image') const bis = style.getPropertyValue('border-image-source') return (bi && bi !== 'none') || (bis && bis !== 'none') })() for (const prop of BG_LAYOUT_PROPS) { const v = style.getPropertyValue(prop) if (!v) continue cloneNode.style.setProperty(prop, v) } // 1) Inline URL-bearing properties for (const prop of URL_PROPS) { let val = style.getPropertyValue(prop) // Fallback: when background-image is none/empty, parse url() from background shorthand (#343) if ((prop === 'background-image') && (!val || val === 'none')) { const bgShorthand = style.getPropertyValue('background') if (bgShorthand && /url\s*\(/.test(bgShorthand)) { val = splitBackgroundImage(bgShorthand).find(p => /url\s*\(/.test(p)) || val } } if (!val || val === 'none') continue // Split multiple layers (comma-separated) const splits = splitBackgroundImage(val) const inlined = await Promise.all( splits.map(entry => inlineSingleBackgroundEntry(entry, options)) ) if (inlined.some(p => p && p !== 'none' && !/^url\(undefined/.test(p))) { cloneNode.style.setProperty(prop, inlined.join(', ')) } } // 2) Copy mask layout longhands (position / size / repeat, etc.) for (const prop of MASK_LAYOUT_PROPS) { const val = style.getPropertyValue(prop) // Skip empty/initial defaults to avoid bloating if (!val || val === 'initial') continue cloneNode.style.setProperty(prop, val) } // 3) Copy border-image auxiliaries only if border-image is active if (hasBorderImage) { for (const prop of BORDER_AUX_PROPS) { const val = style.getPropertyValue(prop) if (!val || val === 'initial') continue cloneNode.style.setProperty(prop, val) } } // 4) Recurse // Fix: When srcNode is a shadow DOM host, its light DOM children are empty // (content lives in shadowRoot). Use shadowRoot.children instead, filtering // out