domotion-svg 0.22.2 → 0.24.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.
Files changed (49) hide show
  1. package/README.md +6 -0
  2. package/dist/capture/backdrop-isolation.d.ts +14 -0
  3. package/dist/capture/backdrop-isolation.js +49 -0
  4. package/dist/capture/emoji.js +130 -0
  5. package/dist/capture/index.d.ts +3 -1
  6. package/dist/capture/index.js +2 -2
  7. package/dist/capture/script/emoji-detect.js +22 -1
  8. package/dist/capture/script/font-feature-values.d.ts +2 -0
  9. package/dist/capture/script/font-feature-values.js +62 -0
  10. package/dist/capture/script/index.js +176 -42
  11. package/dist/capture/script/walker/form-controls.d.ts +1 -19
  12. package/dist/capture/script/walker/form-controls.js +34 -3
  13. package/dist/capture/script/walker/fragmentation.js +60 -0
  14. package/dist/capture/script/walker/lists-counters.d.ts +3 -1
  15. package/dist/capture/script/walker/lists-counters.js +2 -1
  16. package/dist/capture/script/walker/pseudo-content.d.ts +2 -0
  17. package/dist/capture/script/walker/pseudo-content.js +57 -38
  18. package/dist/capture/script/walker/pseudo-inject.js +4 -2
  19. package/dist/capture/script/walker/text-segments.d.ts +10 -0
  20. package/dist/capture/script/walker/text-segments.js +82 -49
  21. package/dist/capture/script.generated.js +1 -1
  22. package/dist/capture/types.d.ts +80 -3
  23. package/dist/cli/capture.js +4 -2
  24. package/dist/cli/common.d.ts +6 -1
  25. package/dist/cli/common.js +7 -3
  26. package/dist/cli/index.js +3 -0
  27. package/dist/render/element-tree-to-svg.js +111 -60
  28. package/dist/render/embedded-font-builder.d.ts +24 -0
  29. package/dist/render/embedded-font-builder.js +73 -6
  30. package/dist/render/font-resolution.d.ts +6 -2
  31. package/dist/render/font-resolution.js +32 -11
  32. package/dist/render/form-controls.js +34 -10
  33. package/dist/render/gradient-defs.js +113 -32
  34. package/dist/render/gradients.js +35 -27
  35. package/dist/render/harfbuzz-shaper.js +4 -4
  36. package/dist/render/helper-acquire.js +1 -1
  37. package/dist/render/list-marker-geometry.d.ts +24 -0
  38. package/dist/render/list-marker-geometry.js +57 -0
  39. package/dist/render/script-segmentation.js +19 -9
  40. package/dist/render/text-to-path.d.ts +13 -3
  41. package/dist/render/text-to-path.js +156 -41
  42. package/dist/render/text.d.ts +9 -0
  43. package/dist/render/text.js +101 -68
  44. package/dist/render/vertical-text.d.ts +17 -9
  45. package/dist/render/vertical-text.js +81 -81
  46. package/dist/templates/builtin/kinetic-text.d.ts +1 -1
  47. package/dist/templates/render.js +1 -1
  48. package/dist/templates/types.d.ts +2 -0
  49. package/package.json +3 -2
package/README.md CHANGED
@@ -79,6 +79,12 @@ cat demo.html | domotion capture - -o demo.svg
79
79
  domotion capture https://example.com --scroll "down:bottom/8s" -o scroll.svg
