domotion-svg 0.27.2 → 0.28.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/dist/cli/animate-orchestrator.js +10 -6
- package/dist/render/element-tree-to-svg.d.ts +15 -0
- package/dist/render/element-tree-to-svg.js +60 -102
- package/dist/review/compare-pngs.d.ts +11 -8
- package/dist/review/compare-pngs.js +8 -8
- package/dist/scroll/composer.js +94 -11
- package/dist/scroll/executor.d.ts +8 -2
- package/dist/scroll/executor.js +17 -2
- package/package.json +1 -1
|
@@ -1371,9 +1371,10 @@ async function buildCapturedFrame(fc, i, ctx) {
|
|
|
1371
1371
|
// can diff it against the next frame's. `null` for scroll-block frames
|
|
1372
1372
|
// (no single tree) — magic-move then falls back to crossfade.
|
|
1373
1373
|
let frameTree = null;
|
|
1374
|
-
//
|
|
1375
|
-
// own
|
|
1376
|
-
//
|
|
1374
|
+
// Timed nested frames (`typeResample`, `jsReveal`, `states`, and `scroll`)
|
|
1375
|
+
// carry their own animated SVG. Record its period so the animator re-anchors
|
|
1376
|
+
// that document-global timeline to this frame's master-loop offset (the same
|
|
1377
|
+
// contract used by a `cast` / animated-`template` frame).
|
|
1377
1378
|
let embeddedAnimationPeriodMs;
|
|
1378
1379
|
// DM-1767 (docs/104): overlays a `states` run's individual states carried,
|
|
1379
1380
|
// already anchor-resolved per state and re-based onto this frame's timeline.
|
|
@@ -1438,9 +1439,8 @@ async function buildCapturedFrame(fc, i, ctx) {
|
|
|
1438
1439
|
// page, cull each segment's tree (DM-603), compose into one
|
|
1439
1440
|
// animated SVG, and use as this frame's svgContent. The composed
|
|
1440
1441
|
// SVG carries its own internal keyframes loop (animation-duration =
|
|
1441
|
-
// pattern's total scroll time)
|
|
1442
|
-
//
|
|
1443
|
-
// the inner scroll loop.
|
|
1442
|
+
// pattern's total scroll time). Record that period below so the animator
|
|
1443
|
+
// re-anchors the nested loop to this frame's master-timeline window.
|
|
1444
1444
|
log(` scroll pattern: ${fc.scroll.pattern}`);
|
|
1445
1445
|
const scrollPattern = parseScrollPattern(fc.scroll.pattern);
|
|
1446
1446
|
const scrollClip = fc.scroll.clip ?? [0, 0, cfg.width, cfg.height];
|
|
@@ -1476,6 +1476,10 @@ async function buildCapturedFrame(fc, i, ctx) {
|
|
|
1476
1476
|
restoreGeneration(outerGeneration);
|
|
1477
1477
|
}
|
|
1478
1478
|
composed = namespaceEmbeddedAnimatedSvg(composed, `sf${i}_`);
|
|
1479
|
+
embeddedAnimationPeriodMs = Math.max(segments[segments.length - 1]?.segmentEndMs ?? 0, 1);
|
|
1480
|
+
if (fc.duration < embeddedAnimationPeriodMs) {
|
|
1481
|
+
log(` note: frame duration ${fc.duration}ms < scroll play time ${embeddedAnimationPeriodMs}ms — the scroll will be cut off; size duration to ≈ ${embeddedAnimationPeriodMs}ms`);
|
|
1482
|
+
}
|
|
1479
1483
|
// The composer emits a full `<?xml ...><svg>...</svg>` document. The
|
|
1480
1484
|
// outer animator wraps `svgContent` in a `<g class="f f-N">`, which
|
|
1481
1485
|
// happily contains a nested `<svg>` element — strip just the XML
|
|
@@ -7,6 +7,7 @@ import { type SessionGenericFamilyOverrides } from "./font-resolution.js";
|
|
|
7
7
|
export { parseGradientStops, buildRadialGradientDef, parseBgPositionPx } from "./gradient-defs.js";
|
|
8
8
|
export { buildMaskDef, maskPaintAreas, positionFragmentMaskDef, rewriteFragmentMaskDef } from "./mask.js";
|
|
9
9
|
export { resolveMaskContainCoverRect, resolveMaskPosition, resolveMaskPositionAxis } from "./mask-position.js";
|
|
10
|
+
import { type CornerRadii } from "./borders.js";
|
|
10
11
|
import type { CapturedElement, CapturedTreeInput } from "../capture/types.js";
|
|
11
12
|
export { _dataUriCache, _resizedDataUriCache, embedResizedDataUri, embedRemoteImages, resolveSvgSource, type EmbedRemoteImagesOptions } from "../capture/embed.js";
|
|
12
13
|
export { getLastCaptureWarnings, logCaptureWarnings } from "../capture/warnings.js";
|
|
@@ -98,6 +99,20 @@ interface BoxReflectionSpec {
|
|
|
98
99
|
/** Parse Chromium's computed `-webkit-box-reflect` serialization. */
|
|
99
100
|
export declare function parseBoxReflection(value: string | undefined, width: number, height: number): BoxReflectionSpec | null;
|
|
100
101
|
export declare function boxReflectionTransform(spec: BoxReflectionSpec, x: number, y: number, width: number, height: number): string;
|
|
102
|
+
export interface ChildOverflowClipGeometry {
|
|
103
|
+
x: number;
|
|
104
|
+
y: number;
|
|
105
|
+
width: number;
|
|
106
|
+
height: number;
|
|
107
|
+
corners: CornerRadii;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the fixed page-space clip applied to an element's descendants.
|
|
111
|
+
* Keeping this geometry separate from emission lets compound renderers retain
|
|
112
|
+
* an ancestor clip after detaching a descendant, without duplicating the CSS
|
|
113
|
+
* padding-edge, containment, radius, and overflow-clip-margin rules.
|
|
114
|
+
*/
|
|
115
|
+
export declare function childOverflowClipGeometry(el: CapturedElement, corners?: CornerRadii): ChildOverflowClipGeometry | null;
|
|
101
116
|
/**
|
|
102
117
|
* DM-950: render a CapturedElement tree into a **complete `<svg>`
|
|
103
118
|
* document** — the obvious entry point for "I have a tree, give me a
|
|
@@ -4506,6 +4506,63 @@ function openChildOverflowClip(state, el, indent, corners) {
|
|
|
4506
4506
|
state.svgParts.push(`${indent}<g clip-path="url(#${id})">`);
|
|
4507
4507
|
return id;
|
|
4508
4508
|
}
|
|
4509
|
+
/**
|
|
4510
|
+
* Resolve the fixed page-space clip applied to an element's descendants.
|
|
4511
|
+
* Keeping this geometry separate from emission lets compound renderers retain
|
|
4512
|
+
* an ancestor clip after detaching a descendant, without duplicating the CSS
|
|
4513
|
+
* padding-edge, containment, radius, and overflow-clip-margin rules.
|
|
4514
|
+
*/
|
|
4515
|
+
export function childOverflowClipGeometry(el, corners = parseCornerRadii(el.styles, el.width, el.height)) {
|
|
4516
|
+
const ox = el.styles.overflowX;
|
|
4517
|
+
const oy = el.styles.overflowY;
|
|
4518
|
+
const containVal = el.styles.contain;
|
|
4519
|
+
const containClips = containVal != null && containVal !== "" && containVal !== "none"
|
|
4520
|
+
&& /\b(?:paint|strict|content)\b/i.test(containVal);
|
|
4521
|
+
const clipsOverflow = (ox != null && ox !== "visible") || (oy != null && oy !== "visible") || containClips;
|
|
4522
|
+
// Body overflow propagates to the viewport instead of clipping body itself.
|
|
4523
|
+
if (!clipsOverflow || el.tag === "body" || el.children.length === 0)
|
|
4524
|
+
return null;
|
|
4525
|
+
// CSS overflow clips descendants to the padding edge, whose inner corner
|
|
4526
|
+
// radii are the captured outer radii inset by the adjacent border widths.
|
|
4527
|
+
const top = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
|
|
4528
|
+
const right = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
4529
|
+
const bottom = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
4530
|
+
const left = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
4531
|
+
let clipCorners = insetCornerRadii(corners, top, right, bottom, left);
|
|
4532
|
+
let x = el.x + left;
|
|
4533
|
+
let y = el.y + top;
|
|
4534
|
+
let width = Math.max(0, el.width - left - right);
|
|
4535
|
+
let height = Math.max(0, el.height - top - bottom);
|
|
4536
|
+
// An active overflow-clip-margin replaces the ordinary padding-box contour.
|
|
4537
|
+
const marginGeometry = resolvedOverflowClipMarginGeometry(el, corners);
|
|
4538
|
+
if (marginGeometry != null) {
|
|
4539
|
+
x = marginGeometry.x;
|
|
4540
|
+
y = marginGeometry.y;
|
|
4541
|
+
width = marginGeometry.width;
|
|
4542
|
+
height = marginGeometry.height;
|
|
4543
|
+
clipCorners = marginGeometry.corners;
|
|
4544
|
+
}
|
|
4545
|
+
if (el.fieldsetLegendNotch != null) {
|
|
4546
|
+
// A rendered legend belongs to the fieldset border, not its scrollport.
|
|
4547
|
+
const protrude = y - el.fieldsetLegendNotch.y;
|
|
4548
|
+
if (protrude > 0) {
|
|
4549
|
+
y -= protrude;
|
|
4550
|
+
height += protrude;
|
|
4551
|
+
}
|
|
4552
|
+
}
|
|
4553
|
+
const unbounded = 100000;
|
|
4554
|
+
// SVG has one clip shape, so model a visible axis by extending it beyond any
|
|
4555
|
+
// plausible paint area while retaining the other axis's authored clip.
|
|
4556
|
+
if (!containClips && ox === "visible" && oy === "clip") {
|
|
4557
|
+
x = el.x - unbounded;
|
|
4558
|
+
width = el.width + unbounded * 2;
|
|
4559
|
+
}
|
|
4560
|
+
if (!containClips && oy === "visible" && ox === "clip") {
|
|
4561
|
+
y = el.y - unbounded;
|
|
4562
|
+
height = el.height + unbounded * 2;
|
|
4563
|
+
}
|
|
4564
|
+
return { x, y, width, height, corners: clipCorners };
|
|
4565
|
+
}
|
|
4509
4566
|
/**
|
|
4510
4567
|
* Mint (once) the `<clipPath>` that clips `el`'s children to its overflow
|
|
4511
4568
|
* region, and return its id — or null when the element doesn't clip.
|
|
@@ -4521,110 +4578,11 @@ function ensureChildOverflowClipId(state, el, corners) {
|
|
|
4521
4578
|
const memoized = overflowClipPathIds.get(el);
|
|
4522
4579
|
if (memoized != null)
|
|
4523
4580
|
return memoized;
|
|
4524
|
-
|
|
4525
|
-
// auto/clip on either axis), its children must be clipped to its box.
|
|
4526
|
-
// We wrap just the child recursion in a <g clip-path="..."> so the element's
|
|
4527
|
-
// own bg/border/text render unclipped.
|
|
4528
|
-
//
|
|
4529
|
-
// CSS spec (DM-363): overflow clips to the **padding edge**, not the
|
|
4530
|
-
// border-box edge. If we clip to the border-box, child fills extending to
|
|
4531
|
-
// the bottom of the box paint OVER the bottom border stroke and the
|
|
4532
|
-
// border disappears from the rendered output (e.g. 13-pos-sticky:
|
|
4533
|
-
// Section B's `.filler` rect was hiding the scroller's `border-bottom`).
|
|
4534
|
-
// Inset the clip rect by the per-side border widths so the border stroke
|
|
4535
|
-
// remains visible above the clipped children.
|
|
4536
|
-
const ox = el.styles.overflowX;
|
|
4537
|
-
const oy = el.styles.overflowY;
|
|
4538
|
-
// DM-522: `contain: paint | strict | content` clips descendants to the
|
|
4539
|
-
// principal (padding) box per the CSS Containment spec — same effective
|
|
4540
|
-
// clip as overflow:hidden, so route it through the same machinery. Without
|
|
4541
|
-
// this, a `contain:paint` ancestor lets descendants overflow visually
|
|
4542
|
-
// (regression observable on `13-deep-stacking-context-creators`'s
|
|
4543
|
-
// contain:paint stage: the blue inner z:9999 box paints past the dashed
|
|
4544
|
-
// ancestor instead of being trapped). The `containClips` test deliberately
|
|
4545
|
-
// excludes `contain: layout` / `size` / `inline-size` since those don't
|
|
4546
|
-
// imply paint clipping.
|
|
4547
|
-
const containVal = el.styles.contain;
|
|
4548
|
-
const containClips = containVal != null && containVal !== "" && containVal !== "none"
|
|
4549
|
-
&& /\b(?:paint|strict|content)\b/i.test(containVal);
|
|
4550
|
-
const clipsOverflow = (ox != null && ox !== "visible") || (oy != null && oy !== "visible") || containClips;
|
|
4551
|
-
// DM-650: same body-overflow-propagation rule as the earlier clip-path
|
|
4552
|
-
// emission — when body has non-visible overflow it propagates to the
|
|
4553
|
-
// viewport rather than clipping body itself; skip the children-overflow
|
|
4554
|
-
// clip too so descendants positioned outside body's bbox (e.g. NYT
|
|
4555
|
-
// desktop's content wrapper, which extends below body's height: 100vh
|
|
4556
|
-
// box) stay visible after the document scroll moves body off-viewport.
|
|
4557
|
-
const isBodyOverflowPropagatedHere = el.tag === "body";
|
|
4581
|
+
const geometry = childOverflowClipGeometry(el, corners);
|
|
4558
4582
|
let overflowClipId = null;
|
|
4559
|
-
if (
|
|
4583
|
+
if (geometry != null) {
|
|
4560
4584
|
overflowClipId = paintCtx.nextClipId("ov");
|
|
4561
|
-
|
|
4562
|
-
const cbr = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
|
|
4563
|
-
const cbb = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
|
|
4564
|
-
const cbl = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
|
|
4565
|
-
// DM-698: overflow clips to the inner border-radius (per CSS Backgrounds 3
|
|
4566
|
-
// — the rounded clip on the padding box uses radii inset by each side's
|
|
4567
|
-
// border width, clamped to zero). Previously we passed the OUTER `corners`
|
|
4568
|
-
// which made the clip too generous near each corner, exposing a sliver of
|
|
4569
|
-
// the parent's background between the border and the clipped child.
|
|
4570
|
-
// (e.g. `18-deep-radius-overflow` `.card` border-radius:32 / border:4 +
|
|
4571
|
-
// child `position:absolute inset:0`: 4 px gradient sliver visible inside
|
|
4572
|
-
// each rounded corner.)
|
|
4573
|
-
let overflowCorners = insetCornerRadii(corners, cbt, cbr, cbb, cbl);
|
|
4574
|
-
// Default clip = padding box (border-inset). DM-2419: for an active
|
|
4575
|
-
// overflow-clip-margin, Blink instead starts from a PIXEL-SNAPPED inner
|
|
4576
|
-
// border, applies the physical reference-box/margin outsets, and grows or
|
|
4577
|
-
// contracts every corner with FloatRoundedRect's coverage correction.
|
|
4578
|
-
// Reference-box-only zero values remain active; a negative CSS length is
|
|
4579
|
-
// invalid and never reaches this branch.
|
|
4580
|
-
let ocX = el.x + cbl;
|
|
4581
|
-
let ocY = el.y + cbt;
|
|
4582
|
-
let ocW = Math.max(0, el.width - cbl - cbr);
|
|
4583
|
-
let ocH = Math.max(0, el.height - cbt - cbb);
|
|
4584
|
-
const ocmGeometry = resolvedOverflowClipMarginGeometry(el, corners);
|
|
4585
|
-
if (ocmGeometry != null) {
|
|
4586
|
-
ocX = ocmGeometry.x;
|
|
4587
|
-
ocY = ocmGeometry.y;
|
|
4588
|
-
ocW = ocmGeometry.width;
|
|
4589
|
-
ocH = ocmGeometry.height;
|
|
4590
|
-
overflowCorners = ocmGeometry.corners;
|
|
4591
|
-
}
|
|
4592
|
-
// DM-1264: a <fieldset>'s rendered <legend> is part of the block-start BORDER,
|
|
4593
|
-
// not the scrollport — per Blink `fieldset_layout_algorithm.cc`: "the rendered
|
|
4594
|
-
// legend shouldn't be part of the scrollport; the legend is essentially a part
|
|
4595
|
-
// of the block-start border ... scrollbars are handled by the anonymous child
|
|
4596
|
-
// box." So a `fieldset { overflow: auto }` (resize needs overflow != visible)
|
|
4597
|
-
// must NOT clip the legend, which straddles the border line and protrudes above
|
|
4598
|
-
// the padding box. Raise the clip's top edge to clear the legend (the block-
|
|
4599
|
-
// start border strip holds nothing else that could leak out).
|
|
4600
|
-
if (el.fieldsetLegendNotch != null) {
|
|
4601
|
-
const protrude = ocY - el.fieldsetLegendNotch.y;
|
|
4602
|
-
if (protrude > 0) {
|
|
4603
|
-
ocY -= protrude;
|
|
4604
|
-
ocH += protrude;
|
|
4605
|
-
}
|
|
4606
|
-
}
|
|
4607
|
-
// DM-787: CSS Overflow 3 allows mixing `overflow-x: clip; overflow-y:
|
|
4608
|
-
// visible` (only `clip` permits this — `hidden + visible` coerces to
|
|
4609
|
-
// `auto + hidden`). Chrome clips only the clipped axis; content can
|
|
4610
|
-
// still escape on the visible axis. The SVG clipPath is a single rect,
|
|
4611
|
-
// so to NOT clip on an axis we extend that axis past any plausible
|
|
4612
|
-
// paint area with `±UNBOUNDED`. Such a one-axis combination is also an
|
|
4613
|
-
// explicit negative activation control for overflow-clip-margin.
|
|
4614
|
-
const UNBOUNDED = 100000;
|
|
4615
|
-
// Paint containment supplies its own both-axis overflow clip edge, so an
|
|
4616
|
-
// authored visible axis does not punch through that containment clip.
|
|
4617
|
-
const xVisible = !containClips && ox === "visible" && oy === "clip";
|
|
4618
|
-
const yVisible = !containClips && oy === "visible" && ox === "clip";
|
|
4619
|
-
if (xVisible) {
|
|
4620
|
-
ocX = el.x - UNBOUNDED;
|
|
4621
|
-
ocW = el.width + UNBOUNDED * 2;
|
|
4622
|
-
}
|
|
4623
|
-
if (yVisible) {
|
|
4624
|
-
ocY = el.y - UNBOUNDED;
|
|
4625
|
-
ocH = el.height + UNBOUNDED * 2;
|
|
4626
|
-
}
|
|
4627
|
-
defsParts.push(`<clipPath id="${overflowClipId}">${roundedRectSvg(ocX, ocY, ocW, ocH, overflowCorners, "")}</clipPath>`);
|
|
4585
|
+
defsParts.push(`<clipPath id="${overflowClipId}">${roundedRectSvg(geometry.x, geometry.y, geometry.width, geometry.height, geometry.corners, "")}</clipPath>`);
|
|
4628
4586
|
// DM-673: stash the clip-path id so hoisted descendants of this
|
|
4629
4587
|
// overflow scroller can re-wrap their emission in the same clip.
|
|
4630
4588
|
overflowClipPathIds.set(el, overflowClipId);
|
|
@@ -250,10 +250,11 @@ export declare function passes(cmp: CompareResult): boolean;
|
|
|
250
250
|
* 3712 px with `regionCount === 0`, including a component larger than the
|
|
251
251
|
* unchanged 256 px single-region cap. The same Chromium build's pinned Linux
|
|
252
252
|
* x64 run measures 2065 px total in the two-pane fixture (48 sparse regions,
|
|
253
|
-
* 135 px largest, zero high-severity regions).
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
253
|
+
* 135 px largest, zero high-severity regions). GitHub's macOS 26.6.2 runner
|
|
254
|
+
* image moved the clean ceiling to 2835 px total / 171 px largest, with five
|
|
255
|
+
* edge components crossing the default high-severity classification. The 3072
|
|
256
|
+
* aggregate cap admits those measured scattered text-edge floors while the
|
|
257
|
+
* independent 256 px component cap still rejects the known structural break.
|
|
257
258
|
*
|
|
258
259
|
* Unlike the visual gate's per-platform hinting floor ("Per-platform coverage
|
|
259
260
|
* floor" in docs/12-diff-scoring.md), this bar needs no per-platform relief:
|
|
@@ -266,10 +267,12 @@ export interface StrictCaps {
|
|
|
266
267
|
export declare function strictCapsFor(_platform: NodeJS.Platform | string): StrictCaps;
|
|
267
268
|
/** The host's caps. Never null: the bar is calibrated on every platform. */
|
|
268
269
|
export declare const STRICT_CAPS: StrictCaps;
|
|
269
|
-
/** The no-motion pass criterion:
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
270
|
+
/** The no-motion pass criterion: "nothing block-sized moved or swapped paint
|
|
271
|
+
* order". Lifts the high-severity-fraction gate that splits components between
|
|
272
|
+
* `regionCount` and `shiftyRegionCount`, then bounds ALL of them by area (see
|
|
273
|
+
* `strictCapsFor` for the measured sizing). Requiring `passes()` as well would
|
|
274
|
+
* put that raster-sensitive severity split back into the strict gate even
|
|
275
|
+
* though the strict aggregates already contain both buckets.
|
|
273
276
|
*
|
|
274
277
|
* Use this ONLY where both images are known to depict the same content at the
|
|
275
278
|
* same positions — e.g. the frame-sequence compressor's flipbook-parity
|
|
@@ -570,14 +570,16 @@ export function passes(cmp) {
|
|
|
570
570
|
return cmp.regionCount === 0;
|
|
571
571
|
}
|
|
572
572
|
export function strictCapsFor(_platform) {
|
|
573
|
-
return { maxRegionArea: 256, totalRegionArea:
|
|
573
|
+
return { maxRegionArea: 256, totalRegionArea: 3072 };
|
|
574
574
|
}
|
|
575
575
|
/** The host's caps. Never null: the bar is calibrated on every platform. */
|
|
576
576
|
export const STRICT_CAPS = strictCapsFor(process.platform);
|
|
577
|
-
/** The no-motion pass criterion:
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
577
|
+
/** The no-motion pass criterion: "nothing block-sized moved or swapped paint
|
|
578
|
+
* order". Lifts the high-severity-fraction gate that splits components between
|
|
579
|
+
* `regionCount` and `shiftyRegionCount`, then bounds ALL of them by area (see
|
|
580
|
+
* `strictCapsFor` for the measured sizing). Requiring `passes()` as well would
|
|
581
|
+
* put that raster-sensitive severity split back into the strict gate even
|
|
582
|
+
* though the strict aggregates already contain both buckets.
|
|
581
583
|
*
|
|
582
584
|
* Use this ONLY where both images are known to depict the same content at the
|
|
583
585
|
* same positions — e.g. the frame-sequence compressor's flipbook-parity
|
|
@@ -591,10 +593,8 @@ export const STRICT_CAPS = strictCapsFor(process.platform);
|
|
|
591
593
|
* Pass an explicit set to score a result against different numbers; passing
|
|
592
594
|
* `null` deliberately degrades the bar to plain `passes()`. */
|
|
593
595
|
export function passesStrict(cmp, caps = STRICT_CAPS) {
|
|
594
|
-
if (!passes(cmp))
|
|
595
|
-
return false;
|
|
596
596
|
if (caps == null)
|
|
597
|
-
return
|
|
597
|
+
return passes(cmp);
|
|
598
598
|
return cmp.strictMaxRegionArea <= caps.maxRegionArea
|
|
599
599
|
&& cmp.strictRegionArea <= caps.totalRegionArea;
|
|
600
600
|
}
|
package/dist/scroll/composer.js
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
* shrinking output size on mostly-static-content pages.
|
|
22
22
|
*/
|
|
23
23
|
import { capturedScrollOwnerBindingSha256, validateCapturedFrameScrollState, } from "../capture/frame-scroll-state.js";
|
|
24
|
-
import { elementTreeToSvgInner } from "../render/element-tree-to-svg.js";
|
|
24
|
+
import { childOverflowClipGeometry, elementTreeToSvgInner, } from "../render/element-tree-to-svg.js";
|
|
25
|
+
import { roundedRectSvg } from "../render/borders.js";
|
|
25
26
|
import { rootSvgA11y } from "../render/format.js";
|
|
26
27
|
import { isTransparentBackground } from "../utils/transparent-background.js";
|
|
27
28
|
import { resetGeneration, getEmbeddedFontFaceCss, getGlyphDefs, withRenderTextMode, } from "../render/text-to-path.js";
|
|
@@ -29,6 +30,7 @@ import { beginCharacterFallbackDocument, endCharacterFallbackDocument } from "..
|
|
|
29
30
|
import { hoistDuplicateImagePayloads } from "../post-processing/hoist-image-payloads.js";
|
|
30
31
|
import { extractFixedSubtrees, dedupeFixedAcrossSegments } from "./hoist-fixed.js";
|
|
31
32
|
import { extractStickyWindows } from "./hoist-sticky.js";
|
|
33
|
+
import { mapTreePruning } from "../tree-ops/prune-tree.js";
|
|
32
34
|
/**
|
|
33
35
|
* Map a parsed scroll-action easing (DM-1076) to a CSS `<timing-function>`, or
|
|
34
36
|
* `null` when it's absent or `linear` — the composite's animation-level default
|
|
@@ -81,6 +83,63 @@ function scrollOwner(state, ownerId) {
|
|
|
81
83
|
}
|
|
82
84
|
return undefined;
|
|
83
85
|
}
|
|
86
|
+
function findCapturedScrollOwnerPath(tree, ownerId, ancestors = []) {
|
|
87
|
+
for (const element of tree) {
|
|
88
|
+
const path = [...ancestors, element];
|
|
89
|
+
if (element.scrollbars?.owner?.ownerId === ownerId)
|
|
90
|
+
return path;
|
|
91
|
+
const descendant = findCapturedScrollOwnerPath(element.children ?? [], ownerId, path);
|
|
92
|
+
if (descendant != null)
|
|
93
|
+
return descendant;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* An element scroll owner moves its own clipped contents, not the page around
|
|
99
|
+
* it. Keep the first capture's surrounding page as a static underlay and feed
|
|
100
|
+
* only the authenticated owner subtree through the offset-stacking composer.
|
|
101
|
+
* The existing stack translation then cancels the owner's per-segment offset,
|
|
102
|
+
* leaving its border box in place while its captured children move inside it.
|
|
103
|
+
*/
|
|
104
|
+
function isolateElementScrollOwner(segments) {
|
|
105
|
+
const first = segments[0];
|
|
106
|
+
if (first?.frameScrollState == null || first.scrollOwnerId == null)
|
|
107
|
+
return null;
|
|
108
|
+
const firstOwnerRecord = scrollOwner(first.frameScrollState, first.scrollOwnerId);
|
|
109
|
+
if (firstOwnerRecord?.kind !== "element")
|
|
110
|
+
return null;
|
|
111
|
+
const firstOwnerPath = findCapturedScrollOwnerPath(first.tree, first.scrollOwnerId);
|
|
112
|
+
const firstOwner = firstOwnerPath?.at(-1);
|
|
113
|
+
// An explicitly owner-only capture (`--selector` / frame `selector`) keeps
|
|
114
|
+
// the established list-strip contract; there is no surrounding page to pin.
|
|
115
|
+
if (firstOwnerPath == null || firstOwner == null || first.tree.includes(firstOwner))
|
|
116
|
+
return null;
|
|
117
|
+
const staticUnderlay = mapTreePruning(first.tree, (element) => element.scrollbars?.owner?.ownerId === first.scrollOwnerId);
|
|
118
|
+
const isolated = segments.map((segment, index) => {
|
|
119
|
+
if (segment.frameScrollState == null || segment.scrollOwnerId == null) {
|
|
120
|
+
throw new Error(`composeScrollSvg: inner-scroll segment ${index} omitted its owner authority`);
|
|
121
|
+
}
|
|
122
|
+
const ownerRecord = scrollOwner(segment.frameScrollState, segment.scrollOwnerId);
|
|
123
|
+
const ownerElement = findCapturedScrollOwnerPath(segment.tree, segment.scrollOwnerId)?.at(-1);
|
|
124
|
+
if (ownerRecord?.kind !== "element" || ownerElement == null) {
|
|
125
|
+
throw new Error(`composeScrollSvg: inner-scroll segment ${index} changed or omitted its element owner`);
|
|
126
|
+
}
|
|
127
|
+
const sessionGenericFamilies = segment.tree.find((root) => root.sessionGenericFamilies != null)
|
|
128
|
+
?.sessionGenericFamilies;
|
|
129
|
+
return {
|
|
130
|
+
...segment,
|
|
131
|
+
tree: [{
|
|
132
|
+
...ownerElement,
|
|
133
|
+
...(sessionGenericFamilies == null ? {} : { sessionGenericFamilies }),
|
|
134
|
+
}],
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
segments: isolated,
|
|
139
|
+
staticUnderlay,
|
|
140
|
+
ownerClips: firstOwnerPath.map((element) => childOverflowClipGeometry(element)).filter((clip) => clip != null),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
84
143
|
/**
|
|
85
144
|
* Fail closed before composition when frame/allowlist/offset authority is
|
|
86
145
|
* omitted, stale, or assigned to a sibling Chromium frame.
|
|
@@ -244,6 +303,8 @@ export function composeScrollSvg(segments, opts) {
|
|
|
244
303
|
if (chunkSize < 1 || !Number.isInteger(chunkSize)) {
|
|
245
304
|
throw new Error(`composeScrollSvg: chunkSize must be a positive integer, got ${chunkSize}`);
|
|
246
305
|
}
|
|
306
|
+
const elementScroll = isolateElementScrollOwner(segments);
|
|
307
|
+
const composedSegments = elementScroll?.segments ?? segments;
|
|
247
308
|
// DM-652: arm the text-render lifecycle. Default is "embedded-font" —
|
|
248
309
|
// the per-segment text renderer emits `<text>` runs against a single
|
|
249
310
|
// @font-face per used webfont, ~2× faster in WebKit and ~5× smaller
|
|
@@ -265,7 +326,11 @@ export function composeScrollSvg(segments, opts) {
|
|
|
265
326
|
// `elementTreeToSvgInner` scopes nest inside this one as no-ops.
|
|
266
327
|
beginCharacterFallbackDocument();
|
|
267
328
|
try {
|
|
268
|
-
return withRenderTextMode(renderTextMode, () => composeScrollSvgBody(
|
|
329
|
+
return withRenderTextMode(renderTextMode, () => composeScrollSvgBody(composedSegments, opts, {
|
|
330
|
+
axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize,
|
|
331
|
+
staticUnderlay: elementScroll?.staticUnderlay,
|
|
332
|
+
elementOwnerClips: elementScroll?.ownerClips,
|
|
333
|
+
}));
|
|
269
334
|
}
|
|
270
335
|
finally {
|
|
271
336
|
endCharacterFallbackDocument();
|
|
@@ -320,13 +385,16 @@ function buildScrollVisibility(segments, segOffsets, totalMs) {
|
|
|
320
385
|
* reads (axis / viewport / background / hiDPI / chunk size).
|
|
321
386
|
*/
|
|
322
387
|
function composeScrollSvgBody(segments, opts, ctx) {
|
|
323
|
-
const { axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize } = ctx;
|
|
388
|
+
const { axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize, staticUnderlay, elementOwnerClips, } = ctx;
|
|
324
389
|
// ── Total scene duration ──
|
|
325
390
|
// The last segment's endMs is the cycle length. For a single-segment input,
|
|
326
391
|
// the scene is effectively static — emit a 1 s loop so the SVG renders
|
|
327
392
|
// sensibly without a degenerate 0 s animation.
|
|
328
393
|
const totalMs = Math.max(segments[segments.length - 1].segmentEndMs, 1);
|
|
329
394
|
const totalSec = totalMs / 1000;
|
|
395
|
+
const paintSegments = segments
|
|
396
|
+
.map((segment, index) => ({ segment, index }))
|
|
397
|
+
.filter(({ segment }) => segment.timelineOnly !== true);
|
|
330
398
|
// ── Compute composite dimensions ──
|
|
331
399
|
// The composite spans the full scroll range. For axis=y, that's from min
|
|
332
400
|
// scrollY (typically 0) to max scrollY + viewportH (covers the LAST
|
|
@@ -351,7 +419,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
351
419
|
// viewport-level overlay below.
|
|
352
420
|
const fixedStripped = [];
|
|
353
421
|
const perSegFixed = [];
|
|
354
|
-
for (const seg of
|
|
422
|
+
for (const { segment: seg } of paintSegments) {
|
|
355
423
|
const { stripped, fixed } = extractFixedSubtrees(seg.tree);
|
|
356
424
|
fixedStripped.push(stripped);
|
|
357
425
|
perSegFixed.push(fixed);
|
|
@@ -383,10 +451,10 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
383
451
|
// ── Render each capture's content at its position offset ──
|
|
384
452
|
const captureGroups = [];
|
|
385
453
|
const segmentCullCss = [];
|
|
386
|
-
for (let
|
|
387
|
-
const seg =
|
|
388
|
-
const offset = segOffsets[
|
|
389
|
-
const inner = elementTreeToSvgInner(strippedTrees[
|
|
454
|
+
for (let paintIndex = 0; paintIndex < paintSegments.length; paintIndex++) {
|
|
455
|
+
const { segment: seg, index: segmentIndex } = paintSegments[paintIndex];
|
|
456
|
+
const offset = segOffsets[segmentIndex];
|
|
457
|
+
const inner = elementTreeToSvgInner(strippedTrees[paintIndex], W, VH, `seg${segmentIndex}-`, false, hiDPIFactor, false);
|
|
390
458
|
const tx = axis === "x" ? offset : 0;
|
|
391
459
|
const ty = axis === "y" ? offset : 0;
|
|
392
460
|
// Visibility window: visible while scroll-y is in the rasterisation
|
|
@@ -424,7 +492,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
424
492
|
`</g>`);
|
|
425
493
|
}
|
|
426
494
|
else {
|
|
427
|
-
const cls = `${animClass}-s${
|
|
495
|
+
const cls = `${animClass}-s${segmentIndex}`;
|
|
428
496
|
// step-end so the segment snaps in/out at the boundary, no fractional
|
|
429
497
|
// opacity that would force the browser to keep compositing it.
|
|
430
498
|
segmentCullCss.push(buildVisibilityKeyframes(cls, enterPct, leavePct, totalSec));
|
|
@@ -436,7 +504,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
436
504
|
}
|
|
437
505
|
}
|
|
438
506
|
// ── Sticky overlay markup + visibility keyframes (DM-647) ──
|
|
439
|
-
const { markup: stickyMarkup, cullCss: stickyCullCss } = buildStickyOverlays(stickyOverlays,
|
|
507
|
+
const { markup: stickyMarkup, cullCss: stickyCullCss } = buildStickyOverlays(stickyOverlays, paintSegments.map(({ segment }) => segment), totalMs, animClass, W, VH, hiDPIFactor);
|
|
440
508
|
const fixedMarkup = fixedOverlay.length === 0
|
|
441
509
|
? ""
|
|
442
510
|
: `\n <g>` +
|
|
@@ -491,6 +559,15 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
491
559
|
const slice = captureGroups.slice(i, i + chunkSize);
|
|
492
560
|
chunks.push(`<g style="will-change: transform">\n${slice.join("\n")}\n </g>`);
|
|
493
561
|
}
|
|
562
|
+
// Render the pinned page context before taking the generation-global font /
|
|
563
|
+
// glyph snapshots. Its text can add PUA glyphs to the same subset registries
|
|
564
|
+
// as the moving owner captures; collecting first would leave those late
|
|
565
|
+
// glyph references absent from the emitted embedded fonts.
|
|
566
|
+
const staticMarkup = staticUnderlay == null
|
|
567
|
+
? ""
|
|
568
|
+
: `\n <g data-scroll-static-context="true"><svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
|
|
569
|
+
elementTreeToSvgInner(staticUnderlay, W, VH, "static-", false, hiDPIFactor, false) +
|
|
570
|
+
`</svg></g>`;
|
|
494
571
|
// DM-652: collect every `@font-face` rule the embedded-font path
|
|
495
572
|
// registered during segment + overlay rendering above, into a single
|
|
496
573
|
// top-level <style> block. Each font appears once (registry is keyed
|
|
@@ -505,6 +582,9 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
505
582
|
// an animate frame (Blink resolves local SVG IRIs through the shared TreeScope,
|
|
506
583
|
// not through the nearest nested <svg> element).
|
|
507
584
|
const glyphDefs = getGlyphDefs();
|
|
585
|
+
const ownerClipDefs = (elementOwnerClips ?? []).map((clip, index) => ` <clipPath id="${animClass}-owner-clip-${index}">${roundedRectSvg(clip.x, clip.y, clip.width, clip.height, clip.corners, "")}</clipPath>`).join("\n");
|
|
586
|
+
const ownerClipOpen = (elementOwnerClips ?? []).map((_clip, index) => ` <g clip-path="url(#${animClass}-owner-clip-${index})">`).join("\n");
|
|
587
|
+
const ownerClipClose = (elementOwnerClips ?? []).map(() => " </g>").join("\n");
|
|
508
588
|
// ── Compose final SVG ──
|
|
509
589
|
// Share each raster payload across the segments that show it (the `<image>`
|
|
510
590
|
// emit is per-element, and a scroll composite repeats a sticky header /
|
|
@@ -514,7 +594,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
|
|
|
514
594
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${VH}" width="${W}" height="${VH}"${a11y.roleAttr}>${a11y.markup}
|
|
515
595
|
<defs>
|
|
516
596
|
<clipPath id="${animClass}-clip"><rect width="${W}" height="${VH}"/></clipPath>
|
|
517
|
-
${glyphDefs !== "" ? ` ${glyphDefs}\n` : ""} <style>
|
|
597
|
+
${ownerClipDefs === "" ? "" : ownerClipDefs + "\n"}${glyphDefs !== "" ? ` ${glyphDefs}\n` : ""} <style>
|
|
518
598
|
${fontFaceCss !== "" ? fontFaceCss + "\n" : ""} .${animClass} { animation: ${animClass} ${totalSec.toFixed(3)}s linear infinite; will-change: transform; }
|
|
519
599
|
@keyframes ${animClass} {
|
|
520
600
|
${keyframes}
|
|
@@ -527,11 +607,14 @@ ${stickyCullCss.join("\n")}
|
|
|
527
607
|
</style>
|
|
528
608
|
</defs>
|
|
529
609
|
${paintBg ? ` <rect width="${W}" height="${VH}" fill="${bg}"/>\n` : ""} <g clip-path="url(#${animClass}-clip)">
|
|
610
|
+
${staticMarkup}
|
|
611
|
+
${ownerClipOpen === "" ? "" : ownerClipOpen + "\n"}
|
|
530
612
|
<g class="${animClass}">
|
|
531
613
|
<svg x="0" y="0" width="${compositeW}" height="${compositeH}" viewBox="0 0 ${compositeW} ${compositeH}">
|
|
532
614
|
${paintBg ? ` <rect width="${compositeW}" height="${compositeH}" fill="${bg}"/>\n` : ""} ${chunks.join("\n ")}
|
|
533
615
|
</svg>
|
|
534
616
|
</g>
|
|
617
|
+
${ownerClipClose === "" ? "" : ownerClipClose + "\n"}
|
|
535
618
|
</g>${overlayMarkup}
|
|
536
619
|
</svg>`);
|
|
537
620
|
}
|
|
@@ -77,6 +77,12 @@ export interface ScrollSegmentCapture {
|
|
|
77
77
|
tree: CapturedElement[];
|
|
78
78
|
/** Diff from the previous segment's capture. Null for the very first. */
|
|
79
79
|
diffFromPrev: TreeDiff | null;
|
|
80
|
+
/**
|
|
81
|
+
* This capture contributes a same-offset timeline stop but no additional
|
|
82
|
+
* paint subtree. Used for pauses whose settled DOM is unchanged, so the
|
|
83
|
+
* composer holds position without duplicating identical scene content.
|
|
84
|
+
*/
|
|
85
|
+
timelineOnly?: true;
|
|
80
86
|
/** Exact Chromium FrameId/scroll-owner authority sampled with this tree. */
|
|
81
87
|
frameScrollState?: CapturedFrameScrollState;
|
|
82
88
|
/** Scroll owner whose raw offset drives this segment's composition anchor. */
|
|
@@ -170,8 +176,8 @@ export declare class ScrollExecutionError extends Error {
|
|
|
170
176
|
* 3. Walk the pattern AST. For each scroll action: snapshot page state,
|
|
171
177
|
* resolve destination, scroll there, wait the action's duration plus a
|
|
172
178
|
* small settle, then capture and diff against previous.
|
|
173
|
-
* 4. Pause actions
|
|
174
|
-
*
|
|
179
|
+
* 4. Pause actions wait, then add either a changed-DOM capture or a
|
|
180
|
+
* non-painting same-offset timeline stop when the DOM stayed unchanged.
|
|
175
181
|
* 5. `until` loops re-resolve conditions each iteration. The grammar's
|
|
176
182
|
* "clamp on overshoot" rule is honored: the last iteration of a
|
|
177
183
|
* position-bounded loop has its scroll magnitude shrunk so the
|
package/dist/scroll/executor.js
CHANGED
|
@@ -177,8 +177,8 @@ export class ScrollExecutionError extends Error {
|
|
|
177
177
|
* 3. Walk the pattern AST. For each scroll action: snapshot page state,
|
|
178
178
|
* resolve destination, scroll there, wait the action's duration plus a
|
|
179
179
|
* small settle, then capture and diff against previous.
|
|
180
|
-
* 4. Pause actions
|
|
181
|
-
*
|
|
180
|
+
* 4. Pause actions wait, then add either a changed-DOM capture or a
|
|
181
|
+
* non-painting same-offset timeline stop when the DOM stayed unchanged.
|
|
182
182
|
* 5. `until` loops re-resolve conditions each iteration. The grammar's
|
|
183
183
|
* "clamp on overshoot" rule is honored: the last iteration of a
|
|
184
184
|
* position-bounded loop has its scroll magnitude shrunk so the
|
|
@@ -267,6 +267,21 @@ export async function executeScrollPattern(page, pattern, opts) {
|
|
|
267
267
|
prevTree = nextTree;
|
|
268
268
|
log(` captured frame ${captures.length} (DOM changed during pause)`);
|
|
269
269
|
}
|
|
270
|
+
else {
|
|
271
|
+
captures.push({
|
|
272
|
+
scrollX: nextCapture.scrollX, scrollY: nextCapture.scrollY,
|
|
273
|
+
segmentStartMs: sceneTime - op.durationMs,
|
|
274
|
+
segmentEndMs: sceneTime,
|
|
275
|
+
tree: nextTree,
|
|
276
|
+
diffFromPrev: diff,
|
|
277
|
+
timelineOnly: true,
|
|
278
|
+
frameScrollState: nextCapture.frameScrollState,
|
|
279
|
+
captureWarnings: nextCapture.captureWarnings,
|
|
280
|
+
scrollOwnerId: nextCapture.scrollOwnerId,
|
|
281
|
+
scrollOwnerBindingSha256: capturedScrollOwnerBindingSha256(nextCapture.frameScrollState, nextCapture.scrollOwnerId, nextCapture.scrollX, nextCapture.scrollY),
|
|
282
|
+
});
|
|
283
|
+
log(` recorded hold through ${sceneTime} ms (DOM unchanged during pause)`);
|
|
284
|
+
}
|
|
270
285
|
return;
|
|
271
286
|
}
|
|
272
287
|
// op.kind === "scroll"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domotion-svg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "DOM-to-animated-SVG renderer. Captures HTML/CSS via Playwright Chromium and converts it to self-contained SVG with CSS animations — pixel-faithful demos that scale crisply and load lazily.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Brian Westphal",
|