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.
Files changed (73) hide show
  1. package/FEATURES.md +5 -1
  2. package/README.md +7 -2
  3. package/dist/animation/animator.js +30 -47
  4. package/dist/animation/composite.js +4 -4
  5. package/dist/capture/embed.js +9 -6
  6. package/dist/capture/emoji.d.ts +8 -0
  7. package/dist/capture/emoji.js +50 -42
  8. package/dist/capture/index.d.ts +89 -9
  9. package/dist/capture/index.js +132 -76
  10. package/dist/capture/initial-letter-probe.js +8 -12
  11. package/dist/capture/script/cross-origin.d.ts +17 -0
  12. package/dist/capture/script/cross-origin.js +83 -0
  13. package/dist/capture/script/index.js +305 -166
  14. package/dist/capture/script/utils.d.ts +1 -0
  15. package/dist/capture/script/utils.js +19 -0
  16. package/dist/capture/script/walker/borders-backgrounds.d.ts +130 -17
  17. package/dist/capture/script/walker/borders-backgrounds.js +236 -15
  18. package/dist/capture/script/walker/counter-prewalk.d.ts +1 -1
  19. package/dist/capture/script/walker/counter-prewalk.js +6 -3
  20. package/dist/capture/script/walker/fieldset-legend.d.ts +12 -0
  21. package/dist/capture/script/walker/fieldset-legend.js +37 -0
  22. package/dist/capture/script/walker/fragmentation.d.ts +1 -0
  23. package/dist/capture/script/walker/fragmentation.js +114 -0
  24. package/dist/capture/script/walker/input-value.js +19 -9
  25. package/dist/capture/script/walker/lists-counters.d.ts +2 -0
  26. package/dist/capture/script/walker/lists-counters.js +21 -0
  27. package/dist/capture/script/walker/masks-clips.js +16 -4
  28. package/dist/capture/script/walker/pseudo-content.d.ts +3 -2
  29. package/dist/capture/script/walker/pseudo-content.js +83 -14
  30. package/dist/capture/script/walker/pseudo-inject.js +11 -0
  31. package/dist/capture/script/walker/replaced-elements.js +6 -1
  32. package/dist/capture/script/walker/text-segments.js +9 -10
  33. package/dist/capture/script/walker/transforms.d.ts +1 -0
  34. package/dist/capture/script/walker/transforms.js +1 -1
  35. package/dist/capture/script.generated.js +1 -1
  36. package/dist/capture/types.d.ts +23 -0
  37. package/dist/cli/capture.js +76 -39
  38. package/dist/cli/common.d.ts +22 -6
  39. package/dist/cli/common.js +37 -31
  40. package/dist/cli/index.js +6 -0
  41. package/dist/cli/review.js +11 -8
  42. package/dist/cli/scrubber.js +18 -0
  43. package/dist/cli/svg-to-image.js +9 -24
  44. package/dist/cli/svg-to-video.js +9 -24
  45. package/dist/post-processing/optimize.js +7 -0
  46. package/dist/render/element-tree-to-svg.js +1252 -970
  47. package/dist/render/font-resolution.d.ts +30 -1
  48. package/dist/render/font-resolution.js +304 -53
  49. package/dist/render/form-controls.js +9 -5
  50. package/dist/render/glyph-helper.js +8 -1
  51. package/dist/render/index.d.ts +1 -1
  52. package/dist/render/index.js +1 -1
  53. package/dist/render/mask.js +271 -252
  54. package/dist/render/text.js +247 -202
  55. package/dist/render/unicode-font-routing.noto-linux.generated.d.ts +8 -0
  56. package/dist/render/unicode-font-routing.noto-linux.generated.js +487 -0
  57. package/dist/render/vertical-text.js +2 -3
  58. package/dist/review/server.js +44 -44
  59. package/dist/scroll/composer.js +58 -55
  60. package/dist/scrubber/client.bundle.generated.js +1 -1
  61. package/dist/scrubber/client.d.ts +2 -0
  62. package/dist/scrubber/client.js +181 -5
  63. package/dist/scrubber/server.d.ts +90 -0
  64. package/dist/scrubber/server.js +144 -18
  65. package/dist/tree-ops/for-each-element.d.ts +12 -0
  66. package/dist/tree-ops/for-each-element.js +17 -0
  67. package/dist/tree-ops/viewbox-culling.js +1 -1
  68. package/dist/utils/local-server.d.ts +24 -0
  69. package/dist/utils/local-server.js +30 -0
  70. package/dist/utils/transparent-background.d.ts +22 -0
  71. package/dist/utils/transparent-background.js +40 -0
  72. package/llms.txt +3 -3
  73. 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**: 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
