domotion-svg 0.17.0 → 0.18.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 (35) hide show
  1. package/FEATURES.md +5 -1
  2. package/README.md +3 -1
  3. package/dist/animation/animator.js +16 -31
  4. package/dist/animation/composite.js +4 -4
  5. package/dist/capture/index.d.ts +54 -0
  6. package/dist/capture/index.js +92 -24
  7. package/dist/capture/script/cross-origin.d.ts +17 -0
  8. package/dist/capture/script/cross-origin.js +83 -0
  9. package/dist/capture/script/index.js +259 -4
  10. package/dist/capture/script/walker/counter-prewalk.d.ts +1 -1
  11. package/dist/capture/script/walker/counter-prewalk.js +6 -3
  12. package/dist/capture/script/walker/masks-clips.js +16 -4
  13. package/dist/capture/script/walker/replaced-elements.js +6 -1
  14. package/dist/capture/script.generated.js +1 -1
  15. package/dist/cli/capture.js +76 -39
  16. package/dist/cli/common.d.ts +5 -6
  17. package/dist/cli/common.js +5 -24
  18. package/dist/cli/index.js +6 -0
  19. package/dist/cli/scrubber.js +18 -0
  20. package/dist/post-processing/optimize.js +7 -0
  21. package/dist/render/element-tree-to-svg.js +851 -760
  22. package/dist/render/mask.js +271 -252
  23. package/dist/render/text.js +224 -198
  24. package/dist/render/vertical-text.js +2 -3
  25. package/dist/scroll/composer.js +46 -32
  26. package/dist/scrubber/client.bundle.generated.js +1 -1
  27. package/dist/scrubber/client.d.ts +2 -0
  28. package/dist/scrubber/client.js +181 -5
  29. package/dist/scrubber/server.d.ts +90 -0
  30. package/dist/scrubber/server.js +139 -2
  31. package/dist/tree-ops/viewbox-culling.js +1 -1
  32. package/dist/utils/transparent-background.d.ts +22 -0
  33. package/dist/utils/transparent-background.js +40 -0
  34. package/llms.txt +3 -3
  35. package/package.json +1 -1
package/FEATURES.md CHANGED
@@ -57,7 +57,11 @@ Each feature has a visual regression test that compares HTML-to-PNG with SVG-to-
57
57
  - [x] **replaced-video-poster**: `<video poster=…>` paused — poster image captured
58
58
  - [x] **replaced-canvas-overlay**: `<canvas>` under a positioned `<div z-index:10>` overlay — overlay does NOT bleed into the canvas snapshot
59
59
  - [x] **replaced-canvas-fixed-overlay**: `<canvas>` under a sibling-positioned `<div>` painting on top — sibling does NOT bleed into the canvas snapshot
60
- - [x] **replaced-iframe-same-origin**: same-origin `<iframe>` (srcdoc) — iframe content paints into the snapshot
60
+ - [x] **replaced-iframe-same-origin** (DM-1441): same-origin `<iframe>` (srcdoc) — content **recurses into native SVG** (crisp/scalable/selectable), not rastered; pixel-identical to the prior snapshot. See [docs/81](docs/81-iframe-recursion.md).
61
+ - [x] **iframe-recursion-bordered** (DM-1441): same-origin iframe recursion through a non-zero border + padding on the iframe — inner document's origin lands at the iframe **content box** and the inner subtree clips to it.
62
+ - [x] **iframe-canvas-bg-fill** (DM-1448): recursed iframe **taller than its content** — the inner document's propagated canvas background (html-bg-or-else-body-bg) fills the whole inner viewport, so the strip below the content paints the canvas color instead of showing through to the outer page.
63
+ - [x] **iframe-inner-clip-mask** (DM-1446): recursed iframe whose inner content uses `clip-path: url(#id)` + `mask-image: url(#id)` defined in the **iframe's own** `<defs>` — fragment refs resolve against `el.ownerDocument`, so the `<clipPath>`/`<mask>` defs hoist and paint (clipped circle + masked square-with-hole, 0.00%). (Inner `element(#id)` paint refs are frame-aware — DM-1447 — though CSS `element()` itself is unsupported in Chromium / dormant, DM-1450; tall-iframe canvas-bg fill shipped in DM-1448.)
64
+ - [x] **cross-origin iframe recursion** (DM-1442): `--cross-origin-frames "*"|host[:port],…` (config: `captureCrossOriginFrames`) recurses **allowlisted** cross-origin frames into native SVG by launching Chromium with web security disabled; non-allowlisted frames stay raster. Default off + a stderr security warning (disabling web security disables CORS). Unit + e2e tested (`tests/cross-origin-iframe-recursion.e2e.test.ts`). See [docs/81](docs/81-iframe-recursion.md).
61
65
  - [x] **snapshot-isolation-pseudo-overlay** (DM-458, `tests/snapshot-isolation.tsx`): canvas covered by a sibling's `::after` pseudo overlay. Inspection-style — decodes the captured snapshot's PNG data URI and asserts no overlay-color pixels leaked through. Catches regressions in the hide-everything-else stylesheet that a comparison-style fixture wouldn't.
