domotion-svg 0.16.0 → 0.17.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 (51) hide show
  1. package/README.md +4 -1
  2. package/dist/animation/animator.js +14 -16
  3. package/dist/capture/embed.js +9 -6
  4. package/dist/capture/emoji.d.ts +8 -0
  5. package/dist/capture/emoji.js +50 -42
  6. package/dist/capture/index.d.ts +35 -9
  7. package/dist/capture/index.js +41 -53
  8. package/dist/capture/initial-letter-probe.js +8 -12
  9. package/dist/capture/script/index.js +47 -163
  10. package/dist/capture/script/utils.d.ts +1 -0
  11. package/dist/capture/script/utils.js +19 -0
  12. package/dist/capture/script/walker/borders-backgrounds.d.ts +130 -17
  13. package/dist/capture/script/walker/borders-backgrounds.js +236 -15
  14. package/dist/capture/script/walker/fieldset-legend.d.ts +12 -0
  15. package/dist/capture/script/walker/fieldset-legend.js +37 -0
  16. package/dist/capture/script/walker/fragmentation.d.ts +1 -0
  17. package/dist/capture/script/walker/fragmentation.js +114 -0
  18. package/dist/capture/script/walker/input-value.js +19 -9
  19. package/dist/capture/script/walker/lists-counters.d.ts +2 -0
  20. package/dist/capture/script/walker/lists-counters.js +21 -0
  21. package/dist/capture/script/walker/pseudo-content.d.ts +3 -2
  22. package/dist/capture/script/walker/pseudo-content.js +83 -14
  23. package/dist/capture/script/walker/pseudo-inject.js +11 -0
  24. package/dist/capture/script/walker/text-segments.js +9 -10
  25. package/dist/capture/script/walker/transforms.d.ts +1 -0
  26. package/dist/capture/script/walker/transforms.js +1 -1
  27. package/dist/capture/script.generated.js +1 -1
  28. package/dist/capture/types.d.ts +23 -0
  29. package/dist/cli/common.d.ts +17 -0
  30. package/dist/cli/common.js +32 -7
  31. package/dist/cli/review.js +11 -8
  32. package/dist/cli/svg-to-image.js +9 -24
  33. package/dist/cli/svg-to-video.js +9 -24
  34. package/dist/render/element-tree-to-svg.js +481 -290
  35. package/dist/render/font-resolution.d.ts +30 -1
  36. package/dist/render/font-resolution.js +304 -53
  37. package/dist/render/form-controls.js +9 -5
  38. package/dist/render/glyph-helper.js +8 -1
  39. package/dist/render/index.d.ts +1 -1
  40. package/dist/render/index.js +1 -1
  41. package/dist/render/text.js +23 -4
  42. package/dist/render/unicode-font-routing.noto-linux.generated.d.ts +8 -0
  43. package/dist/render/unicode-font-routing.noto-linux.generated.js +487 -0
  44. package/dist/review/server.js +44 -44
  45. package/dist/scroll/composer.js +14 -25
  46. package/dist/scrubber/server.js +5 -16
  47. package/dist/tree-ops/for-each-element.d.ts +12 -0
  48. package/dist/tree-ops/for-each-element.js +17 -0
  49. package/dist/utils/local-server.d.ts +24 -0
  50. package/dist/utils/local-server.js +30 -0
  51. package/package.json +2 -2
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,6 +74,9 @@ 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
 
79
82
  For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
@@ -290,19 +290,16 @@ export function generateAnimatedSvg(config) {
290
290
  };
291
291
  });
292
292
  }
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
- };
293
+ // Pre-compute each frame's start-of-window percentage the only field
294
+ // `buildIntraFrameAnimationCss` reads. (The per-frame loop below recomputes the
295
+ // hold / transition windows it needs locally via `pct()`, so they aren't
296
+ // collected here.)
297
+ const frameTiming = { startPct: [] };
298
298
  {
299
299
  let t = 0;
300
300
  for (const f of frames) {
301
- const td = transitionDurationMs(f);
302
301
  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;
302
+ t += f.duration + transitionDurationMs(f);
306
303
  }
307
304
  }
308
305
  // Every sequence composites: each frame is emitted as a complete, internally
@@ -876,7 +873,7 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
876
873
  const e = overlay.enter;
877
874
  const easing = e.easing ?? "ease-out";
878
875
  const enterDelay = e.delay ?? 0;
879
- const fromStr = offsetForDirection(e.from, overlay.width, overlay.height, true);
876
+ const fromStr = offsetForDirection(e.from, overlay.width, overlay.height);
880
877
  const enterStart = frameStart + enterDelay;
881
878
  const enterEnd = enterStart + e.duration;
882
879
  const enterId = `${id}-enter`;
