@zumer/snapdom-plugins 1.0.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/ascii-export.js +60 -0
- package/color-tint.js +36 -0
- package/filter.js +35 -0
- package/html-in-canvas.js +123 -0
- package/index.js +16 -0
- package/package.json +27 -0
- package/pdf-image.js +114 -0
- package/picture-resolver.js +306 -0
- package/replace-text.js +43 -0
- package/timestamp-overlay.js +64 -0
package/ascii-export.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* asciiExport - Official SnapDOM Plugin
|
|
3
|
+
* Adds a toAscii() export method that converts captures to ASCII art.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} [options]
|
|
6
|
+
* @param {number} [options.width=80] - Character width of output
|
|
7
|
+
* @param {string} [options.charset=' .:-=+*#%@'] - Characters from lightest to darkest
|
|
8
|
+
* @param {boolean} [options.invert=false] - Invert luminance mapping
|
|
9
|
+
* @returns {Object} SnapDOM plugin
|
|
10
|
+
*/
|
|
11
|
+
export function asciiExport(options = {}) {
|
|
12
|
+
const {
|
|
13
|
+
width: charWidth = 80,
|
|
14
|
+
charset = ' .:-=+*#%@',
|
|
15
|
+
invert = false,
|
|
16
|
+
} = options;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
name: 'ascii-export',
|
|
20
|
+
|
|
21
|
+
defineExports() {
|
|
22
|
+
return {
|
|
23
|
+
ascii: async (ctx, opts = {}) => {
|
|
24
|
+
const cols = opts.width || charWidth;
|
|
25
|
+
const chars = opts.charset || charset;
|
|
26
|
+
const inv = opts.invert ?? invert;
|
|
27
|
+
|
|
28
|
+
const img = new Image();
|
|
29
|
+
img.src = ctx.export.url;
|
|
30
|
+
await new Promise((res, rej) => {
|
|
31
|
+
img.onload = res;
|
|
32
|
+
img.onerror = rej;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const aspect = img.height / img.width;
|
|
36
|
+
const rows = Math.round(cols * aspect * 0.5);
|
|
37
|
+
const canvas = document.createElement('canvas');
|
|
38
|
+
canvas.width = cols;
|
|
39
|
+
canvas.height = rows;
|
|
40
|
+
const c = canvas.getContext('2d');
|
|
41
|
+
c.drawImage(img, 0, 0, cols, rows);
|
|
42
|
+
const data = c.getImageData(0, 0, cols, rows).data;
|
|
43
|
+
|
|
44
|
+
let ascii = '';
|
|
45
|
+
for (let y = 0; y < rows; y++) {
|
|
46
|
+
for (let x = 0; x < cols; x++) {
|
|
47
|
+
const i = (y * cols + x) * 4;
|
|
48
|
+
let lum = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) / 255;
|
|
49
|
+
if (inv) lum = 1 - lum;
|
|
50
|
+
const ci = Math.min(Math.floor(lum * chars.length), chars.length - 1);
|
|
51
|
+
ascii += chars[ci];
|
|
52
|
+
}
|
|
53
|
+
ascii += '\n';
|
|
54
|
+
}
|
|
55
|
+
return ascii;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
package/color-tint.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* colorTint - Official SnapDOM Plugin
|
|
3
|
+
* Tints the entire capture to a specified color using an overlay
|
|
4
|
+
* with mix-blend-mode, following the same pattern as the overlay plugin.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} [options]
|
|
7
|
+
* @param {string} [options.color='red'] - CSS color value
|
|
8
|
+
* @param {number} [options.opacity=1] - Overlay opacity (0-1)
|
|
9
|
+
* @returns {Object} SnapDOM plugin
|
|
10
|
+
*/
|
|
11
|
+
export function colorTint(options = {}) {
|
|
12
|
+
const {
|
|
13
|
+
color = 'red',
|
|
14
|
+
opacity = 1,
|
|
15
|
+
} = options;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
name: 'color-tint',
|
|
19
|
+
|
|
20
|
+
async afterClone(context) {
|
|
21
|
+
const root = context.clone;
|
|
22
|
+
if (!root || !(root instanceof HTMLElement)) return;
|
|
23
|
+
root.style.position = 'relative';
|
|
24
|
+
root.style.overflow = 'hidden';
|
|
25
|
+
const overlay = document.createElement('div');
|
|
26
|
+
overlay.style.position = 'absolute';
|
|
27
|
+
overlay.style.inset = '0';
|
|
28
|
+
overlay.style.backgroundColor = color;
|
|
29
|
+
overlay.style.opacity = String(opacity);
|
|
30
|
+
overlay.style.mixBlendMode = 'color';
|
|
31
|
+
overlay.style.pointerEvents = 'none';
|
|
32
|
+
overlay.style.zIndex = '999999';
|
|
33
|
+
root.appendChild(overlay);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
package/filter.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* filter - Official SnapDOM Plugin
|
|
3
|
+
* Applies CSS filter effects to the captured clone.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} [options]
|
|
6
|
+
* @param {string} [options.filter=''] - CSS filter string, e.g. 'grayscale(1) blur(2px)'
|
|
7
|
+
* @param {string} [options.preset] - 'grayscale' | 'sepia' | 'blur' | 'invert' | 'vintage' | 'dramatic'
|
|
8
|
+
* @returns {Object} SnapDOM plugin
|
|
9
|
+
*/
|
|
10
|
+
export function filter(options = {}) {
|
|
11
|
+
const presets = {
|
|
12
|
+
grayscale: 'grayscale(1)',
|
|
13
|
+
sepia: 'sepia(1)',
|
|
14
|
+
blur: 'blur(2px)',
|
|
15
|
+
invert: 'invert(1)',
|
|
16
|
+
vintage: 'sepia(0.4) contrast(1.1) brightness(0.9) saturate(0.8)',
|
|
17
|
+
dramatic: 'contrast(1.4) brightness(0.85) saturate(1.3)',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
filter: filterValue = '',
|
|
22
|
+
preset,
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
const cssFilter = preset ? (presets[preset] || '') : filterValue;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
name: 'filter',
|
|
29
|
+
|
|
30
|
+
afterClone(ctx) {
|
|
31
|
+
if (!cssFilter) return;
|
|
32
|
+
ctx.clone.style.filter = cssFilter;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Experimental plugin for WICG html-in-canvas (issue #172).
|
|
3
|
+
* Uses drawElementImage() to render the snapdom clone directly into canvas,
|
|
4
|
+
* bypassing SVG/foreignObject.
|
|
5
|
+
*
|
|
6
|
+
* Requires: Chrome with chrome://flags/#canvas-draw-element enabled.
|
|
7
|
+
* @see https://github.com/WICG/html-in-canvas
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PLUGIN_NAME = 'html-in-canvas'
|
|
11
|
+
|
|
12
|
+
function isDrawElementImageAvailable() {
|
|
13
|
+
try {
|
|
14
|
+
const c = document.createElement('canvas')
|
|
15
|
+
const ctx = c.getContext('2d')
|
|
16
|
+
return ctx && typeof ctx.drawElementImage === 'function'
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @returns {import('../../src/core/plugins.js').Plugin}
|
|
24
|
+
*/
|
|
25
|
+
export function htmlInCanvasPlugin() {
|
|
26
|
+
const available = isDrawElementImageAvailable()
|
|
27
|
+
if (!available) {
|
|
28
|
+
console.warn('[snapdom] html-in-canvas plugin: drawElementImage not available. Enable chrome://flags/#canvas-draw-element')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: PLUGIN_NAME,
|
|
33
|
+
|
|
34
|
+
beforeRender(state) {
|
|
35
|
+
if (!available) return
|
|
36
|
+
if (!state.clone || !state.element) return
|
|
37
|
+
state.options.__htmlInCanvas = {
|
|
38
|
+
clone: state.clone,
|
|
39
|
+
baseCSS: state.baseCSS || '',
|
|
40
|
+
fontsCSS: state.fontsCSS || '',
|
|
41
|
+
classCSS: state.classCSS || '',
|
|
42
|
+
element: state.element,
|
|
43
|
+
w0: null,
|
|
44
|
+
h0: null
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
afterRender(state) {
|
|
49
|
+
if (!available) return
|
|
50
|
+
const meta = state.options?.meta
|
|
51
|
+
const stored = state.options?.__htmlInCanvas
|
|
52
|
+
if (meta && stored) {
|
|
53
|
+
stored.w0 = meta.w0
|
|
54
|
+
stored.h0 = meta.h0
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async defineExports(ctx) {
|
|
59
|
+
if (!available) return {}
|
|
60
|
+
const stored = ctx.__htmlInCanvas
|
|
61
|
+
if (!stored) return {}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
htmlInCanvas: async (opts = {}) => {
|
|
65
|
+
const { clone, baseCSS, fontsCSS, classCSS, element } = stored
|
|
66
|
+
const w0 = stored.w0 ?? element?.offsetWidth
|
|
67
|
+
const h0 = stored.h0 ?? element?.offsetHeight
|
|
68
|
+
const scale = opts.scale ?? ctx.scale ?? 1
|
|
69
|
+
const dpr = opts.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1)
|
|
70
|
+
|
|
71
|
+
const rect = element?.getBoundingClientRect?.()
|
|
72
|
+
const width = w0 ?? rect?.width ?? 100
|
|
73
|
+
const height = h0 ?? rect?.height ?? 100
|
|
74
|
+
const outW = Math.round(width * scale * dpr)
|
|
75
|
+
const outH = Math.round(height * scale * dpr)
|
|
76
|
+
|
|
77
|
+
const canvas = document.createElement('canvas')
|
|
78
|
+
canvas.width = outW
|
|
79
|
+
canvas.height = outH
|
|
80
|
+
canvas.setAttribute('layoutsubtree', '')
|
|
81
|
+
|
|
82
|
+
const wrapper = document.createElement('div')
|
|
83
|
+
wrapper.style.cssText = `width:${width}px;height:${height}px;overflow:visible;box-sizing:border-box;`
|
|
84
|
+
wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
|
|
85
|
+
|
|
86
|
+
const styleTag = document.createElement('style')
|
|
87
|
+
styleTag.textContent = `${baseCSS}${fontsCSS}svg{overflow:visible;}${classCSS}`
|
|
88
|
+
wrapper.appendChild(styleTag)
|
|
89
|
+
|
|
90
|
+
const cloneCopy = clone.cloneNode(true)
|
|
91
|
+
wrapper.appendChild(cloneCopy)
|
|
92
|
+
|
|
93
|
+
canvas.appendChild(wrapper)
|
|
94
|
+
|
|
95
|
+
const container = document.createElement('div')
|
|
96
|
+
container.id = 'snapdom-html-in-canvas-temp'
|
|
97
|
+
container.style.cssText = 'position:fixed;left:-9999px;top:0;visibility:hidden;'
|
|
98
|
+
container.appendChild(canvas)
|
|
99
|
+
document.body.appendChild(container)
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await new Promise(r => requestAnimationFrame(r))
|
|
103
|
+
const ctx2d = canvas.getContext('2d')
|
|
104
|
+
if (!ctx2d || typeof ctx2d.drawElementImage !== 'function') {
|
|
105
|
+
throw new Error('drawElementImage not available')
|
|
106
|
+
}
|
|
107
|
+
ctx2d.save()
|
|
108
|
+
ctx2d.scale(dpr * scale, dpr * scale)
|
|
109
|
+
ctx2d.drawElementImage(wrapper, 0, 0, width, height)
|
|
110
|
+
ctx2d.restore()
|
|
111
|
+
return canvas
|
|
112
|
+
} finally {
|
|
113
|
+
try {
|
|
114
|
+
document.body.removeChild(container)
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default htmlInCanvasPlugin
|
package/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zumer/snapdom-plugins
|
|
3
|
+
* Official plugins for SnapDOM.
|
|
4
|
+
*
|
|
5
|
+
* Individual: import { filter } from '@zumer/snapdom-plugins/filter';
|
|
6
|
+
* All: import { filter, asciiExport } from '@zumer/snapdom-plugins';
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { timestampOverlay } from './timestamp-overlay.js';
|
|
10
|
+
export { asciiExport } from './ascii-export.js';
|
|
11
|
+
export { replaceText } from './replace-text.js';
|
|
12
|
+
export { filter } from './filter.js';
|
|
13
|
+
export { colorTint } from './color-tint.js';
|
|
14
|
+
export { pictureResolver } from './picture-resolver.js';
|
|
15
|
+
export { pdfImage } from './pdf-image.js';
|
|
16
|
+
// export { htmlInCanvas } from './html-in-canvas.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zumer/snapdom-plugins",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Official plugins for SnapDOM",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./timestamp-overlay": "./timestamp-overlay.js",
|
|
9
|
+
"./ascii-export": "./ascii-export.js",
|
|
10
|
+
"./replace-text": "./replace-text.js",
|
|
11
|
+
"./filter": "./filter.js",
|
|
12
|
+
"./color-tint": "./color-tint.js",
|
|
13
|
+
"./picture-resolver": "./picture-resolver.js",
|
|
14
|
+
"./html-in-canvas": "./html-in-canvas.js",
|
|
15
|
+
"./pdf-image": "./pdf-image.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"snapdom",
|
|
19
|
+
"snapdom-plugin",
|
|
20
|
+
"dom-capture",
|
|
21
|
+
"plugins"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@zumer/snapdom": ">=0.9.0"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT"
|
|
27
|
+
}
|
package/pdf-image.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pdfImage - Official SnapDOM Plugin
|
|
3
|
+
* Exports the capture as a PNG embedded in a downloadable PDF.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} [options]
|
|
6
|
+
* @param {string} [options.orientation='portrait'] - 'portrait' | 'landscape'
|
|
7
|
+
* @param {number} [options.quality=0.92] - JPEG quality (0-1)
|
|
8
|
+
* @param {string} [options.filename='capture.pdf'] - Download filename
|
|
9
|
+
* @returns {Object} SnapDOM plugin
|
|
10
|
+
*/
|
|
11
|
+
export function pdfImage(options = {}) {
|
|
12
|
+
const {
|
|
13
|
+
orientation = 'portrait',
|
|
14
|
+
quality = 0.92,
|
|
15
|
+
filename = 'capture.pdf',
|
|
16
|
+
} = options;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
name: 'pdf-image',
|
|
20
|
+
|
|
21
|
+
defineExports() {
|
|
22
|
+
return {
|
|
23
|
+
pdfImage: async (ctx) => {
|
|
24
|
+
const img = new Image();
|
|
25
|
+
img.src = ctx.export.url;
|
|
26
|
+
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
|
|
27
|
+
|
|
28
|
+
const isLandscape = orientation === 'landscape';
|
|
29
|
+
const pageW = isLandscape ? 841.89 : 595.28; // A4 in points
|
|
30
|
+
const pageH = isLandscape ? 595.28 : 841.89;
|
|
31
|
+
const margin = 40;
|
|
32
|
+
const maxW = pageW - margin * 2;
|
|
33
|
+
const maxH = pageH - margin * 2;
|
|
34
|
+
const scale = Math.min(maxW / img.width, maxH / img.height, 1);
|
|
35
|
+
const w = img.width * scale;
|
|
36
|
+
const h = img.height * scale;
|
|
37
|
+
const x = (pageW - w) / 2;
|
|
38
|
+
const y = (pageH - h) / 2;
|
|
39
|
+
|
|
40
|
+
// Get image as JPEG data
|
|
41
|
+
const canvas = document.createElement('canvas');
|
|
42
|
+
canvas.width = img.width;
|
|
43
|
+
canvas.height = img.height;
|
|
44
|
+
canvas.getContext('2d').drawImage(img, 0, 0);
|
|
45
|
+
const jpegDataUrl = canvas.toDataURL('image/jpeg', quality);
|
|
46
|
+
const jpegBase64 = jpegDataUrl.split(',')[1];
|
|
47
|
+
const jpegBytes = atob(jpegBase64);
|
|
48
|
+
const jpegLen = jpegBytes.length;
|
|
49
|
+
|
|
50
|
+
const enc = new TextEncoder();
|
|
51
|
+
const realOffsets = [];
|
|
52
|
+
|
|
53
|
+
// Obj 1-3
|
|
54
|
+
let textPart = '%PDF-1.4\n';
|
|
55
|
+
realOffsets.push(textPart.length);
|
|
56
|
+
textPart += '1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n';
|
|
57
|
+
realOffsets.push(textPart.length);
|
|
58
|
+
textPart += '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n';
|
|
59
|
+
realOffsets.push(textPart.length);
|
|
60
|
+
textPart += `3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents 5 0 R /Resources << /XObject << /Img 4 0 R >> >> >>\nendobj\n`;
|
|
61
|
+
|
|
62
|
+
// Obj 4 — image with binary JPEG stream
|
|
63
|
+
realOffsets.push(textPart.length);
|
|
64
|
+
const imgHeader = `4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${img.width} /Height ${img.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpegLen} >>\nstream\n`;
|
|
65
|
+
|
|
66
|
+
const imgHeaderBytes = enc.encode(imgHeader);
|
|
67
|
+
const preImgBytes = enc.encode(textPart);
|
|
68
|
+
|
|
69
|
+
const jpegBuf = new Uint8Array(jpegLen);
|
|
70
|
+
for (let i = 0; i < jpegLen; i++) jpegBuf[i] = jpegBytes.charCodeAt(i);
|
|
71
|
+
|
|
72
|
+
const afterImg = '\nendstream\nendobj\n';
|
|
73
|
+
const afterImgBytes = enc.encode(afterImg);
|
|
74
|
+
|
|
75
|
+
// Obj 5 — content stream
|
|
76
|
+
const contentStr = `q ${w.toFixed(2)} 0 0 ${h.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm /Img Do Q`;
|
|
77
|
+
const obj5 = `5 0 obj\n<< /Length ${contentStr.length} >>\nstream\n${contentStr}\nendstream\nendobj\n`;
|
|
78
|
+
const obj5Bytes = enc.encode(obj5);
|
|
79
|
+
|
|
80
|
+
realOffsets.push(preImgBytes.length + imgHeaderBytes.length + jpegLen + afterImgBytes.length);
|
|
81
|
+
|
|
82
|
+
// xref table
|
|
83
|
+
const xrefOffset = preImgBytes.length + imgHeaderBytes.length + jpegLen + afterImgBytes.length + obj5Bytes.length;
|
|
84
|
+
let xref = 'xref\n0 6\n0000000000 65535 f \n';
|
|
85
|
+
for (let i = 0; i < 5; i++) {
|
|
86
|
+
xref += `${String(realOffsets[i]).padStart(10, '0')} 00000 n \n`;
|
|
87
|
+
}
|
|
88
|
+
xref += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
|
89
|
+
const xrefBytes = enc.encode(xref);
|
|
90
|
+
|
|
91
|
+
// Combine all parts
|
|
92
|
+
const totalLen = preImgBytes.length + imgHeaderBytes.length + jpegLen + afterImgBytes.length + obj5Bytes.length + xrefBytes.length;
|
|
93
|
+
const pdfBuf = new Uint8Array(totalLen);
|
|
94
|
+
let pos = 0;
|
|
95
|
+
pdfBuf.set(preImgBytes, pos); pos += preImgBytes.length;
|
|
96
|
+
pdfBuf.set(imgHeaderBytes, pos); pos += imgHeaderBytes.length;
|
|
97
|
+
pdfBuf.set(jpegBuf, pos); pos += jpegLen;
|
|
98
|
+
pdfBuf.set(afterImgBytes, pos); pos += afterImgBytes.length;
|
|
99
|
+
pdfBuf.set(obj5Bytes, pos); pos += obj5Bytes.length;
|
|
100
|
+
pdfBuf.set(xrefBytes, pos);
|
|
101
|
+
|
|
102
|
+
const blob = new Blob([pdfBuf], { type: 'application/pdf' });
|
|
103
|
+
const url = URL.createObjectURL(blob);
|
|
104
|
+
const a = document.createElement('a');
|
|
105
|
+
a.href = url;
|
|
106
|
+
a.download = filename;
|
|
107
|
+
a.click();
|
|
108
|
+
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
109
|
+
return url;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pictureResolver - Official SnapDOM Plugin
|
|
3
|
+
*
|
|
4
|
+
* Resolves lazy-loaded <picture> elements that use base64/low-quality placeholders
|
|
5
|
+
* in <img src> while the real image lives in <source srcset>.
|
|
6
|
+
*
|
|
7
|
+
* Common pattern in news sites (La Nación, Clarín, NYT, Medium, etc.):
|
|
8
|
+
* <picture>
|
|
9
|
+
* <source srcset="https://cdn.example.com/real-image.jpg" media="...">
|
|
10
|
+
* <img src="data:image/jpeg;base64,/9j/4AAQ..." alt="..."> ← tiny placeholder
|
|
11
|
+
* </picture>
|
|
12
|
+
*
|
|
13
|
+
* The browser renders the real image visually, but DOM cloning captures the
|
|
14
|
+
* placeholder because cloneNode copies the HTML attribute, not the rendered source.
|
|
15
|
+
*
|
|
16
|
+
* This plugin:
|
|
17
|
+
* 1. Detects <picture> elements with base64/blob placeholder <img src>
|
|
18
|
+
* 2. Resolves the real URL from <source srcset> or img.currentSrc
|
|
19
|
+
* 3. Fetches the real image using the browser's context (correct Referer/cookies)
|
|
20
|
+
* 4. Replaces img src with the fetched data URL (attribute + property)
|
|
21
|
+
* 5. Removes <source> elements to prevent re-resolution during clone
|
|
22
|
+
* 6. Restores original DOM after cloning (zero side effects)
|
|
23
|
+
*
|
|
24
|
+
* Also handles common lazy-loading patterns:
|
|
25
|
+
* - data-src / data-srcset attributes (lazysizes, vanilla-lazyload, etc.)
|
|
26
|
+
* - loading="lazy" images that haven't entered viewport yet
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} [options]
|
|
29
|
+
* @param {number} [options.timeout=5000] Max ms per image fetch
|
|
30
|
+
* @param {number} [options.concurrency=4] Max parallel fetches
|
|
31
|
+
* @param {boolean} [options.resolveLazySrc=true] Also resolve data-src/data-srcset patterns
|
|
32
|
+
* @param {boolean} [options.silent=false] Suppress console warnings
|
|
33
|
+
* @param {string} [options.useProxy=''] CORS proxy for cross-origin images
|
|
34
|
+
* @returns {import('../../src/core/plugins.js').Plugin}
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* // Per-capture usage
|
|
38
|
+
* const result = await snapdom(element, {
|
|
39
|
+
* plugins: [pictureResolver()]
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* // Global registration
|
|
44
|
+
* snapdom.plugins(pictureResolver({ timeout: 3000 }));
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // With proxy for cross-origin images
|
|
48
|
+
* const result = await snapdom(element, {
|
|
49
|
+
* plugins: [pictureResolver({ useProxy: 'https://corsproxy.io/?' })]
|
|
50
|
+
* });
|
|
51
|
+
*/
|
|
52
|
+
export function pictureResolver(options = {}) {
|
|
53
|
+
const {
|
|
54
|
+
timeout = 5000,
|
|
55
|
+
concurrency = 4,
|
|
56
|
+
resolveLazySrc = true,
|
|
57
|
+
silent = false,
|
|
58
|
+
useProxy = '',
|
|
59
|
+
} = options
|
|
60
|
+
|
|
61
|
+
/** @type {Array<() => void>} */
|
|
62
|
+
let undoStack = []
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a src string is a placeholder (base64, blob, empty, tiny SVG placeholder, etc.)
|
|
66
|
+
* @param {string} src
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function isPlaceholder(src) {
|
|
70
|
+
if (!src) return true
|
|
71
|
+
if (src.startsWith('data:')) return true
|
|
72
|
+
if (src.startsWith('blob:')) return true
|
|
73
|
+
// Common 1x1 transparent pixel patterns
|
|
74
|
+
if (/^data:image\/(gif|png|svg)/.test(src) && src.length < 200) return true
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find the best real URL for an image inside a <picture>.
|
|
80
|
+
* Priority: img.currentSrc > <source srcset> matching viewport > first <source srcset>
|
|
81
|
+
* @param {HTMLImageElement} img
|
|
82
|
+
* @param {HTMLPictureElement} picture
|
|
83
|
+
* @returns {string|null}
|
|
84
|
+
*/
|
|
85
|
+
function findRealUrl(img, picture) {
|
|
86
|
+
// 1) currentSrc is the browser's resolved choice (best match for viewport)
|
|
87
|
+
const current = img.currentSrc || ''
|
|
88
|
+
if (current && !isPlaceholder(current)) return current
|
|
89
|
+
|
|
90
|
+
// 2) Walk <source> elements; prefer one whose media query matches
|
|
91
|
+
const sources = picture.querySelectorAll('source[srcset]')
|
|
92
|
+
let fallback = null
|
|
93
|
+
for (const source of sources) {
|
|
94
|
+
const srcset = source.getAttribute('srcset')
|
|
95
|
+
if (!srcset || isPlaceholder(srcset)) continue
|
|
96
|
+
|
|
97
|
+
const media = source.getAttribute('media')
|
|
98
|
+
if (media) {
|
|
99
|
+
try {
|
|
100
|
+
if (window.matchMedia(media).matches) {
|
|
101
|
+
// Best candidate: matches current viewport
|
|
102
|
+
return srcset.split(',')[0].trim().split(/\s+/)[0]
|
|
103
|
+
}
|
|
104
|
+
} catch { /* invalid media query, skip */ }
|
|
105
|
+
}
|
|
106
|
+
// Keep first valid source as fallback
|
|
107
|
+
if (!fallback) fallback = srcset.split(',')[0].trim().split(/\s+/)[0]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return fallback
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find lazy-load URLs from data-* attributes.
|
|
115
|
+
* Supports: data-src, data-srcset, data-lazy-src, data-original
|
|
116
|
+
* @param {HTMLImageElement} img
|
|
117
|
+
* @returns {string|null}
|
|
118
|
+
*/
|
|
119
|
+
function findLazySrc(img) {
|
|
120
|
+
const candidates = [
|
|
121
|
+
img.getAttribute('data-src'),
|
|
122
|
+
img.getAttribute('data-lazy-src'),
|
|
123
|
+
img.getAttribute('data-original'),
|
|
124
|
+
img.getAttribute('data-hi-res-src'),
|
|
125
|
+
]
|
|
126
|
+
for (const c of candidates) {
|
|
127
|
+
if (c && !isPlaceholder(c)) return c
|
|
128
|
+
}
|
|
129
|
+
// data-srcset: take first candidate
|
|
130
|
+
const dataSrcset = img.getAttribute('data-srcset') || img.getAttribute('data-lazy-srcset')
|
|
131
|
+
if (dataSrcset) {
|
|
132
|
+
const first = dataSrcset.split(',')[0].trim().split(/\s+/)[0]
|
|
133
|
+
if (first && !isPlaceholder(first)) return first
|
|
134
|
+
}
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fetch an image URL using the browser's context (sends correct Referer/cookies).
|
|
140
|
+
* Falls back to proxy if direct fetch fails.
|
|
141
|
+
* @param {string} url
|
|
142
|
+
* @returns {Promise<string|null>} data URL or null on failure
|
|
143
|
+
*/
|
|
144
|
+
async function fetchAsDataUrl(url) {
|
|
145
|
+
const controller = new AbortController()
|
|
146
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
// Try direct fetch first (browser sends Referer/cookies)
|
|
150
|
+
let resp = await fetch(url, {
|
|
151
|
+
credentials: 'include',
|
|
152
|
+
signal: controller.signal,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// If blocked, try proxy
|
|
156
|
+
if (!resp.ok && useProxy) {
|
|
157
|
+
const proxyUrl = useProxy.includes('{url}')
|
|
158
|
+
? useProxy.replace('{url}', encodeURIComponent(url))
|
|
159
|
+
: useProxy.endsWith('?')
|
|
160
|
+
? `${useProxy}${encodeURIComponent(url)}`
|
|
161
|
+
: `${useProxy}${useProxy.includes('?') ? '&' : '?'}url=${encodeURIComponent(url)}`
|
|
162
|
+
resp = await fetch(proxyUrl, { signal: controller.signal })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!resp.ok) return null
|
|
166
|
+
|
|
167
|
+
const blob = await resp.blob()
|
|
168
|
+
return await new Promise((resolve, reject) => {
|
|
169
|
+
const fr = new FileReader()
|
|
170
|
+
fr.onload = () => resolve(/** @type {string} */ (fr.result))
|
|
171
|
+
fr.onerror = reject
|
|
172
|
+
fr.readAsDataURL(blob)
|
|
173
|
+
})
|
|
174
|
+
} catch {
|
|
175
|
+
return null
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Process a batch of tasks with limited concurrency.
|
|
183
|
+
* @param {Array<() => Promise<void>>} tasks
|
|
184
|
+
*/
|
|
185
|
+
async function runBatched(tasks) {
|
|
186
|
+
for (let i = 0; i < tasks.length; i += concurrency) {
|
|
187
|
+
const batch = tasks.slice(i, i + concurrency)
|
|
188
|
+
await Promise.allSettled(batch.map(fn => fn()))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
name: 'picture-resolver',
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Before cloning: resolve placeholders to real images in the live DOM.
|
|
197
|
+
* Stores undo functions to restore original state after clone.
|
|
198
|
+
*/
|
|
199
|
+
async beforeClone(ctx) {
|
|
200
|
+
const root = ctx.element
|
|
201
|
+
if (!root || !(root instanceof Element)) return
|
|
202
|
+
|
|
203
|
+
undoStack = []
|
|
204
|
+
const tasks = []
|
|
205
|
+
|
|
206
|
+
// ── 1) <picture> with placeholder <img src> ──
|
|
207
|
+
const pictures = root.querySelectorAll('picture')
|
|
208
|
+
for (const picture of pictures) {
|
|
209
|
+
const img = picture.querySelector('img')
|
|
210
|
+
if (!img) continue
|
|
211
|
+
|
|
212
|
+
const originalSrc = img.getAttribute('src') || ''
|
|
213
|
+
if (!isPlaceholder(originalSrc)) continue
|
|
214
|
+
|
|
215
|
+
const realUrl = findRealUrl(img, picture)
|
|
216
|
+
if (!realUrl) continue
|
|
217
|
+
|
|
218
|
+
tasks.push(async () => {
|
|
219
|
+
const dataUrl = await fetchAsDataUrl(realUrl)
|
|
220
|
+
if (!dataUrl) {
|
|
221
|
+
if (!silent) console.warn(`[snapdom:picture-resolver] Failed to fetch: ${realUrl.slice(0, 60)}`)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Save original state for undo
|
|
226
|
+
const origAttr = img.getAttribute('src')
|
|
227
|
+
const origSrcset = img.getAttribute('srcset')
|
|
228
|
+
const origSizes = img.getAttribute('sizes')
|
|
229
|
+
const removedSources = []
|
|
230
|
+
|
|
231
|
+
// Swap to real image
|
|
232
|
+
img.src = dataUrl
|
|
233
|
+
img.setAttribute('src', dataUrl)
|
|
234
|
+
img.removeAttribute('srcset')
|
|
235
|
+
img.removeAttribute('sizes')
|
|
236
|
+
|
|
237
|
+
// Remove <source> elements to prevent clone re-resolution
|
|
238
|
+
const sources = picture.querySelectorAll('source')
|
|
239
|
+
for (const s of sources) {
|
|
240
|
+
removedSources.push({ el: s, parent: s.parentElement, next: s.nextSibling })
|
|
241
|
+
s.remove()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Register undo
|
|
245
|
+
undoStack.push(() => {
|
|
246
|
+
if (origAttr !== null) img.setAttribute('src', origAttr)
|
|
247
|
+
else img.removeAttribute('src')
|
|
248
|
+
if (origSrcset !== null) img.setAttribute('srcset', origSrcset)
|
|
249
|
+
if (origSizes !== null) img.setAttribute('sizes', origSizes)
|
|
250
|
+
for (const { el, parent, next } of removedSources) {
|
|
251
|
+
if (parent) parent.insertBefore(el, next)
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── 2) Lazy-loaded <img> with data-src / data-srcset ──
|
|
258
|
+
if (resolveLazySrc) {
|
|
259
|
+
const imgs = root.querySelectorAll('img')
|
|
260
|
+
for (const img of imgs) {
|
|
261
|
+
// Skip images already handled by <picture> pass
|
|
262
|
+
if (img.closest('picture') && isPlaceholder(img.getAttribute('src') || '')) continue
|
|
263
|
+
|
|
264
|
+
const currentSrc = img.getAttribute('src') || ''
|
|
265
|
+
const lazySrc = findLazySrc(img)
|
|
266
|
+
|
|
267
|
+
// Case A: has data-src but current src is placeholder
|
|
268
|
+
if (lazySrc && isPlaceholder(currentSrc)) {
|
|
269
|
+
tasks.push(async () => {
|
|
270
|
+
const dataUrl = await fetchAsDataUrl(lazySrc)
|
|
271
|
+
if (!dataUrl) return
|
|
272
|
+
|
|
273
|
+
const origSrc = img.getAttribute('src')
|
|
274
|
+
img.src = dataUrl
|
|
275
|
+
img.setAttribute('src', dataUrl)
|
|
276
|
+
img.removeAttribute('srcset')
|
|
277
|
+
img.removeAttribute('sizes')
|
|
278
|
+
|
|
279
|
+
undoStack.push(() => {
|
|
280
|
+
if (origSrc !== null) img.setAttribute('src', origSrc)
|
|
281
|
+
else img.removeAttribute('src')
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Run all fetches with concurrency limit
|
|
289
|
+
if (tasks.length > 0) {
|
|
290
|
+
await runBatched(tasks)
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* After cloning: restore original DOM (undo all mutations).
|
|
296
|
+
*/
|
|
297
|
+
async afterClone(ctx) {
|
|
298
|
+
for (const undo of undoStack) {
|
|
299
|
+
try { undo() } catch { /* non-blocking */ }
|
|
300
|
+
}
|
|
301
|
+
undoStack = []
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export default pictureResolver
|
package/replace-text.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* replaceText - Official SnapDOM Plugin
|
|
3
|
+
* Find-and-replace text in the captured clone.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} [options]
|
|
6
|
+
* @param {Array<{find: string|RegExp, replace: string}>} [options.replacements=[]]
|
|
7
|
+
* @returns {Object} SnapDOM plugin
|
|
8
|
+
*/
|
|
9
|
+
export function replaceText(options = {}) {
|
|
10
|
+
const {
|
|
11
|
+
replacements = [],
|
|
12
|
+
} = options;
|
|
13
|
+
|
|
14
|
+
function walkTextNodes(node, fn) {
|
|
15
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
16
|
+
fn(node);
|
|
17
|
+
} else {
|
|
18
|
+
for (const child of node.childNodes) {
|
|
19
|
+
walkTextNodes(child, fn);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
name: 'replace-text',
|
|
26
|
+
|
|
27
|
+
afterClone(ctx) {
|
|
28
|
+
if (!replacements.length) return;
|
|
29
|
+
|
|
30
|
+
walkTextNodes(ctx.clone, (textNode) => {
|
|
31
|
+
let text = textNode.textContent;
|
|
32
|
+
for (const { find, replace } of replacements) {
|
|
33
|
+
if (find instanceof RegExp) {
|
|
34
|
+
text = text.replace(find, replace);
|
|
35
|
+
} else {
|
|
36
|
+
text = text.split(find).join(replace);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
textNode.textContent = text;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timestampOverlay - Official SnapDOM Plugin
|
|
3
|
+
* Adds a translucent timestamp label to the captured clone.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} [options]
|
|
6
|
+
* @param {string} [options.format='datetime'] - 'datetime' | 'date' | 'time' | 'iso' | custom function
|
|
7
|
+
* @param {string} [options.position='bottom-right'] - 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
|
8
|
+
* @param {string} [options.background='rgba(0,0,0,0.6)']
|
|
9
|
+
* @param {string} [options.color='#fff']
|
|
10
|
+
* @param {number} [options.fontSize=11]
|
|
11
|
+
* @returns {Object} SnapDOM plugin
|
|
12
|
+
*/
|
|
13
|
+
export function timestampOverlay(options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
format = 'datetime',
|
|
16
|
+
position = 'bottom-right',
|
|
17
|
+
background = 'rgba(0,0,0,0.6)',
|
|
18
|
+
color = '#fff',
|
|
19
|
+
fontSize = 11,
|
|
20
|
+
} = options;
|
|
21
|
+
|
|
22
|
+
function getTimestamp() {
|
|
23
|
+
const now = new Date();
|
|
24
|
+
if (typeof format === 'function') return format(now);
|
|
25
|
+
switch (format) {
|
|
26
|
+
case 'date': return now.toLocaleDateString();
|
|
27
|
+
case 'time': return now.toLocaleTimeString();
|
|
28
|
+
case 'iso': return now.toISOString();
|
|
29
|
+
case 'datetime':
|
|
30
|
+
default: return now.toLocaleString();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const posMap = {
|
|
35
|
+
'top-left': 'top:6px;left:6px',
|
|
36
|
+
'top-right': 'top:6px;right:6px',
|
|
37
|
+
'bottom-left': 'bottom:6px;left:6px',
|
|
38
|
+
'bottom-right': 'bottom:6px;right:6px',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name: 'timestamp-overlay',
|
|
43
|
+
|
|
44
|
+
afterClone(ctx) {
|
|
45
|
+
const label = document.createElement('div');
|
|
46
|
+
label.textContent = getTimestamp();
|
|
47
|
+
label.style.cssText = `
|
|
48
|
+
position:absolute;
|
|
49
|
+
${posMap[position] || posMap['bottom-right']};
|
|
50
|
+
background:${background};
|
|
51
|
+
color:${color};
|
|
52
|
+
font-size:${fontSize}px;
|
|
53
|
+
padding:2px 6px;
|
|
54
|
+
border-radius:3px;
|
|
55
|
+
font-family:monospace;
|
|
56
|
+
pointer-events:none;
|
|
57
|
+
z-index:999999;
|
|
58
|
+
line-height:1.4;
|
|
59
|
+
`;
|
|
60
|
+
ctx.clone.style.position = 'relative';
|
|
61
|
+
ctx.clone.appendChild(label);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|