62
66
 
63
67
  ### Showcase Integration Tests
package/README.md CHANGED
@@ -79,6 +79,8 @@ 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
+ 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
+
82
84
  For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
83
85
 
84
86
  ```bash
@@ -150,7 +152,7 @@ svg-review --expected example.debug/expected.png --actual example.debug/actual.s
150
152
 
151
153
  The browser opens a single review card showing the expected / actual / diff PNGs. Arrow keys cycle through the three at full size; drag on any image to mark a problem region and caption it. The side panel builds a GitHub-issue-ready Markdown block as you go — copy it, then file the issue at <https://github.com/brianwestphal/domotion/issues/new> and attach `expected.png` + `actual.svg` so a maintainer can reproduce.
152
154
 
153
- For an *animated* SVG, the package also ships `svg-scrubber` — a local video-style bench to play / pause / scrub / mark an in-out range, export the current frame as PNG, export the range as MP4, or trim it to a new self-contained animated SVG.
155
+ For an *animated* SVG, the package also ships `svg-scrubber` — a local video-style bench to play / pause / scrub / mark an in-out range, export the current frame as PNG, export the range as MP4, or trim it to a new self-contained animated SVG. Add `--review` to file a focused issue against a moment in the timeline: it writes an importable `.ticket` (frame time, range, and drawn regions) the same way `svg-review` builds a report for a still.
154
156
 
155
157
  ### Scripting API
156
158
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
8
8
  import { escapeHtml } from "../utils/escapeHtml.js";
9
+ import { isTransparentBackground } from "../utils/transparent-background.js";
9
10
  import { DEFAULT_TRANSITION_MS, frameAdvanceMs, transitionDurationMs } from "./frame-timeline.js";
10
11
  import { offsetEmbeddedAnimatedSvgTimeline } from "./embed-timeline.js";
11
12
  import { KEYFRAME_EPSILON, padAfter, padBefore } from "../utils/keyframe-pad.js";
@@ -38,7 +39,7 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
38
39
  ${afterH}% { opacity: 0; visibility: hidden; }
39
40
  100% { opacity: 0; visibility: hidden; }
40
41
  }
41
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
42
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
42
43
  // Bridge composite: visible during the transition window only.
43
44
  groups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
44
45
  keyframes.push(`
@@ -50,7 +51,7 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
50
51
  ${afterT}% { opacity: 0; visibility: hidden; }
51
52
  100% { opacity: 0; visibility: hidden; }
52
53
  }
53
- .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
54
+ .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
54
55
  // Per-element slide / fade keyframes within the window (linear interp).
55
56
  // The composite is only visible [holdEnd..transEnd], so the held values
56
57
  // outside that window are never painted — they just pin the endpoints.
@@ -111,18 +112,11 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
111
112
  }
112
113
  return { groups, keyframes };
113
114
  }
114
- /**
115
- * Emit one crossfade or cut frame (the default transition path): the frame
116
- * blob plus its opacity keyframes. `cut` (or zero-duration) uses disjoint
117
- * step-end keyframes so opacity flips instantly with no interpolation smear;
118
- * crossfade overlaps the fade-in with the previous frame's fade-out, with the
119
- * visible window driven by `fadeInStartPct` (precomputed by the caller, which
120
- * knows the overlap state). Extracted from `generateAnimatedSvg` (DM-1089).
121
- */
122
- function emitCrossfadeOrCutFrame(i, frame, transType, transDur, startPct, holdEndPct, transEndPct, fadeInStartPct, totalSec,
115
+ function emitCrossfadeOrCutFrame(i, frame, transType, transDur, win, totalSec,
123
116
  /** DM-1148: the last frame, when the loop must NOT cross-dissolve, holds
124
117
  * opacity 1 to 100% instead of fading out over its transition window. */
125
118
  holdToEnd) {
119
+ const { startPct, holdEndPct, transEndPct, fadeInStartPct } = win;
126
120
  const groups = [];
127
121
  const keyframes = [];
128
122
  groups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
@@ -141,7 +135,7 @@ holdToEnd) {
141
135
  ${afterEnd}% { opacity: 0; visibility: hidden; }
142
136
  100% { opacity: 0; visibility: hidden; }
143
137
  }
144
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
138
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
145
139
  }
146
140
  else {
147
141
  const prevEnd = i > 0
@@ -212,18 +206,9 @@ export function dedupeFrameIds(frames) {
212
206
  return { ...frame, svgContent: out };
213
207
  });
214
208
  }
