domotion-svg 0.16.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 +7 -2
- package/dist/animation/animator.js +30 -47
- package/dist/animation/composite.js +4 -4
- package/dist/capture/embed.js +9 -6
- package/dist/capture/emoji.d.ts +8 -0
- package/dist/capture/emoji.js +50 -42
- package/dist/capture/index.d.ts +89 -9
- package/dist/capture/index.js +132 -76
- package/dist/capture/initial-letter-probe.js +8 -12
- 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 +305 -166
- package/dist/capture/script/utils.d.ts +1 -0
- package/dist/capture/script/utils.js +19 -0
- package/dist/capture/script/walker/borders-backgrounds.d.ts +130 -17
- package/dist/capture/script/walker/borders-backgrounds.js +236 -15
- 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/fieldset-legend.d.ts +12 -0
- package/dist/capture/script/walker/fieldset-legend.js +37 -0
- package/dist/capture/script/walker/fragmentation.d.ts +1 -0
- package/dist/capture/script/walker/fragmentation.js +114 -0
- package/dist/capture/script/walker/input-value.js +19 -9
- package/dist/capture/script/walker/lists-counters.d.ts +2 -0
- package/dist/capture/script/walker/lists-counters.js +21 -0
- package/dist/capture/script/walker/masks-clips.js +16 -4
- package/dist/capture/script/walker/pseudo-content.d.ts +3 -2
- package/dist/capture/script/walker/pseudo-content.js +83 -14
- package/dist/capture/script/walker/pseudo-inject.js +11 -0
- package/dist/capture/script/walker/replaced-elements.js +6 -1
- package/dist/capture/script/walker/text-segments.js +9 -10
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +1 -1
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +23 -0
- package/dist/cli/capture.js +76 -39
- package/dist/cli/common.d.ts +22 -6
- package/dist/cli/common.js +37 -31
- package/dist/cli/index.js +6 -0
- package/dist/cli/review.js +11 -8
- package/dist/cli/scrubber.js +18 -0
- package/dist/cli/svg-to-image.js +9 -24
- package/dist/cli/svg-to-video.js +9 -24
- package/dist/post-processing/optimize.js +7 -0
- package/dist/render/element-tree-to-svg.js +1252 -970
- package/dist/render/font-resolution.d.ts +30 -1
- package/dist/render/font-resolution.js +304 -53
- package/dist/render/form-controls.js +9 -5
- package/dist/render/glyph-helper.js +8 -1
- package/dist/render/index.d.ts +1 -1
- package/dist/render/index.js +1 -1
- package/dist/render/mask.js +271 -252
- package/dist/render/text.js +247 -202
- package/dist/render/unicode-font-routing.noto-linux.generated.d.ts +8 -0
- package/dist/render/unicode-font-routing.noto-linux.generated.js +487 -0
- package/dist/render/vertical-text.js +2 -3
- package/dist/review/server.js +44 -44
- package/dist/scroll/composer.js +58 -55
- 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 +144 -18
- package/dist/tree-ops/for-each-element.d.ts +12 -0
- package/dist/tree-ops/for-each-element.js +17 -0
- package/dist/tree-ops/viewbox-culling.js +1 -1
- package/dist/utils/local-server.d.ts +24 -0
- package/dist/utils/local-server.js +30 -0
- 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 +2 -2
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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
**Domotion turns real HTML/CSS into one self-contained, animated SVG** — an accurate reproduction of the rendered page, with optional animation and simulated interaction built in. Text is emitted as real glyph paths, so it looks identical across browsers; the output scales crisply at any size and embeds anywhere with a plain `<img>`, no external assets.
|
|
16
16
|
|
|
17
|
-
Beyond raw capture it ships a **template library** that turns a few flags into a polished animated SVG, **terminal-session capture** (a recording → an animated terminal), multi-frame **animation** with transitions, overlays, and simulated interaction, **device-chrome** framing, **nested compositing** (animated layers inside animated layers), one-command **SVG → MP4/WebM**, and a fidelity **review** tool.
|
|
17
|
+
Beyond raw capture it ships a **template library** that turns a few flags into a polished animated SVG, **terminal-session capture** (a recording → an animated terminal), **scroll capture** (a long page replayed as one self-contained scrolling SVG), multi-frame **animation** with transitions, overlays, and simulated interaction, **device-chrome** framing, **nested compositing** (animated layers inside animated layers), one-command **SVG → MP4/WebM**, and a fidelity **review** tool.
|
|
18
18
|
|
|
19
19
|
<p align="center">
|
|
20
20
|
<img src="examples/output/hero-product-demo.svg" alt="An analytics dashboard assembling itself inside a browser window — KPI cards rise in, a bar chart grows with its peak highlighted, a search query types itself, and a nav item is clicked — all in one self-contained animated SVG" width="760">
|
|
@@ -74,8 +74,13 @@ domotion capture ./demo.html \
|
|
|
74
74
|
|
|
75
75
|
# Capture HTML piped on stdin.
|
|
76
76
|
cat demo.html | domotion capture - -o demo.svg
|
|
77
|
+
|
|
78
|
+
# Capture a long page as one animated scrolling SVG (scrolls to the bottom over 8s).
|
|
79
|
+
domotion capture https://example.com --scroll "down:bottom/8s" -o scroll.svg
|
|
77
80
|
```
|
|
78
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
|
+
|
|
79
84
|
For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
|
|
80
85
|
|
|
81
86
|
```bash
|
|
@@ -147,7 +152,7 @@ svg-review --expected example.debug/expected.png --actual example.debug/actual.s
|
|
|
147
152
|
|
|
148
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.
|
|
149
154
|
|
|
150
|
-
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.
|
|
151
156
|
|
|
152
157
|
### Scripting API
|
|
153
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
|
/**
|
|
@@ -290,19 +275,16 @@ export function generateAnimatedSvg(config) {
|
|
|
290
275
|
};
|
|
291
276
|
});
|
|
292
277
|
}
|
|
293
|
-
// Pre-compute
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
};
|
|
278
|
+
// Pre-compute each frame's start-of-window percentage — the only field
|
|
279
|
+
// `buildIntraFrameAnimationCss` reads. (The per-frame loop below recomputes the
|
|
280
|
+
// hold / transition windows it needs locally via `pct()`, so they aren't
|
|
281
|
+
// collected here.)
|
|
282
|
+
const frameTiming = { startPct: [] };
|
|
298
283
|
{
|
|
299
284
|
let t = 0;
|
|
300
285
|
for (const f of frames) {
|
|
301
|
-
const td = transitionDurationMs(f);
|
|
302
286
|
frameTiming.startPct.push((t / totalDuration) * 100);
|
|
303
|
-
|
|
304
|
-
frameTiming.transEndPct.push(((t + f.duration + td) / totalDuration) * 100);
|
|
305
|
-
t += f.duration + td;
|
|
287
|
+
t += f.duration + transitionDurationMs(f);
|
|
306
288
|
}
|
|
307
289
|
}
|
|
308
290
|
// Every sequence composites: each frame is emitted as a complete, internally
|
|
@@ -364,7 +346,7 @@ export function generateAnimatedSvg(config) {
|
|
|
364
346
|
// The parallel `fd-${i}` display snap (inside slideKeyframes) lets the
|
|
365
347
|
// browser skip painting this frame's content while it's fully off-screen
|
|
366
348
|
// between cycles (DM-599).
|
|
367
|
-
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);
|
|
368
350
|
frameGroups.push(r.group);
|
|
369
351
|
keyframes.push(r.keyframe);
|
|
370
352
|
}
|
|
@@ -372,7 +354,7 @@ export function generateAnimatedSvg(config) {
|
|
|
372
354
|
// DM-609: `scroll` is a real geometric scroll — the vertical equivalent of
|
|
373
355
|
// push-left (translateY over height instead of translateX over width),
|
|
374
356
|
// otherwise identical machinery. Exit slides up; enter per `slideEnter`.
|
|
375
|
-
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);
|
|
376
358
|
frameGroups.push(r.group);
|
|
377
359
|
keyframes.push(r.keyframe);
|
|
378
360
|
}
|
|
@@ -402,7 +384,7 @@ export function generateAnimatedSvg(config) {
|
|
|
402
384
|
// holds-then-cuts — so this is a no-op for cut frames.
|
|
403
385
|
const isCutFrame = transType === "cut" || transDur === 0;
|
|
404
386
|
const holdToEnd = i === frames.length - 1 && config.loopFade !== true && !isCutFrame;
|
|
405
|
-
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);
|
|
406
388
|
frameGroups.push(...r.groups);
|
|
407
389
|
keyframes.push(...r.keyframes);
|
|
408
390
|
}
|
|
@@ -437,7 +419,7 @@ export function generateAnimatedSvg(config) {
|
|
|
437
419
|
// Default (none / transparent) emits nothing so the SVG composites over the
|
|
438
420
|
// host page, matching the single-frame `transparentRootBgRect` path (DM-554).
|
|
439
421
|
const bg = config.background;
|
|
440
|
-
const canvasBgRect = (bg != null && bg
|
|
422
|
+
const canvasBgRect = (bg != null && !isTransparentBackground(bg))
|
|
441
423
|
? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
|
|
442
424
|
: "";
|
|
443
425
|
const out = `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -876,25 +858,25 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
|
|
|
876
858
|
const e = overlay.enter;
|
|
877
859
|
const easing = e.easing ?? "ease-out";
|
|
878
860
|
const enterDelay = e.delay ?? 0;
|
|
879
|
-
const fromStr = offsetForDirection(e.from, overlay.width, overlay.height
|
|
861
|
+
const fromStr = offsetForDirection(e.from, overlay.width, overlay.height);
|
|
880
862
|
const enterStart = frameStart + enterDelay;
|
|
881
863
|
const enterEnd = enterStart + e.duration;
|
|
882
864
|
const enterId = `${id}-enter`;
|
|
883
865
|
cssRules.push(`
|
|
884
866
|
@keyframes ${enterId} { 0% { transform: ${fromStr}; } ${pct(enterStart, totalDuration)} { transform: ${fromStr}; } ${pct(enterEnd, totalDuration)} { transform: translate(0, 0); } 100% { transform: translate(0, 0); } }
|
|
885
|
-
.${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s
|
|
867
|
+
.${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s ${easing} infinite; }`);
|
|
886
868
|
}
|
|
887
869
|
// Slide-out exit. Mirror of enter — translate from (0,0) to off-screen.
|
|
888
870
|
if (overlay.exit != null) {
|
|
889
871
|
const e = overlay.exit;
|
|
890
872
|
const easing = e.easing ?? "ease-in";
|
|
891
873
|
const exitDelay = e.delay ?? 0;
|
|
892
|
-
const toStr = offsetForDirection(e.from, overlay.width, overlay.height
|
|
874
|
+
const toStr = offsetForDirection(e.from, overlay.width, overlay.height);
|
|
893
875
|
const exitStart = overlayEnd - e.duration - exitDelay;
|
|
894
876
|
const exitId = `${id}-exit`;
|
|
895
877
|
cssRules.push(`
|
|
896
878
|
@keyframes ${exitId} { 0%, ${pct(exitStart, totalDuration)} { transform: translate(0, 0); } ${pct(exitStart + e.duration, totalDuration)} { transform: ${toStr}; } 100% { transform: ${toStr}; } }
|
|
897
|
-
.${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s
|
|
879
|
+
.${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s ${easing} infinite; }`);
|
|
898
880
|
}
|
|
899
881
|
// Markup: outer wrapper translates to (x, y) and clips, inner wrapper
|
|
900
882
|
// carries the visibility class, then the enter/exit transform wrapper, then
|
|
@@ -908,12 +890,13 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
|
|
|
908
890
|
return { svgMarkup, css: cssRules.join("") };
|
|
909
891
|
}
|
|
910
892
|
/**
|
|
911
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
893
|
+
* Off-screen offset string for a slide direction — the point the overlay sits
|
|
894
|
+
* at when fully off-screen on the given side. Enter animates from this offset to
|
|
895
|
+
* `translate(0,0)`; exit animates from `translate(0,0)` back to it. The offset
|
|
896
|
+
* is the same for both (the enter-vs-exit direction is carried by the keyframe
|
|
897
|
+
* construction, not by this function), so it takes only the direction + size.
|
|
915
898
|
*/
|
|
916
|
-
function offsetForDirection(dir, w, h
|
|
899
|
+
function offsetForDirection(dir, w, h) {
|
|
917
900
|
if (dir === "top")
|
|
918
901
|
return `translate(0, -${h}px)`;
|
|
919
902
|
if (dir === "bottom")
|
|
@@ -1001,7 +984,7 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
|
|
|
1001
984
|
${endPct.toFixed(3)}% { ${propValue(a.to)} }
|
|
1002
985
|
100% { ${propValue(a.to)} }
|
|
1003
986
|
}
|
|
1004
|
-
.anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s
|
|
987
|
+
.anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s ${easing} infinite;${originDecl} }`);
|
|
1005
988
|
}
|
|
1006
989
|
}
|
|
1007
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/embed.js
CHANGED
|
@@ -85,18 +85,21 @@ function embedAsDataUri(url) {
|
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
87
87
|
* DM-540 — active hiDPI multiplier used by `embedResizedDataUri` lookups
|
|
88
|
-
* during a single `elementTreeToSvg` invocation.
|
|
89
|
-
*
|
|
90
|
-
* value
|
|
91
|
-
*
|
|
92
|
-
*
|
|
88
|
+
* during a single `elementTreeToSvg` invocation. `elementTreeToSvgInner` sets it
|
|
89
|
+
* (via `setActiveHiDPIFactor`) as the FIRST thing it does on EVERY call, so each
|
|
90
|
+
* render reads its own value and a stale value can't leak across renders — it's
|
|
91
|
+
* only ever read (as the `embedResizedDataUri` default) inside the render that
|
|
92
|
+
* just set it. Must match the value passed to `resizeEmbeddedImages` for the
|
|
93
|
+
* same tree, otherwise the lookup misses and the renderer falls back to the
|
|
94
|
+
* source-resolution data URI.
|
|
93
95
|
*
|
|
94
96
|
* Module-scoped because the resize lookup is buried in a dozen helper
|
|
95
97
|
* functions (border-image, repeat-pattern, list marker, pseudo-image,
|
|
96
98
|
* background-layer); threading the factor through every signature would
|
|
97
99
|
* touch every call site and grow the renderer surface area for no
|
|
98
100
|
* functional benefit. Captures run sequentially per Node event loop so
|
|
99
|
-
* there's no concurrency hazard.
|
|
101
|
+
* there's no concurrency hazard. (DM-1435: considered a save/restore scope
|
|
102
|
+
* guard like `withRenderTextMode`, but set-at-entry already prevents the leak.)
|
|
100
103
|
*/
|
|
101
104
|
let _activeHiDPIFactor = 2;
|
|
102
105
|
export function setActiveHiDPIFactor(n) {
|
package/dist/capture/emoji.d.ts
CHANGED
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { Page } from "@playwright/test";
|
|
19
19
|
import type { CapturedElement } from "./types.js";
|
|
20
|
+
/**
|
|
21
|
+
* Reset the process-global emoji caches — the opened Apple Color Emoji font and
|
|
22
|
+
* the extracted sbix-bitmap cache. These are process-stable (the system font
|
|
23
|
+
* doesn't change between renders), so this isn't needed per-generation; it
|
|
24
|
+
* exists for convention parity with `clearEmbeddedImageCaches()` and for test
|
|
25
|
+
* isolation / forcing a re-open. DM-1435.
|
|
26
|
+
*/
|
|
27
|
+
export declare function clearEmojiCaches(): void;
|
|
20
28
|
/**
|
|
21
29
|
* Snap an Apple Color Emoji sbix bitmap to the SQUARE box Chrome actually
|
|
22
30
|
* paints it in.
|
package/dist/capture/emoji.js
CHANGED
|
@@ -18,12 +18,25 @@
|
|
|
18
18
|
import { existsSync } from "node:fs";
|
|
19
19
|
import * as fontkit from "fontkit";
|
|
20
20
|
import { clipRectForScreenshot } from "./clip-rect.js";
|
|
21
|
+
import { forEachElement } from "../tree-ops/for-each-element.js";
|
|
21
22
|
const APPLE_COLOR_EMOJI_PATH = "/System/Library/Fonts/Apple Color Emoji.ttc";
|
|
22
23
|
let _aceFont = null;
|
|
23
24
|
let _aceFontLoaded = false;
|
|
24
25
|
// Available sbix strikes on macOS Apple Color Emoji.ttc.
|
|
25
26
|
const SBIX_STRIKES = [20, 26, 32, 40, 48, 52, 64, 96, 160];
|
|
26
27
|
const _sbixCache = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* Reset the process-global emoji caches — the opened Apple Color Emoji font and
|
|
30
|
+
* the extracted sbix-bitmap cache. These are process-stable (the system font
|
|
31
|
+
* doesn't change between renders), so this isn't needed per-generation; it
|
|
32
|
+
* exists for convention parity with `clearEmbeddedImageCaches()` and for test
|
|
33
|
+
* isolation / forcing a re-open. DM-1435.
|
|
34
|
+
*/
|
|
35
|
+
export function clearEmojiCaches() {
|
|
36
|
+
_aceFont = null;
|
|
37
|
+
_aceFontLoaded = false;
|
|
38
|
+
_sbixCache.clear();
|
|
39
|
+
}
|
|
27
40
|
// Only take the sbix path when the glyph rect is roughly emoji-shaped (wide
|
|
28
41
|
// enough relative to its height). Narrow rects are usually text-presentation
|
|
29
42
|
// dingbats / partial clusters where the page-screenshot fallback is safer.
|
|
@@ -201,51 +214,46 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
|
|
|
201
214
|
// otherwise path-rendered plain-text run; renderer stamps an <image>
|
|
202
215
|
// over each char on top of the text path.
|
|
203
216
|
const candidates = [];
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
queueRasterGlyph(g, seg, el, candidates);
|
|
241
|
-
}
|
|
217
|
+
forEachElement(tree, (el) => {
|
|
218
|
+
// Element-level raster (SK-1108): textarea content region, too
|
|
219
|
+
// involved to word-wrap in the path pipeline. Key on text+size+color so
|
|
220
|
+
// identical textareas dedupe to one screenshot.
|
|
221
|
+
if (el.elementRaster != null) {
|
|
222
|
+
const er = el.elementRaster;
|
|
223
|
+
// DM-936: include text-decoration + text-underline-position in the
|
|
224
|
+
// dedupe key so 3 identical-text `.vert.pos-{left,right,auto}`
|
|
225
|
+
// columns don't collapse to the same screenshot (the underline
|
|
226
|
+
// paints in different places per pos-* but tag+text+color+size
|
|
227
|
+
// alone hashes them all together → wrong-side underline in 2/3
|
|
228
|
+
// of the columns). Same for text-shadow / writing-mode variants.
|
|
229
|
+
// All the decoration keys are typed reads off CapturedStyles now; only
|
|
230
|
+
// the `text-decoration` shorthand isn't captured (just the longhands),
|
|
231
|
+
// so narrow-cast that single fallback read.
|
|
232
|
+
const s = el.styles;
|
|
233
|
+
const tdShorthand = s.textDecoration;
|
|
234
|
+
const tdKey = `${s.textDecorationLine ?? tdShorthand ?? ""}|${s.textUnderlinePosition ?? ""}|${s.textUnderlineOffset ?? ""}|${s.textDecorationStyle ?? ""}|${s.textDecorationColor ?? ""}|${s.textDecorationThickness ?? ""}|${s.textShadow ?? ""}|${s.writingMode ?? ""}`;
|
|
235
|
+
candidates.push({
|
|
236
|
+
rect: { x: er.x, y: er.y, width: er.width, height: er.height },
|
|
237
|
+
key: `el|${el.tag}|${el.text}|${el.styles.color}|${el.styles.fontSize}|${er.width}x${er.height}|${tdKey}`,
|
|
238
|
+
setDataUri: (uri) => { er.dataUri = uri; },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (el.textSegments != null) {
|
|
242
|
+
for (const seg of el.textSegments) {
|
|
243
|
+
if (seg.rasterRect != null) {
|
|
244
|
+
candidates.push({
|
|
245
|
+
rect: seg.rasterRect,
|
|
246
|
+
key: `seg|${seg.text}|${seg.color ?? ""}|${seg.fontSize ?? ""}|${seg.fontWeight ?? ""}`,
|
|
247
|
+
setDataUri: (uri) => { seg.rasterDataUri = uri; },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (seg.rasterGlyphs != null) {
|
|
251
|
+
for (const g of seg.rasterGlyphs)
|
|
252
|
+
queueRasterGlyph(g, seg, el, candidates);
|
|
242
253
|
}
|
|
243
254
|
}
|
|
244
|
-
if (el.children.length > 0)
|
|
245
|
-
walk(el.children);
|
|
246
255
|
}
|
|
247
|
-
};
|
|
248
|
-
walk(tree);
|
|
256
|
+
});
|
|
249
257
|
if (candidates.length === 0)
|
|
250
258
|
return;
|
|
251
259
|
const cache = new Map();
|
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,10 +115,19 @@ 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. */
|
|
97
122
|
captureUrl(path: string, waitMs?: number, idPrefix?: string): Promise<string>;
|
|
123
|
+
/**
|
|
124
|
+
* Shared post-capture pipeline (DM-1434): self-contained remote-image
|
|
125
|
+
* embedding + optional resize + conic-gradient rasterization, then reset the
|
|
126
|
+
* generation-scoped caches and render the tree to SVG body markup at `height`.
|
|
127
|
+
* `captureCurrent` (viewport) and `captureFullPage` (scrollable) differ only in
|
|
128
|
+
* the height, so they both funnel through here.
|
|
129
|
+
*/
|
|
130
|
+
private renderCapturedTree;
|
|
98
131
|
/** Capture the current page state as SVG content. */
|
|
99
132
|
captureCurrent(idPrefix?: string): Promise<string>;
|
|
100
133
|
/**
|
|
@@ -131,6 +164,32 @@ export declare function attachWebfontTracker(page: Page): {
|
|
|
131
164
|
urls: Set<string>;
|
|
132
165
|
detach: () => void;
|
|
133
166
|
};
|
|
167
|
+
/**
|
|
168
|
+
* Discover all `@font-face` rules in the page's stylesheets, fetch each
|
|
169
|
+
* font file via the browser context's request API (so cookies / CORS / auth
|
|
170
|
+
* follow whatever the browser is using), and register the bytes with
|
|
171
|
+
* `text-to-path.ts` so the renderer can draw with the actual webfont glyphs
|
|
172
|
+
* instead of falling through to the system-font substitutes.
|
|
173
|
+
*
|
|
174
|
+
* Should be called AFTER `await page.evaluate(() => document.fonts.ready)`
|
|
175
|
+
* — otherwise late-loading fonts may not be in `document.styleSheets` yet.
|
|
176
|
+
*
|
|
177
|
+
* Cross-origin stylesheets whose `cssRules` throw a SecurityError are silently
|
|
178
|
+
* skipped (we can't enumerate their rules from JS). Same-origin sheets and
|
|
179
|
+
* inline `<style>` blocks always work.
|
|
180
|
+
*
|
|
181
|
+
* Caller is responsible for `clearWebfonts()` between captures if needed.
|
|
182
|
+
* No-op when the page declares no `@font-face` rules.
|
|
183
|
+
*/
|
|
184
|
+
type FaceRule = {
|
|
185
|
+
kind: "font-face";
|
|
186
|
+
family: string;
|
|
187
|
+
weight: string;
|
|
188
|
+
style: string;
|
|
189
|
+
url: string;
|
|
190
|
+
urls?: string[];
|
|
191
|
+
unicodeRange?: Array<[number, number]>;
|
|
192
|
+
};
|
|
134
193
|
/** One row of the report returned by discoverAndRegisterWebfonts. */
|
|
135
194
|
type WebfontRegisterReport = {
|
|
136
195
|
family: string;
|
|
@@ -155,15 +214,7 @@ export declare function discoverAndRegisterWebfonts(page: Page, observedFontUrls
|
|
|
155
214
|
* that never serialised back to text) aren't covered. Adequate for the
|
|
156
215
|
* mainstream marketing-site case that motivated the change.
|
|
157
216
|
*/
|
|
158
|
-
export declare function parseFontFaceRulesFromCssText(cssText: string, baseUrl: string):
|
|
159
|
-
kind: "font-face";
|
|
160
|
-
family: string;
|
|
161
|
-
weight: string;
|
|
162
|
-
style: string;
|
|
163
|
-
url: string;
|
|
164
|
-
urls?: string[];
|
|
165
|
-
unicodeRange?: Array<[number, number]>;
|
|
166
|
-
}>;
|
|
217
|
+
export declare function parseFontFaceRulesFromCssText(cssText: string, baseUrl: string): FaceRule[];
|
|
167
218
|
/**
|
|
168
219
|
* Parse a CSS `unicode-range` descriptor value (CSS Fonts 4 §4.5) into a list
|
|
169
220
|
* of inclusive `[from, to]` codepoint intervals. Accepts the three forms:
|
|
@@ -187,6 +238,8 @@ export declare function captureElementTree(page: Page, selector: string | undefi
|
|
|
187
238
|
y: number;
|
|
188
239
|
width: number;
|
|
189
240
|
height: number;
|
|
241
|
+
}, opts?: {
|
|
242
|
+
crossOriginFrames?: string;
|
|
190
243
|
}): Promise<CapturedElement[]>;
|
|
191
244
|
/**
|
|
192
245
|
* Same capture as `captureElementTree` but returns the warnings inline so
|
|
@@ -206,10 +259,37 @@ export declare function captureElementTreeWithWarnings(page: Page, selector: str
|
|
|
206
259
|
* rotating cross-origin-iframe content. Caller is responsible for
|
|
207
260
|
* ensuring the source covers the same coordinate space as `viewport`. */
|
|
208
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;
|
|
209
269
|
}): Promise<{
|
|
210
270
|
tree: CapturedElement[];
|
|
211
271
|
warnings: CaptureWarning[];
|
|
212
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>;
|
|
213
293
|
/**
|
|
214
294
|
* Calibrate `fontAscent` on text-bearing elements by scanning a reference
|
|
215
295
|
* PNG (Chrome's actual paint) for each element's painted ink top, then
|