80
80
  ```
81
81
 
82
+ Navigation waits for the page's `load` event, then Domotion applies its normal
83
+ font/image/paint readiness checks. Pages with a finite request chain can opt
84
+ into Playwright's stricter network-idle heuristic with `--network-idle`; it is
85
+ off by default because analytics, long polling, and streaming requests may
86
+ never become idle.
87
+
82
88
  Same-origin `<iframe>` content is recursed into the capture as native, selectable SVG rather than flattened to a screenshot; opt into cross-origin frames you trust with `--cross-origin-frames "<hosts>"`.
83
89
 
84
90
  For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
@@ -0,0 +1,14 @@
1
+ export interface SnapshotNode {
2
+ backendNodeId: number;
3
+ parentIndex: number;
4
+ attributes: string[];
5
+ bounds?: [number, number, number, number];
6
+ paintOrder?: number;
7
+ layoutOrder?: number;
8
+ }
9
+ export interface IsolationPlan {
10
+ targetBackendNodeId: number;
11
+ hideBackendNodeIds: number[];
12
+ }
13
+ /** Pure CDP snapshot planner; DOM mutation and restoration stay in emoji.ts. */
14
+ export declare function planBackdropIsolation(nodes: SnapshotNode[], token: string): IsolationPlan | null;
@@ -0,0 +1,49 @@
1
+ function overlaps(a, b) {
2
+ return a[0] < b[0] + b[2] && a[0] + a[2] > b[0]
3
+ && a[1] < b[1] + b[3] && a[1] + a[3] > b[1];
4
+ }
5
+ function hasToken(node, token) {
6
+ for (let i = 0; i + 1 < node.attributes.length; i += 2) {
7
+ if (node.attributes[i] === "data-domotion-backdrop-raster" && node.attributes[i + 1] === token)
8
+ return true;
9
+ }
10
+ return false;
11
+ }
12
+ function isAncestor(nodes, ancestor, child) {
13
+ for (let i = child; i >= 0; i = nodes[i]?.parentIndex ?? -1)
14
+ if (i === ancestor)
15
+ return true;
16
+ return false;
17
+ }
18
+ /** Pure CDP snapshot planner; DOM mutation and restoration stay in emoji.ts. */
19
+ export function planBackdropIsolation(nodes, token) {
20
+ const target = nodes.findIndex((node) => hasToken(node, token));
21
+ if (target < 0)
22
+ return null;
23
+ const targetNode = nodes[target];
24
+ if (targetNode.bounds == null || targetNode.paintOrder == null)
25
+ return null;
26
+ const hide = [];
27
+ for (let i = 0; i < nodes.length; i++) {
28
+ const node = nodes[i];
29
+ if (i === target || node.bounds == null || node.paintOrder == null)
30
+ continue;
31
+ const paintsLater = node.paintOrder > targetNode.paintOrder
32
+ || (node.paintOrder === targetNode.paintOrder
33
+ && node.layoutOrder != null && targetNode.layoutOrder != null
34
+ && node.layoutOrder > targetNode.layoutOrder);
35
+ if (!paintsLater || !overlaps(node.bounds, targetNode.bounds))
36
+ continue;
37
+ if (isAncestor(nodes, i, target) || isAncestor(nodes, target, i))
38
+ continue;
39
+ // If an already-selected later ancestor hides this node, do not resolve and
40
+ // mutate the descendant too. Snapshot order is parent-before-child.
41
+ if (hide.some((id) => {
42
+ const ancestor = nodes.findIndex((n) => n.backendNodeId === id);
43
+ return ancestor >= 0 && isAncestor(nodes, ancestor, i);
44
+ }))
45
+ continue;
46
+ hide.push(node.backendNodeId);
47
+ }
48
+ return { targetBackendNodeId: targetNode.backendNodeId, hideBackendNodeIds: hide };
49
+ }
@@ -20,6 +20,7 @@ import * as fontkit from "fontkit";
20
20
  import sharp from "sharp";
21
21
  import { clipRectForScreenshot } from "./clip-rect.js";
22
22
  import { forEachElement } from "../tree-ops/for-each-element.js";
23
+ import { planBackdropIsolation } from "./backdrop-isolation.js";
23
24
  const APPLE_COLOR_EMOJI_PATH = "/System/Library/Fonts/Apple Color Emoji.ttc";
24
25
  let _aceFont = null;
25
26
  let _aceFontLoaded = false;
@@ -382,7 +383,119 @@ async function calibrateSbixOverlays(page, viewport, jobs) {
382
383
  catch { /* leave this overlay as stamped */ }
383
384
  }
384
385
  }
386
+ async function rasterizeBackdropFilters(page, tree, viewport) {
387
+ const targets = [];
388
+ forEachElement(tree, (el) => { if (el.backdropFilterRaster?.token != null)
389
+ targets.push(el.backdropFilterRaster); });
390
+ if (targets.length === 0)
391
+ return;
392
+ let cdp;
393
+ try {
394
+ cdp = await page.context().newCDPSession(page);
395
+ const snap = await cdp.send("DOMSnapshot.captureSnapshot", {
396
+ computedStyles: [], includePaintOrder: true, includeDOMRects: true,
397
+ });
398
+ const doc = snap.documents?.[0];
399
+ const strings = snap.strings ?? [];
400
+ const paintByNode = new Map();
401
+ for (let i = 0; i < (doc?.layout?.nodeIndex?.length ?? 0); i++) {
402
+ const nodeIndex = doc.layout.nodeIndex[i];
403
+ const bounds = doc.layout.bounds[i];
404
+ const paintOrder = doc.layout.paintOrders?.[i];
405
+ if (paintOrder != null && !paintByNode.has(nodeIndex))
406
+ paintByNode.set(nodeIndex, { bounds, paintOrder, layoutOrder: i });
407
+ }
408
+ const attributesByNode = new Map();
409
+ const rareAttributes = doc?.nodes?.attributes;
410
+ if (Array.isArray(rareAttributes)) {
411
+ for (let i = 0; i < rareAttributes.length; i++)
412
+ attributesByNode.set(i, rareAttributes[i]);
413
+ }
414
+ else {
415
+ for (let i = 0; i < (rareAttributes?.index?.length ?? 0); i++) {
416
+ attributesByNode.set(rareAttributes.index[i], rareAttributes.value[i]);
417
+ }
418
+ }
419
+ const nodes = (doc?.nodes?.backendNodeId ?? []).map((backendNodeId, i) => {
420
+ const attrIndexes = attributesByNode.get(i) ?? [];
421
+ const attributes = attrIndexes.map((idx) => strings[idx]);
422
+ const layout = paintByNode.get(i);
423
+ return {
424
+ backendNodeId,
425
+ parentIndex: doc.nodes.parentIndex?.[i] ?? -1,
426
+ attributes,
427
+ bounds: layout?.bounds,
428
+ paintOrder: layout?.paintOrder,
429
+ layoutOrder: layout?.layoutOrder,
430
+ };
431
+ });
432
+ for (const target of targets) {
433
+ const plan = planBackdropIsolation(nodes, target.token);
434
+ const restores = [];
435
+ try {
436
+ if (plan != null) {
437
+ for (const backendNodeId of plan.hideBackendNodeIds) {
438
+ try {
439
+ const resolved = await cdp.send("DOM.resolveNode", { backendNodeId });
440
+ const objectId = resolved.object?.objectId;
441
+ if (objectId == null)
442
+ continue;
443
+ const changed = await cdp.send("Runtime.callFunctionOn", {
444
+ objectId,
445
+ functionDeclaration: "function(){const v=this.style.getPropertyValue('visibility');const p=this.style.getPropertyPriority('visibility');this.style.setProperty('visibility','hidden','important');return {v,p};}",
446
+ returnByValue: true,
447
+ });
448
+ restores.push({ objectId, value: changed.result?.value?.v ?? "", priority: changed.result?.value?.p ?? "" });
449
+ }
450
+ catch { /* conservative fallback: leave this node painted */ }
451
+ }
452
+ }
453
+ const clip = clipRectForScreenshot(target, viewport);
454
+ const buf = await page.screenshot({ clip, omitBackground: true, type: "png" });
455
+ target.dataUri = `data:image/png;base64,${Buffer.from(buf).toString("base64")}`;
456
+ target.x = clip.x - viewport.x;
457
+ target.y = clip.y - viewport.y;
458
+ target.width = clip.width;
459
+ target.height = clip.height;
460
+ }
461
+ catch { /* retain vector fallback if screenshot itself fails */ }
462
+ finally {
463
+ for (let i = restores.length - 1; i >= 0; i--) {
464
+ const restore = restores[i];
465
+ try {
466
+ await cdp.send("Runtime.callFunctionOn", {
467
+ objectId: restore.objectId,
468
+ functionDeclaration: "function(v,p){if(v==='')this.style.removeProperty('visibility');else this.style.setProperty('visibility',v,p);}",
469
+ arguments: [{ value: restore.value }, { value: restore.priority }],
470
+ });
471
+ }
472
+ catch { /* page teardown */ }
473
+ }
474
+ }
475
+ }
476
+ }
477
+ catch {
478
+ // DOMSnapshot is Chromium-only and mapping can fail for pseudo/fragments.
479
+ // Fall back to the original full-page crop for every unresolved target.
480
+ for (const target of targets) {
481
+ try {
482
+ const clip = clipRectForScreenshot(target, viewport);
483
+ const buf = await page.screenshot({ clip, omitBackground: true, type: "png" });
484
+ target.dataUri = `data:image/png;base64,${Buffer.from(buf).toString("base64")}`;
485
+ }
486
+ catch { /* leave dataUri absent */ }
487
+ }
488
+ }
489
+ finally {
490
+ await cdp?.detach().catch(() => undefined);
491
+ await page.evaluate(() => {
492
+ for (const el of document.querySelectorAll("[data-domotion-backdrop-raster]"))
493
+ el.removeAttribute("data-domotion-backdrop-raster");
494
+ }).catch(() => undefined);
495
+ }
496
+ }
385
497
  export async function rasterizeBitmapGlyphs(page, tree, viewport) {
498
+ await rasterizeBackdropFilters(page, tree, viewport);
386
499
  // Two kinds of candidates share the pipeline:
387
500
  // - Segment-level rasterRect (SK-1058): the whole pseudo text is a color-
388
501
  // bitmap run; renderer emits one <image> and skips the text path.
@@ -392,6 +505,23 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
392
505
  const candidates = [];
393
506
  const sbixAligns = [];
394
507
  forEachElement(tree, (el) => {
508
+ if (el.transformSubtreeRaster != null) {
509
+ const tr = el.transformSubtreeRaster;
510
+ candidates.push({
511
+ rect: { x: tr.x, y: tr.y, width: tr.width, height: tr.height },
512
+ key: `transform-subtree|${tr.x}|${tr.y}|${tr.width}x${tr.height}`,
513
+ setDataUri: (uri) => { tr.dataUri = uri; },
514
+ });
515
+ }
516
+ if (el.nativeControlRaster != null) {
517
+ const nr = el.nativeControlRaster;
518
+ candidates.push({
519
+ rect: nr,
520
+ key: `native-control|${el.tag}|${el.styles.inputType ?? ''}|${nr.x}|${nr.y}|${nr.width}x${nr.height}`,
521
+ setDataUri: (uri) => { nr.dataUri = uri; },
522
+ snapRectToClip: true,
523
+ });
524
+ }
395
525
  // Element-level raster (SK-1108): textarea content region, too
396
526
  // involved to word-wrap in the path pipeline. Key on text+size+color so
397
527
  // identical textareas dedupe to one screenshot.
@@ -139,7 +139,9 @@ export declare class DemoRecorder {
139
139
  constructor(baseUrl: string, opts: CaptureOptions);
140
140
  init(opts: CaptureOptions): Promise<void>;
141
141
  /** Navigate to a URL and capture the visible DOM as SVG. */
142
- captureUrl(path: string, waitMs?: number, idPrefix?: string): Promise<string>;
142
+ captureUrl(path: string, waitMs?: number, idPrefix?: string, opts?: {
143
+ networkIdle?: boolean;
144
+ }): Promise<string>;
143
145
  /**
144
146
  * Shared post-capture pipeline (DM-1434): self-contained remote-image
145
147
  * embedding + optional resize + conic-gradient rasterization, then reset the
@@ -191,10 +191,10 @@ export class DemoRecorder {
191
191
  this.page.setDefaultNavigationTimeout(90_000);
192
192
  }
193
193
  /** Navigate to a URL and capture the visible DOM as SVG. */
194
- async captureUrl(path, waitMs = 800, idPrefix = "") {
194
+ async captureUrl(path, waitMs = 800, idPrefix = "", opts) {
195
195
  if (this.page == null)
196
196
  throw new Error("Call init() first");
197
- await this.page.goto(`${this.baseUrl}${path}`, { waitUntil: "networkidle" });
197
+ await this.page.goto(`${this.baseUrl}${path}`, { waitUntil: opts?.networkIdle === true ? "networkidle" : "load" });
198
198
  await this.page.waitForTimeout(waitMs);
199
199
  return this.captureCurrent(idPrefix);
200
200
  }
@@ -338,6 +338,23 @@ export const createEmojiDetect = () => {
338
338
  // emoji over Chrome's text glyph.
339
339
  if (emojiPresentation26.has(cp))
340
340
  return isColorGlyph(cp, font);
341
+ // DM-2167: Emoji_Presentation is not confined to the 2600 block or the
342
+ // supplementary emoji planes. Misc Technical contains default-emoji
343
+ // characters such as WATCH, HOURGLASS and the media-control buttons
344
+ // (U+231A/U+231B/U+23E9..U+23F3). Linux Chromium routes these through
345
+ // Noto Color Emoji, but the old hand-partitioned ranges left them on the
346
+ // outline path, where a CBDT-only face has no vector glyph and vanished.
347
+ // Keep this cascade-sensitive: a leading monochrome face must still win.
348
+ if (cp >= 0x2300 && cp <= 0x23FF
349
+ && RE_EMOJI_PRESENTATION.test(String.fromCodePoint(cp)))
350
+ return isColorGlyph(cp, font);
351
+ // The Mahjong Tiles and Playing Cards blocks are another gap below the
352
+ // historical U+1F300 supplementary-plane floor. Chromium paints MAHJONG
353
+ // TILE RED DRAGON and PLAYING CARD BLACK JOKER from the color font when
354
+ // that is the resolved cascade face; keep text-font coverage authoritative.
355
+ if (cp >= 0x1F000 && cp <= 0x1F0FF
356
+ && RE_EMOJI_PRESENTATION.test(String.fromCodePoint(cp)))
357
+ return isColorGlyph(cp, font);
341
358
  // U+FE0F (Variation Selector-16) after a base emoji codepoint requests
342
359
  // emoji presentation — Chrome paints the colorful glyph instead of the
343
360
  // text-mode path glyph. DM-278.
@@ -425,8 +442,12 @@ export const createEmojiDetect = () => {
425
442
  // ⭐, ⭕). Cascade-dependent — Chrome paints text when the stack reaches a
426
443
  // monochrome symbol/math font first (Apple Symbols / STIX Two Math), color
427
444
  // otherwise. See `emojiPresentation2B` above.
445
+ // Some color-font glyphs in this set are intentionally achromatic (the
446
+ // black/white square pair). A saturation-only probe mistakes those bitmap
447
+ // glyphs for text. Color fonts ignore CSS fill while monochrome symbol
448
+ // fonts inherit it, so fill invariance supplies the missing discriminator.
428
449
  if (emojiPresentation2B.has(cp))
429
- return isColorGlyph(cp, font);
450
+ return isColorGlyph(cp, font) || ignoresFillColor(cp, font);
430
451
  // DM-1167: the ONLY two codepoints in the Misc Symbols & Pictographs block
431
452
  // (U+1F300-1F5FF) that a macOS text font also covers monochrome are
432
453
  // 🌐 U+1F310 (GLOBE WITH MERIDIANS) and 🎤 U+1F3A4 (MICROPHONE) — Apple
@@ -0,0 +1,2 @@
1
+ /** Collect Blink's CSSOM view of author @font-feature-values rules. */
2
+ export declare function collectFontFeatureValues(doc: any): {};
@@ -0,0 +1,62 @@
1
+ // @ts-nocheck
2
+ /** Collect Blink's CSSOM view of author @font-feature-values rules. */
3
+ export function collectFontFeatureValues(doc) {
4
+ const families = {};
5
+ const categories = ["annotation", "ornaments", "stylistic", "swash", "characterVariant", "styleset"];
6
+ function familyNames(css) {
7
+ const out = [];
8
+ let token = "", quote = "";
9
+ for (const ch of css) {
10
+ if (quote) {
11
+ if (ch === quote)
12
+ quote = "";
13
+ else
14
+ token += ch;
15
+ continue;
16
+ }
17
+ if (ch === "\"" || ch === "'") {
18
+ quote = ch;
19
+ continue;
20
+ }
21
+ if (ch === ",") {
22
+ if (token.trim())
23
+ out.push(token.trim().toLowerCase());
24
+ token = "";
25
+ }
26
+ else
27
+ token += ch;
28
+ }
29
+ if (token.trim())
30
+ out.push(token.trim().toLowerCase());
31
+ return out;
32
+ }
33
+ function visit(rules) {
34
+ if (!rules)
35
+ return;
36
+ for (const rule of rules) {
37
+ if (typeof rule.fontFamily === "string" && rule.stylistic != null) {
38
+ for (const family of familyNames(rule.fontFamily)) {
39
+ const table = families[family] || (families[family] = {});
40
+ for (const category of categories) {
41
+ const entries = Array.from(rule[category].entries());
42
+ if (!entries.length)
43
+ continue;
44
+ const aliases = table[category] || (table[category] = {});
45
+ for (const [name, values] of entries)
46
+ aliases[name] = Array.from(values);
47
+ }
48
+ }
49
+ }
50
+ else if (rule.cssRules) {
51
+ visit(rule.cssRules);
52
+ }
53
+ }
54
+ }
55
+ for (const sheet of doc.styleSheets) {
56
+ try {
57
+ visit(sheet.cssRules);
58
+ }
59
+ catch { /* cross-origin sheet */ }
60
+ }
61
+ return families;
62
+ }
@@ -22,6 +22,7 @@ import { createDottedCircleDetect } from "./dotted-circle-detect.js";
22
22
  import { createFontMetrics } from "./font-metrics.js";
23
23
  import { createPlaceholderShown } from "./placeholder-shown.js";
24
24
  import { createFontFamilyDefault } from "./font-family-default.js";
25
+ import { collectFontFeatureValues } from "./font-feature-values.js";
25
26
  import { createPseudoRules } from "./pseudo-rules.js";
26
27
  import { createWarnings } from "./warnings.js";
27
28
  import { createCounterStyleResolver } from "./walker/counter-style-resolver.js";
@@ -48,6 +49,7 @@ const captureDocumentTree = (args) => {
48
49
  // `--cross-origin-frames` value, passed in as `args.cof`). null in the
49
50
  // default (Phase 1) configuration — only same-origin frames recurse then.
50
51
  const _crossOriginAllow = parseCrossOriginAllowlist(args.cof);
52
+ let _backdropRasterSeq = 0;
51
53
  // Wire up per-concern helpers. Each factory closes over its own state and
52
54
  // returns the handles captureInner / the orchestration tail call. Renamed
53
55
  // (e.g. `warnings: _warnings`) to keep captureInner's existing references
@@ -58,6 +60,15 @@ const captureDocumentTree = (args) => {
58
60
  const { measureFontMetrics: _measureFontMetrics, substituteAliasedFamilies: _substituteAliasedFamilies } = createFontMetrics();
59
61
  const { resolvePlaceholderShownBg: _resolvePlaceholderShownBg } = createPlaceholderShown();
60
62
  const { familyIsUADefault: _familyIsUADefault } = createFontFamilyDefault();
63
+ const _fontFeatureValuesByDocument = new WeakMap();
64
+ const _fontFeatureValuesFor = (doc) => {
65
+ let tables = _fontFeatureValuesByDocument.get(doc);
66
+ if (tables == null) {
67
+ tables = collectFontFeatureValues(doc);
68
+ _fontFeatureValuesByDocument.set(doc, tables);
69
+ }
70
+ return tables;
71
+ };
61
72
  const { resolvePseudo: _resolvePseudo, resolveCornerRadius: _resolveCornerRadius } = createPseudoRules();
62
73
  const { warn, shortSelector, warnings: _warnings } = createWarnings();
63
74
  // DM-770: counter-style map is populated by the pre-walk below (which
@@ -69,7 +80,7 @@ const captureDocumentTree = (args) => {
69
80
  // against a recursed iframe's own document (`_runCounterStylePrewalk(doc)`).
70
81
  const _runCounterStylePrewalk = createCounterStylePrewalk({ counterStyles: _counterStyles });
71
82
  const { resolveCounterStyle, resolveCounterValue, isCustomCounterStyle } = createCounterStyleResolver({ counterStyles: _counterStyles });
72
- const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle });
83
+ const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle, measureFontMetrics: _measureFontMetrics });
73
84
  const { handleReplacedElement } = createReplacedElementsHandler({ vp });
74
85
  const { discoverMasks, discoverClipPaths, discoverFilters, maskDefs: _maskDefs, maskRasters: _maskRasters, clipPathDefs: _clipPathDefs, filterDefs: _filterDefs } = createMasksClipsHandler({ vp, warn });
75
86
  const { captureFormControls } = createFormControlsHandler({ normColor, resolvePseudo: _resolvePseudo });
@@ -206,7 +217,7 @@ const captureDocumentTree = (args) => {
206
217
  // them so the fidelity gaps are self-documenting.
207
218
  const sel = shortSelector(el);
208
219
  if (cs.transform && cs.transform.startsWith('matrix3d')) {
209
- warn(sel, 'transform-3d', 'matrix3d/translate3d/rotate3d/perspective downgraded to 2D submatrix; z component + perspective dropped (SK-1135)');
220
+ warn(sel, 'transform-3d', 'non-affine 3D rendering context captured from Chromium as a bitmap snapshot');
210
221
  }
211
222
  if (cs.backdropFilter && cs.backdropFilter !== 'none') {
212
223
  warn(sel, 'backdrop-filter', 'approximated via a frosted-glass background fallback for the transparent-backdrop case (doc 19); no true backdrop blur');
@@ -612,6 +623,11 @@ const captureDocumentTree = (args) => {
612
623
  fontStretch: cs.fontStretch,
613
624
  fontVariationSettings: cs.fontVariationSettings,
614
625
  fontFeatureSettings: cs.fontFeatureSettings,
626
+ fontVariantAlternates: cs.fontVariantAlternates,
627
+ // The alias table is document-global but only alternate-bearing nodes
628
+ // can consume it; omit it from the common element shape to avoid
629
+ // repeating author rule data throughout the serialized tree.
630
+ fontFeatureValues: cs.fontVariantAlternates && cs.fontVariantAlternates !== 'normal' ? _fontFeatureValuesFor(el.ownerDocument) : undefined,
615
631
  // CSS font-variant-caps. 'small-caps' / 'all-small-caps' route to
616
632
  // the OpenType smcp feature; renderer applies synthesized small-caps
617
633
  // when the active font lacks smcp (Helvetica, Times, etc.). DM-361.
@@ -711,6 +727,83 @@ const captureDocumentTree = (args) => {
711
727
  // SK-1108 / SK-1128: textarea soft-wrap + writing-mode != horizontal-tb
712
728
  // content-box raster rect — see walker/text-segments.ts.
713
729
  elementRaster: computeElementRaster(el, cs, tag, rect, vp),
730
+ // DM-2150: SVG transforms are affine and cannot reproduce CSS
731
+ // perspective or preserve-3d flattening. Capture the top-level 3D
732
+ // rendering context as Chromium composited it. Descendant matrix3d
733
+ // nodes remain part of this one bitmap rather than being stamped
734
+ // independently. Include overflowing faces by unioning live descendant
735
+ // client rects before the walker freezes any child transforms.
736
+ transformSubtreeRaster: (function () {
737
+ const is3dRoot = cs.transformStyle === 'preserve-3d'
738
+ || (cs.perspective != null && cs.perspective !== '' && cs.perspective !== 'none');
739
+ if (!is3dRoot)
740
+ return undefined;
741
+ let p = el.parentElement;
742
+ while (p != null) {
743
+ const pcs = getComputedStyle(p);
744
+ if (pcs.transformStyle === 'preserve-3d'
745
+ || (pcs.perspective != null && pcs.perspective !== '' && pcs.perspective !== 'none'))
746
+ return undefined;
747
+ p = p.parentElement;
748
+ }
749
+ let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
750
+ const descendants = el.getElementsByTagName('*');
751
+ for (let i = 0; i < descendants.length; i++) {
752
+ const dr = descendants[i].getBoundingClientRect();
753
+ if (dr.width <= 0 || dr.height <= 0)
754
+ continue;
755
+ left = Math.min(left, dr.left);
756
+ top = Math.min(top, dr.top);
757
+ right = Math.max(right, dr.right);
758
+ bottom = Math.max(bottom, dr.bottom);
759
+ }
760
+ return { x: left - vp.x, y: top - vp.y, width: right - left, height: bottom - top };
761
+ })(),
762
+ // DM-2149: `appearance:auto` controls are painted by Blink's platform
763
+ // LayoutTheme (including native shadow-DOM parts), so a single hardcoded
764
+ // SVG geometry/palette cannot match macOS, Windows, and Linux. Preserve
765
+ // author-owned `appearance:none` controls as vectors; snapshot only the
766
+ // native-themed host rectangle from the same Chromium doing capture.
767
+ nativeControlRaster: (function () {
768
+ const nativeTag = tag === 'input' || tag === 'select' || tag === 'textarea'
769
+ || tag === 'button' || tag === 'progress' || tag === 'meter';
770
+ if (!nativeTag || cs.appearance === 'none' || rect.width <= 0 || rect.height <= 0)
771
+ return undefined;
772
+ // Author outlines and validation-state focus rings paint outside the
773
+ // host border box even though the themed control itself is native.
774
+ // Blink's outline visual overflow is width + positive offset; include
775
+ // that surface in the same snapshot instead of clipping it at `rect`.
776
+ const outlineWidth = parseFloat(cs.outlineWidth) || 0;
777
+ const outlineOffset = parseFloat(cs.outlineOffset) || 0;
778
+ const expand = cs.outlineStyle !== 'none' && cs.outlineStyle !== 'hidden'
779
+ ? Math.max(0, outlineWidth + outlineOffset)
780
+ : 0;
781
+ // Skia AA coverage may extend one device-independent pixel past the
782
+ // layout border box (notably the lower edge of rounded author borders
783
+ // on otherwise native inputs). Preserve that visual-overflow fringe;
784
+ // the screenshot and emitted <image> use this same rect, so no scaling
785
+ // or fixture geometry is introduced.
786
+ const paintOverflow = 1;
787
+ const rasterExpand = expand + paintOverflow;
788
+ return {
789
+ x: rect.left - vp.x - rasterExpand,
790
+ y: rect.top - vp.y - rasterExpand,
791
+ width: rect.width + rasterExpand * 2,
792
+ height: rect.height + rasterExpand * 2,
793
+ };
794
+ })(),
795
+ // DM-2171: backdrop-filter samples already-painted content behind this
796
+ // element through a distinct Blink effect node. An img-rendered SVG has
797
+ // no equivalent input surface, so preserve Chromium's composited pixels
798
+ // for the complete isolation subtree at its paint-order position.
799
+ backdropFilterRaster: (function () {
800
+ const value = cs.backdropFilter || cs.webkitBackdropFilter || '';
801
+ if (value === '' || value === 'none' || rect.width <= 0 || rect.height <= 0)
802
+ return undefined;
803
+ const token = 'bf' + (_backdropRasterSeq++);
804
+ el.setAttribute('data-domotion-backdrop-raster', token);
805
+ return { x: rect.left - vp.x, y: rect.top - vp.y, width: rect.width, height: rect.height, token };
806
+ })(),
714
807
  // DM-680: per-axis cumulative ancestor scale, exposed ONLY when
715
808
  // anisotropic (sx ≠ sy within a small epsilon). The geometric mean is
716
809
  // already folded into fontSize / fontAscent / fontDescent above, so
@@ -1516,23 +1609,72 @@ const captureDocumentTree = (args) => {
1516
1609
  }
1517
1610
  return out;
1518
1611
  }
1519
- // Active counter scope stack: each entry { name, value, owner }.
1520
- const _activeScopes = [];
1612
+ // Blink keeps one stack per counter name. A counter introduced on an
1613
+ // element remains visible to later siblings because its originating
1614
+ // element's parent is still an ancestor of those siblings.
1615
+ const _counterStacks = new Map();
1616
+ const _isAncestorOrSelf = (ancestor, node) => ancestor === node || ancestor.contains(node);
1617
+ function _stack(name) {
1618
+ let stack = _counterStacks.get(name);
1619
+ if (stack == null) {
1620
+ stack = [];
1621
+ _counterStacks.set(name, stack);
1622
+ }
1623
+ return stack;
1624
+ }
1625
+ function _removeStale(name, el) {
1626
+ const stack = _stack(name);
1627
+ while (stack.length > 0) {
1628
+ const parent = stack[stack.length - 1].scopeParent;
1629
+ if (parent == null || _isAncestorOrSelf(parent, el))
1630
+ break;
1631
+ stack.pop();
1632
+ }
1633
+ }
1521
1634
  function _findInnermost(name) {
1522
- for (let i = _activeScopes.length - 1; i >= 0; i--) {
1523
- if (_activeScopes[i].name === name)
1524
- return _activeScopes[i];
1635
+ const stack = _stack(name);
1636
+ return stack.length ? stack[stack.length - 1] : null;
1637
+ }
1638
+ function _snapshotCounters() {
1639
+ const result = [];
1640
+ for (const [name, stack] of _counterStacks)
1641
+ for (const entry of stack)
1642
+ result.push({ name, value: entry.value });
1643
+ return result;
1644
+ }
1645
+ function _applyCounterStyle(owner, scopeParent, style) {
1646
+ const touched = new Set();
1647
+ const resets = _parseCounterDecl(style.counterReset, 0);
1648
+ const increments = _parseCounterDecl(style.counterIncrement, 1);
1649
+ const sets = _parseCounterDecl(style.counterSet, 0);
1650
+ for (const item of [...resets, ...increments, ...sets])
1651
+ touched.add(item.name);
1652
+ for (const name of touched)
1653
+ _removeStale(name, owner);
1654
+ for (const { name, value } of resets) {
1655
+ const stack = _stack(name);
1656
+ if (stack.length && stack[stack.length - 1].scopeParent === scopeParent)
1657
+ stack.pop();
1658
+ stack.push({ name, value, owner, scopeParent });
1525
1659
  }
1526
- return null;
1660
+ for (const { name, value } of increments) {
1661
+ const current = _findInnermost(name);
1662
+ if (current)
1663
+ current.value += value;
1664
+ else
1665
+ _stack(name).push({ name, value, owner, scopeParent });
1666
+ }
1667
+ for (const { name, value } of sets) {
1668
+ const current = _findInnermost(name);
1669
+ if (current)
1670
+ current.value = value;
1671
+ else
1672
+ _stack(name).push({ name, value, owner, scopeParent });
1673
+ }
1674
+ return touched;
1527
1675
  }
1528
1676
  function _counterPreWalk(el) {
1529
1677
  const cs = window.getComputedStyle(el);
1530
- const owned = [];
1531
- _parseCounterDecl(cs.counterReset, 0).forEach(({ name, value }) => {
1532
- const scope = { name, value, owner: el };
1533
- _activeScopes.push(scope);
1534
- owned.push(scope);
1535
- });
1536
1678
  // DM-705 / DM-706: CSS Lists 3 §2.3 ("Properties on a single element are
1537
1679
  // processed in the order reset, increment, set") — increment runs BEFORE
1538
1680
  // set. Our previous order (reset, set, increment) made
@@ -1540,36 +1682,28 @@ const captureDocumentTree = (args) => {
1540
1682
  // section` paint as "100." instead of Chrome's "99." for the