215
- /**
216
- * Push-left (horizontal) / scroll (vertical) slide frame. Both emit the SAME
217
- * clipped frame group and a `slideKeyframes` track they differ only in the
218
- * EXIT slide axis (`X`/`Y`) and the slid extent (viewport width / height). The
219
- * group rect always spans `width × height`. The frame's ENTRANCE is composed
220
- * separately from `enter` (DM-1414): a slide frame after a crossfade fades in,
221
- * after a different-axis slide slides in on the predecessor's axis, etc.
222
- * Extracted from generateAnimatedSvg (DM-1375), mirroring emit*Frame.
223
- */
224
- function emitSlideFrame(i, svgContent, exitAxis, exitSize, enter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame) {
225
- const group = ` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${width}" height="${height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${svgContent}\n </g></g>`;
226
- const keyframe = slideKeyframes(i, exitAxis, exitSize, enter, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec, holdLastFrame);
209
+ function emitSlideFrame(i, svgContent, exitAxis, exitSize, enter, dims, win, totalSec, holdLastFrame) {
210
+ const group = ` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${dims.width}" height="${dims.height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${svgContent}\n </g></g>`;
211
+ const keyframe = slideKeyframes(i, exitAxis, exitSize, enter, win.enterStartPct, win.startPct, win.holdEndPct, win.transEndPct, win.enterStartPct, win.transEndPct, totalSec, holdLastFrame);
227
212
  return { group, keyframe };
228
213
  }
229
214
  /**
@@ -361,7 +346,7 @@ export function generateAnimatedSvg(config) {
361
346
  // The parallel `fd-${i}` display snap (inside slideKeyframes) lets the
362
347
  // browser skip painting this frame's content while it's fully off-screen
363
348
  // between cycles (DM-599).
364
- const r = emitSlideFrame(i, frame.svgContent, "X", width, slideEnter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame);
349
+ const r = emitSlideFrame(i, frame.svgContent, "X", width, slideEnter, { width, height }, { enterStartPct, startPct, holdEndPct, transEndPct }, totalSec, holdLastFrame);
365
350
  frameGroups.push(r.group);
366
351
  keyframes.push(r.keyframe);
367
352
  }
@@ -369,7 +354,7 @@ export function generateAnimatedSvg(config) {
369
354
  // DM-609: `scroll` is a real geometric scroll — the vertical equivalent of
370
355
  // push-left (translateY over height instead of translateX over width),
371
356
  // otherwise identical machinery. Exit slides up; enter per `slideEnter`.
372
- const r = emitSlideFrame(i, frame.svgContent, "Y", height, slideEnter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame);
357
+ const r = emitSlideFrame(i, frame.svgContent, "Y", height, slideEnter, { width, height }, { enterStartPct, startPct, holdEndPct, transEndPct }, totalSec, holdLastFrame);
373
358
  frameGroups.push(r.group);
374
359
  keyframes.push(r.keyframe);
375
360
  }
@@ -399,7 +384,7 @@ export function generateAnimatedSvg(config) {
399
384
  // holds-then-cuts — so this is a no-op for cut frames.
400
385
  const isCutFrame = transType === "cut" || transDur === 0;
401
386
  const holdToEnd = i === frames.length - 1 && config.loopFade !== true && !isCutFrame;
402
- const r = emitCrossfadeOrCutFrame(i, frame, transType, transDur, startPct, holdEndPct, transEndPct, fadeInStartPct, totalSec, holdToEnd);
387
+ const r = emitCrossfadeOrCutFrame(i, frame, transType, transDur, { startPct, holdEndPct, transEndPct, fadeInStartPct }, totalSec, holdToEnd);
403
388
  frameGroups.push(...r.groups);
404
389
  keyframes.push(...r.keyframes);
405
390
  }
@@ -434,7 +419,7 @@ export function generateAnimatedSvg(config) {
434
419
  // Default (none / transparent) emits nothing so the SVG composites over the
435
420
  // host page, matching the single-frame `transparentRootBgRect` path (DM-554).
436
421
  const bg = config.background;
437
- const canvasBgRect = (bg != null && bg !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)")
422
+ const canvasBgRect = (bg != null && !isTransparentBackground(bg))
438
423
  ? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
439
424
  : "";
440
425
  const out = `<?xml version="1.0" encoding="UTF-8"?>
@@ -879,7 +864,7 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
879
864
  const enterId = `${id}-enter`;
880
865
  cssRules.push(`
881
866
  @keyframes ${enterId} { 0% { transform: ${fromStr}; } ${pct(enterStart, totalDuration)} { transform: ${fromStr}; } ${pct(enterEnd, totalDuration)} { transform: translate(0, 0); } 100% { transform: translate(0, 0); } }
882
- .${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
867
+ .${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s ${easing} infinite; }`);
883
868
  }
884
869
  // Slide-out exit. Mirror of enter — translate from (0,0) to off-screen.
885
870
  if (overlay.exit != null) {
@@ -891,7 +876,7 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
891
876
  const exitId = `${id}-exit`;
892
877
  cssRules.push(`
893
878
  @keyframes ${exitId} { 0%, ${pct(exitStart, totalDuration)} { transform: translate(0, 0); } ${pct(exitStart + e.duration, totalDuration)} { transform: ${toStr}; } 100% { transform: ${toStr}; } }
894
- .${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
879
+ .${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s ${easing} infinite; }`);
895
880
  }
896
881
  // Markup: outer wrapper translates to (x, y) and clips, inner wrapper
897
882
  // carries the visibility class, then the enter/exit transform wrapper, then
@@ -999,7 +984,7 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
999
984
  ${endPct.toFixed(3)}% { ${propValue(a.to)} }
1000
985
  100% { ${propValue(a.to)} }
1001
986
  }
1002
- .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing};${originDecl} }`);
987
+ .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s ${easing} infinite;${originDecl} }`);
1003
988
  }
1004
989
  }
1005
990
  }
@@ -29,6 +29,7 @@
29
29
  * one-transform-animation-per-element rule, layer transforms live on the layer's
30
30
  * own wrapper group, separate from the nested content's animations.
31
31
  */
32
+ import { isTransparentBackground } from "../utils/transparent-background.js";
32
33
  import { namespaceEmbeddedAnimatedSvg } from "./embed-namespace.js";
33
34
  import { offsetEmbeddedAnimatedSvgTimeline } from "./embed-timeline.js";
34
35
  import { parseSvgIntrinsicSize, fmt, clampPct } from "./svg-meta.js";
@@ -163,10 +164,9 @@ export function composeAnimatedLayers(layers, opts) {
163
164
  }
164
165
  groups.push(`<g class="${token}layer"${clipAttr}${groupStyle}>${nested}</g>`);
165
166
  }
