@onerjs/snapdom 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,969 @@
1
+ <p align="center">
2
+ <a href="http://zumerlab.github.io/snapdom">
3
+ <img src="https://raw.githubusercontent.com/zumerlab/snapdom/main/docs/assets/newhero.png" width="80%">
4
+ </a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@zumer/snapdom">
9
+ <img alt="NPM version" src="https://img.shields.io/npm/v/@zumer/snapdom?style=flat-square&label=Version">
10
+ </a>
11
+ <a href="https://www.npmjs.com/package/@zumer/snapdom">
12
+ <img alt="NPM weekly downloads" src="https://img.shields.io/npm/dw/@zumer/snapdom?style=flat-square&label=Downloads">
13
+ </a>
14
+ <a href="https://github.com/zumerlab/snapdom/graphs/contributors">
15
+ <img alt="GitHub contributors" src="https://img.shields.io/github/contributors/zumerlab/snapdom?style=flat-square&label=Contributors">
16
+ </a>
17
+ <a href="https://github.com/zumerlab/snapdom/stargazers">
18
+ <img alt="GitHub stars" src="https://img.shields.io/github/stars/zumerlab/snapdom?style=flat-square&label=Stars">
19
+ </a>
20
+ <a href="https://github.com/zumerlab/snapdom/network/members">
21
+ <img alt="GitHub forks" src="https://img.shields.io/github/forks/zumerlab/snapdom?style=flat-square&label=Forks">
22
+ </a>
23
+ <a href="https://github.com/sponsors/tinchox5">
24
+ <img alt="Sponsor tinchox5" src="https://img.shields.io/github/sponsors/tinchox5?style=flat-square&label=Sponsor">
25
+ </a>
26
+
27
+ <a href="https://github.com/zumerlab/snapdom/blob/main/LICENSE">
28
+ <img alt="License" src="https://img.shields.io/github/license/zumerlab/snapdom?style=flat-square">
29
+ </a>
30
+ </p>
31
+
32
+ <p align="center">English | <a href="README_CN.md">简体中文</a></p>
33
+
34
+ # SnapDOM
35
+
36
+ **SnapDOM** is a next-generation **DOM Capture Engine** — ultra-fast, modular, and extensible.
37
+ 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.
38
+
39
+ * Full DOM capture
40
+ * Embedded styles, pseudo-elements, and fonts
41
+ * Export to SVG, PNG, JPG, WebP, `canvas`, or Blob
42
+ * ⚡ Ultra fast, no dependencies
43
+ * 100% based on standard Web APIs
44
+ * Support same-origin `ìframe`
45
+ * Support CSS counter() and CSS counters()
46
+ * Support `...` line-clamp
47
+
48
+ ## Demo
49
+
50
+ [https://snapdom.dev](https://snapdom.dev)
51
+
52
+
53
+ ## Quick Start
54
+
55
+ **Capture any DOM element to PNG in one line:**
56
+
57
+ ```js
58
+ import { snapdom } from '@zumer/snapdom';
59
+
60
+ const img = await snapdom.toPng(document.querySelector('#card'));
61
+ document.body.appendChild(img);
62
+ ```
63
+
64
+ **Reusable capture** (one clone, multiple exports):
65
+
66
+ ```js
67
+ const result = await snapdom(document.querySelector('#card'));
68
+ await result.toPng(); // → HTMLImageElement
69
+ await result.toSvg(); // → SVG as Image
70
+ await result.download({ format: 'jpg', filename: 'card.jpg' });
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Capture Flow
76
+
77
+ SnapDOM transforms your DOM element through these stages:
78
+
79
+ ```
80
+ DOM Element
81
+
82
+ Clone
83
+
84
+ Styles & Pseudo
85
+
86
+ Images & Backgrounds
87
+
88
+ Fonts
89
+
90
+ SVG foreignObject
91
+
92
+ data:image/svg+xml
93
+
94
+ toPng / toSvg / toBlob / download
95
+ ```
96
+
97
+ | Stage | What happens |
98
+ |-------|--------------|
99
+ | **Clone** | Deep clone with styles, Shadow DOM, iframes. Exclude/filter nodes. |
100
+ | **Styles & Pseudo** | Inline `::before`/`::after` as elements, resolve `counter()`/`counters()`. |
101
+ | **Images & Backgrounds** | Fetch and inline external images/backgrounds as data URLs. |
102
+ | **Fonts** | Embed `@font-face` (optional) and icon fonts. |
103
+ | **SVG** | Wrap clone in `<foreignObject>`, serialize to `data:image/svg+xml`. |
104
+ | **Export** | Convert SVG to PNG/JPG/WebP/Blob or trigger download. |
105
+
106
+ Plugin hooks: `beforeSnap` → `beforeClone` → `afterClone` → `beforeRender` → `afterRender` → `beforeExport` → `afterExport`.
107
+
108
+
109
+ ## Table of Contents
110
+
111
+ - [Quick Start](#quick-start)
112
+ - [Capture Flow](#capture-flow)
113
+ - [Installation](#installation)
114
+ - [NPM / Yarn (stable)](#npm--yarn-stable)
115
+ - [NPM / Yarn (dev builds)](#npm--yarn-dev-builds)
116
+ - [CDN (stable)](#cdn-stable)
117
+ - [CDN (dev builds)](#cdn-dev-builds)
118
+ - [Build Outputs](#build-outputs--tree-shaking)
119
+ - [Usage](#usage)
120
+ - [Reusable capture](#reusable-capture)
121
+ - [One-step shortcuts](#one-step-shortcuts)
122
+ - [API](#api)
123
+ - [snapdom(el, options?)](#snapdomel-options)
124
+ - [Shortcut methods](#shortcut-methods)
125
+ - [Options](#options)
126
+ - [debug](#debug)
127
+ - [Fallback image on `<img>` load failure](#fallback-image-on-img-load-failure)
128
+ - [Dimensions (`scale`, `width`, `height`)](#dimensions-scale-width-height)
129
+ - [Cross-Origin Images & Fonts (`useProxy`)](#cross-origin-images--fonts-useproxy)
130
+ - [Fonts](#fonts)
131
+ - [embedFonts](#embedfonts)
132
+ - [localFonts](#localfonts)
133
+ - [iconFonts](#iconfonts)
134
+ - [excludeFonts](#excludefonts)
135
+ - [Filtering nodes: `exclude` vs `filter`](#filtering-nodes-exclude-vs-filter)
136
+ - [outerTransforms](#outerTransforms)
137
+ - [outerShadows](#no-shadows)
138
+ - [Cache control](#cache-control)
139
+ - [preCache](#precache--optional-helper)
140
+ - [Plugins (BETA)](#plugins-beta)
141
+ - [Official Plugins](#official-plugins)
142
+ - [Community Plugins](#community-plugins)
143
+ - [Build a Plugin in 5 Minutes](#build-a-plugin-in-5-minutes)
144
+ - [Registering Plugins](#registering-plugins)
145
+ - [Plugin Lifecycle Hooks](#plugin-lifecycle-hooks)
146
+ - [Context Object](#context-object)
147
+ - [Custom Exports via Plugins](#custom-exports-via-plugins)
148
+ - [Example: Overlay Filter Plugin](#example-overlay-filter-plugin)
149
+ - [Full Plugin Template](#full-plugin-template)
150
+ - [Limitations](#limitations)
151
+ - [⚡ Performance Benchmarks (Chromium)](#performance-benchmarks)
152
+ - [Simple elements](#simple-elements)
153
+ - [Complex elements](#complex-elements)
154
+ - [Run the benchmarks](#run-the-benchmarks)
155
+ - [Roadmap](#roadmap)
156
+ - [Development](#development)
157
+ - [Contributors 🙌](#contributors)
158
+ - [💖 Sponsors](#sponsors)
159
+ - [Star History](#star-history)
160
+ - [License](#license)
161
+
162
+
163
+
164
+ ## Installation
165
+
166
+ ### NPM / Yarn (stable)
167
+
168
+ ```bash
169
+ npm i @zumer/snapdom
170
+ yarn add @zumer/snapdom
171
+ ```
172
+
173
+ ### NPM / Yarn (dev builds)
174
+
175
+ For early access to new features and fixes:
176
+
177
+ ```bash
178
+ npm i @zumer/snapdom@dev
179
+ yarn add @zumer/snapdom@dev
180
+ ```
181
+
182
+ ⚠️ The `@dev` tag usually includes improvements before they reach production, but may be less stable.
183
+
184
+
185
+ ### CDN (stable)
186
+
187
+ ```html
188
+ <!-- Minified build -->
189
+ <script src="https://unpkg.com/@zumer/snapdom/dist/snapdom.js"></script>
190
+
191
+ <!-- Minified ES Module build -->
192
+ <script type="module">
193
+ import { snapdom } from "https://unpkg.com/@zumer/snapdom/dist/snapdom.mjs";
194
+ </script>
195
+ ```
196
+
197
+ ### CDN (dev builds)
198
+
199
+ ```html
200
+ <!-- Minified build (dev) -->
201
+ <script src="https://unpkg.com/@zumer/snapdom@dev/dist/snapdom.js"></script>
202
+
203
+ <!-- Minified ES Module build (dev) -->
204
+ <script type="module">
205
+ import { snapdom } from "https://unpkg.com/@zumer/snapdom@dev/dist/snapdom.mjs";
206
+ </script>
207
+ ```
208
+
209
+ ## Build Outputs
210
+
211
+ | Variant | File | Use case |
212
+ |---------|------|----------|
213
+ | **ESM** (tree-shakeable) | `dist/snapdom.mjs` | Bundlers (Vite, webpack), `import` |
214
+ | **IIFE** (global) | `dist/snapdom.js` | Script tag, legacy `require` |
215
+
216
+ **Bundler (npm):**
217
+ ```js
218
+ import { snapdom } from '@zumer/snapdom'; // → dist/snapdom.mjs
219
+ ```
220
+
221
+ **Script tag (CDN):**
222
+ ```html
223
+ <script src="https://unpkg.com/@zumer/snapdom/dist/snapdom.js"></script>
224
+ <script> snapdom.toPng(document.body).then(img => document.body.appendChild(img)); </script>
225
+ ```
226
+
227
+ **Subpath imports** (lighter bundle if you only need one):
228
+ ```js
229
+ import { preCache } from '@zumer/snapdom/preCache';
230
+ import { plugins } from '@zumer/snapdom/plugins';
231
+ ```
232
+
233
+
234
+ ## Usage
235
+
236
+ | Pattern | When to use |
237
+ |---------|-------------|
238
+ | **Reusable** `snapdom(el)` | One clone → many exports (PNG + JPG + download). |
239
+ | **Shortcuts** `snapdom.toPng(el)` | Single export, less code. |
240
+
241
+ ### Reusable capture
242
+
243
+ Capture once, export many times (no re-clone):
244
+
245
+ ```js
246
+ const el = document.querySelector('#target');
247
+ const result = await snapdom(el);
248
+
249
+ const img = await result.toPng();
250
+ document.body.appendChild(img);
251
+ await result.download({ format: 'jpg', filename: 'my-capture.jpg' });
252
+ ```
253
+
254
+ ### One-step shortcuts
255
+
256
+ Direct export when you need a single format:
257
+
258
+ ```js
259
+ const png = await snapdom.toPng(el);
260
+ const blob = await snapdom.toBlob(el);
261
+ document.body.appendChild(png);
262
+ ```
263
+
264
+ ## API
265
+
266
+ ### `snapdom(el, options?)`
267
+
268
+ Returns an object with reusable export methods:
269
+
270
+ ```js
271
+ {
272
+ url: string;
273
+ toRaw(): string;
274
+ toImg(): Promise<HTMLImageElement>; // deprecated
275
+ toSvg(): Promise<HTMLImageElement>;
276
+ toCanvas(): Promise<HTMLCanvasElement>;
277
+ toBlob(options?): Promise<Blob>;
278
+ toPng(options?): Promise<HTMLImageElement>;
279
+ toJpg(options?): Promise<HTMLImageElement>;
280
+ toWebp(options?): Promise<HTMLImageElement>;
281
+ download(options?): Promise<void>;
282
+ }
283
+ ```
284
+
285
+ ### Shortcut methods
286
+
287
+ | Method | Description |
288
+ | ------------------------------ | --------------------------------- |
289
+ | `snapdom.toImg(el, options?)` | Returns an SVG `HTMLImageElement` (deprecated) |
290
+ | `snapdom.toSvg(el, options?)` | Returns an SVG `HTMLImageElement` |
291
+ | `snapdom.toCanvas(el, options?)` | Returns a `Canvas` |
292
+ | `snapdom.toBlob(el, options?)` | Returns an SVG or raster `Blob` |
293
+ | `snapdom.toPng(el, options?)` | Returns a PNG image |
294
+ | `snapdom.toJpg(el, options?)` | Returns a JPG image |
295
+ | `snapdom.toWebp(el, options?)` | Returns a WebP image |
296
+ | `snapdom.download(el, options?)` | Triggers a download |
297
+
298
+ ### Exporter-specific options
299
+
300
+ Some exporters accept a small set of **export-only options** in addition to the global capture options.
301
+
302
+ #### `download()`
303
+
304
+ | Option | Type | Default | Description |
305
+ | --- | --- | --- | --- |
306
+ | `filename` | `string` | `snapdom` | Download name. |
307
+ | `format` | `"png" \| "jpeg" \| "jpg" \| "webp" \| "svg"` | `"png"` | Output format for the downloaded file. |
308
+
309
+ **Example:**
310
+
311
+ ```js
312
+ await result.download({
313
+ format: 'jpg',
314
+ quality: 0.92,
315
+ filename: 'my-capture'
316
+ });
317
+ ```
318
+
319
+ #### `toBlob()`
320
+
321
+ | Option | Type | Default | Description |
322
+ | --- | --- | --- | --- |
323
+ | `type` | `"svg" \| "png" \| "jpeg" \| "jpg" \| "webp"` | `"svg"` | Blob type to generate. |
324
+
325
+ **Example:**
326
+
327
+ ```js
328
+ const blob = await result.toBlob({ type: 'jpeg', quality: 0.92 });
329
+ ```
330
+
331
+ ## Options
332
+
333
+ All capture methods accept an `options` object:
334
+
335
+
336
+ | Option | Type | Default | Description |
337
+ | ----------------- | -------- | -------- | ----------------------------------------------- |
338
+ | `debug` | boolean | `false` | When `true`, logs suppressed errors to `console.warn` for troubleshooting |
339
+ | `fast` | boolean | `true` | Skips small idle delays for faster results |
340
+ | `embedFonts` | boolean | `false` | Inlines non-icon fonts (icon fonts always on) |
341
+ | `localFonts` | array | `[]` | Local fonts `{ family, src, weight?, style? }` |
342
+ | `iconFonts` | string\|RegExp\|Array | `[]` | Extra icon font matchers |
343
+ | `excludeFonts` | object | `{}` | Exclude families/domains/subsets during embedding |
344
+ | `scale` | number | `1` | Output scale multiplier |
345
+ | `dpr` | number | `devicePixelRatio` | Device pixel ratio |
346
+ | `width` | number | - | Output width |
347
+ | `height` | number | - | Output height |
348
+ | `backgroundColor` | string | `"#fff"` | Fallback color for JPG/WebP |
349
+ | `quality` | number | `1` | Quality for JPG/WebP (0 to 1) |
350
+ | `useProxy` | string | `''` | Proxy base for CORS fallbacks |
351
+ | `exclude` | string[] | - | CSS selectors to exclude |
352
+ | `excludeMode` | `"hide"`\|`"remove"` | `"hide"` | How `exclude` is applied |
353
+ | `filter` | function | - | Custom predicate `(el) => boolean` |
354
+ | `filterMode` | `"hide"`\|`"remove"` | `"hide"` | How `filter` is applied |
355
+ | `cache` | string | `"soft"` | `disabled` \| `soft` \| `auto` \| `full` |
356
+ | `placeholders` | boolean | `true` | Show placeholders for images/CORS iframes |
357
+ | `fallbackURL` | string \| function | - | Fallback image for `<img>` load failure |
358
+ | `outerTransforms` | boolean | `true` | When `false` removes `translate/rotate` but preserves `scale/skew`, producing a flat, reusable capture |
359
+ | `outerShadows` | boolean | `false` | Do not expand the root’s bounding box for shadows/blur/outline, and strip those visual effects from the cloned root |
360
+
361
+ | `safariWarmupAttempts` | number | `3` | Safari only: iterations to prime font/decode (WebKit #219770). Use `1` if 3 causes lag |
362
+
363
+ ### debug
364
+
365
+ 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.
366
+
367
+ ```js
368
+ await snapdom.toPng(el, { debug: true });
369
+ ```
370
+
371
+ ### Fallback image on `<img>` load failure
372
+
373
+ Provide a default image for failed `<img>` loads. You can pass a fixed URL or a callback that receives measured dimensions and returns a URL (handy to generate dynamic placeholders).
374
+
375
+ ```js
376
+ // 1) Fixed URL fallback
377
+ await snapdom.toSvg(element, {
378
+ fallbackURL: '/images/fallback.png'
379
+ });
380
+
381
+ // 2) Dynamic placeholder via callback
382
+ await snapdom.toSvg(element, {
383
+ fallbackURL: ({ width: 300, height: 150 }) =>
384
+ `https://placehold.co/${width}x${height}`
385
+ });
386
+
387
+ // 3) With proxy (if your fallback host has no CORS)
388
+ await snapdom.toSvg(element, {
389
+ fallbackURL: ({ width = 300, height = 150 }) =>
390
+ `https://dummyimage.com/${width}x${height}/cccccc/666.png&text=img`,
391
+ useProxy: 'https://proxy.corsfix.com/?'
392
+ });
393
+ ```
394
+
395
+ Notes:
396
+ - If the fallback image also fails to load, snapDOM replaces the `<img>` with a placeholder block preserving width/height.
397
+ - Width/height used by the callback are gathered from the original element (dataset, style/attrs, etc.) when available.
398
+
399
+
400
+ ### Dimensions (`scale`, `width`, `height`)
401
+
402
+ * If `scale` is provided, it **takes precedence** over `width`/`height`.
403
+ * If only `width` is provided, height scales proportionally (and vice versa).
404
+ * Providing both `width` and `height` forces an exact size (may distort).
405
+
406
+ ### Cross-Origin Images & Fonts (`useProxy`)
407
+
408
+ 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`:
409
+
410
+ ```js
411
+ await snapdom.toPng(el, {
412
+ useProxy: 'https://proxy.corsfix.com/?' // Note: Any cors proxy could be used 'https://proxy.corsfix.com/?'
413
+ });
414
+ ```
415
+
416
+
417
+ * The proxy is only used as a **fallback**; same-origin and CORS-enabled assets skip it.
418
+
419
+ ### Fonts
420
+
421
+ #### `embedFonts`
422
+ 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**.
423
+
424
+ #### `localFonts`
425
+ If you serve fonts yourself or have data URLs, you can declare them here to avoid extra CSS discovery:
426
+
427
+ ```js
428
+ await snapdom.toPng(el, {
429
+ embedFonts: true,
430
+ localFonts: [
431
+ { family: 'Inter', src: '/fonts/Inter-Variable.woff2', weight: 400, style: 'normal' },
432
+ { family: 'Inter', src: '/fonts/Inter-Italic.woff2', style: 'italic' }
433
+ ]
434
+ });
435
+ ```
436
+
437
+ #### `iconFonts`
438
+ Add custom icon families (names or regex matchers). Useful for private icon sets:
439
+
440
+ ```js
441
+ await snapdom.toPng(el, {
442
+ iconFonts: ['MyIcons', /^(Remix|Feather) Icons?$/i]
443
+ });
444
+ ```
445
+
446
+ #### `excludeFonts`
447
+ Skip specific non-icon fonts to speed up capture or avoid unnecessary downloads.
448
+
449
+ ```js
450
+ await snapdom.toPng(el, {
451
+ embedFonts: true,
452
+ excludeFonts: {
453
+ families: ['Noto Serif', 'SomeHeavyFont'], // skip by family name
454
+ domains: ['fonts.gstatic.com', 'cdn.example'], // skip by source host
455
+ subsets: ['cyrillic-ext'] // skip by unicode-range subset tag
456
+ }
457
+ });
458
+ ```
459
+ *Notes*
460
+ - `excludeFonts` only applies to **non-icon** fonts. Icon fonts are always embedded.
461
+ - Matching is case-insensitive for `families`. Hosts are matched by substring against the resolved URL.
462
+
463
+
464
+ #### Filtering nodes: `exclude` vs `filter`
465
+
466
+ * `exclude`: remove by **selector**.
467
+ * `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.
468
+ * `filter`: advanced predicate per element (return `false` to drop).
469
+ * `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.
470
+
471
+ **Example: filter out elements with `display:none`:**
472
+ ```js
473
+ /**
474
+ * Example filter: skip elements with display:none
475
+ * @param {Element} el
476
+ * @returns {boolean} true = keep, false = exclude
477
+ */
478
+ function filterHidden(el) {
479
+ const cs = window.getComputedStyle(el);
480
+ if (cs.display === 'none') return false;
481
+ return true;
482
+ }
483
+
484
+ await snapdom.toPng(document.body, { filter: filterHidden });
485
+ ```
486
+
487
+ **Example with `exclude`:** remove banners or tooltips by selector
488
+ ```js
489
+ await snapdom.toPng(el, {
490
+ exclude: ['.cookie-banner', '.tooltip', '[data-test="debug"]']
491
+ });
492
+ ```
493
+
494
+ ### outerTransforms
495
+
496
+ 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.
497
+
498
+ - **`outerTransforms: true (default)`**
499
+ **Keeps the original `transforms` and `rotate`**.
500
+
501
+
502
+ ### outerShadows
503
+ - **`outerShadows: false (default)`**
504
+ 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.
505
+
506
+ > 💡 **Tip:** Using both (`outerTransforms: false` + `outerShadows: false`) produces a strict, minimal bounding box with no visual bleed.
507
+
508
+ **Example**
509
+
510
+ ```js
511
+ // outerTransforms and remove shadow bleed
512
+ await snapdom.toSvg(el, { outerTransforms: true, outerShadows: true });
513
+ ```
514
+
515
+ ## Cache control
516
+
517
+ SnapDOM maintains internal caches for images, backgrounds, resources, styles, and fonts.
518
+ You can control how they are cleared between captures using the `cache` option:
519
+
520
+ | Mode | Description |
521
+ | ----------- | --------------------------------------------------------------------------- |
522
+ | `"disabled"`| No cache |
523
+ | `"soft"` | Clears session caches (`styleMap`, `nodeMap`, `styleCache`) _(default)_ |
524
+ | `"auto"` | Minimal cleanup: only clears transient maps |
525
+ | `"full"` | Keeps all caches (nothing is cleared, maximum performance) |
526
+
527
+ **Examples:**
528
+
529
+ ```js
530
+ // Use minimal but fast cache
531
+ await snapdom.toPng(el, { cache: 'auto' });
532
+
533
+ // Keep everything in memory between captures
534
+ await snapdom.toPng(el, { cache: 'full' });
535
+
536
+ // Force a full cleanup on every capture
537
+ await snapdom.toPng(el, { cache: 'disabled' });
538
+ ```
539
+
540
+ ## `preCache()` – Optional helper
541
+
542
+ Preloads external resources to avoid first-capture stalls (helpful for big/complex trees).
543
+
544
+ ```js
545
+ import { preCache } from '@zumer/snapdom';
546
+
547
+ await preCache({
548
+ root: document.body,
549
+ embedFonts: true,
550
+ localFonts: [{ family: 'Inter', src: '/fonts/Inter.woff2', weight: 400 }],
551
+ useProxy: 'https://proxy.corsfix.com/?'
552
+ });
553
+ ```
554
+
555
+ ## Plugins (BETA)
556
+
557
+ 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.
558
+
559
+ A plugin is a simple object with a unique `name` and one or more lifecycle **hooks**.
560
+ Hooks can be synchronous or `async`, and they receive a shared **`context`** object.
561
+
562
+ ### Official Plugins
563
+
564
+ Install the official plugin package:
565
+
566
+ ```bash
567
+ npm install @zumer/snapdom-plugins
568
+ ```
569
+
570
+ ```js
571
+ import { filter } from '@zumer/snapdom-plugins/filter';
572
+ import { timestampOverlay } from '@zumer/snapdom-plugins/timestamp-overlay';
573
+ ```
574
+
575
+ | Plugin | Category | Description |
576
+ |--------|----------|-------------|
577
+ | `picture-resolver` | Capture | Resolves lazy-loaded `<picture>` placeholders. Detects base64 stubs and fetches the real image before capture. |
578
+ | `timestamp-overlay` | Transform | Adds a configurable timestamp label on the captured clone. Supports multiple date formats and positions. |
579
+ | `filter` | Transform | Applies CSS filter effects to captures. Ships with presets: `grayscale`, `sepia`, `blur`, `vintage`, `dramatic`. |
580
+ | `replace-text` | Transform | Find-and-replace text in the captured clone. Supports strings and regex patterns. |
581
+ | `color-tint` | Transform | Tints the entire capture to a specified color using an overlay with `mix-blend-mode`. |
582
+ | `ascii-export` | Export | Adds a `toAscii()` method that converts captures to ASCII art. Configurable width, charset, and luminance. |
583
+ | `pdf-image` | Export | Exports the capture as a PNG embedded in a downloadable PDF. Supports portrait and landscape orientations. |
584
+ | `html-in-canvas` | Export | Uses the experimental WICG `drawElementImage` API for direct DOM-to-canvas rendering where supported. |
585
+ | `prompt-export` | Export | LLM-friendly capture: adds a `toPrompt()` method that returns an annotated screenshot, structured element map with bounding boxes, and a pre-formatted text prompt. |
586
+
587
+ ### Community Plugins
588
+
589
+ Community plugins are listed on the [Plugins page](https://zumerlab.github.io/snapdom/plugins.html). To submit your plugin, open a PR adding one line to `community-plugins.md`. See [CONTRIBUTING_PLUGINS.md](CONTRIBUTING_PLUGINS.md).
590
+
591
+ ### Build a Plugin in 5 Minutes
592
+
593
+ SnapDOM's hook system gives you full control over every stage of the capture pipeline:
594
+
595
+ 1. **Clone the template** — `npx degit zumerlab/snapdom/packages/plugin-template my-plugin`
596
+ 2. **Write your hook logic** — `export function myPlugin() {}`
597
+ 3. **Get listed** — open a PR adding one line to `community-plugins.md`
598
+
599
+ See [PLUGIN_SPEC.md](PLUGIN_SPEC.md) for the full specification and [CONTRIBUTING_PLUGINS.md](CONTRIBUTING_PLUGINS.md) for submission guidelines.
600
+
601
+ ### Registering Plugins
602
+
603
+ **Global registration** (applies to all captures):
604
+
605
+ ```js
606
+ import { snapdom } from '@zumer/snapdom';
607
+
608
+ // You can register instances, factories, or [factory, options]
609
+ snapdom.plugins(
610
+ myPluginInstance,
611
+ [myPluginFactory, { optionA: true }],
612
+ { plugin: anotherFactory, options: { level: 2 } }
613
+ );
614
+ ```
615
+
616
+ **Per-capture registration** (only for that specific call):
617
+
618
+ ```js
619
+ const out = await snapdom(element, {
620
+ plugins: [
621
+ [overlayFilterPlugin, { color: 'rgba(0,0,0,0.25)' }],
622
+ [myFullPlugin, { providePdf: true }]
623
+ ]
624
+ });
625
+ ```
626
+
627
+ * **Execution order = registration order** (first registered, first executed).
628
+ * **Per-capture plugins** run **before** global ones.
629
+ * Duplicates are automatically skipped by `name`; a per-capture plugin with the same `name` overrides its global version.
630
+
631
+ ### Plugin Lifecycle Hooks
632
+
633
+ Hooks run in capture order (see [Capture Flow](#capture-flow)):
634
+
635
+ | Hook | Stage | Purpose |
636
+ |------|-------|---------|
637
+ | `beforeSnap` | Start | Adjust options before any work. |
638
+ | `beforeClone` | Pre-clone | Before DOM clone (modify live DOM carefully). |
639
+ | `afterClone` | Post-clone | Modify cloned tree safely (e.g. inject overlay). |
640
+ | `beforeRender` | Pre-serialize | Right before SVG → data URL. |
641
+ | `afterRender` | Post-serialize | Inspect `context.svgString` / `context.dataURL`. |
642
+ | `beforeExport` | Per export | Before each `toPng`, `toSvg`, etc. |
643
+ | `afterExport` | Per export | Transform returned result. |
644
+ | `afterSnap` | Once | After first export; cleanup. |
645
+ | `defineExports` | Setup | Add custom exporters (e.g. `toPdf`). |
646
+
647
+ > Returned values from `afterExport` are chained to the next plugin (transform pipeline).
648
+
649
+ ### Context Object
650
+
651
+ Every hook receives a single `context` object that contains normalized capture state:
652
+
653
+ * **Input & options:**
654
+ `element`, `debug`, `fast`, `scale`, `dpr`, `width`, `height`, `backgroundColor`, `quality`, `useProxy`, `cache`, `outerTransforms`, `outerShadows`, `safariWarmupAttempts`, `embedFonts`, `localFonts`, `iconFonts`, `excludeFonts`, `exclude`, `excludeMode`, `filter`, `filterMode`, `fallbackURL`.
655
+
656
+ * **Intermediate values (depending on stage):**
657
+ `clone`, `classCSS`, `styleCache`, `fontsCSS`, `baseCSS`, `svgString`, `dataURL`.
658
+
659
+ * **During export:**
660
+ `context.export = { type, options, url }`
661
+ where `type` is the exporter name (`"png"`, `"jpeg"`, `"svg"`, `"blob"`, etc.), and `url` is the serialized SVG base.
662
+
663
+ > 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.
664
+
665
+
666
+ ## Custom Exports via Plugins
667
+
668
+ Plugins can add new exports using `defineExports(context)`.
669
+ For each export key you return (e.g., `"pdf"`), SnapDOM automatically exposes a helper method named **`toPdf()`** on the capture result.
670
+
671
+ **Register the plugin (global or per capture):**
672
+
673
+ ```js
674
+ import { snapdom } from '@zumer/snapdom';
675
+
676
+ // global
677
+ snapdom.plugins(pdfExportPlugin());
678
+
679
+ // or per capture
680
+ const out = await snapdom(element, { plugins: [pdfExportPlugin()] });
681
+ ```
682
+
683
+ **Call the custom export:**
684
+
685
+ ```js
686
+ const out = await snapdom(document.querySelector('#report'));
687
+
688
+ // because the plugin returns { pdf: async (ctx, opts) => ... }
689
+ const pdfBlob = await out.toPdf({
690
+ // exporter-specific options (width, height, quality, filename, etc.)
691
+ });
692
+ ```
693
+
694
+ ### Example: Overlay Filter Plugin
695
+
696
+ Adds a translucent overlay or color filter **only** to the captured clone (not your live DOM).
697
+ Useful for highlighting or dimming sections before export.
698
+
699
+ ```js
700
+ /**
701
+ * Ultra-simple overlay filter for SnapDOM (HTML-only).
702
+ * Inserts a full-size <div> overlay on the cloned root.
703
+ *
704
+ * @param {{ color?: string; blur?: number }} [options]
705
+ * color: overlay color (rgba/hex/hsl). Default: 'rgba(0,0,0,0.25)'
706
+ * blur: optional blur in px (default: 0)
707
+ */
708
+ export function overlayFilterPlugin(options = {}) {
709
+ const color = options.color ?? 'rgba(0,0,0,0.25)';
710
+ const blur = Math.max(0, options.blur ?? 0);
711
+
712
+ return {
713
+ name: 'overlay-filter',
714
+
715
+ /**
716
+ * Add a full-coverage overlay to the cloned HTML root.
717
+ * @param {any} context
718
+ */
719
+ async afterClone(context) {
720
+ const root = context.clone;
721
+ if (!(root instanceof HTMLElement)) return; // HTML-only
722
+
723
+ // Ensure containing block so absolute overlay anchors to the root
724
+ if (getComputedStyle(root).position === 'static') {
725
+ root.style.position = 'relative';
726
+ }
727
+
728
+ const overlay = document.createElement('div');
729
+ overlay.style.position = 'absolute';
730
+ overlay.style.left = '0';
731
+ overlay.style.top = '0';
732
+ overlay.style.right = '0';
733
+ overlay.style.bottom = '0';
734
+ overlay.style.background = color;
735
+ overlay.style.pointerEvents = 'none';
736
+ if (blur) overlay.style.filter = `blur(${blur}px)`;
737
+
738
+ root.appendChild(overlay);
739
+ }
740
+ };
741
+ }
742
+
743
+ ```
744
+
745
+ **Usage:**
746
+
747
+ ```js
748
+ import { snapdom } from '@zumer/snapdom';
749
+
750
+ // Global registration
751
+ snapdom.plugins([overlayFilterPlugin, { color: 'rgba(0,0,0,0.3)', blur: 2 }]);
752
+
753
+ // Per-capture
754
+ const out = await snapdom(document.querySelector('#card'), {
755
+ plugins: [[overlayFilterPlugin, { color: 'rgba(255,200,0,0.15)' }]]
756
+ });
757
+
758
+ const png = await out.toPng();
759
+ document.body.appendChild(png);
760
+ ```
761
+
762
+ > The overlay is injected **only in the cloned tree**, never in your live DOM, ensuring perfect fidelity and zero flicker.
763
+
764
+
765
+ ### Full Plugin Template
766
+
767
+ Use this as a starting point for custom logic or exporters.
768
+
769
+ ```js
770
+ export function myPlugin(options = {}) {
771
+ return {
772
+ /** Unique name used for de-duplication/overrides */
773
+ name: 'my-plugin',
774
+
775
+ /** Early adjustments before any clone/style work. */
776
+ async beforeSnap(context) {},
777
+
778
+ /** Before subtree cloning (use sparingly if touching the live DOM). */
779
+ async beforeClone(context) {},
780
+
781
+ /** After subtree cloning (safe to modify the cloned tree). */
782
+ async afterClone(context) {},
783
+
784
+ /** Right before serialization (SVG/dataURL). */
785
+ async beforeRender(context) {},
786
+
787
+ /** After serialization; inspect context.svgString/context.dataURL if needed. */
788
+ async afterRender(context) {},
789
+
790
+ /** Before EACH export call (toPng/toSvg/toBlob/...). */
791
+ async beforeExport(context) {},
792
+
793
+ /**
794
+ * After EACH export call.
795
+ * If you return a value, it becomes the result for the next plugin (chaining).
796
+ */
797
+ async afterExport(context, result) { return result; },
798
+
799
+ /**
800
+ * Define custom exporters (auto-added as helpers like out.toPdf()).
801
+ * Return a map { [key: string]: (ctx:any, opts:any) => Promise<any> }.
802
+ */
803
+ async defineExports(context) { return {}; },
804
+
805
+ /** Runs ONCE after the FIRST export finishes (cleanup). */
806
+ async afterSnap(context) {}
807
+ };
808
+ }
809
+ ```
810
+
811
+ **Quick recap:**
812
+
813
+ * Plugins can modify capture behavior (`beforeSnap`, `afterClone`, etc.).
814
+ * You can inject visuals or transformations safely into the cloned tree.
815
+ * New exporters defined in `defineExports()` automatically become helpers like `out.toPdf()`.
816
+ * All hooks can be asynchronous, run in order, and share the same `context`.
817
+
818
+
819
+ ## Limitations
820
+
821
+ * External images should be CORS-accessible (use `useProxy` option for handling CORS denied)
822
+ * When WebP format is used on Safari, it will fallback to PNG rendering.
823
+ * `@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)
824
+ * **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).
825
+ * **Custom scrollbar styles** (`::-webkit-scrollbar`): Applied only when the element has *not* been scrolled. When scrolled, the viewport content is captured without the scrollbar.
826
+
827
+
828
+ ## Performance Benchmarks
829
+
830
+ **Setup.** Vitest benchmarks on Chromium, repo tests. Hardware may affect results.
831
+ Values are **average capture time (ms)** → lower is better.
832
+
833
+ ### Simple elements
834
+
835
+ | Scenario | SnapDOM current | SnapDOM v1.9.9 | html2canvas | html-to-image |
836
+ | ------------------------ | --------------- | -------------- | ----------- | ------------- |
837
+ | Small (200×100) | **0.5 ms** | 0.8 ms | 67.7 ms | 3.1 ms |
838
+ | Modal (400×300) | **0.5 ms** | 0.8 ms | 75.5 ms | 3.6 ms |
839
+ | Page View (1200×800) | **0.5 ms** | 0.8 ms | 114.2 ms | 3.3 ms |
840
+ | Large Scroll (2000×1500) | **0.5 ms** | 0.8 ms | 186.3 ms | 3.2 ms |
841
+ | Very Large (4000×2000) | **0.5 ms** | 0.9 ms | 425.9 ms | 3.3 ms |
842
+
843
+
844
+ ### Complex elements
845
+
846
+ | Scenario | SnapDOM current | SnapDOM v1.9.9 | html2canvas | html-to-image |
847
+ | ------------------------ | --------------- | -------------- | ----------- | ------------- |
848
+ | Small (200×100) | **1.6 ms** | 3.3 ms | 68.0 ms | 14.3 ms |
849
+ | Modal (400×300) | **2.9 ms** | 6.8 ms | 87.5 ms | 34.8 ms |
850
+ | Page View (1200×800) | **17.5 ms** | 50.2 ms | 178.0 ms | 429.0 ms |
851
+ | Large Scroll (2000×1500) | **54.0 ms** | 201.8 ms | 735.2 ms | 984.2 ms |
852
+ | Very Large (4000×2000) | **171.4 ms** | 453.7 ms | 1,800.4 ms | 2,611.9 ms |
853
+
854
+
855
+ ### Run the benchmarks
856
+
857
+ ```sh
858
+ git clone https://github.com/zumerlab/snapdom.git
859
+ cd snapdom
860
+ npm install
861
+ npm run test:benchmark
862
+ ```
863
+
864
+
865
+ ## Roadmap
866
+
867
+ Planned improvements for future versions of SnapDOM:
868
+
869
+ * [X] **Implement plugin system**
870
+ SnapDOM will support external plugins to extend or override internal behavior (e.g. custom node transformers, exporters, or filters).
871
+
872
+ * [ ] **Refactor to modular architecture**
873
+ Internal logic will be split into smaller, focused modules to improve maintainability and code reuse.
874
+
875
+ * [X] **Decouple internal logic from global options**
876
+ 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)
877
+
878
+ * [X] **Expose cache control**
879
+ Users will be able to manually clear image and font caches or configure their own caching strategies.
880
+
881
+ * [X] **Auto font preloading**
882
+ Required fonts will be automatically detected and preloaded before capture, reducing the need for manual `preCache()` calls.
883
+
884
+ * [X] **Document plugin development**
885
+ A full guide will be provided for creating and registering custom SnapDOM plugins.
886
+
887
+ * [ ] **Make export utilities tree-shakeable**
888
+ Export functions like `toPng`, `toJpg`, `toBlob`, etc. will be restructured into independent modules to support tree shaking and minimal builds.
889
+
890
+ Have ideas or feature requests?
891
+ Feel free to share suggestions or feedback in [GitHub Discussions](https://github.com/zumerlab/snapdom/discussions).
892
+
893
+
894
+ ## Development
895
+
896
+ **Source layout:**
897
+ - `src/api/` – Public API (`snapdom`, `preCache`)
898
+ - `src/core/` – Capture pipeline, clone, prepare, plugins
899
+ - `src/modules/` – Images, fonts, pseudo-elements, backgrounds, SVG
900
+ - `src/exporters/` – toPng, toSvg, toBlob, etc.
901
+ - `dist/` – Build output (`snapdom.js`, `snapdom.mjs`, `preCache.mjs`, `plugins.mjs`)
902
+
903
+ **Build:**
904
+ ```sh
905
+ git clone https://github.com/zumerlab/snapdom.git
906
+ cd snapdom
907
+ git checkout dev
908
+ npm install
909
+ npm run compile
910
+ ```
911
+
912
+ **Test:**
913
+ ```sh
914
+ npx playwright install # Required for browser tests
915
+ npm test
916
+ npm run test:benchmark
917
+ ```
918
+
919
+ For detailed guidelines, see [CONTRIBUTING](https://github.com/zumerlab/snapdom/blob/main/CONTRIBUTING.md).
920
+
921
+
922
+ ## Contributors
923
+
924
+ <!-- CONTRIBUTORS:START -->
925
+ <p>
926
+ <a href="https://github.com/tinchox5" title="tinchox5"><img src="https://avatars.githubusercontent.com/u/11557901?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="tinchox5"/></a>
927
+ <a href="https://github.com/Jarvis2018" title="Jarvis2018"><img src="https://avatars.githubusercontent.com/u/36788851?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="Jarvis2018"/></a>
928
+ <a href="https://github.com/tarwin" title="tarwin"><img src="https://avatars.githubusercontent.com/u/646149?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="tarwin"/></a>
929
+ <a href="https://github.com/Amyuan23" title="Amyuan23"><img src="https://avatars.githubusercontent.com/u/25892910?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="Amyuan23"/></a>
930
+ <a href="https://github.com/airamhr9" title="airamhr9"><img src="https://avatars.githubusercontent.com/u/57371081?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="airamhr9"/></a>
931
+ <a href="https://github.com/FlavioLimaMindera" title="FlavioLimaMindera"><img src="https://avatars.githubusercontent.com/u/96424442?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="FlavioLimaMindera"/></a>
932
+ <a href="https://github.com/jswhisperer" title="jswhisperer"><img src="https://avatars.githubusercontent.com/u/1177690?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="jswhisperer"/></a>
933
+ <a href="https://github.com/K1ender" title="K1ender"><img src="https://avatars.githubusercontent.com/u/146767945?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="K1ender"/></a>
934
+ <a href="https://github.com/kohaiy" title="kohaiy"><img src="https://avatars.githubusercontent.com/u/15622127?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="kohaiy"/></a>
935
+ <a href="https://github.com/17biubiu" title="17biubiu"><img src="https://avatars.githubusercontent.com/u/13295895?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="17biubiu"/></a>
936
+ <a href="https://github.com/av01d" title="av01d"><img src="https://avatars.githubusercontent.com/u/6247646?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="av01d"/></a>
937
+ <a href="https://github.com/CHOYSEN" title="CHOYSEN"><img src="https://avatars.githubusercontent.com/u/25995358?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="CHOYSEN"/></a>
938
+ <a href="https://github.com/pedrocateexte" title="pedrocateexte"><img src="https://avatars.githubusercontent.com/u/207524750?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="pedrocateexte"/></a>
939
+ <a href="https://github.com/domialex" title="domialex"><img src="https://avatars.githubusercontent.com/u/4694217?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="domialex"/></a>
940
+ <a href="https://github.com/elliots" title="elliots"><img src="https://avatars.githubusercontent.com/u/622455?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="elliots"/></a>
941
+ <a href="https://github.com/stypr" title="stypr"><img src="https://avatars.githubusercontent.com/u/6625978?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="stypr"/></a>
942
+ <a href="https://github.com/mon-jai" title="mon-jai"><img src="https://avatars.githubusercontent.com/u/91261297?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="mon-jai"/></a>
943
+ <a href="https://github.com/sharuzzaman" title="sharuzzaman"><img src="https://avatars.githubusercontent.com/u/7421941?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="sharuzzaman"/></a>
944
+ <a href="https://github.com/simon1uo" title="simon1uo"><img src="https://avatars.githubusercontent.com/u/60037549?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="simon1uo"/></a>
945
+ <a href="https://github.com/titoBouzout" title="titoBouzout"><img src="https://avatars.githubusercontent.com/u/64156?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="titoBouzout"/></a>
946
+ <a href="https://github.com/ZiuChen" title="ZiuChen"><img src="https://avatars.githubusercontent.com/u/64892985?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="ZiuChen"/></a>
947
+ <a href="https://github.com/harshasiddartha" title="harshasiddartha"><img src="https://avatars.githubusercontent.com/u/147021873?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="harshasiddartha"/></a>
948
+ <a href="https://github.com/karasHou" title="karasHou"><img src="https://avatars.githubusercontent.com/u/27048083?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="karasHou"/></a>
949
+ <a href="https://github.com/jhbae200" title="jhbae200"><img src="https://avatars.githubusercontent.com/u/20170610?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="jhbae200"/></a>
950
+ <a href="https://github.com/xiaobai-web715" title="xiaobai-web715"><img src="https://avatars.githubusercontent.com/u/81091224?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="xiaobai-web715"/></a>
951
+ <a href="https://github.com/miusuncle" title="miusuncle"><img src="https://avatars.githubusercontent.com/u/7549857?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="miusuncle"/></a>
952
+ <a href="https://github.com/rbbydotdev" title="rbbydotdev"><img src="https://avatars.githubusercontent.com/u/101137670?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="rbbydotdev"/></a>
953
+ <a href="https://github.com/zhanghaotian2018" title="zhanghaotian2018"><img src="https://avatars.githubusercontent.com/u/169218899?v=4&s=100" style="border-radius:10px; width:60px; height:60px; object-fit:cover; margin:5px;" alt="zhanghaotian2018"/></a>
954
+ </p>
955
+ <!-- CONTRIBUTORS:END -->
956
+
957
+ ## Sponsors
958
+
959
+ 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!
960
+
961
+ If you'd like to support this project too, you can [become a sponsor](https://github.com/sponsors/tinchox5).
962
+
963
+ ## Star History
964
+
965
+ [![Star History Chart](https://api.star-history.com/svg?repos=zumerlab/snapdom&type=Date)](https://www.star-history.com/#zumerlab/snapdom&Date)
966
+
967
+ ## License
968
+
969
+ MIT © Zumerlab