@@ -889,7 +886,7 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
889
886
  const e = overlay.exit;
890
887
  const easing = e.easing ?? "ease-in";
891
888
  const exitDelay = e.delay ?? 0;
892
- const toStr = offsetForDirection(e.from, overlay.width, overlay.height, false);
889
+ const toStr = offsetForDirection(e.from, overlay.width, overlay.height);
893
890
  const exitStart = overlayEnd - e.duration - exitDelay;
894
891
  const exitId = `${id}-exit`;
895
892
  cssRules.push(`
@@ -908,12 +905,13 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
908
905
  return { svgMarkup, css: cssRules.join("") };
909
906
  }
910
907
  /**
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).
908
+ * Off-screen offset string for a slide direction the point the overlay sits
909
+ * at when fully off-screen on the given side. Enter animates from this offset to
910
+ * `translate(0,0)`; exit animates from `translate(0,0)` back to it. The offset
911
+ * is the same for both (the enter-vs-exit direction is carried by the keyframe
912
+ * construction, not by this function), so it takes only the direction + size.
915
913
  */
916
- function offsetForDirection(dir, w, h, _outFrom) {
914
+ function offsetForDirection(dir, w, h) {
917
915
  if (dir === "top")
918
916
  return `translate(0, -${h}px)`;
919
917
  if (dir === "bottom")
@@ -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();
@@ -95,6 +95,14 @@ export declare class DemoRecorder {
95
95
  init(opts: CaptureOptions): Promise<void>;
96
96
  /** Navigate to a URL and capture the visible DOM as SVG. */
97
97
  captureUrl(path: string, waitMs?: number, idPrefix?: string): Promise<string>;
98
+ /**
99
+ * Shared post-capture pipeline (DM-1434): self-contained remote-image
100
+ * embedding + optional resize + conic-gradient rasterization, then reset the
101
+ * generation-scoped caches and render the tree to SVG body markup at `height`.
102
+ * `captureCurrent` (viewport) and `captureFullPage` (scrollable) differ only in
103
+ * the height, so they both funnel through here.
104
+ */
105
+ private renderCapturedTree;
98
106
  /** Capture the current page state as SVG content. */
99
107
  captureCurrent(idPrefix?: string): Promise<string>;
100
108
  /**
@@ -131,6 +139,32 @@ export declare function attachWebfontTracker(page: Page): {
131
139
  urls: Set<string>;
132
140
  detach: () => void;
133
141
  };
142
+ /**
143
+ * Discover all `@font-face` rules in the page's stylesheets, fetch each
144
+ * font file via the browser context's request API (so cookies / CORS / auth
145
+ * follow whatever the browser is using), and register the bytes with
146
+ * `text-to-path.ts` so the renderer can draw with the actual webfont glyphs
147
+ * instead of falling through to the system-font substitutes.
148
+ *
149
+ * Should be called AFTER `await page.evaluate(() => document.fonts.ready)`
150
+ * — otherwise late-loading fonts may not be in `document.styleSheets` yet.
151
+ *
152
+ * Cross-origin stylesheets whose `cssRules` throw a SecurityError are silently
153
+ * skipped (we can't enumerate their rules from JS). Same-origin sheets and
154
+ * inline `<style>` blocks always work.
155
+ *
156
+ * Caller is responsible for `clearWebfonts()` between captures if needed.
157
+ * No-op when the page declares no `@font-face` rules.
158
+ */
159
+ type FaceRule = {
160
+ kind: "font-face";
161
+ family: string;
162
+ weight: string;
163
+ style: string;
164
+ url: string;
165
+ urls?: string[];
166
+ unicodeRange?: Array<[number, number]>;
167
+ };
134
168
  /** One row of the report returned by discoverAndRegisterWebfonts. */
135
169
  type WebfontRegisterReport = {
136
170
  family: string;
@@ -155,15 +189,7 @@ export declare function discoverAndRegisterWebfonts(page: Page, observedFontUrls
155
189
  * that never serialised back to text) aren't covered. Adequate for the
156
190
  * mainstream marketing-site case that motivated the change.
157
191
  */
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
- }>;
192
+ export declare function parseFontFaceRulesFromCssText(cssText: string, baseUrl: string): FaceRule[];
167
193
  /**
168
194
  * Parse a CSS `unicode-range` descriptor value (CSS Fonts 4 §4.5) into a list
169
195
  * of inclusive `[from, to]` codepoint intervals. Accepts the three forms:
@@ -10,12 +10,13 @@ import { elementTreeToSvgInner } from "../render/element-tree-to-svg.js";
10
10
  import { embedRemoteImages } from "./embed.js";
11
11
  import { resizeEmbeddedImages } from "../tree-ops/resize-embedded-images.js";
12
12
  import { rasterizeConicGradients } from "../render/conic-raster.js";
13
- import { clearEmbeddedFonts, clearGlyphDefs, registerLocalFontAlias, registerWebfont } from "../render/text-to-path.js";
13
+ import { resetGeneration, registerLocalFontAlias, registerWebfont } from "../render/text-to-path.js";
14
14
  import { CAPTURE_SCRIPT } from "./script.generated.js";
15
15
  import { rasterizeBitmapGlyphs } from "./emoji.js";
16
16
  import { clipRectForScreenshot } from "./clip-rect.js";
17
17
  import { refineInitialLetterPositions } from "./initial-letter-probe.js";
18
18
  import { _resetLastCaptureWarnings } from "./warnings.js";
19
+ import { forEachElement } from "../tree-ops/for-each-element.js";
19
20
  /**
20
21
  * Launch Chromium via Playwright, auto-installing the browser binary on first
21
22
  * use if it's missing. Use this instead of importing `chromium` from
@@ -123,13 +124,14 @@ export class DemoRecorder {
123
124
  await this.page.waitForTimeout(waitMs);
124
125
  return this.captureCurrent(idPrefix);
125
126
  }
126
- /** Capture the current page state as SVG content. */
127
- async captureCurrent(idPrefix = "") {
128
- if (this.page == null)
129
- throw new Error("Call init() first");
130
- const tree = await captureElementTree(this.page, "body", {
131
- x: 0, y: 0, width: this.width, height: this.height,
132
- });
127
+ /**
128
+ * Shared post-capture pipeline (DM-1434): self-contained remote-image
129
+ * embedding + optional resize + conic-gradient rasterization, then reset the
130
+ * generation-scoped caches and render the tree to SVG body markup at `height`.
131
+ * `captureCurrent` (viewport) and `captureFullPage` (scrollable) differ only in
132
+ * the height, so they both funnel through here.
133
+ */
134
+ async renderCapturedTree(tree, height, idPrefix) {
133
135
  if (this.selfContained)
134
136
  await embedRemoteImages(tree, {
135
137
  timeoutMs: this.embedRemoteImagesTimeoutMs,
@@ -143,12 +145,21 @@ export class DemoRecorder {
143
145
  // tree contains no conic content; otherwise the renderer (DM-550) emits
144
146
  // <pattern><image href="data:..."/></pattern> instead of dropping the layer.
145
147
  await rasterizeConicGradients(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
146
- // DM-839: reset the embedded-font builder so this capture's `@font-face`
147
- // block contains only its own fonts (the renderer repopulates it during
148
- // elementTreeToSvg, which emits the CSS into this frame's <defs>).
149
- clearEmbeddedFonts();
150
- clearGlyphDefs(); // DM-1338: glyph registry (paths mode) shares the per-generation lifecycle
151
- return elementTreeToSvgInner(tree, this.width, this.height, idPrefix, true, this.embedRemoteImagesHiDPIFactor ?? 2);
148
+ // DM-839/DM-1338/DM-1435: reset the generation-scoped caches (embedded-font
149
+ // builder + paths-mode glyph registry) so this capture's `@font-face` block /
150
+ // <defs> contain only its own fonts/glyphs (the renderer repopulates them
151
+ // during elementTreeToSvg, emitting into this frame's <defs>).
152
+ resetGeneration();
153
+ return elementTreeToSvgInner(tree, this.width, height, idPrefix, true, this.embedRemoteImagesHiDPIFactor ?? 2);
154
+ }
155
+ /** Capture the current page state as SVG content. */
156
+ async captureCurrent(idPrefix = "") {
157
+ if (this.page == null)
158
+ throw new Error("Call init() first");
159
+ const tree = await captureElementTree(this.page, "body", {
160
+ x: 0, y: 0, width: this.width, height: this.height,
161
+ });
162
+ return this.renderCapturedTree(tree, this.height, idPrefix);
152
163
  }
153
164
  /**
154
165
  * Capture a full-page (scrollable) DOM as SVG.
@@ -161,20 +172,7 @@ export class DemoRecorder {
161
172
  const tree = await captureElementTree(this.page, "body", {
162
173
  x: 0, y: 0, width: this.width, height: pageHeight,
163
174
  });
164
- if (this.selfContained)
165
- await embedRemoteImages(tree, {
166
- timeoutMs: this.embedRemoteImagesTimeoutMs,
167
- retries: this.embedRemoteImagesRetries,
168
- retryBackoffMs: this.embedRemoteImagesRetryBackoffMs,
169
- });
170
- if (this.selfContained && this.embedRemoteImagesResize) {
171
- await resizeEmbeddedImages(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
172
- }
173
- // DM-549: rasterize conic-gradient layers — see captureCurrent above.
174
- await rasterizeConicGradients(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
175
- clearEmbeddedFonts(); // DM-839: see captureCurrent
176
- clearGlyphDefs(); // DM-1338: glyph registry shares the per-generation lifecycle
177
- const svgContent = elementTreeToSvgInner(tree, this.width, pageHeight, idPrefix, true, this.embedRemoteImagesHiDPIFactor ?? 2);
175
+ const svgContent = await this.renderCapturedTree(tree, pageHeight, idPrefix);
178
176
  return { svgContent, pageHeight };
179
177
  }
180
178
  /** Get the underlying Playwright page for custom interactions. */
@@ -1010,23 +1008,18 @@ function clampClipToBounds(rect, viewport, boundW, boundH) {
1010
1008
  */
1011
1009
  async function rasterizeReplacedElements(page, tree, viewport, opts) {
1012
1010
  const targets = [];
1013
- const walk = (els) => {
1014
- for (const el of els) {
1015
- if (el.replacedSnapshot != null) {
1016
- const rs = el.replacedSnapshot;
1017
- targets.push({
1018
- rid: rs.rid,
1019
- tag: el.tag,
1020
- rect: { x: rs.x, y: rs.y, width: rs.width, height: rs.height },
1021
- setDataUri: (uri) => { rs.dataUri = uri; },
1022
- setRect: (x, y, w, h) => { rs.x = x; rs.y = y; rs.width = w; rs.height = h; },
1023
- });
1024
- }
1025
- if (el.children.length > 0)
1026
- walk(el.children);
1011
+ forEachElement(tree, (el) => {
1012
+ if (el.replacedSnapshot != null) {
1013
+ const rs = el.replacedSnapshot;
1014
+ targets.push({
1015
+ rid: rs.rid,
1016
+ tag: el.tag,
1017
+ rect: { x: rs.x, y: rs.y, width: rs.width, height: rs.height },
1018
+ setDataUri: (uri) => { rs.dataUri = uri; },
1019
+ setRect: (x, y, w, h) => { rs.x = x; rs.y = y; rs.width = w; rs.height = h; },
1020
+ });
1027
1021
  }
1028
- };
1029
- walk(tree);
1022
+ });
1030
1023
  if (targets.length === 0)
1031
1024
  return;
1032
1025
  // DM-562: custom elements (hyphenated tag — DM-511 routed them through
@@ -1278,16 +1271,11 @@ export async function calibrateBaselines(page, elements, pngBytes) {
1278
1271
  // Flatten the tree into a list of text-bearing elements with stable keys
1279
1272
  const flat = [];
1280
1273
  let counter = 0;
1281
- const walk = (els) => {
1282
- for (const e of els) {
1283
- if ((e.text != null && e.text !== "") && e.textTop != null && e.textWidth != null && e.textWidth > 0 && e.textHeight != null && e.textHeight > 0) {
1284
- flat.push({ key: `c${counter++}`, el: e });
1285
- }
1286
- if (e.children != null && e.children.length > 0)
1287
- walk(e.children);
1274
+ forEachElement(elements, (e) => {
1275
+ if ((e.text != null && e.text !== "") && e.textTop != null && e.textWidth != null && e.textWidth > 0 && e.textHeight != null && e.textHeight > 0) {
1276
+ flat.push({ key: `c${counter++}`, el: e });
1288
1277
  }
1289
- };
1290
- walk(elements);
1278
+ });
1291
1279
  if (flat.length === 0)
1292
1280
  return;
1293
1281
  const items = flat.map((f) => ({
@@ -29,22 +29,18 @@
29
29
  */
30
30
  import sharp from "sharp";
31
31
  import { clipRectForScreenshot } from "./clip-rect.js";
32
+ import { forEachElement } from "../tree-ops/for-each-element.js";
32
33
  export async function refineInitialLetterPositions(page, tree, viewport) {
33
34
  const jobs = [];
34
- const walk = (els) => {
35
- for (const el of els) {
36
- if (el.textSegments != null) {
37
- for (const seg of el.textSegments) {
38
- const probe = seg._initialLetterProbe;
39
- if (probe != null)
40
- jobs.push({ seg, probe });
41
- }
35
+ forEachElement(tree, (el) => {
36
+ if (el.textSegments != null) {
37
+ for (const seg of el.textSegments) {
38
+ const probe = seg._initialLetterProbe;
39
+ if (probe != null)
40
+ jobs.push({ seg, probe });
42
41
  }
43
- if (el.children.length > 0)
44
- walk(el.children);
45
42
  }
46
- };
47
- walk(tree);
43
+ });
48
44
  if (jobs.length === 0)
49
45
  return;
50
46
  for (const { seg, probe } of jobs) {