166
- // Transparent when empty / `transparent` / a fully-transparent rgba (matched
167
- // whitespace-insensitively, so `rgba(0,0,0,0)` and `rgba(0, 0, 0, 0)` both count).
168
- const bgNorm = (opts.background ?? "").replace(/\s+/g, "").toLowerCase();
169
- const bg = bgNorm === "" || bgNorm === "transparent" || bgNorm === "rgba(0,0,0,0)"
167
+ // Transparent (no backdrop rect) for every transparent CSS form keywords,
168
+ // zero-alpha hex, zero-alpha rgba()/hsla() via the canonical predicate.
169
+ const bg = isTransparentBackground(opts.background ?? "")
170
170
  ? ""
171
171
  : `<rect width="${width}" height="${height}" fill="${opts.background}"/>`;
172
172
  const defs = defsParts.join("");
@@ -66,6 +66,16 @@ export interface CaptureOptions {
66
66
  * Values < 1 are clamped to 1.
67
67
  */
68
68
  embedRemoteImagesHiDPIFactor?: number;
69
+ /**
70
+ * DM-1442: opt-in cross-origin `<iframe>` recursion. `"*"` recurses every
71
+ * cross-origin frame; a comma-separated `host[:port]` list recurses only
72
+ * frames whose origin matches an entry (exact host; `:port` requires an exact
73
+ * port, otherwise any port). Undefined / omitted leaves cross-origin frames
74
+ * as raster snapshots (same-origin frames recurse regardless). Enabling this
75
+ * launches Chromium with web security disabled — which also disables CORS, so
76
+ * only use it on trusted pages. See docs/81-iframe-recursion.md.
77
+ */
78
+ captureCrossOriginFrames?: string;
69
79
  }
70
80
  /**
71
81
  * Launch Chromium via Playwright, auto-installing the browser binary on first
@@ -78,6 +88,20 @@ export interface CaptureOptions {
78
88
  * are a normal `chromium.launch()` with no overhead.
79
89
  */
80
90
  export declare function launchChromium(opts?: LaunchOptions): Promise<Browser>;