1541
1683
  // `.restart` h2 in `24-counters.html`. Same off-by-one (always +1) in
1542
1684
  // `24-deep-counter-scope.html`.
1543
- _parseCounterDecl(cs.counterIncrement, 1).forEach(({ name, value }) => {
1544
- const s = _findInnermost(name);
1545
- if (s)
1546
- s.value += value;
1547
- else {
1548
- const ns = { name, value, owner: el };
1549
- _activeScopes.push(ns);
1550
- owned.push(ns);
1551
- }
1552
- });
1553
- _parseCounterDecl(cs.counterSet, 0).forEach(({ name, value }) => {
1554
- const s = _findInnermost(name);
1555
- if (s)
1556
- s.value = value;
1557
- else {
1558
- const ns = { name, value, owner: el };
1559
- _activeScopes.push(ns);
1560
- owned.push(ns);
1561
- }
1562
- });
1563
- // Snapshot the active scopes (shallow copy of name+value pairs).
1564
- _counterSnapshot.set(el, _activeScopes.map((s) => ({ name: s.name, value: s.value })));
1685
+ const touched = _applyCounterStyle(el, el.parentElement, cs);
1686
+ const beforeStyle = window.getComputedStyle(el, '::before');
1687
+ for (const name of _applyCounterStyle(el, el, beforeStyle))
1688
+ touched.add(name);
1689
+ const snapshots = { element: _snapshotCounters(), '::before': _snapshotCounters(), '::after': null };
1565
1690
  for (const child of el.children)