@@ -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 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
  /**
@@ -290,19 +275,16 @@ export function generateAnimatedSvg(config) {
290
275
  };
291
276
  });
292
277
  }
293
- // Pre-compute per-frame timing windows (used by both the merge pipeline for
294
- // timeline keyframes and the atomic push/scroll fallbacks below).
295
- const frameTiming = {
296
- startPct: [], holdEndPct: [], transEndPct: [],
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
- frameTiming.holdEndPct.push(((t + f.duration) / totalDuration) * 100);
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 !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)")
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, true);
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 infinite; animation-timing-function: ${easing}; }`);
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, false);
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 infinite; animation-timing-function: ${easing}; }`);
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
- * Offset string for a slide direction. When `outFrom` is true the offset is
912
- * the off-screen starting position (i.e. the overlay sits there before
913
- * animating to `(0,0)`). When false, it's the off-screen end position
914
- * (overlay animates from `(0,0)` to here on exit).
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, _outFrom) {
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 infinite; animation-timing-function: ${easing};${originDecl} }`);
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 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("");
@@ -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. Set at entry to that
89
- * function (via `setActiveHiDPIFactor`) and reset on exit. Must match the
90
- * value passed to `resizeEmbeddedImages` for the same tree, otherwise the
91
- * lookup misses and the renderer falls back to the source-resolution data
92
- * URI.
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) {
@@ -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.
@@ -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
- const walk = (els) => {
205
- for (const el of els) {
206
- // Element-level raster (SK-1108): textarea content region, too
207
- // involved to word-wrap in the path pipeline. Key on text+size+color so
208
- // identical textareas dedupe to one screenshot.
209
- if (el.elementRaster != null) {
210
- const er = el.elementRaster;
211
- // DM-936: include text-decoration + text-underline-position in the
212
- // dedupe key so 3 identical-text `.vert.pos-{left,right,auto}`
213
- // columns don't collapse to the same screenshot (the underline
214
- // paints in different places per pos-* but tag+text+color+size
215
- // alone hashes them all together → wrong-side underline in 2/3
216
- // of the columns). Same for text-shadow / writing-mode variants.
217
- // All the decoration keys are typed reads off CapturedStyles now; only
218
- // the `text-decoration` shorthand isn't captured (just the longhands),
219
- // so narrow-cast that single fallback read.
220
- const s = el.styles;
221
- const tdShorthand = s.textDecoration;
222
- const tdKey = `${s.textDecorationLine ?? tdShorthand ?? ""}|${s.textUnderlinePosition ?? ""}|${s.textUnderlineOffset ?? ""}|${s.textDecorationStyle ?? ""}|${s.textDecorationColor ?? ""}|${s.textDecorationThickness ?? ""}|${s.textShadow ?? ""}|${s.writingMode ?? ""}`;
223
- candidates.push({
224
- rect: { x: er.x, y: er.y, width: er.width, height: er.height },
225
- key: `el|${el.tag}|${el.text}|${el.styles.color}|${el.styles.fontSize}|${er.width}x${er.height}|${tdKey}`,
226
- setDataUri: (uri) => { er.dataUri = uri; },
227
- });
228
- }
229
- if (el.textSegments != null) {
230
- for (const seg of el.textSegments) {
231
- if (seg.rasterRect != null) {
232
- candidates.push({
233
- rect: seg.rasterRect,
234
- key: `seg|${seg.text}|${seg.color ?? ""}|${seg.fontSize ?? ""}|${seg.fontWeight ?? ""}`,
235
- setDataUri: (uri) => { seg.rasterDataUri = uri; },
236
- });
237
- }
238
- if (seg.rasterGlyphs != null) {
239
- for (const g of seg.rasterGlyphs)
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();
@@ -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): Array<{
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