91
+ /**
92
+ * DM-1442: Chromium launch args needed to make cross-origin `<iframe>`
93
+ * documents readable from the in-page capture script. `--disable-web-security`
94
+ * lifts the Same-Origin Policy on cross-document access; the
95
+ * `--disable-features` flag co-locates frames in one renderer process so the
96
+ * cross-origin `contentDocument` is reachable (some builds need it, harmless on
97
+ * those that don't). Returns `[]` when `value` requests no cross-origin
98
+ * recursion, so callers can spread it unconditionally:
99
+ * `launchChromium({ args: crossOriginFramesLaunchArgs(value) })`.
100
+ *
101
+ * NOTE: disabling web security also disables CORS — only launch this way when
102
+ * capturing your own / trusted pages. Callers should print a visible warning.
103
+ */
104
+ export declare function crossOriginFramesLaunchArgs(value: string | undefined | null): string[];
81
105
  export declare class DemoRecorder {
82
106
  private browser;
83
107
  private context;
@@ -91,6 +115,7 @@ export declare class DemoRecorder {
91
115
  private embedRemoteImagesRetryBackoffMs;
92
116
  private embedRemoteImagesResize;
93
117
  private embedRemoteImagesHiDPIFactor;
118
+ private captureCrossOriginFrames;
94
119
  constructor(baseUrl: string, opts: CaptureOptions);
95
120
  init(opts: CaptureOptions): Promise<void>;
96
121
  /** Navigate to a URL and capture the visible DOM as SVG. */
@@ -213,6 +238,8 @@ export declare function captureElementTree(page: Page, selector: string | undefi
213
238
  y: number;
214
239
  width: number;
215
240
  height: number;
241
+ }, opts?: {
242
+ crossOriginFrames?: string;
216
243
  }): Promise<CapturedElement[]>;
217
244
  /**
218
245
  * Same capture as `captureElementTree` but returns the warnings inline so
@@ -232,10 +259,37 @@ export declare function captureElementTreeWithWarnings(page: Page, selector: str
232
259
  * rotating cross-origin-iframe content. Caller is responsible for
233
260
  * ensuring the source covers the same coordinate space as `viewport`. */
234
261
  rasterizeFromImagePath?: string;
262
+ /** DM-1442: the raw `--cross-origin-frames` allowlist value (`"*"` or a
263
+ * comma-separated `host[:port]` list). Passed into the capture script so
264
+ * cross-origin iframes whose origin is on the list recurse into native SVG
265
+ * instead of staying a raster snapshot. Requires the browser to have been
266
+ * launched with web security disabled (see `crossOriginFramesLaunchArgs`).
267
+ * Same-origin recursion (Phase 1) happens regardless. */
268
+ crossOriginFrames?: string;
235
269
  }): Promise<{
236
270
  tree: CapturedElement[];
237
271
  warnings: CaptureWarning[];
238
272
  }>;
