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.
- package/README.md +4 -1
- package/dist/animation/animator.js +14 -16
- 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 +35 -9
- package/dist/capture/index.js +41 -53
- package/dist/capture/initial-letter-probe.js +8 -12
- package/dist/capture/script/index.js +47 -163
- 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/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/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/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/common.d.ts +17 -0
- package/dist/cli/common.js +32 -7
- package/dist/cli/review.js +11 -8
- package/dist/cli/svg-to-image.js +9 -24
- package/dist/cli/svg-to-video.js +9 -24
- package/dist/render/element-tree-to-svg.js +481 -290
- 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/text.js +23 -4
- 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/review/server.js +44 -44
- package/dist/scroll/composer.js +14 -25
- package/dist/scrubber/server.js +5 -16
- package/dist/tree-ops/for-each-element.d.ts +12 -0
- package/dist/tree-ops/for-each-element.js +17 -0
- package/dist/utils/local-server.d.ts +24 -0
- package/dist/utils/local-server.js +30 -0
- 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
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
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
|
|
914
|
+
function offsetForDirection(dir, w, h) {
|
|
917
915
|
if (dir === "top")
|
|
918
916
|
return `translate(0, -${h}px)`;
|
|
919
917
|
if (dir === "bottom")
|
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
|
@@ -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):
|
|
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:
|
package/dist/capture/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return elementTreeToSvgInner(tree, this.width,
|
|
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
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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) {
|