1566
1691
  _counterPreWalk(child);
1567
- // Pop scopes owned by this element on exit (counter scope ends with the
1568
- // owner element's subtree).
1569
- while (_activeScopes.length > 0 && owned.length > 0
1570
- && _activeScopes[_activeScopes.length - 1] === owned[owned.length - 1]) {
1571
- _activeScopes.pop();
1572
- owned.pop();
1692
+ const afterStyle = window.getComputedStyle(el, '::after');
1693
+ for (const name of _applyCounterStyle(el, el, afterStyle))
1694
+ touched.add(name);
1695
+ snapshots['::after'] = _snapshotCounters();
1696
+ _counterSnapshot.set(el, snapshots);
1697
+ // Match CountersAttachmentContext::RemoveCounterIfAncestorExists: a
1698
+ // descendant-origin counter cannot remain atop an ancestor counter after
1699
+ // leaving its originating element.
1700
+ for (const name of touched) {
1701
+ const stack = _stack(name);
1702
+ if (stack.length < 2 || stack[stack.length - 1].owner !== el)
1703
+ continue;
1704
+ const previous = stack[stack.length - 2].owner;
1705
+ if (previous instanceof Element && previous.contains(el))
1706
+ stack.pop();
1573
1707
  }
1574
1708
  }
1575
1709
  _counterPreWalk(root);