273
+ /**
274
+ * DM-494: Rasterise each element referenced by `mask-image: element(#id)`.
275
+ * Mirrors `rasterizeReplacedElements` (doc 17): hide-everything-else
276
+ * stylesheet → screenshot the target's painted box → encode as a data URI →
277
+ * stash on `tree[0].maskRasters[i].dataUri`. The renderer's `buildMaskDef`
278
+ * picks up the data URI from the root tree's lookup table when it sees an
279
+ * `element()` mask layer.
280
+ *
281
+ * Same-document only — CSS spec doesn't define cross-document `element()`.
282
+ * Always-on (no opt-in flag) — dedupe by referenced id (one screenshot per
283
+ * unique target regardless of how many consumers reference it). Targets that
284
+ * are display:none / 0-area are filtered at capture time and never reach
285
+ * here. See `docs/22-mask-element-paint-references.md`.
286
+ */
287
+ export declare function rasterizeMaskSources(page: Page, tree: CapturedElement[], viewport: {
288
+ x: number;
289
+ y: number;
290
+ width: number;
291
+ height: number;
292
+ }): Promise<void>;
239
293
  /**
240
294
  * Calibrate `fontAscent` on text-bearing elements by scanning a reference
241
295
  * PNG (Chrome's actual paint) for each element's painted ink top, then
@@ -12,6 +12,7 @@ import { resizeEmbeddedImages } from "../tree-ops/resize-embedded-images.js";
12
12
  import { rasterizeConicGradients } from "../render/conic-raster.js";
13
13
  import { resetGeneration, registerLocalFontAlias, registerWebfont } from "../render/text-to-path.js";
14
14
  import { CAPTURE_SCRIPT } from "./script.generated.js";
15
+ import { parseCrossOriginAllowlist } from "./script/cross-origin.js";
15
16
  import { rasterizeBitmapGlyphs } from "./emoji.js";
16
17
  import { clipRectForScreenshot } from "./clip-rect.js";
17
18
  import { refineInitialLetterPositions } from "./initial-letter-probe.js";
@@ -50,6 +51,24 @@ function isMissingBrowserError(err) {
50
51
  const msg = err instanceof Error ? err.message : String(err);
51
52
  return /Executable doesn't exist|playwright install|browserType\.launch.*Failed to launch/i.test(msg);
52
53
  }
54
+ /**
55
+ * DM-1442: Chromium launch args needed to make cross-origin `<iframe>`
56
+ * documents readable from the in-page capture script. `--disable-web-security`
57
+ * lifts the Same-Origin Policy on cross-document access; the
58
+ * `--disable-features` flag co-locates frames in one renderer process so the
59
+ * cross-origin `contentDocument` is reachable (some builds need it, harmless on
60
+ * those that don't). Returns `[]` when `value` requests no cross-origin
61
+ * recursion, so callers can spread it unconditionally:
62
+ * `launchChromium({ args: crossOriginFramesLaunchArgs(value) })`.
63
+ *
64
+ * NOTE: disabling web security also disables CORS — only launch this way when
65
+ * capturing your own / trusted pages. Callers should print a visible warning.
66
+ */
67
+ export function crossOriginFramesLaunchArgs(value) {
68
+ if (parseCrossOriginAllowlist(value) == null)
69
+ return [];
70
+ return ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"];
71
+ }
53
72
  export class DemoRecorder {
54
73
  browser = null;
55
74
  context = null;
@@ -63,6 +82,7 @@ export class DemoRecorder {
63
82
  embedRemoteImagesRetryBackoffMs;
64
83
  embedRemoteImagesResize;
65
84
  embedRemoteImagesHiDPIFactor;
85
+ captureCrossOriginFrames;
66
86
  constructor(baseUrl, opts) {
67
87
  this.baseUrl = baseUrl;
68
88
  this.width = opts.width;
@@ -73,9 +93,12 @@ export class DemoRecorder {
73
93
  this.embedRemoteImagesRetryBackoffMs = opts.embedRemoteImagesRetryBackoffMs;
74
94
  this.embedRemoteImagesResize = opts.embedRemoteImagesResize ?? false;
75
95
  this.embedRemoteImagesHiDPIFactor = opts.embedRemoteImagesHiDPIFactor;
96
+ this.captureCrossOriginFrames = opts.captureCrossOriginFrames;
76
97
  }
77
98
  async init(opts) {
78
- this.browser = await launchChromium();
99
+ // DM-1442: when cross-origin iframe recursion is requested, launch with web
100
+ // security disabled so cross-origin contentDocuments are readable.
101
+ this.browser = await launchChromium({ args: crossOriginFramesLaunchArgs(opts.captureCrossOriginFrames) });
79
102
  this.context = await this.browser.newContext({
80
103
  viewport: { width: opts.width, height: opts.height },
81
104
  isMobile: opts.mobile ?? false,
@@ -158,7 +181,7 @@ export class DemoRecorder {
158
181
  throw new Error("Call init() first");
159
182
  const tree = await captureElementTree(this.page, "body", {
160
183
  x: 0, y: 0, width: this.width, height: this.height,
161
- });
184
+ }, { crossOriginFrames: this.captureCrossOriginFrames });
162
185
  return this.renderCapturedTree(tree, this.height, idPrefix);
163
186
  }
164
187
  /**
@@ -171,7 +194,7 @@ export class DemoRecorder {
171
194
  const pageHeight = await this.page.evaluate(() => document.body.scrollHeight);
172
195
  const tree = await captureElementTree(this.page, "body", {
173
196
  x: 0, y: 0, width: this.width, height: pageHeight,
174
- });
197
+ }, { crossOriginFrames: this.captureCrossOriginFrames });
175
198
  const svgContent = await this.renderCapturedTree(tree, pageHeight, idPrefix);
176
199
  return { svgContent, pageHeight };
177
200
  }
@@ -818,8 +841,8 @@ function parseWeightDescriptor(value) {
818
841
  * unsupported features encountered during capture are stored and accessible
819
842
  * via getLastCaptureWarnings() / logCaptureWarnings().
820
843
  */
821
- export async function captureElementTree(page, selector = "body", viewport) {
822
- const { tree } = await captureElementTreeWithWarnings(page, selector, viewport);
844
+ export async function captureElementTree(page, selector = "body", viewport, opts) {
845
+ const { tree } = await captureElementTreeWithWarnings(page, selector, viewport, opts);
823
846
  return tree;
824
847
  }
825
848
  /**
@@ -839,7 +862,7 @@ export async function captureElementTreeWithWarnings(page, selector = "body", vi
839
862
  // external refs exist; a fetch failure leaves the ref intact so the walk
840
863
  // warns as before.
841
864
  await inlineExternalSvgRefs(page);
842
- const result = await page.evaluate(`(${CAPTURE_SCRIPT})({sel: ${JSON.stringify(selector)}, vp: ${JSON.stringify(viewport)}})`);
865
+ const result = await page.evaluate(`(${CAPTURE_SCRIPT})({sel: ${JSON.stringify(selector)}, vp: ${JSON.stringify(viewport)}, cof: ${JSON.stringify(opts?.crossOriginFrames ?? "")}})`);
843
866
  const typed = result;
844
867
  const warnings = typed.warnings ?? [];
845
868
  _resetLastCaptureWarnings(warnings);
@@ -1210,21 +1233,71 @@ async function rasterizeReplacedElements(page, tree, viewport, opts) {
1210
1233
  * are display:none / 0-area are filtered at capture time and never reach
1211
1234
  * here. See `docs/22-mask-element-paint-references.md`.
1212
1235
  */
1213
- async function rasterizeMaskSources(page, tree, viewport) {
1236
+ export async function rasterizeMaskSources(page, tree, viewport) {
1214
1237
  if (tree.length === 0 || tree[0].maskRasters == null || tree[0].maskRasters.length === 0)
1215
1238
  return;
1216
1239
  const rasters = tree[0].maskRasters;
1217
- let styleHandle = null;
1240
+ // DM-1447: a mask-image: element(#id) target can live inside a recursed
1241
+ // same-origin <iframe>. Isolating it for the clipped screenshot means hiding
1242
+ // everything in BOTH the top document AND the owning frame, while keeping the
1243
+ // enclosing <iframe> element(s) visible so the target shows through. So inject
1244
+ // the hide-everything CSS into every accessible frame, then per target mark
1245
+ // it (in its own frame) + each enclosing <iframe> element up the chain. The
1246
+ // screenshot clip is already in top-document px (the capture recorded the
1247
+ // rect against the iframe-shifted viewport — DM-1441). For a top-document
1248
+ // target the chain is just the target itself, identical to the prior path.
1249
+ const main = page.mainFrame();
1250
+ const frames = page.frames();
1251
+ const styleHandles = [];
1252
+ const clearMarkers = async () => {
1253
+ for (const f of frames) {
1254
+ try {
1255
+ await f.evaluate(() => document.querySelectorAll("[data-domotion-snapshot-target]").forEach((el) => el.removeAttribute("data-domotion-snapshot-target")));
1256
+ }
1257
+ catch { /* cross-origin / detached frame */ }
1258
+ }
1259
+ };
1218
1260
  try {
1219
- styleHandle = await page.addStyleTag({ content: SNAPSHOT_HIDE_CSS });
1261
+ for (const f of frames) {
1262
+ try {
1263
+ styleHandles.push(await f.addStyleTag({ content: SNAPSHOT_HIDE_CSS }));
1264
+ }
1265
+ catch { /* cross-origin / detached */ }
1266
+ }
1220
1267
  for (const mr of rasters) {
1221
- await page.evaluate((rid) => {
1222
- const prev = document.querySelectorAll("[data-domotion-snapshot-target]");
1223
- prev.forEach((el) => el.removeAttribute("data-domotion-snapshot-target"));
1224
- const next = document.querySelector(`[data-domotion-rid="${rid}"]`);
1225
- if (next != null)
1226
- next.setAttribute("data-domotion-snapshot-target", "");
1227
- }, mr.rid);
1268
+ await clearMarkers();
1269
+ // Locate the frame whose document holds the rid'd target.
1270
+ let owner = null;
1271
+ for (const f of frames) {
1272
+ try {
1273
+ if (await f.evaluate((rid) => document.querySelector(`[data-domotion-rid="${rid}"]`) != null, mr.rid)) {
1274
+ owner = f;
1275
+ break;
1276
+ }
1277
+ }
1278
+ catch { /* cross-origin / detached */ }
1279
+ }
1280
+ if (owner == null)
1281
+ continue;
1282
+ try {
1283
+ await owner.evaluate((rid) => {
1284
+ const t = document.querySelector(`[data-domotion-rid="${rid}"]`);
1285
+ if (t != null)
1286
+ t.setAttribute("data-domotion-snapshot-target", "");
1287
+ }, mr.rid);
1288
+ // Mark each enclosing <iframe> element up to the main frame so the
1289
+ // target shows through (a visible child shows even inside a hidden
1290
+ // ancestor; the inner frame's own hide-CSS isolates the target there).
1291
+ let f = owner;
1292
+ while (f != null && f !== main) {
1293
+ const iframeEl = await f.frameElement();
1294
+ await iframeEl.evaluate((el) => el.setAttribute("data-domotion-snapshot-target", ""));
1295
+ f = f.parentFrame();
1296
+ }
1297
+ }
1298
+ catch {
1299
+ continue;
1300
+ }
1228
1301
  const clip = clipRectForScreenshot(mr.rect, viewport);
1229
1302
  try {
1230
1303
  const buf = await page.screenshot({ clip, omitBackground: true, type: "png" });
@@ -1236,15 +1309,10 @@ async function rasterizeMaskSources(page, tree, viewport) {
1236
1309
  }
1237
1310
  }
1238
1311
  finally {
1239
- try {
1240
- await page.evaluate(() => {
1241
- document.querySelectorAll("[data-domotion-snapshot-target]").forEach((el) => el.removeAttribute("data-domotion-snapshot-target"));
1242
- });
1243
- }
1244
- catch { }
1245
- if (styleHandle != null) {
1312
+ await clearMarkers();
1313
+ for (const h of styleHandles) {
1246
1314
  try {
1247
- await styleHandle.evaluate((node) => { node.remove(); });
1315
+ await h.evaluate((node) => { node.remove(); });
1248
1316
  }
1249
1317
  catch { }
1250
1318
  }
@@ -0,0 +1,17 @@
1
+ export type CrossOriginAllowEntry = {
2
+ host: string;
3
+ port: string | null;
4
+ };
5
+ export type CrossOriginAllowlist = "*" | CrossOriginAllowEntry[];
6
+ /**
7
+ * Parse the raw `--cross-origin-frames` value into a normalized allowlist, or
8
+ * `null` when no cross-origin recursion is requested (empty / undefined).
9
+ * `"*"` (alone or as any entry) collapses to recurse-all.
10
+ */
11
+ export declare function parseCrossOriginAllowlist(value: string | undefined | null): CrossOriginAllowlist | null;
12
+ /**
13
+ * Does `frameUrl`'s origin match the allowlist? Default ports are normalized
14
+ * (http→80, https→443) so `maps.google.com:443` matches `https://maps.google.com/`.
15
+ * Returns false for an unparseable URL or a null allowlist.
16
+ */
17
+ export declare function frameHostAllowed(frameUrl: string, allow: CrossOriginAllowlist | null): boolean;
@@ -0,0 +1,83 @@
1
+ // Cross-origin iframe allowlist (DM-1442) — parsing + matching for the
2
+ // `--cross-origin-frames` flag. Pure functions with no DOM / Node deps (only
3
+ // the `URL` global, available in both the page and Node), so this module is
4
+ // BOTH bundled into the page-context capture script (via
5
+ // `src/capture/script/index.ts`, under CAPTURE_SCRIPT discipline) AND imported
6
+ // + unit-tested node-side. See docs/81-iframe-recursion.md.
7
+ //
8
+ // Allowlist grammar (the value of `--cross-origin-frames`):
9
+ // "*" → every cross-origin frame is recursed
10
+ // "host[:port],host[:port]" → exact-host entries. An entry with a `:port`
11
+ // requires an exact host+port match; without a
12
+ // port the host matches on ANY port. Subdomains
13
+ // are NOT implied (example.com ≠ www.example.com).
14
+ // "" / undefined → no cross-origin recursion (null)
15
+ /**
16
+ * Parse the raw `--cross-origin-frames` value into a normalized allowlist, or
17
+ * `null` when no cross-origin recursion is requested (empty / undefined).
18
+ * `"*"` (alone or as any entry) collapses to recurse-all.
19
+ */
20
+ export function parseCrossOriginAllowlist(value) {
21
+ if (value == null)
22
+ return null;
23
+ const trimmed = value.trim();
24
+ if (trimmed === "")
25
+ return null;
26
+ if (trimmed === "*")
27
+ return "*";
28
+ const entries = [];
29
+ for (const raw of trimmed.split(",")) {
30
+ const part = raw.trim();
31
+ if (part === "")
32
+ continue;
33
+ if (part === "*")
34
+ return "*"; // a bare * anywhere means "all"
35
+ // host[:port] — a single trailing `:digits` is the port; everything else is
36
+ // the host. (Hosts here are plain registrable names / localhost / IPs, so a
37
+ // simple last-colon split is sufficient.)
38
+ const colon = part.lastIndexOf(":");
39
+ if (colon > 0 && /^[0-9]+$/.test(part.slice(colon + 1))) {
40
+ entries.push({ host: part.slice(0, colon).toLowerCase(), port: part.slice(colon + 1) });
41
+ }
42
+ else {
43
+ entries.push({ host: part.toLowerCase(), port: null });
44
+ }
45
+ }
46
+ return entries.length > 0 ? entries : null;
47
+ }
48
+ /**
49
+ * Does `frameUrl`'s origin match the allowlist? Default ports are normalized
50
+ * (http→80, https→443) so `maps.google.com:443` matches `https://maps.google.com/`.
51
+ * Returns false for an unparseable URL or a null allowlist.
52
+ */
53
+ export function frameHostAllowed(frameUrl, allow) {
54
+ if (allow == null)
55
+ return false;
56
+ if (allow === "*")
57
+ return true;
58
+ let host;
59
+ let port;
60
+ try {
61
+ const u = new URL(frameUrl);
62
+ host = u.hostname.toLowerCase();
63
+ port = u.port;
64
+ if (port === "") {
65
+ if (u.protocol === "https:")
66
+ port = "443";
67
+ else if (u.protocol === "http:")
68
+ port = "80";
69
+ }
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ for (const entry of allow) {
75
+ if (entry.host !== host)
76
+ continue;
77
+ if (entry.port == null)
78
+ return true; // host on any port
79
+ if (entry.port === port)
80
+ return true; // exact host + port
81
+ }
82
+ return false;
83
+ }