@zumer/snapdom-plugins 2.2.2 → 3.0.0-beta.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/LICENSE +21 -0
- package/README.md +192 -50
- package/agent-map.d.ts +31 -0
- package/agent-map.js +248 -88
- package/ascii-export.d.ts +20 -0
- package/ascii-export.js +17 -12
- package/capture-frames.js +85 -0
- package/color-tint.d.ts +18 -0
- package/color-tint.js +7 -4
- package/context-export.d.ts +25 -0
- package/context-export.js +283 -0
- package/filter.d.ts +17 -0
- package/filter.js +10 -3
- package/gif-export.d.ts +29 -0
- package/gif-export.js +15 -21
- package/html-export.d.ts +31 -0
- package/html-export.js +62 -16
- package/index.d.ts +19 -0
- package/index.js +2 -1
- package/package.json +11 -4
- package/pdf-image.d.ts +18 -0
- package/pdf-image.js +14 -14
- package/privacy-policy.js +55 -0
- package/redact-clone.js +150 -0
- package/redact-inputs.d.ts +43 -0
- package/redact-inputs.js +175 -0
- package/replace-text.d.ts +20 -0
- package/replace-text.js +6 -0
- package/timestamp-overlay.d.ts +19 -0
- package/timestamp-overlay.js +4 -1
- package/video-export.d.ts +28 -0
- package/video-export.js +70 -52
- package/html-in-canvas.js +0 -139
package/agent-map.js
CHANGED
|
@@ -29,8 +29,11 @@
|
|
|
29
29
|
* @param {string} [options.interactiveSelector] CSS selector (default below).
|
|
30
30
|
* @param {string} [options.semanticSelector] CSS selector (default below).
|
|
31
31
|
* @param {Object} [options.labelStyle={}] Override badge styles.
|
|
32
|
+
* @param {'clone'|'render'} [options.needs='render'] How far the capture runs.
|
|
33
|
+
* 'clone' skips rendering entirely — requires image: false, and result.url throws.
|
|
32
34
|
* @returns {Object} SnapDOM plugin
|
|
33
35
|
*/
|
|
36
|
+
import { getPrivacyPolicy } from './privacy-policy.js';
|
|
34
37
|
|
|
35
38
|
const DEFAULT_INTERACTIVE =
|
|
36
39
|
'a[href], button, input, select, textarea, ' +
|
|
@@ -54,83 +57,112 @@ export function agentMap(options = {}) {
|
|
|
54
57
|
labelStyle = {},
|
|
55
58
|
} = options;
|
|
56
59
|
|
|
60
|
+
// `needs` names how far the capture has to run. Default 'render': dropping the image is
|
|
61
|
+
// the caller's call. The two rejections below are cases core cannot catch — without
|
|
62
|
+
// them the caller gets an EMPTY map, or a late error, instead of the reason.
|
|
63
|
+
const needs = options.needs ?? 'render';
|
|
64
|
+
// The map is read in afterClone: with no clone there is no map, only a plausible-looking
|
|
65
|
+
// empty one.
|
|
66
|
+
if (needs !== 'clone' && needs !== 'render') {
|
|
67
|
+
throw new Error(`[snapdom] agent-map cannot run at stage '${needs}': it supports clone, render`);
|
|
68
|
+
}
|
|
69
|
+
// A capture with no render can never carry an image, and asking for both is a
|
|
70
|
+
// contradiction the caller should hear now, not at export time.
|
|
71
|
+
if (needs === 'clone' && image !== false) {
|
|
72
|
+
throw new Error("[snapdom] agent-map: needs: 'clone' produces no image — pass image: false, or needs: 'render'");
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
return {
|
|
58
76
|
name: 'agent-map',
|
|
77
|
+
needs,
|
|
59
78
|
|
|
60
79
|
afterClone(ctx) {
|
|
61
80
|
const meta = extractMap(
|
|
62
81
|
ctx.element,
|
|
63
82
|
interactiveSelector,
|
|
64
83
|
semantic ? semanticSelector : null,
|
|
65
|
-
fields
|
|
84
|
+
fields,
|
|
85
|
+
// The capture's ONE exclusion policy (src/core/context.js). Absent only when a
|
|
86
|
+
// caller drives the hook with a hand-built context.
|
|
87
|
+
typeof ctx.shouldExclude === 'function' ? ctx.shouldExclude : NEVER,
|
|
88
|
+
ctx.outerTransforms,
|
|
89
|
+
getPrivacyPolicy(ctx)
|
|
66
90
|
);
|
|
67
|
-
|
|
68
|
-
// both for the agentMap() call below to find it.
|
|
91
|
+
meta.labelStyle = { ...labelStyle };
|
|
69
92
|
ctx.__agentMapMeta = meta;
|
|
70
|
-
if (ctx.options) ctx.options.__agentMapMeta = meta;
|
|
71
|
-
|
|
72
|
-
if (image === 'annotated') {
|
|
73
|
-
addAnnotations(ctx.clone, meta.map, labelStyle);
|
|
74
|
-
}
|
|
75
93
|
},
|
|
76
94
|
|
|
77
|
-
defineExports() {
|
|
95
|
+
defineExports(capture) {
|
|
78
96
|
return {
|
|
79
97
|
agentMap: async (ctx, opts = {}) => {
|
|
80
98
|
const meta = ctx.__agentMapMeta;
|
|
81
99
|
const wantImage = opts.image !== undefined ? opts.image : image;
|
|
82
|
-
|
|
83
|
-
if (!meta || !meta.map.length) {
|
|
84
|
-
const out = { dimensions: { width: 0, height: 0 }, map: [] };
|
|
85
|
-
if (wantImage) out.image = ctx.export.url;
|
|
86
|
-
return out;
|
|
87
|
-
}
|
|
100
|
+
if (!meta) throw new Error('[snapdom] agent-map: this capture carries no frozen map.');
|
|
88
101
|
|
|
89
102
|
const format = opts.imageFormat || imageFormat;
|
|
90
|
-
const quality = opts.imageQuality
|
|
91
|
-
const maxWidth = opts.maxImageWidth
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const mime =
|
|
108
|
-
format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
|
|
109
|
-
: format === 'webp' ? 'image/webp'
|
|
110
|
-
: 'image/png';
|
|
111
|
-
dataURL = canvas.toDataURL(mime, quality);
|
|
112
|
-
} else {
|
|
113
|
-
const sourceW = meta.dimensions.width || 1;
|
|
114
|
-
const ratio = sourceW > maxWidth ? maxWidth / sourceW : 1;
|
|
115
|
-
w = Math.round(sourceW * ratio);
|
|
116
|
-
h = Math.round(meta.dimensions.height * ratio);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const sx = w / (meta.dimensions.width || 1);
|
|
120
|
-
const sy = h / (meta.dimensions.height || 1);
|
|
103
|
+
const quality = opts.imageQuality ?? imageQuality;
|
|
104
|
+
const maxWidth = opts.maxImageWidth ?? maxImageWidth;
|
|
105
|
+
const geometry = ctx.meta;
|
|
106
|
+
const sourceW = geometry?.vbW || meta.dimensions.width || 1;
|
|
107
|
+
const sourceH = geometry?.vbH || meta.dimensions.height || 1;
|
|
108
|
+
const hasW = Number.isFinite(opts.width), hasH = Number.isFinite(opts.height);
|
|
109
|
+
let w = hasW ? opts.width : hasH ? opts.height * sourceW / sourceH : sourceW * (opts.scale ?? 1);
|
|
110
|
+
let h = hasH ? opts.height : hasW ? opts.width * sourceH / sourceW : sourceH * (opts.scale ?? 1);
|
|
111
|
+
w *= opts.dpr ?? 1;
|
|
112
|
+
h *= opts.dpr ?? 1;
|
|
113
|
+
const ratio = w > maxWidth ? maxWidth / w : 1;
|
|
114
|
+
w = Math.max(1, Math.round(w * ratio));
|
|
115
|
+
h = Math.max(1, Math.round(h * ratio));
|
|
116
|
+
const sx = w / sourceW, sy = h / sourceH;
|
|
117
|
+
const dx = (geometry?.contentX || 0) - (geometry?.clip?.x || 0);
|
|
118
|
+
const dy = (geometry?.contentY || 0) - (geometry?.clip?.y || 0);
|
|
119
|
+
const frame = geometry ? meta.frame : { x: 0, y: 0, sx: 1, sy: 1 };
|
|
121
120
|
|
|
122
121
|
const scaledMap = meta.map.map(e => {
|
|
123
122
|
const scaled = { ...e, b: [
|
|
124
|
-
Math.round(e.b[0] * sx),
|
|
125
|
-
Math.round(e.b[1] * sy),
|
|
126
|
-
Math.round(e.b[2] * sx),
|
|
127
|
-
Math.round(e.b[3] * sy),
|
|
123
|
+
Math.round((e.b[0] * frame.sx + frame.x + dx) * sx),
|
|
124
|
+
Math.round((e.b[1] * frame.sy + frame.y + dy) * sy),
|
|
125
|
+
Math.round(e.b[2] * frame.sx * sx),
|
|
126
|
+
Math.round(e.b[3] * frame.sy * sy),
|
|
128
127
|
] };
|
|
128
|
+
if (e.s) scaled.s = { ...e.s };
|
|
129
|
+
if (e.a) scaled.a = { ...e.a };
|
|
129
130
|
return scaled;
|
|
130
131
|
});
|
|
131
132
|
|
|
132
133
|
const out = { dimensions: { width: w, height: h }, map: scaledMap };
|
|
133
|
-
if (wantImage)
|
|
134
|
+
if (wantImage) {
|
|
135
|
+
// Core owns Safari drawing and restoration of compressed originals. Decode at
|
|
136
|
+
// the original aspect ratio first; Firefox letterboxes an SVG decoded with a
|
|
137
|
+
// different width/height ratio. Only the finished bitmap is stretched below.
|
|
138
|
+
const density = Math.max(sx, sy);
|
|
139
|
+
const opaque = format === 'jpg' || format === 'jpeg' || format === 'webp';
|
|
140
|
+
const backgroundColor = opaque && (opts.backgroundColor == null || opts.backgroundColor === 'transparent')
|
|
141
|
+
? '#ffffff' : opts.backgroundColor;
|
|
142
|
+
const source = await capture.exports.canvas({ width: sourceW * density, height: null,
|
|
143
|
+
scale: 1, dpr: 1, canvas: null, crop: null, backgroundColor });
|
|
144
|
+
const canvas = document.createElement('canvas');
|
|
145
|
+
canvas.width = w;
|
|
146
|
+
canvas.height = h;
|
|
147
|
+
const draw = canvas.getContext('2d');
|
|
148
|
+
draw.drawImage(source, 0, 0, w, h);
|
|
149
|
+
if (wantImage === 'annotated' && scaledMap.some(e => !e.isSemanticOnly)) {
|
|
150
|
+
// Badges belong to this export, not the frozen base image: raw/annotated
|
|
151
|
+
// overrides and later exports must never inherit another call's overlay.
|
|
152
|
+
const layer = document.createElement('div');
|
|
153
|
+
layer.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
|
154
|
+
layer.style.cssText = `width:${w}px;height:${h}px;`;
|
|
155
|
+
addAnnotations(layer, scaledMap, meta.labelStyle);
|
|
156
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><foreignObject width="100%" height="100%">${new XMLSerializer().serializeToString(layer)}</foreignObject></svg>`;
|
|
157
|
+
const overlay = new Image();
|
|
158
|
+
overlay.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
|
159
|
+
await overlay.decode();
|
|
160
|
+
draw.drawImage(overlay, 0, 0);
|
|
161
|
+
}
|
|
162
|
+
const mime = format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
|
|
163
|
+
: format === 'webp' ? 'image/webp' : 'image/png';
|
|
164
|
+
out.image = canvas.toDataURL(mime, quality);
|
|
165
|
+
}
|
|
134
166
|
return out;
|
|
135
167
|
},
|
|
136
168
|
};
|
|
@@ -140,13 +172,15 @@ export function agentMap(options = {}) {
|
|
|
140
172
|
|
|
141
173
|
/* ── Role derivation ────────────────────────────── */
|
|
142
174
|
|
|
143
|
-
function deriveRole(el) {
|
|
144
|
-
const explicit = el
|
|
175
|
+
function deriveRole(el, attribute, policy) {
|
|
176
|
+
const explicit = attribute(el, 'role');
|
|
145
177
|
if (explicit) return explicit;
|
|
146
178
|
const tag = el.tagName.toLowerCase();
|
|
147
|
-
|
|
179
|
+
// Preserve native reflection (unknown input types resolve to text). Only an explicit
|
|
180
|
+
// type-attribute rule changes what can be inferred from that reflected property.
|
|
181
|
+
const type = (policy?.redactsAttribute(el, 'type') ? '' : el.type || '').toLowerCase();
|
|
148
182
|
if (tag === 'button') return 'button';
|
|
149
|
-
if (tag === 'a' && el
|
|
183
|
+
if (tag === 'a' && attribute(el, 'href') !== null) return 'link';
|
|
150
184
|
if (tag === 'input') {
|
|
151
185
|
if (type === 'checkbox') return 'checkbox';
|
|
152
186
|
if (type === 'radio') return 'radio';
|
|
@@ -173,36 +207,36 @@ function deriveRole(el) {
|
|
|
173
207
|
|
|
174
208
|
/* ── Accessible name ────────────────────────────── */
|
|
175
209
|
|
|
176
|
-
function accessibleName(el) {
|
|
177
|
-
const ariaLabel = el
|
|
210
|
+
function accessibleName(el, textOf, attribute, policy) {
|
|
211
|
+
const ariaLabel = attribute(el, 'aria-label');
|
|
178
212
|
if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();
|
|
179
213
|
|
|
180
|
-
const labelledBy = el
|
|
214
|
+
const labelledBy = attribute(el, 'aria-labelledby');
|
|
181
215
|
if (labelledBy) {
|
|
182
216
|
const root = el.getRootNode();
|
|
183
217
|
const getById = (id) =>
|
|
184
218
|
root && typeof root.getElementById === 'function'
|
|
185
219
|
? root.getElementById(id) : document.getElementById(id);
|
|
186
220
|
const parts = labelledBy.trim().split(/\s+/)
|
|
187
|
-
.map(id => { const r = getById(id); return r ? (r
|
|
221
|
+
.map(id => { const r = getById(id); return r ? textOf(r).trim() : ''; })
|
|
188
222
|
.filter(Boolean);
|
|
189
223
|
if (parts.length) return parts.join(' ');
|
|
190
224
|
}
|
|
191
225
|
|
|
192
|
-
if (el.tagName === 'IMG' || (el.tagName === 'INPUT' && (el.type || '').toLowerCase() === 'image')) {
|
|
193
|
-
const alt = el
|
|
226
|
+
if (el.tagName === 'IMG' || (el.tagName === 'INPUT' && !policy?.redactsAttribute(el, 'type') && (el.type || '').toLowerCase() === 'image')) {
|
|
227
|
+
const alt = attribute(el, 'alt');
|
|
194
228
|
if (alt && alt.trim()) return alt.trim();
|
|
195
229
|
}
|
|
196
230
|
|
|
197
|
-
const title = el
|
|
231
|
+
const title = attribute(el, 'title');
|
|
198
232
|
if (title && title.trim()) return title.trim();
|
|
199
233
|
|
|
200
234
|
if (el.labels && el.labels[0]) {
|
|
201
|
-
const t = (el.labels[0]
|
|
235
|
+
const t = textOf(el.labels[0]).trim();
|
|
202
236
|
if (t) return t;
|
|
203
237
|
}
|
|
204
238
|
|
|
205
|
-
const text = (el
|
|
239
|
+
const text = textOf(el).replace(/\s+/g, ' ').trim();
|
|
206
240
|
if (text) return text.length > 60 ? text.slice(0, 59) + '…' : text;
|
|
207
241
|
return '';
|
|
208
242
|
}
|
|
@@ -215,45 +249,81 @@ function accessibleName(el) {
|
|
|
215
249
|
* aria-pressed are included for BOTH values (true and false) because
|
|
216
250
|
* "pressed: false" on a toggle is meaningful information.
|
|
217
251
|
*/
|
|
218
|
-
|
|
252
|
+
|
|
253
|
+
/* Sensitive-input guard (self-contained: this package publishes standalone).
|
|
254
|
+
* Secrets are EXCLUDED, never truncated — a truncated password is still a leak. */
|
|
255
|
+
const SENSITIVE_AC = new Set(['current-password', 'new-password', 'one-time-code']);
|
|
256
|
+
function isSensitiveInput(el) {
|
|
257
|
+
if (!el || el.tagName !== 'INPUT') return false;
|
|
258
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase();
|
|
259
|
+
if (type === 'password' || type === 'email' || type === 'tel') return true;
|
|
260
|
+
const ac = (el.getAttribute('autocomplete') || '').toLowerCase();
|
|
261
|
+
if (!ac) return false;
|
|
262
|
+
for (const token of ac.split(/\s+/)) {
|
|
263
|
+
if (SENSITIVE_AC.has(token) || token.startsWith('cc-')) return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
function maskedValue(value) {
|
|
268
|
+
return '\u2022'.repeat(Math.min(String(value ?? '').length, 12));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function deriveState(el, role, rect, attribute, policy, textOf, blocked) {
|
|
219
272
|
const s = {};
|
|
273
|
+
const redacts = policy ? name => policy.redactsAttribute(el, name) : NEVER;
|
|
220
274
|
|
|
221
275
|
try {
|
|
222
|
-
if (el.matches(':checked')) s.checked = true;
|
|
223
|
-
else if (role === 'checkbox' || role === 'radio') {
|
|
276
|
+
if (!redacts('checked') && el.matches(':checked')) s.checked = true;
|
|
277
|
+
else if (!redacts('checked') && (role === 'checkbox' || role === 'radio')) {
|
|
224
278
|
// include checked:false for form groups where an agent needs to
|
|
225
279
|
// know "unchecked" is a valid state distinct from "not a checkbox".
|
|
226
280
|
s.checked = false;
|
|
227
281
|
}
|
|
228
|
-
if (el.matches(':disabled')) s.disabled = true;
|
|
282
|
+
if (!redacts('disabled') && el.matches(':disabled')) s.disabled = true;
|
|
229
283
|
if (el.matches(':focus')) s.focus = true;
|
|
230
284
|
} catch { /* exotic nodes */ }
|
|
231
285
|
|
|
232
|
-
const expanded = el
|
|
286
|
+
const expanded = attribute(el, 'aria-expanded');
|
|
233
287
|
if (expanded === 'true') s.expanded = true;
|
|
234
288
|
else if (expanded === 'false') s.expanded = false;
|
|
235
289
|
|
|
236
|
-
const pressed = el
|
|
290
|
+
const pressed = attribute(el, 'aria-pressed');
|
|
237
291
|
if (pressed === 'true') s.pressed = true;
|
|
238
292
|
else if (pressed === 'false') s.pressed = false;
|
|
239
293
|
|
|
240
|
-
const selected = el
|
|
294
|
+
const selected = attribute(el, 'aria-selected');
|
|
241
295
|
if (selected === 'true') s.selected = true;
|
|
242
296
|
else if (selected === 'false' && (role === 'tab' || role === 'option')) s.selected = false;
|
|
243
297
|
|
|
244
298
|
if (el.tagName === 'INPUT') {
|
|
245
299
|
const type = (el.type || 'text').toLowerCase();
|
|
246
300
|
if (type !== 'checkbox' && type !== 'radio' && type !== 'submit' && type !== 'button' && type !== 'reset' && el.value) {
|
|
247
|
-
|
|
301
|
+
// Sensitive inputs (password/email/tel/cc-*): value NEVER emitted, in any field.
|
|
302
|
+
// Remaining inputs: masked by default — the agent learns "field has content"
|
|
303
|
+
// without the output carrying user data.
|
|
304
|
+
if (!isSensitiveInput(el)) {
|
|
305
|
+
if (!policy?.isField(el)) s.value = maskedValue(el.value);
|
|
306
|
+
s.hasValue = true;
|
|
307
|
+
}
|
|
248
308
|
}
|
|
249
309
|
} else if (el.tagName === 'TEXTAREA') {
|
|
250
|
-
if (el.value)
|
|
310
|
+
if (el.value) {
|
|
311
|
+
if (!policy?.isField(el)) s.value = maskedValue(el.value);
|
|
312
|
+
s.hasValue = true;
|
|
313
|
+
}
|
|
251
314
|
} else if (el.tagName === 'SELECT') {
|
|
252
|
-
s.value = el.value;
|
|
253
315
|
const opt = el.options && el.options[el.selectedIndex];
|
|
254
|
-
if (opt
|
|
316
|
+
if (!opt || !blocked(opt)) {
|
|
317
|
+
// HTMLOptionElement.text collapses ASCII whitespace, preserving nonbreaking spaces.
|
|
318
|
+
if (opt) s.selectedText = policy ? textOf(opt).replace(/[\t\n\f\r ]+/g, ' ').replace(/^ | $/g, '') : opt.text || '';
|
|
319
|
+
if (!redacts('value') && !(opt && policy?.redactsAttribute(opt, 'value'))) {
|
|
320
|
+
// Without an explicit value attribute, select.value derives from the option's
|
|
321
|
+
// text too, so use the same permitted text as selectedText under a privacy policy.
|
|
322
|
+
s.value = policy && opt && !opt.hasAttribute('value') ? s.selectedText : el.value;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
255
325
|
} else if (el.tagName === 'DETAILS') {
|
|
256
|
-
s.open = !!el.open;
|
|
326
|
+
if (!redacts('open')) s.open = !!el.open;
|
|
257
327
|
}
|
|
258
328
|
|
|
259
329
|
// Covered — element visually occluded by something else at its center.
|
|
@@ -276,21 +346,73 @@ function deriveState(el, role, rect) {
|
|
|
276
346
|
|
|
277
347
|
/* ── Map extraction ─────────────────────────────── */
|
|
278
348
|
|
|
279
|
-
|
|
349
|
+
const NEVER = () => false;
|
|
350
|
+
const sourceAttribute = (el, name) => el.getAttribute(name);
|
|
351
|
+
|
|
352
|
+
/** What actually paints under `el`, mirroring deepClone: a <slot> renders its assigned
|
|
353
|
+
* elements, and an open shadow root renders in place of the host's light children —
|
|
354
|
+
* except unassigned ones, which core clones after the shadow fragment. */
|
|
355
|
+
function renderedChildren(el) {
|
|
356
|
+
if (el.localName === 'slot') {
|
|
357
|
+
const assigned = el.assignedElements?.({ flatten: true }) || [];
|
|
358
|
+
return assigned.length ? assigned : el.children;
|
|
359
|
+
}
|
|
360
|
+
const sr = el.shadowRoot;
|
|
361
|
+
if (!sr) return el.children;
|
|
362
|
+
const slotted = new Set();
|
|
363
|
+
for (const s of sr.querySelectorAll('slot')) for (const n of s.assignedElements()) slotted.add(n);
|
|
364
|
+
return [...sr.children, ...Array.from(el.children).filter((c) => !slotted.has(c))];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function extractMap(element, interactiveSelector, semanticSelector, fields, shouldExclude, outerTransforms, policy) {
|
|
280
368
|
const rootRect = element.getBoundingClientRect();
|
|
281
369
|
const map = [];
|
|
282
370
|
let i = 0;
|
|
283
371
|
const tracked = new Set();
|
|
372
|
+
const attribute = policy ? policy.attribute : sourceAttribute;
|
|
373
|
+
const excluded = policy ? el => shouldExclude(el) || policy.isBlocked(el) : shouldExclude;
|
|
374
|
+
const blocked = (el) => {
|
|
375
|
+
for (let ancestor = el; ancestor; ancestor = ancestor.parentElement || ancestor.getRootNode()?.host) {
|
|
376
|
+
if (excluded(ancestor)) return true;
|
|
377
|
+
}
|
|
378
|
+
return false;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// Names/full text must obey the same redaction boundary as the entry walk. Plain
|
|
382
|
+
// textContent resurrects excluded descendants, and textarea defaults bypass masked
|
|
383
|
+
// state. Referenced labels may also live under an excluded ancestor outside this root.
|
|
384
|
+
const textOf = (el) => {
|
|
385
|
+
if (blocked(el)) return '';
|
|
386
|
+
const read = (node) => {
|
|
387
|
+
if (node.nodeType === 3) return node.nodeValue || '';
|
|
388
|
+
if (node.nodeType !== 1 || node.tagName === 'TEXTAREA' || excluded(node)) return '';
|
|
389
|
+
return Array.from(node.childNodes, read).join('');
|
|
390
|
+
};
|
|
391
|
+
return read(el);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// querySelectorAll never matches the root and never crosses a shadow boundary, so a
|
|
395
|
+
// capture root that IS a button, and every shadow-DOM control, were missing from the
|
|
396
|
+
// map while both render in the image. Walk what core clones instead, and prune excluded
|
|
397
|
+
// subtrees so a redacted node cannot come back as an actionable badge.
|
|
398
|
+
const els = [];
|
|
399
|
+
const visit = (el) => {
|
|
400
|
+
if (excluded(el)) return;
|
|
401
|
+
els.push(el);
|
|
402
|
+
for (const c of renderedChildren(el)) visit(c);
|
|
403
|
+
};
|
|
404
|
+
visit(element);
|
|
284
405
|
|
|
285
|
-
for (const el of
|
|
286
|
-
|
|
406
|
+
for (const el of els) {
|
|
407
|
+
if (!el.matches(interactiveSelector)) continue;
|
|
408
|
+
const entry = buildEntry(el, rootRect, i, fields, 'interactive', textOf, attribute, policy, blocked);
|
|
287
409
|
if (entry) { map.push(entry); tracked.add(el); i++; }
|
|
288
410
|
}
|
|
289
411
|
|
|
290
412
|
if (semanticSelector) {
|
|
291
|
-
for (const el of
|
|
292
|
-
if (tracked.has(el)) continue;
|
|
293
|
-
const entry = buildEntry(el, rootRect, i, fields, 'semantic');
|
|
413
|
+
for (const el of els) {
|
|
414
|
+
if (tracked.has(el) || !el.matches(semanticSelector)) continue;
|
|
415
|
+
const entry = buildEntry(el, rootRect, i, fields, 'semantic', textOf, attribute, policy, blocked);
|
|
294
416
|
if (entry) { map.push(entry); i++; }
|
|
295
417
|
}
|
|
296
418
|
}
|
|
@@ -298,11 +420,48 @@ function extractMap(element, interactiveSelector, semanticSelector, fields) {
|
|
|
298
420
|
return {
|
|
299
421
|
map,
|
|
300
422
|
dimensions: { width: rootRect.width, height: rootRect.height },
|
|
423
|
+
frame: captureFrame(element, rootRect, outerTransforms),
|
|
301
424
|
};
|
|
302
425
|
}
|
|
303
426
|
|
|
304
|
-
|
|
427
|
+
// gBCR starts at the transformed bounding box; render meta starts at the root's
|
|
428
|
+
// pre-transform origin. Freeze that offset with the map, including individual transforms.
|
|
429
|
+
// Ancestor rotation/perspective and stripping a root rotation cannot be reconstructed from
|
|
430
|
+
// axis-aligned source rectangles; those cases still need a capture-space geometry API.
|
|
431
|
+
function captureFrame(element, rect, outerTransforms = true) {
|
|
432
|
+
const css = getComputedStyle(element);
|
|
433
|
+
const w = element.offsetWidth || parseFloat(css.width) || rect.width;
|
|
434
|
+
const h = element.offsetHeight || parseFloat(css.height) || rect.height;
|
|
435
|
+
// Core keeps scale/skew but anchors the clone at 0 0 when outer transforms are stripped.
|
|
436
|
+
const [ox, oy] = outerTransforms !== false ? css.transformOrigin.split(/\s+/).map(parseFloat) : [0, 0];
|
|
437
|
+
const scale = css.scale && css.scale !== 'none' ? css.scale.split(/\s+/).map(Number) : [1];
|
|
438
|
+
const rotation = css.rotate && css.rotate !== 'none' ? css.rotate.split(/\s+/) : ['0deg'];
|
|
439
|
+
const angleText = rotation.pop();
|
|
440
|
+
const angle = parseFloat(angleText) * (angleText.endsWith('turn') ? 360
|
|
441
|
+
: angleText.endsWith('grad') ? 0.9 : angleText.endsWith('rad') ? 180 / Math.PI : 1);
|
|
442
|
+
const axis = rotation.length === 3 ? rotation.map(Number)
|
|
443
|
+
: rotation.length ? ['x', 'y', 'z'].map(name => Number(name === rotation[0])) : [0, 0, 1];
|
|
444
|
+
// Firefox marks rotateAxisAngle as 3D even around z, so keep the ordinary 2D path.
|
|
445
|
+
const rotationMatrix = !axis[0] && !axis[1] ? new DOMMatrix().rotate(Math.sign(axis[2]) * angle)
|
|
446
|
+
: new DOMMatrix().rotateAxisAngle(...axis, angle);
|
|
447
|
+
const matrix = rotationMatrix.scale(scale[0], scale[1] ?? scale[0], scale[2] ?? 1)
|
|
448
|
+
.multiply(new DOMMatrix(css.transform === 'none' ? undefined : css.transform));
|
|
449
|
+
if (!matrix.is2D) return { x: 0, y: 0, sx: 1, sy: 1 };
|
|
450
|
+
const corners = [[0, 0], [w, 0], [0, h], [w, h]].map(([x, y]) => ({
|
|
451
|
+
x: (x - ox) * matrix.a + (y - oy) * matrix.c + ox,
|
|
452
|
+
y: (x - ox) * matrix.b + (y - oy) * matrix.d + oy,
|
|
453
|
+
}));
|
|
454
|
+
const x = Math.min(...corners.map(p => p.x)), y = Math.min(...corners.map(p => p.y));
|
|
455
|
+
return { x, y,
|
|
456
|
+
sx: rect.width ? (Math.max(...corners.map(p => p.x)) - x) / rect.width : 1,
|
|
457
|
+
sy: rect.height ? (Math.max(...corners.map(p => p.y)) - y) / rect.height : 1 };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function buildEntry(el, rootRect, i, fields, kind, textOf, attribute, policy, blocked) {
|
|
305
461
|
const rect = el.getBoundingClientRect();
|
|
462
|
+
// The annotation pass filters on this flag — without it, semantic:true put badges
|
|
463
|
+
// on headings/paragraphs (the filter was a no-op because nothing ever set it).
|
|
464
|
+
// Kept in the output so consumers can distinguish actables from context entries.
|
|
306
465
|
const b = [
|
|
307
466
|
Math.round(rect.left - rootRect.left),
|
|
308
467
|
Math.round(rect.top - rootRect.top),
|
|
@@ -311,22 +470,23 @@ function buildEntry(el, rootRect, i, fields, kind) {
|
|
|
311
470
|
];
|
|
312
471
|
if (b[2] <= 0 && b[3] <= 0) return null;
|
|
313
472
|
|
|
314
|
-
const role = deriveRole(el);
|
|
315
|
-
const n = accessibleName(el);
|
|
473
|
+
const role = deriveRole(el, attribute, policy);
|
|
474
|
+
const n = accessibleName(el, textOf, attribute, policy);
|
|
316
475
|
|
|
317
476
|
const entry = { i, n, r: role, b };
|
|
477
|
+
if (kind === 'semantic') entry.isSemanticOnly = true;
|
|
318
478
|
|
|
319
479
|
if (kind === 'interactive') {
|
|
320
|
-
const s = deriveState(el, role, rect);
|
|
480
|
+
const s = deriveState(el, role, rect, attribute, policy, textOf, blocked);
|
|
321
481
|
if (s) entry.s = s;
|
|
322
482
|
}
|
|
323
483
|
|
|
324
484
|
if (fields === 'full') {
|
|
325
|
-
const t = (el
|
|
485
|
+
const t = textOf(el).replace(/\s+/g, ' ').trim();
|
|
326
486
|
if (t && t !== n) entry.t = t.length > 160 ? t.slice(0, 159) + '…' : t;
|
|
327
487
|
const a = {};
|
|
328
488
|
for (const name of ['href', 'type', 'name', 'placeholder', 'alt', 'title', 'role', 'aria-label']) {
|
|
329
|
-
const v = el
|
|
489
|
+
const v = attribute(el, name);
|
|
330
490
|
if (v && v !== 'false') a[name] = v;
|
|
331
491
|
}
|
|
332
492
|
if (Object.keys(a).length) entry.a = a;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/ascii-export`.
|
|
3
|
+
*
|
|
4
|
+
* The plugin instance type comes from the core package: one definition of what a plugin IS,
|
|
5
|
+
* so a plugin that stops matching the host's contract fails to typecheck instead of failing
|
|
6
|
+
* at capture time.
|
|
7
|
+
*/
|
|
8
|
+
import type { SnapdomPlugin } from '@zumer/snapdom';
|
|
9
|
+
|
|
10
|
+
export interface AsciiExportOptions {
|
|
11
|
+
/** Character width of the output. */
|
|
12
|
+
width?: number;
|
|
13
|
+
/** Characters from lightest to darkest. */
|
|
14
|
+
charset?: string;
|
|
15
|
+
/** Invert the luminance mapping. */
|
|
16
|
+
invert?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Adds `result.toAscii()`. */
|
|
20
|
+
export declare function asciiExport(options?: AsciiExportOptions): SnapdomPlugin;
|
package/ascii-export.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* asciiExport - Official SnapDOM Plugin
|
|
3
3
|
* Adds a toAscii() export method that converts captures to ASCII art.
|
|
4
|
+
* Pinned by __tests__/plugins.imageExports.v3.test.js.
|
|
5
|
+
* @module plugins/ascii-export
|
|
4
6
|
*
|
|
5
7
|
* @param {Object} [options]
|
|
6
8
|
* @param {number} [options.width=80] - Character width of output
|
|
@@ -18,34 +20,37 @@ export function asciiExport(options = {}) {
|
|
|
18
20
|
return {
|
|
19
21
|
name: 'ascii-export',
|
|
20
22
|
|
|
21
|
-
defineExports() {
|
|
23
|
+
defineExports({ exports }) {
|
|
22
24
|
return {
|
|
23
25
|
ascii: async (ctx, opts = {}) => {
|
|
24
|
-
|
|
26
|
+
// Capture width is a pixel size, not a character count. v3 merges it into opts.
|
|
27
|
+
const width = Object.hasOwn(ctx.export.requestedOptions, 'width') || opts.width !== ctx.width
|
|
28
|
+
? opts.width : charWidth;
|
|
29
|
+
if (!Number.isFinite(width) || width < 1) throw new RangeError('[snapdom] ascii-export: width must be a positive number');
|
|
30
|
+
const cols = Math.floor(width);
|
|
25
31
|
const chars = opts.charset || charset;
|
|
26
32
|
const inv = opts.invert ?? invert;
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
if (!chars.length) throw new RangeError('[snapdom] ascii-export: charset must not be empty');
|
|
35
|
+
// Keep core's decoded-image/Safari path and captured originals without firing hooks again.
|
|
36
|
+
const source = await exports.canvas({
|
|
37
|
+
...opts, width: ctx.width, height: ctx.height, scale: 1, dpr: 1, canvas: null,
|
|
38
|
+
backgroundColor: !opts.backgroundColor || opts.backgroundColor === 'transparent' ? '#ffffff' : opts.backgroundColor,
|
|
33
39
|
});
|
|
34
|
-
|
|
35
|
-
const aspect = img.height / img.width;
|
|
36
|
-
const rows = Math.round(cols * aspect * 0.5);
|
|
40
|
+
const rows = Math.max(1, Math.round(cols * source.height / source.width * 0.5));
|
|
37
41
|
const canvas = document.createElement('canvas');
|
|
38
42
|
canvas.width = cols;
|
|
39
43
|
canvas.height = rows;
|
|
40
44
|
const c = canvas.getContext('2d');
|
|
41
|
-
|
|
45
|
+
// Squash the bitmap, not the SVG viewport: Firefox preserves its viewBox aspect ratio.
|
|
46
|
+
c.drawImage(source, 0, 0, cols, rows);
|
|
42
47
|
const data = c.getImageData(0, 0, cols, rows).data;
|
|
43
48
|
|
|
44
49
|
let ascii = '';
|
|
45
50
|
for (let y = 0; y < rows; y++) {
|
|
46
51
|
for (let x = 0; x < cols; x++) {
|
|
47
52
|
const i = (y * cols + x) * 4;
|
|
48
|
-
let lum = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) / 255;
|
|
53
|
+
let lum = 1 - (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) / 255;
|
|
49
54
|
if (inv) lum = 1 - lum;
|
|
50
55
|
const ci = Math.min(Math.floor(lum * chars.length), chars.length - 1);
|
|
51
56
|
ascii += chars[ci];
|