@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/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
- // snapdom's export ctx is a fresh spread of ctx.options, so we stash on
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 || imageQuality;
91
- const maxWidth = opts.maxImageWidth || maxImageWidth;
92
-
93
- // Scale dimensions — whether we rasterize or not, bboxes get resized
94
- // to the target output size so callers can overlay them on the image.
95
- let w, h, dataURL;
96
- if (wantImage) {
97
- const img = new Image();
98
- img.src = ctx.export.url;
99
- await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
100
- const ratio = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
101
- w = Math.round(img.naturalWidth * ratio);
102
- h = Math.round(img.naturalHeight * ratio);
103
- const canvas = document.createElement('canvas');
104
- canvas.width = w;
105
- canvas.height = h;
106
- canvas.getContext('2d').drawImage(img, 0, 0, w, h);
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) out.image = dataURL;
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.getAttribute('role');
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
- const type = (el.type || '').toLowerCase();
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.hasAttribute('href')) return 'link';
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.getAttribute('aria-label');
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.getAttribute('aria-labelledby');
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.textContent || '').trim() : ''; })
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.getAttribute('alt');
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.getAttribute('title');
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].textContent || '').trim();
235
+ const t = textOf(el.labels[0]).trim();
202
236
  if (t) return t;
203
237
  }
204
238
 
205
- const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
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
- function deriveState(el, role, rect) {
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.getAttribute('aria-expanded');
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.getAttribute('aria-pressed');
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.getAttribute('aria-selected');
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
- s.value = el.value;
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) s.value = 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) s.selectedText = opt.text || '';
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
- function extractMap(element, interactiveSelector, semanticSelector, fields) {
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 element.querySelectorAll(interactiveSelector)) {
286
- const entry = buildEntry(el, rootRect, i, fields, 'interactive');
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 element.querySelectorAll(semanticSelector)) {
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
- function buildEntry(el, rootRect, i, fields, kind) {
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.textContent || '').replace(/\s+/g, ' ').trim();
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.getAttribute(name);
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
- const cols = opts.width || charWidth;
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
- const img = new Image();
29
- img.src = ctx.export.url;
30
- await new Promise((res, rej) => {
31
- img.onload = res;
32
- img.onerror = rej;
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
- c.drawImage(img, 0, 0, cols, rows);
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];