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.
- package/FEATURES.md +5 -1
- package/README.md +3 -1
- package/dist/animation/animator.js +16 -31
- package/dist/animation/composite.js +4 -4
- package/dist/capture/index.d.ts +54 -0
- package/dist/capture/index.js +92 -24
- package/dist/capture/script/cross-origin.d.ts +17 -0
- package/dist/capture/script/cross-origin.js +83 -0
- package/dist/capture/script/index.js +259 -4
- package/dist/capture/script/walker/counter-prewalk.d.ts +1 -1
- package/dist/capture/script/walker/counter-prewalk.js +6 -3
- package/dist/capture/script/walker/masks-clips.js +16 -4
- package/dist/capture/script/walker/replaced-elements.js +6 -1
- package/dist/capture/script.generated.js +1 -1
- package/dist/cli/capture.js +76 -39
- package/dist/cli/common.d.ts +5 -6
- package/dist/cli/common.js +5 -24
- package/dist/cli/index.js +6 -0
- package/dist/cli/scrubber.js +18 -0
- package/dist/post-processing/optimize.js +7 -0
- package/dist/render/element-tree-to-svg.js +851 -760
- package/dist/render/mask.js +271 -252
- package/dist/render/text.js +224 -198
- package/dist/render/vertical-text.js +2 -3
- package/dist/scroll/composer.js +46 -32
- package/dist/scrubber/client.bundle.generated.js +1 -1
- package/dist/scrubber/client.d.ts +2 -0
- package/dist/scrubber/client.js +181 -5
- package/dist/scrubber/server.d.ts +90 -0
- package/dist/scrubber/server.js +139 -2
- package/dist/tree-ops/viewbox-culling.js +1 -1
- package/dist/utils/transparent-background.d.ts +22 -0
- package/dist/utils/transparent-background.js +40 -0
- package/llms.txt +3 -3
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
167
|
-
//
|
|
168
|
-
const
|
|
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("");
|
package/dist/capture/index.d.ts
CHANGED
|
@@ -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
|
package/dist/capture/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1240
|
-
|
|
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
|
|
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
|
+
}
|