@crouton-kit/capture 1.3.7 → 1.3.9
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/bin/capture +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1894 -0
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +302 -34
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -77
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pixels.json` collector — per-element raster crops, written ONLY when
|
|
3
|
+
* `--pixels` was requested (`ctx.pixels`); otherwise this is a no-op (no
|
|
4
|
+
* file written). Owned by U11. A `mutating`-phase collector: it forces the
|
|
5
|
+
* page background transparent (and restores it) for its alpha sample, so
|
|
6
|
+
* `snapshot.ts` runs it serialized, AFTER the baseline `screenshot.png` +
|
|
7
|
+
* `dom.html` are already captured.
|
|
8
|
+
*
|
|
9
|
+
* Drives its own quad-derived element geometry by reading `DOM.getContentQuads`
|
|
10
|
+
* directly (see `readContentQuads`) rather than reading `geometry.json`, which
|
|
11
|
+
* may not exist when this collector runs. Clipping always derives from real CDP
|
|
12
|
+
* content quads, never `getBoundingClientRect()`.
|
|
13
|
+
*
|
|
14
|
+
* Rather than one `Page.captureScreenshot` CDP round-trip per element, this
|
|
15
|
+
* takes exactly two full-page screenshots — one with the page's normal
|
|
16
|
+
* background (source of the crop pixels + color/hash facts) and one with
|
|
17
|
+
* `Emulation.setDefaultBackgroundColorOverride` forced fully transparent
|
|
18
|
+
* (source of the alpha/visible-pixel facts, since only truly-unpainted
|
|
19
|
+
* pixels come back transparent) — then crops both raster buffers in memory
|
|
20
|
+
* per element. The transparent override is always restored: on the normal
|
|
21
|
+
* success path after the transparent capture, and on the failure path in a
|
|
22
|
+
* `catch` that restores the override and still emits `pixels.json` carrying
|
|
23
|
+
* the `backgroundOverrideRestored` fact (with `captureFailed: true`) BEFORE
|
|
24
|
+
* re-throwing, so a screenshot failure never propagates with the override
|
|
25
|
+
* left set and the restoration outcome silently lost. The baseline
|
|
26
|
+
* `screenshot.png` is captured BEFORE this collector runs, so that artifact
|
|
27
|
+
* is already isolated from this collector's background override.
|
|
28
|
+
*
|
|
29
|
+
* Enumeration scope: this collector walks ONLY the top document's light DOM
|
|
30
|
+
* (`DOM.getDocument({pierce:false})` + `querySelectorAll('*')`). Iframe and
|
|
31
|
+
* shadow-DOM subtrees are not enumerated, so their elements are absent from
|
|
32
|
+
* `elements` — an absence of coverage, not a measured absence of pixels.
|
|
33
|
+
* This is emitted as the explicit `scope` fact on `pixels.json` so a reader
|
|
34
|
+
* treats those omissions as out-of-scope rather than as negative findings.
|
|
35
|
+
*
|
|
36
|
+
* Off-quad masking: an element's crop rect is the axis-aligned union of its
|
|
37
|
+
* content quads, but the pixels between/around rotated or disjoint quads do
|
|
38
|
+
* NOT belong to the element. Every derived fact (hash/color/alpha/visible)
|
|
39
|
+
* is computed over ONLY the pixels whose center falls inside a content quad,
|
|
40
|
+
* and off-mask pixels are written transparent in the crop PNG, so a rotated
|
|
41
|
+
* or multi-fragment element's facts are not contaminated by the background
|
|
42
|
+
* or neighbors sitting inside its bounding box.
|
|
43
|
+
*
|
|
44
|
+
* Ancestor-clip masking: an element's own content quads are its full layout
|
|
45
|
+
* box regardless of visual clipping — a child inside an `overflow:hidden` /
|
|
46
|
+
* `clip`/`clip-path` ancestor still reports its whole unclipped quad via
|
|
47
|
+
* `DOM.getContentQuads`. Before any metric or crop pixel is derived, each
|
|
48
|
+
* element's crop rect is intersected with its effective ANCESTOR CLIP BOUNDING
|
|
49
|
+
* RECT, and its per-pixel mask is further constrained by any exact ancestor
|
|
50
|
+
* CLIP SHAPES — both computed page-side, per element, by
|
|
51
|
+
* {@link computeAncestorClip} walking the live ancestor chain. Every
|
|
52
|
+
* clipping ancestor (`overflow-x`/`overflow-y` clipping, or a `clip-path`)
|
|
53
|
+
* contributes to a running bounding-rect intersection; in addition:
|
|
54
|
+
* - `clip-path: inset(...)` resolves to an exact rect (folded into the
|
|
55
|
+
* bounding rect directly — no separate shape needed).
|
|
56
|
+
* - `clip-path: circle(...)` / `ellipse(...)` / `polygon(...)` resolve to
|
|
57
|
+
* an exact shape (reference box, %/px/keyword radii,
|
|
58
|
+
* `closest-side`/`farthest-side`, polygon vertices — nonzero/evenodd
|
|
59
|
+
* fill rule) that every candidate pixel is tested against in
|
|
60
|
+
* {@link pointInClipShape}, in addition to the bounding-rect
|
|
61
|
+
* intersection (which uses the shape's own bbox as a fast pre-filter).
|
|
62
|
+
* - Every length/percentage token fed into the shape math above — `inset()`
|
|
63
|
+
* edges, `circle()`/`ellipse()` radii and center positions, `polygon()`
|
|
64
|
+
* vertices — is resolved through a single `calc()`-aware evaluator
|
|
65
|
+
* (`resolveLength`/`evalCalcExpr`). Computed style can preserve CSS
|
|
66
|
+
* four-value edge-offset positions (e.g. `right 10px bottom 10px`) or
|
|
67
|
+
* serialize them as two `calc()` values; both exact forms are parsed.
|
|
68
|
+
* - `clip-path: path(...)` / `url(...)` (any other clip-path form this
|
|
69
|
+
* collector doesn't recognize), OR a recognized `inset()`/`circle()`/
|
|
70
|
+
* `ellipse()`/`polygon()` whose arguments contain a token the evaluator
|
|
71
|
+
* genuinely cannot resolve (e.g. `var()`, viewport units, or any other
|
|
72
|
+
* `calc()` operand outside the plain px/%/number grammar), CANNOT be
|
|
73
|
+
* resolved to exact per-pixel geometry. Rather than silently treating
|
|
74
|
+
* the ancestor's full bounding box as the clip, or silently defaulting
|
|
75
|
+
* an unresolvable token to a guessed value (contaminating every derived
|
|
76
|
+
* fact with wrong or unclipped pixels — the bug this masking scheme
|
|
77
|
+
* replaces), every such case is handled explicitly and honestly through
|
|
78
|
+
* the SAME single fallback: the ancestor's own bounding rect is still
|
|
79
|
+
* used as a conservative bound, but `approximate: true` is threaded
|
|
80
|
+
* through to the element's `ancestorClipApproximate` fact, and
|
|
81
|
+
* `ancestorClipped` is forced `true` regardless of whether the bounding
|
|
82
|
+
* rect actually shrank — so a reader is told the mask for that element
|
|
83
|
+
* is a best-effort bound, never mistaken for an exact one. There is no
|
|
84
|
+
* second/lenient parser: a token either resolves exactly through this
|
|
85
|
+
* one evaluator, or the whole ancestor clip is marked approximate.
|
|
86
|
+
*
|
|
87
|
+
* This all runs BEFORE `clampRectToPixels` and `buildQuadMask`, so pixels
|
|
88
|
+
* outside the visible clip never enter the crop, the mask, or any derived
|
|
89
|
+
* fact — they are excluded, not merely masked transparent after the fact.
|
|
90
|
+
* `ancestorClipped` states whether any ancestor clip constrains this
|
|
91
|
+
* element at all (bounding-rect shrink, an exact shape, OR an approximate
|
|
92
|
+
* clip) — note a circle/ellipse/polygon inscribed entirely within its own
|
|
93
|
+
* ancestor's bounding box leaves the bounding rect UNCHANGED even though it
|
|
94
|
+
* removes real pixels, so `ancestorClipped` must NOT be inferred from rect
|
|
95
|
+
* shrinkage alone.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
import { PNG } from 'pngjs';
|
|
99
|
+
|
|
100
|
+
import type { CDPClient } from '../../client.js';
|
|
101
|
+
import { axisAlignedRectFromQuad, type Rect, type Quad } from '../../coordinates.js';
|
|
102
|
+
import { sanitizeString, sanitizeFilenameSlug } from '../redaction.js';
|
|
103
|
+
import type { Collector } from '../types.js';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Defensive cap on how many elements this collector will crop for a single
|
|
107
|
+
* snapshot. Never silent (I-5): `pixels.json` always carries `elementsTotal`
|
|
108
|
+
* (the full enumerated count, before slicing — already known for free) and
|
|
109
|
+
* `elementsTruncated` (`true` when the cap actually dropped elements).
|
|
110
|
+
* Separately, `elementsSkipped` counts enumerated-but-uncroppable elements
|
|
111
|
+
* dropped by the 4 per-element `continue` sites below (no layout box, a
|
|
112
|
+
* fully ancestor-clipped rect, an off-viewport pixel rect, or a fully
|
|
113
|
+
* off-mask element) — a distinct concern from the enumeration cap. And
|
|
114
|
+
* separately again, `elementsReadFailed` counts a DIFFERENT thing from both:
|
|
115
|
+
* a per-element `DOM.getContentQuads` read that genuinely THREW (a real CDP
|
|
116
|
+
* protocol failure), which is never folded into `elementsSkipped` — a real
|
|
117
|
+
* no-layout-box element resolves that same call cleanly with an honest empty
|
|
118
|
+
* array (see {@link readContentQuads}), so a throw can only mean the read
|
|
119
|
+
* itself failed, and coercing it into the same silent skip a genuinely
|
|
120
|
+
* uncroppable element gets would hide a real failure as "nothing to crop
|
|
121
|
+
* here" (I-5).
|
|
122
|
+
*/
|
|
123
|
+
const MAX_ELEMENTS = 2000;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Factual enumeration-scope descriptor emitted on `pixels.json`. States, in
|
|
127
|
+
* measurement-only terms, exactly what this collector's element walk did and
|
|
128
|
+
* did not cover: the top document's light DOM only, no iframe content and no
|
|
129
|
+
* shadow-DOM subtrees (`DOM.getDocument({pierce:false})`). Absent iframe /
|
|
130
|
+
* shadow-DOM elements are therefore out-of-scope, not measured-absent.
|
|
131
|
+
*/
|
|
132
|
+
const PIXELS_SCOPE = {
|
|
133
|
+
enumeration: 'top-document-light-dom',
|
|
134
|
+
pierce: false,
|
|
135
|
+
includesIframeContent: false,
|
|
136
|
+
includesShadowDom: false,
|
|
137
|
+
} as const;
|
|
138
|
+
|
|
139
|
+
/** Fixed, factual reason the page-side viewport CSS-size read could not be used as the pixel-scale basis — present only when {@link ViewportScaleFact.available} is `false`. */
|
|
140
|
+
type ViewportScaleUnavailableReason = 'viewport-read-unavailable' | 'capture-failed';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The scale basis (`scaleX`/`scaleY` = screenshot px / CSS px) used to turn
|
|
144
|
+
* every element's CSS-pixel rect into the crop/mask pixel geometry that
|
|
145
|
+
* every color/hash/alpha fact in `elements` is derived from. Normally
|
|
146
|
+
* `scaleX`/`scaleY` come from a genuine `window.innerWidth`/`innerHeight`
|
|
147
|
+
* page-side read (`available: true`). When that read resolves without a
|
|
148
|
+
* usable value (missing/zero/non-numeric — see `readViewportCssSize`), the
|
|
149
|
+
* screenshot's own pixel dimensions are used as a same-size stand-in so the
|
|
150
|
+
* collector can still produce crops — but that stand-in is only a correct
|
|
151
|
+
* scale basis when `devicePixelRatio` happens to be 1. Silently presenting
|
|
152
|
+
* it as a resolved CSS size would fabricate an exact-looking scale for
|
|
153
|
+
* every downstream measurement (I-4) with no signal that it never happened
|
|
154
|
+
* (I-5); `available: false` + `unavailableReason` is that signal, always
|
|
155
|
+
* present alongside the (now explicitly approximate) `scaleX`/`scaleY` a
|
|
156
|
+
* reader would otherwise trust as exact.
|
|
157
|
+
*/
|
|
158
|
+
interface ViewportScaleFact {
|
|
159
|
+
readonly available: boolean;
|
|
160
|
+
readonly innerWidth: number;
|
|
161
|
+
readonly innerHeight: number;
|
|
162
|
+
readonly scaleX: number;
|
|
163
|
+
readonly scaleY: number;
|
|
164
|
+
/** Present only when `available` is `false`. */
|
|
165
|
+
readonly unavailableReason?: ViewportScaleUnavailableReason;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Perceptual-hash grid size (8x8 -> 64-bit average hash). */
|
|
169
|
+
const HASH_GRID = 8;
|
|
170
|
+
/** Color-quantization bucket width (0-255) used to find the dominant color. */
|
|
171
|
+
const DOMINANT_BUCKET = 16;
|
|
172
|
+
|
|
173
|
+
interface RGBColor {
|
|
174
|
+
readonly r: number;
|
|
175
|
+
readonly g: number;
|
|
176
|
+
readonly b: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A raw, already-cropped RGBA pixel buffer (row-major, 4 bytes/pixel). */
|
|
180
|
+
interface RawImage {
|
|
181
|
+
readonly width: number;
|
|
182
|
+
readonly height: number;
|
|
183
|
+
readonly data: Buffer;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface PixelRect {
|
|
187
|
+
readonly x: number;
|
|
188
|
+
readonly y: number;
|
|
189
|
+
readonly width: number;
|
|
190
|
+
readonly height: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A per-pixel inclusion mask over a crop, in the crop's own row-major pixel
|
|
195
|
+
* order (`data[row * width + col]`): `1` where the pixel center lies inside
|
|
196
|
+
* one of the element's content quads, `0` where it does not. `count` is the
|
|
197
|
+
* number of on-mask pixels — the denominator for every masked metric.
|
|
198
|
+
*/
|
|
199
|
+
interface Mask {
|
|
200
|
+
readonly data: Uint8Array;
|
|
201
|
+
readonly count: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface PixelElementRecord {
|
|
205
|
+
readonly id: string;
|
|
206
|
+
/** `null` (never an omitted key) when this element's identity did not resolve — see {@link identityUnresolved}. Mirrors `hittest.ts`'s `HitTestElementSample.backendNodeId` shape (I-3). */
|
|
207
|
+
readonly backendNodeId: number | null;
|
|
208
|
+
/** `true` when {@link backendNodeId} is `null` because `DOM.describeNode` failed/omitted it for this element. Absent (not `false`) when identity resolved. */
|
|
209
|
+
readonly identityUnresolved?: boolean;
|
|
210
|
+
readonly selector?: string;
|
|
211
|
+
/** Effective crop rect: the axis-aligned union of the content quads, intersected with the element's ancestor clip rect (if any) — in top-viewport CSS-pixel space (same space as `geometry.json` rects). Equal to the unclipped quad union when no ancestor clips this element. */
|
|
212
|
+
readonly rect: Rect;
|
|
213
|
+
/** `true` when any ancestor `overflow`/`clip-path` clip constrains this element at all — a bounding-rect shrink, an exact circle/ellipse/polygon shape (which can clip real pixels without changing the bounding rect), or an approximate (`path()`/`url()`) clip. See the module doc for why this is not just "did the rect shrink". */
|
|
214
|
+
readonly ancestorClipped: boolean;
|
|
215
|
+
/** `true` when an ancestor's `clip-path` uses a shape this collector cannot resolve to exact per-pixel geometry (`path()`, `url()`, or any other unrecognized form) — the emitted rect/mask are only a conservative bounding-box approximation for that ancestor, not an exact clip. Always `false` when `ancestorClipped` is `false`. */
|
|
216
|
+
readonly ancestorClipApproximate: boolean;
|
|
217
|
+
/**
|
|
218
|
+
* `true` when the per-element ancestor-clip walk (`DOM.resolveNode` +
|
|
219
|
+
* `Runtime.callFunctionOn`) genuinely failed to run for this element —
|
|
220
|
+
* see {@link AncestorClipInfo.unavailable}. When set, `ancestorClipped`/
|
|
221
|
+
* `ancestorClipApproximate` above read `false` ONLY because the walk
|
|
222
|
+
* never produced a result, not because ancestors were checked and found
|
|
223
|
+
* not to clip — a reader must not treat this element as provably
|
|
224
|
+
* unclipped. Absent (not `false`) when the walk ran.
|
|
225
|
+
*/
|
|
226
|
+
readonly ancestorClipUnavailable?: true;
|
|
227
|
+
/** Present only when {@link ancestorClipUnavailable} is `true`. */
|
|
228
|
+
readonly ancestorClipUnavailableReason?: AncestorClipUnavailableReason;
|
|
229
|
+
/** Id-relative crop path: `{snapId}/crops/<file>.png` — same grammar as `check`'s `{snapId}/findings/<file>.png`. */
|
|
230
|
+
readonly crop: string;
|
|
231
|
+
/** Fraction (0-1) of the crop's bounding-box pixels that actually fall inside a content quad (the rest are masked out of every fact below and written transparent). */
|
|
232
|
+
readonly maskedPixelFraction: number;
|
|
233
|
+
/** 64-bit average-hash (aHash) of the on-mask crop pixels, as 16 hex chars — a stable perceptual hash. */
|
|
234
|
+
readonly hash: string;
|
|
235
|
+
readonly avgColor: RGBColor;
|
|
236
|
+
readonly medianColor: RGBColor;
|
|
237
|
+
readonly dominantColor: RGBColor;
|
|
238
|
+
/** Mean alpha (0-1) over the on-mask pixels, sampled with the page background forced transparent. */
|
|
239
|
+
readonly alphaFraction: number;
|
|
240
|
+
/** Fraction (0-1) of on-mask pixels with any paint at all (alpha > 0), same transparent sample. */
|
|
241
|
+
readonly visiblePixelFraction: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const collectPixels: Collector = async (ctx) => {
|
|
245
|
+
if (!ctx.pixels) return;
|
|
246
|
+
|
|
247
|
+
const { client } = ctx;
|
|
248
|
+
|
|
249
|
+
const normalPng = await captureFullPagePng(client);
|
|
250
|
+
|
|
251
|
+
await client.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
|
|
252
|
+
let backgroundOverrideRestored = false;
|
|
253
|
+
const restoreBackgroundOverride = async (): Promise<void> => {
|
|
254
|
+
try {
|
|
255
|
+
await client.send('Emulation.setDefaultBackgroundColorOverride', {});
|
|
256
|
+
backgroundOverrideRestored = true;
|
|
257
|
+
} catch {
|
|
258
|
+
backgroundOverrideRestored = false;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
let transparentPng: PNG;
|
|
263
|
+
try {
|
|
264
|
+
transparentPng = await captureFullPagePng(client);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// The transparent screenshot (or its decode) failed. Restore the
|
|
267
|
+
// override regardless, then emit `pixels.json` carrying the restoration
|
|
268
|
+
// fact BEFORE re-throwing — otherwise the failure would propagate with
|
|
269
|
+
// no artifact and the restoration outcome would be silently lost.
|
|
270
|
+
// `captureFailed` records that no per-element crops were produced.
|
|
271
|
+
await restoreBackgroundOverride();
|
|
272
|
+
ctx.write.json('pixels.json', {
|
|
273
|
+
scope: PIXELS_SCOPE,
|
|
274
|
+
elements: [],
|
|
275
|
+
backgroundOverrideRestored,
|
|
276
|
+
captureFailed: true,
|
|
277
|
+
elementsTotal: 0,
|
|
278
|
+
elementsTruncated: false,
|
|
279
|
+
elementsSkipped: 0,
|
|
280
|
+
elementsReadFailed: 0,
|
|
281
|
+
// The capture failed before the viewport-scale read was even
|
|
282
|
+
// attempted — there is no scale basis to report, honest or otherwise.
|
|
283
|
+
viewportScale: { available: false, innerWidth: 0, innerHeight: 0, scaleX: 1, scaleY: 1, unavailableReason: 'capture-failed' },
|
|
284
|
+
});
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
await restoreBackgroundOverride();
|
|
288
|
+
|
|
289
|
+
const viewportRead = await readViewportCssSize(client, normalPng.width, normalPng.height);
|
|
290
|
+
const { innerWidth, innerHeight } = viewportRead;
|
|
291
|
+
const scaleX = innerWidth > 0 ? normalPng.width / innerWidth : 1;
|
|
292
|
+
const scaleY = innerHeight > 0 ? normalPng.height / innerHeight : 1;
|
|
293
|
+
// Surfaces the scale basis honestly (I-4/I-5): when the page-side read
|
|
294
|
+
// returned no usable value, `scaleX`/`scaleY` above are only a same-size
|
|
295
|
+
// (devicePixelRatio-1) approximation — `available: false` tells a reader
|
|
296
|
+
// every crop-geometry/color fact below rests on that approximation rather
|
|
297
|
+
// than a genuine measured CSS viewport size.
|
|
298
|
+
const viewportScale: ViewportScaleFact = {
|
|
299
|
+
available: viewportRead.available,
|
|
300
|
+
innerWidth,
|
|
301
|
+
innerHeight,
|
|
302
|
+
scaleX: round3(scaleX),
|
|
303
|
+
scaleY: round3(scaleY),
|
|
304
|
+
...(viewportRead.available ? {} : { unavailableReason: 'viewport-read-unavailable' as const }),
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const allNodeIds = await enumerateElementNodeIds(client);
|
|
308
|
+
const nodeIds = allNodeIds.slice(0, MAX_ELEMENTS);
|
|
309
|
+
const elementsTotal = allNodeIds.length;
|
|
310
|
+
const elementsTruncated = elementsTotal > nodeIds.length;
|
|
311
|
+
const described = await Promise.all(nodeIds.map((nodeId) => describeElementForCrop(client, nodeId)));
|
|
312
|
+
|
|
313
|
+
const elements: PixelElementRecord[] = [];
|
|
314
|
+
let index = 0;
|
|
315
|
+
let elementsSkipped = 0;
|
|
316
|
+
let elementsReadFailed = 0;
|
|
317
|
+
|
|
318
|
+
for (const outcome of described) {
|
|
319
|
+
if (outcome.kind === 'read-failed') {
|
|
320
|
+
elementsReadFailed += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (outcome.kind === 'uncroppable') {
|
|
324
|
+
elementsSkipped += 1;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const entry = outcome.element;
|
|
328
|
+
const clipRect = entry.clipInfo.rect;
|
|
329
|
+
const effectiveRect = clipRect ? intersectRect(entry.rect, clipRect) : entry.rect;
|
|
330
|
+
if (effectiveRect.width <= 0 || effectiveRect.height <= 0) {
|
|
331
|
+
elementsSkipped += 1;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const rectShrunk =
|
|
335
|
+
clipRect !== null &&
|
|
336
|
+
(effectiveRect.x !== entry.rect.x ||
|
|
337
|
+
effectiveRect.y !== entry.rect.y ||
|
|
338
|
+
effectiveRect.width !== entry.rect.width ||
|
|
339
|
+
effectiveRect.height !== entry.rect.height);
|
|
340
|
+
// A circle/ellipse/polygon shape (or an approximate path()/url() clip)
|
|
341
|
+
// can remove real pixels without ever shrinking the bounding rect (e.g.
|
|
342
|
+
// a circle inscribed entirely within its own ancestor's box) — so
|
|
343
|
+
// `ancestorClipped` must also fire on those, not just on rect shrinkage.
|
|
344
|
+
const ancestorClipped = rectShrunk || entry.clipInfo.shapes.length > 0 || entry.clipInfo.approximate;
|
|
345
|
+
|
|
346
|
+
const pixelRect = clampRectToPixels(effectiveRect, scaleX, scaleY, normalPng.width, normalPng.height);
|
|
347
|
+
if (!pixelRect) {
|
|
348
|
+
elementsSkipped += 1;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const mask = buildQuadMask(pixelRect, entry.quads, scaleX, scaleY, entry.clipInfo.shapes);
|
|
353
|
+
if (mask.count === 0) {
|
|
354
|
+
elementsSkipped += 1;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const normalCrop = cropPixels(normalPng, pixelRect);
|
|
359
|
+
const transparentCrop = cropPixels(transparentPng, pixelRect);
|
|
360
|
+
|
|
361
|
+
const cropFilename = `crops/${cropFileBase(index, entry)}.png`;
|
|
362
|
+
ctx.write.binary(cropFilename, encodePng(applyMaskTransparent(normalCrop, mask)));
|
|
363
|
+
|
|
364
|
+
elements.push({
|
|
365
|
+
id: `px-${index}`,
|
|
366
|
+
backendNodeId: entry.backendNodeId,
|
|
367
|
+
...(entry.identityUnresolved ? { identityUnresolved: true as const } : {}),
|
|
368
|
+
...(entry.selector !== undefined ? { selector: entry.selector } : {}),
|
|
369
|
+
rect: effectiveRect,
|
|
370
|
+
ancestorClipped,
|
|
371
|
+
ancestorClipApproximate: entry.clipInfo.approximate,
|
|
372
|
+
...(entry.clipInfo.unavailable
|
|
373
|
+
? { ancestorClipUnavailable: true as const, ancestorClipUnavailableReason: entry.clipInfo.unavailableReason }
|
|
374
|
+
: {}),
|
|
375
|
+
crop: `${ctx.snapId}/${cropFilename}`,
|
|
376
|
+
maskedPixelFraction: round3(mask.count / (pixelRect.width * pixelRect.height)),
|
|
377
|
+
hash: averageHash(normalCrop, mask),
|
|
378
|
+
avgColor: averageColor(normalCrop, mask),
|
|
379
|
+
medianColor: medianColor(normalCrop, mask),
|
|
380
|
+
dominantColor: dominantColor(normalCrop, mask),
|
|
381
|
+
alphaFraction: alphaFraction(transparentCrop, mask),
|
|
382
|
+
visiblePixelFraction: visiblePixelFraction(transparentCrop, mask),
|
|
383
|
+
});
|
|
384
|
+
index += 1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
ctx.write.json('pixels.json', {
|
|
388
|
+
scope: PIXELS_SCOPE,
|
|
389
|
+
elements,
|
|
390
|
+
backgroundOverrideRestored,
|
|
391
|
+
captureFailed: false,
|
|
392
|
+
elementsTotal,
|
|
393
|
+
elementsTruncated,
|
|
394
|
+
elementsSkipped,
|
|
395
|
+
elementsReadFailed,
|
|
396
|
+
viewportScale,
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// ============================================================================
|
|
401
|
+
// CDP-facing helpers
|
|
402
|
+
// ============================================================================
|
|
403
|
+
|
|
404
|
+
async function captureFullPagePng(client: CDPClient): Promise<PNG> {
|
|
405
|
+
const shot = (await client.send('Page.captureScreenshot', { format: 'png' })) as { data: string };
|
|
406
|
+
return PNG.sync.read(Buffer.from(shot.data, 'base64'));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function readViewportCssSize(
|
|
410
|
+
client: CDPClient,
|
|
411
|
+
fallbackWidth: number,
|
|
412
|
+
fallbackHeight: number,
|
|
413
|
+
): Promise<{ innerWidth: number; innerHeight: number; available: boolean }> {
|
|
414
|
+
const response = (await client.send('Runtime.evaluate', {
|
|
415
|
+
expression: '({w: window.innerWidth, h: window.innerHeight})',
|
|
416
|
+
returnByValue: true,
|
|
417
|
+
})) as { result?: { value?: { w?: number; h?: number } } };
|
|
418
|
+
const w = response.result?.value?.w;
|
|
419
|
+
const h = response.result?.value?.h;
|
|
420
|
+
if (typeof w === 'number' && w > 0 && typeof h === 'number' && h > 0) {
|
|
421
|
+
return { innerWidth: w, innerHeight: h, available: true };
|
|
422
|
+
}
|
|
423
|
+
// The page-side read resolved but returned no usable value (missing,
|
|
424
|
+
// zero, or non-numeric). The screenshot's own pixel dimensions are
|
|
425
|
+
// returned as a same-size stand-in so the caller can still produce crops,
|
|
426
|
+
// but `available: false` tells the caller this is NOT a measured CSS
|
|
427
|
+
// size — it must not be presented as an exact scale basis (I-4/I-5).
|
|
428
|
+
return { innerWidth: fallbackWidth, innerHeight: fallbackHeight, available: false };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function enumerateElementNodeIds(client: CDPClient): Promise<number[]> {
|
|
432
|
+
const doc = (await client.send('DOM.getDocument', { depth: -1, pierce: false })) as { root: { nodeId: number } };
|
|
433
|
+
const result = (await client.send('DOM.querySelectorAll', {
|
|
434
|
+
nodeId: doc.root.nodeId,
|
|
435
|
+
selector: '*',
|
|
436
|
+
})) as { nodeIds: number[] };
|
|
437
|
+
return result.nodeIds;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
interface DescribedElement {
|
|
441
|
+
/** Axis-aligned union of the content quads, in top-viewport CSS-pixel space (unclipped by ancestors). */
|
|
442
|
+
readonly rect: Rect;
|
|
443
|
+
/** The original content quads (preserved, not collapsed to `rect`), used to mask off-quad pixels. */
|
|
444
|
+
readonly quads: readonly Quad[];
|
|
445
|
+
readonly selector?: string;
|
|
446
|
+
readonly tag?: string;
|
|
447
|
+
/** `null` (never an omitted key) when `DOM.describeNode` failed to resolve this element's identity — see {@link identityUnresolved}. */
|
|
448
|
+
readonly backendNodeId: number | null;
|
|
449
|
+
/** `true` when {@link backendNodeId} is `null` because `DOM.describeNode` threw or omitted `backendNodeId` from its response. Absent (not `false`) when identity resolved. */
|
|
450
|
+
readonly identityUnresolved?: boolean;
|
|
451
|
+
/** Ancestor-clip info (viewport CSS-pixel space): bounding-rect intersection, exact shapes, and the honest approximate flag. See {@link computeAncestorClip}. */
|
|
452
|
+
readonly clipInfo: AncestorClipInfo;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** A circle resolved from `clip-path: circle(...)`, in absolute viewport CSS-pixel space. */
|
|
456
|
+
interface ClipShapeCircle {
|
|
457
|
+
readonly type: 'circle';
|
|
458
|
+
readonly cx: number;
|
|
459
|
+
readonly cy: number;
|
|
460
|
+
readonly r: number;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** An ellipse resolved from `clip-path: ellipse(...)`, in absolute viewport CSS-pixel space. */
|
|
464
|
+
interface ClipShapeEllipse {
|
|
465
|
+
readonly type: 'ellipse';
|
|
466
|
+
readonly cx: number;
|
|
467
|
+
readonly cy: number;
|
|
468
|
+
readonly rx: number;
|
|
469
|
+
readonly ry: number;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** A polygon resolved from `clip-path: polygon(...)`: a flat `[x1,y1,x2,y2,...]` vertex list in absolute viewport CSS-pixel space, plus its fill rule. */
|
|
473
|
+
interface ClipShapePolygon {
|
|
474
|
+
readonly type: 'polygon';
|
|
475
|
+
readonly points: readonly number[];
|
|
476
|
+
readonly fillRule: 'nonzero' | 'evenodd';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
type ClipShape = ClipShapeCircle | ClipShapeEllipse | ClipShapePolygon;
|
|
480
|
+
|
|
481
|
+
/** Fixed, factual reason the per-element ancestor-clip walk (`DOM.resolveNode` + `Runtime.callFunctionOn`) genuinely failed to run — present only when {@link AncestorClipInfo.unavailable} is `true`. */
|
|
482
|
+
type AncestorClipUnavailableReason = 'resolve-node-no-object-id' | 'call-function-no-value' | 'resolve-or-call-threw';
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Everything {@link computeAncestorClip}'s page-side ancestor walk resolves
|
|
486
|
+
* for one element:
|
|
487
|
+
* - `rect`: the running intersection of every clipping ancestor's own
|
|
488
|
+
* bounding rect (`overflow` clips, exact `inset()` rects, and — as a
|
|
489
|
+
* conservative pre-filter, never a substitute for the shape test below —
|
|
490
|
+
* each circle/ellipse/polygon's own bbox and each approximate clip's
|
|
491
|
+
* ancestor box). `null` when no ancestor clips this element at all.
|
|
492
|
+
* - `shapes`: exact circle/ellipse/polygon constraints a candidate pixel
|
|
493
|
+
* must ALSO satisfy (in addition to `rect` and the element's own quads).
|
|
494
|
+
* Multiple ancestors each contribute their own shape; a pixel must fall
|
|
495
|
+
* inside every one (nested clips intersect).
|
|
496
|
+
* - `approximate`: `true` if any ancestor's `clip-path` could not be
|
|
497
|
+
* resolved to exact geometry (`path()`, `url()`, or any other
|
|
498
|
+
* unrecognized clip-path form) — that ancestor's contribution to `rect`
|
|
499
|
+
* is only a conservative bound, never exact.
|
|
500
|
+
* - `unavailable`/`unavailableReason`: `true` when {@link computeAncestorClip}
|
|
501
|
+
* could NOT complete the walk at all (`DOM.resolveNode` resolved without
|
|
502
|
+
* an `objectId`, `Runtime.callFunctionOn` resolved without `result.value`,
|
|
503
|
+
* or either call threw) — distinct from a genuine "no ancestor clips this
|
|
504
|
+
* element" result, which reaches this SAME `{rect:null,shapes:[],
|
|
505
|
+
* approximate:false}` shape through a walk that actually ran and found
|
|
506
|
+
* nothing. Absent (not `false`) when the walk ran (successfully or not).
|
|
507
|
+
*/
|
|
508
|
+
interface AncestorClipInfo {
|
|
509
|
+
readonly rect: Rect | null;
|
|
510
|
+
readonly shapes: readonly ClipShape[];
|
|
511
|
+
readonly approximate: boolean;
|
|
512
|
+
readonly unavailable?: true;
|
|
513
|
+
/** Present only when {@link unavailable} is `true`. */
|
|
514
|
+
readonly unavailableReason?: AncestorClipUnavailableReason;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Fixed, factual reason {@link describeElementForCrop} could not produce a
|
|
519
|
+
* croppable element — the discriminant every caller must branch on instead
|
|
520
|
+
* of collapsing both cases into the same `null`/skip:
|
|
521
|
+
* - `'described'`: the element has a real layout box and identity/clip
|
|
522
|
+
* resolution ran (possibly with their own honest per-field failure
|
|
523
|
+
* markers — see {@link DescribedElement}).
|
|
524
|
+
* - `'uncroppable'`: the per-element `DOM.getContentQuads` read
|
|
525
|
+
* genuinely SUCCEEDED and reported nothing to crop — a real
|
|
526
|
+
* no-layout-box node (`display:none`, `<head>`/`<script>`/`<style>`, a
|
|
527
|
+
* detached node) resolves that call cleanly with an honest empty quad
|
|
528
|
+
* array (see {@link readContentQuads}), or the quads it did return
|
|
529
|
+
* union to a zero-area rect. This is a genuine observation, not a
|
|
530
|
+
* failure — no marker is warranted.
|
|
531
|
+
* - `'read-failed'`: the `DOM.getContentQuads` read itself THREW — a real
|
|
532
|
+
* CDP protocol failure, never the shape a genuinely boxless element
|
|
533
|
+
* produces. Coercing this into the same silent skip as `'uncroppable'`
|
|
534
|
+
* would hide a real read failure as "nothing to crop here" (I-5); the
|
|
535
|
+
* caller counts this into the distinct `elementsReadFailed` fact.
|
|
536
|
+
*/
|
|
537
|
+
type DescribeOutcome =
|
|
538
|
+
| { readonly kind: 'described'; readonly element: DescribedElement }
|
|
539
|
+
| { readonly kind: 'uncroppable' }
|
|
540
|
+
| { readonly kind: 'read-failed' };
|
|
541
|
+
|
|
542
|
+
/** Same 8-number validation as `../../coordinates.js`'s private `asQuad` — reimplemented locally because this file needs `DOM.getContentQuads`'s raw quads read independently of `DOM.getBoxModel` (see {@link readContentQuads}'s doc comment for why, and note `../../coordinates.js`'s combined `getContentQuadBox` is deliberately NOT used here). */
|
|
543
|
+
function quadFromPoints(points: number[]): Quad {
|
|
544
|
+
if (points.length !== 8) {
|
|
545
|
+
throw new Error(`Expected an 8-number quad (x1,y1,x2,y2,x3,y3,x4,y4), got ${points.length} numbers`);
|
|
546
|
+
}
|
|
547
|
+
return points as Quad;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Reads ONLY `DOM.getContentQuads` for one node — deliberately NOT the
|
|
552
|
+
* combined `getContentQuadBox` from `../../coordinates.js` (whose
|
|
553
|
+
* `DOM.getBoxModel` half this collector never uses anyway: only the quads
|
|
554
|
+
* feed `rect`/masking here). Empirically confirmed against real Chrome
|
|
555
|
+
* (the identical finding `geometry.ts`'s `readContentQuads` documents): a
|
|
556
|
+
* genuinely boxless node (`display:none`, `<head>`/`<script>`/`<style>`, a
|
|
557
|
+
* detached node) makes `DOM.getContentQuads` resolve normally with a real,
|
|
558
|
+
* honest EMPTY array — never throw — while `DOM.getBoxModel` throws
|
|
559
|
+
* "Could not compute box model" for that SAME node. Had this collector kept
|
|
560
|
+
* reading both together (the pre-fix behavior), every genuinely boxless
|
|
561
|
+
* element would throw via the `getBoxModel` half and be indistinguishable
|
|
562
|
+
* from an actual quads-read failure. Calling `DOM.getContentQuads` alone
|
|
563
|
+
* means a throw here can only mean the quad read itself genuinely failed
|
|
564
|
+
* (invalid/detached node reference, a protocol error) — never that the
|
|
565
|
+
* element has no layout box, which always surfaces as a clean empty array
|
|
566
|
+
* instead. Callers must let a throw here propagate as a genuine per-element
|
|
567
|
+
* read failure (`DescribeOutcome.kind === 'read-failed'`, I-4/I-5), not
|
|
568
|
+
* degrade it to the same shape a real empty result produces.
|
|
569
|
+
*/
|
|
570
|
+
async function readContentQuads(client: CDPClient, nodeId: number): Promise<Quad[]> {
|
|
571
|
+
const res = (await client.send('DOM.getContentQuads', { nodeId })) as { quads: number[][] };
|
|
572
|
+
return res.quads.map(quadFromPoints);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Fetches one node's content quads, a best-effort selector, and identity
|
|
577
|
+
* (`backendNodeId`). Returns a {@link DescribeOutcome} distinguishing three
|
|
578
|
+
* cases (see its own doc comment): a real described element, a genuinely
|
|
579
|
+
* uncroppable one (no layout box / zero-area union — an honest observation,
|
|
580
|
+
* silently skipped by the caller), or a genuine `DOM.getContentQuads` read
|
|
581
|
+
* failure (marked, never silently skipped). Identity resolution never
|
|
582
|
+
* drops the element on its own failure: a `DOM.describeNode`
|
|
583
|
+
* failure/omission just leaves `backendNodeId: null` + `identityUnresolved:
|
|
584
|
+
* true` on the returned record instead of silently omitting the field
|
|
585
|
+
* (I-3).
|
|
586
|
+
*/
|
|
587
|
+
async function describeElementForCrop(client: CDPClient, nodeId: number): Promise<DescribeOutcome> {
|
|
588
|
+
let quads: Quad[];
|
|
589
|
+
try {
|
|
590
|
+
quads = await readContentQuads(client, nodeId);
|
|
591
|
+
} catch {
|
|
592
|
+
return { kind: 'read-failed' };
|
|
593
|
+
}
|
|
594
|
+
if (quads.length === 0) return { kind: 'uncroppable' };
|
|
595
|
+
|
|
596
|
+
const rect = unionRect(quads.map(axisAlignedRectFromQuad));
|
|
597
|
+
if (rect.width <= 0 || rect.height <= 0) return { kind: 'uncroppable' };
|
|
598
|
+
|
|
599
|
+
let selector: string | undefined;
|
|
600
|
+
let tag: string | undefined;
|
|
601
|
+
let backendNodeId: number | null = null;
|
|
602
|
+
let identityUnresolved = false;
|
|
603
|
+
try {
|
|
604
|
+
const described = (await client.send('DOM.describeNode', { nodeId })) as {
|
|
605
|
+
node?: { nodeName?: string; backendNodeId?: number; attributes?: string[] };
|
|
606
|
+
};
|
|
607
|
+
if (typeof described.node?.backendNodeId === 'number') {
|
|
608
|
+
backendNodeId = described.node.backendNodeId;
|
|
609
|
+
} else {
|
|
610
|
+
// `DOM.describeNode` resolved but the response omitted `backendNodeId` —
|
|
611
|
+
// identity did not resolve even though nothing threw. Never let this
|
|
612
|
+
// fall through as a silently-omitted field (I-3): mark it explicitly.
|
|
613
|
+
identityUnresolved = true;
|
|
614
|
+
}
|
|
615
|
+
tag = described.node?.nodeName?.toLowerCase();
|
|
616
|
+
selector = buildSelector(described.node?.nodeName, described.node?.attributes);
|
|
617
|
+
} catch {
|
|
618
|
+
// `DOM.describeNode` failure means identity could not be resolved for
|
|
619
|
+
// this element. The rect/crop are still valid without a selector, but
|
|
620
|
+
// `backendNodeId` stays `null` (never omitted) and `identityUnresolved`
|
|
621
|
+
// is set so a downstream join never mistakes this for a resolved record
|
|
622
|
+
// (I-3, mirroring hittest.ts's `identityUnresolved` shape).
|
|
623
|
+
identityUnresolved = true;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const clipInfo = await computeAncestorClip(client, nodeId);
|
|
627
|
+
|
|
628
|
+
return { kind: 'described', element: { rect, quads, selector, tag, backendNodeId, identityUnresolved, clipInfo } };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Page-side JS run per element via `Runtime.callFunctionOn` (`this` bound to
|
|
633
|
+
* the target node): walks the live ancestor chain (starting at the parent —
|
|
634
|
+
* an element's own `overflow`/`clip-path` clips its CHILDREN, never itself)
|
|
635
|
+
* and, for every ancestor that clips (`overflow-x`/`overflow-y` matching
|
|
636
|
+
* hidden/auto/scroll/clip, or a `clip-path` other than `none`), intersects a
|
|
637
|
+
* running bounding-rect clip with that ancestor's own `getBoundingClientRect()`
|
|
638
|
+
* AND, for `clip-path`, resolves the shape itself where possible:
|
|
639
|
+
* - `inset(...)` resolves to an exact rect (folded straight into the
|
|
640
|
+
* bounding-rect intersection).
|
|
641
|
+
* - `circle(...)` / `ellipse(...)` / `polygon(...)` resolve to an exact
|
|
642
|
+
* shape (pushed onto `shapes`, in absolute viewport-px terms) — percentage
|
|
643
|
+
* radii, `closest-side`/`farthest-side` keywords, the CSS `<position>`
|
|
644
|
+
* syntax for `at <position>`, polygon vertex lists (with `nonzero`/
|
|
645
|
+
* `evenodd` fill rule), and the `<geometry-box>` keyword (border-box
|
|
646
|
+
* default; padding-box/content-box resolved via computed border/padding;
|
|
647
|
+
* margin-box via computed margin; fill-box/stroke-box/view-box — SVG
|
|
648
|
+
* reference boxes with no meaningful DOM analog here — fall back to
|
|
649
|
+
* border-box, a documented simplification, not the `approximate` escape
|
|
650
|
+
* hatch below since it's still an exact shape test on a nearby box). The
|
|
651
|
+
* shape's own bbox also feeds the running bounding-rect intersection, as
|
|
652
|
+
* a fast pre-filter only — real exclusion happens per-pixel later via
|
|
653
|
+
* {@link pointInClipShape}.
|
|
654
|
+
* - Anything else (`path()`, `url()`, or an unrecognized clip-path form)
|
|
655
|
+
* cannot be resolved to exact geometry at all. This is handled
|
|
656
|
+
* explicitly rather than silently treated as unclipped: `approximate` is
|
|
657
|
+
* set `true`, and the ancestor's own bounding rect is folded into the
|
|
658
|
+
* running bounding-rect intersection as a conservative (non-exact, but
|
|
659
|
+
* never under-clipping in the common "shape sits within its box" case)
|
|
660
|
+
* bound — the caller uses `approximate` to flag this honestly rather
|
|
661
|
+
* than presenting it as an exact mask.
|
|
662
|
+
* Returns `{ rect, shapes, approximate }` — `rect` is `null` when no ancestor
|
|
663
|
+
* clips at all, else `{x,y,width,height}` in the same viewport CSS-pixel
|
|
664
|
+
* space as `DOM.getContentQuads`; `shapes` accumulates every resolved
|
|
665
|
+
* circle/ellipse/polygon across all ancestors (a pixel must satisfy all of
|
|
666
|
+
* them — nested clips intersect).
|
|
667
|
+
*/
|
|
668
|
+
const ANCESTOR_CLIP_FUNCTION = `function () {
|
|
669
|
+
// Splits a CSS value list on whitespace, but ONLY at paren-depth 0 — so a
|
|
670
|
+
// \`calc(100% - 10px)\` argument (which contains internal spaces) survives
|
|
671
|
+
// as one token instead of being torn apart by a naive \\s+ split. Every
|
|
672
|
+
// multi-token value this file parses (a <position>'s two axes, an
|
|
673
|
+
// ellipse's two radii, a polygon vertex's x/y pair) goes through this.
|
|
674
|
+
function splitTopLevel(str) {
|
|
675
|
+
var tokens = [];
|
|
676
|
+
var current = '';
|
|
677
|
+
var depth = 0;
|
|
678
|
+
for (var i = 0; i < str.length; i += 1) {
|
|
679
|
+
var ch = str[i];
|
|
680
|
+
if (ch === '(') depth += 1;
|
|
681
|
+
if (ch === ')') depth -= 1;
|
|
682
|
+
if (/\\s/.test(ch) && depth === 0) {
|
|
683
|
+
if (current) tokens.push(current);
|
|
684
|
+
current = '';
|
|
685
|
+
} else {
|
|
686
|
+
current += ch;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (current) tokens.push(current);
|
|
690
|
+
return tokens;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Resolves a single <length-percentage> token — \`10px\`, \`50%\`, a bare
|
|
694
|
+
// number, or a \`calc(...)\` expression of those — against \`refPx\` (the
|
|
695
|
+
// percentage basis). Returns \`null\` when the token is not one of these
|
|
696
|
+
// forms (e.g. \`var(...)\`, an unsupported calc() operand) — the SOLE
|
|
697
|
+
// signal every caller uses to mark its shape/rect unresolvable rather than
|
|
698
|
+
// silently guessing a value. This is the ONE parsing path for every
|
|
699
|
+
// length/percentage this file resolves (inset() edges, circle()/ellipse()
|
|
700
|
+
// radii and positions, polygon() vertices) — there is no second, more
|
|
701
|
+
// lenient parser.
|
|
702
|
+
function resolveLength(token, refPx) {
|
|
703
|
+
token = (token || '').trim();
|
|
704
|
+
if (!token) return null;
|
|
705
|
+
if (/^calc\\(/.test(token) && token.charAt(token.length - 1) === ')') {
|
|
706
|
+
return evalCalcExpr(token.slice(5, -1), refPx);
|
|
707
|
+
}
|
|
708
|
+
var pctMatch = /^(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))%$/.exec(token);
|
|
709
|
+
if (pctMatch) {
|
|
710
|
+
var pct = parseFloat(pctMatch[1]);
|
|
711
|
+
return Number.isFinite(pct) ? (pct / 100) * refPx : null;
|
|
712
|
+
}
|
|
713
|
+
var pxMatch = /^(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))px$/.exec(token);
|
|
714
|
+
if (pxMatch) {
|
|
715
|
+
var px = parseFloat(pxMatch[1]);
|
|
716
|
+
return Number.isFinite(px) ? px : null;
|
|
717
|
+
}
|
|
718
|
+
var numMatch = /^(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))$/.exec(token);
|
|
719
|
+
if (numMatch) {
|
|
720
|
+
var num = parseFloat(numMatch[1]);
|
|
721
|
+
return Number.isFinite(num) ? num : null;
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Evaluates the inside of a \`calc(...)\` as a sum of +/- terms — the only
|
|
727
|
+
// shape computed style ever emits for an edge-offset <position> (e.g.
|
|
728
|
+
// \`right 10px\` computes to \`calc(100% - 10px)\`). Per the CSS calc()
|
|
729
|
+
// grammar, a binary +/- operator MUST have whitespace on both sides (so
|
|
730
|
+
// \`-10px\` alone is a signed term, never mistaken for an operator); a
|
|
731
|
+
// nested \`calc(...)\` term is resolved recursively via \`resolveLength\`.
|
|
732
|
+
// Returns \`null\` — propagated by every caller — the moment any term or
|
|
733
|
+
// operator falls outside this grammar (multiplication, division, \`var()\`,
|
|
734
|
+
// viewport units, ...): there is no fallback guess, only an honest
|
|
735
|
+
// "cannot resolve this token" signal.
|
|
736
|
+
function evalCalcExpr(inner, refPx) {
|
|
737
|
+
var tokens = [];
|
|
738
|
+
var current = '';
|
|
739
|
+
var depth = 0;
|
|
740
|
+
for (var i = 0; i < inner.length; i += 1) {
|
|
741
|
+
var ch = inner[i];
|
|
742
|
+
if (ch === '(') depth += 1;
|
|
743
|
+
if (ch === ')') depth -= 1;
|
|
744
|
+
var isOperator =
|
|
745
|
+
(ch === '+' || ch === '-') && depth === 0 && i > 0 && inner[i - 1] === ' ' && i + 1 < inner.length && inner[i + 1] === ' ';
|
|
746
|
+
if (isOperator) {
|
|
747
|
+
tokens.push(current.trim());
|
|
748
|
+
tokens.push(ch);
|
|
749
|
+
current = '';
|
|
750
|
+
} else {
|
|
751
|
+
current += ch;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (current.trim() !== '') tokens.push(current.trim());
|
|
755
|
+
if (tokens.length === 0 || depth !== 0) return null;
|
|
756
|
+
var result = 0;
|
|
757
|
+
var op = '+';
|
|
758
|
+
var expectsTerm = true;
|
|
759
|
+
for (var t = 0; t < tokens.length; t += 1) {
|
|
760
|
+
var tok = tokens[t];
|
|
761
|
+
if (tok === '+' || tok === '-') {
|
|
762
|
+
if (expectsTerm) return null;
|
|
763
|
+
op = tok;
|
|
764
|
+
expectsTerm = true;
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (!expectsTerm) return null;
|
|
768
|
+
var val = resolveLength(tok, refPx);
|
|
769
|
+
if (val === null || !Number.isFinite(val)) return null;
|
|
770
|
+
result += op === '-' ? -val : val;
|
|
771
|
+
expectsTerm = false;
|
|
772
|
+
}
|
|
773
|
+
return expectsTerm || !Number.isFinite(result) ? null : result;
|
|
774
|
+
}
|
|
775
|
+
// \`argsStr\` is the already-extracted, paren-balanced inset() argument
|
|
776
|
+
// list (see \`extractFunctionArgs\` below) — this does NOT re-match against
|
|
777
|
+
// the raw clip-path string, so a \`calc(...)\` edge value (which itself
|
|
778
|
+
// contains parens) can never truncate the extraction.
|
|
779
|
+
function parseInsetRect(argsStr, box) {
|
|
780
|
+
var tokens = splitTopLevel(argsStr.trim());
|
|
781
|
+
var nums = [];
|
|
782
|
+
for (var i = 0; i < tokens.length; i += 1) {
|
|
783
|
+
if (/^-?[\\d.]/.test(tokens[i]) || /^calc\\(/.test(tokens[i])) nums.push(tokens[i]);
|
|
784
|
+
else break;
|
|
785
|
+
}
|
|
786
|
+
if (nums.length === 0) return null;
|
|
787
|
+
var top = nums[0];
|
|
788
|
+
var right = nums.length > 1 ? nums[1] : nums[0];
|
|
789
|
+
var bottom = nums.length > 2 ? nums[2] : nums[0];
|
|
790
|
+
var left = nums.length > 3 ? nums[3] : right;
|
|
791
|
+
var leftVal = resolveLength(left, box.width);
|
|
792
|
+
var topVal = resolveLength(top, box.height);
|
|
793
|
+
var rightVal = resolveLength(right, box.width);
|
|
794
|
+
var bottomVal = resolveLength(bottom, box.height);
|
|
795
|
+
if (leftVal === null || topVal === null || rightVal === null || bottomVal === null) return null;
|
|
796
|
+
return {
|
|
797
|
+
left: box.left + leftVal,
|
|
798
|
+
top: box.top + topVal,
|
|
799
|
+
right: box.right - rightVal,
|
|
800
|
+
bottom: box.bottom - bottomVal,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function intersectBox(a, b) {
|
|
804
|
+
if (!a) return b;
|
|
805
|
+
if (!b) return a;
|
|
806
|
+
return {
|
|
807
|
+
left: Math.max(a.left, b.left),
|
|
808
|
+
top: Math.max(a.top, b.top),
|
|
809
|
+
right: Math.min(a.right, b.right),
|
|
810
|
+
bottom: Math.min(a.bottom, b.bottom),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// <geometry-box> keyword resolution: defaults to border-box. Only
|
|
815
|
+
// border/padding/content/margin are resolved exactly (the DOM boxes this
|
|
816
|
+
// collector already reasons about elsewhere); fill-box/stroke-box/view-box
|
|
817
|
+
// are SVG-specific reference boxes with no meaningful analog against a
|
|
818
|
+
// plain HTML ancestor chain, so they fall back to border-box too — a
|
|
819
|
+
// documented simplification, distinct from the path()/url() "approximate"
|
|
820
|
+
// escape hatch since the shape math itself is still exact.
|
|
821
|
+
function computeReferenceBox(node, clipPath) {
|
|
822
|
+
var rect = node.getBoundingClientRect();
|
|
823
|
+
var boxMatch = /(border-box|padding-box|content-box|margin-box|fill-box|stroke-box|view-box)/.exec(clipPath);
|
|
824
|
+
var boxKeyword = boxMatch ? boxMatch[1] : 'border-box';
|
|
825
|
+
if (boxKeyword === 'border-box' || boxKeyword === 'fill-box' || boxKeyword === 'stroke-box' || boxKeyword === 'view-box') {
|
|
826
|
+
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height };
|
|
827
|
+
}
|
|
828
|
+
var style = getComputedStyle(node);
|
|
829
|
+
var bl = parseFloat(style.borderLeftWidth) || 0;
|
|
830
|
+
var br = parseFloat(style.borderRightWidth) || 0;
|
|
831
|
+
var bt = parseFloat(style.borderTopWidth) || 0;
|
|
832
|
+
var bb = parseFloat(style.borderBottomWidth) || 0;
|
|
833
|
+
if (boxKeyword === 'padding-box') {
|
|
834
|
+
return {
|
|
835
|
+
left: rect.left + bl, top: rect.top + bt, right: rect.right - br, bottom: rect.bottom - bb,
|
|
836
|
+
width: rect.width - bl - br, height: rect.height - bt - bb,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
if (boxKeyword === 'margin-box') {
|
|
840
|
+
var ml = parseFloat(style.marginLeft) || 0;
|
|
841
|
+
var mr = parseFloat(style.marginRight) || 0;
|
|
842
|
+
var mt = parseFloat(style.marginTop) || 0;
|
|
843
|
+
var mb = parseFloat(style.marginBottom) || 0;
|
|
844
|
+
return {
|
|
845
|
+
left: rect.left - ml, top: rect.top - mt, right: rect.right + mr, bottom: rect.bottom + mb,
|
|
846
|
+
width: rect.width + ml + mr, height: rect.height + mt + mb,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
// content-box
|
|
850
|
+
var pl = parseFloat(style.paddingLeft) || 0;
|
|
851
|
+
var pr = parseFloat(style.paddingRight) || 0;
|
|
852
|
+
var pt = parseFloat(style.paddingTop) || 0;
|
|
853
|
+
var pb = parseFloat(style.paddingBottom) || 0;
|
|
854
|
+
return {
|
|
855
|
+
left: rect.left + bl + pl, top: rect.top + bt + pt, right: rect.right - br - pr, bottom: rect.bottom - bb - pb,
|
|
856
|
+
width: rect.width - bl - br - pl - pr, height: rect.height - bt - bb - pt - pb,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// CSS <position> subset: one/two-value keywords/lengths/percentages/calc()
|
|
861
|
+
// (including the top-left keyword-order swap), plus four-value horizontal
|
|
862
|
+
// and vertical edge-offset pairs in either axis order. Computed style may
|
|
863
|
+
// preserve \`right 10px bottom 20px\` rather than serializing it as calc().
|
|
864
|
+
// Unsupported, malformed, or ambiguous token sequences return \`null\`
|
|
865
|
+
// (never guessed coordinates), making the ancestor clip approximate.
|
|
866
|
+
function parsePosition(str, w, h) {
|
|
867
|
+
str = (str || '').trim();
|
|
868
|
+
if (!str) return { x: w / 2, y: h / 2 };
|
|
869
|
+
var tokens = splitTopLevel(str);
|
|
870
|
+
function axisValue(token, dim, isX) {
|
|
871
|
+
if (token === 'center') return dim / 2;
|
|
872
|
+
if (isX && token === 'left') return 0;
|
|
873
|
+
if (isX && token === 'right') return dim;
|
|
874
|
+
if (!isX && token === 'top') return 0;
|
|
875
|
+
if (!isX && token === 'bottom') return dim;
|
|
876
|
+
return resolveLength(token, dim);
|
|
877
|
+
}
|
|
878
|
+
if (tokens.length === 1) {
|
|
879
|
+
var t = tokens[0];
|
|
880
|
+
if (t === 'top' || t === 'bottom') {
|
|
881
|
+
var yOnly = axisValue(t, h, false);
|
|
882
|
+
return yOnly === null ? null : { x: w / 2, y: yOnly };
|
|
883
|
+
}
|
|
884
|
+
var xOnly = axisValue(t, w, true);
|
|
885
|
+
return xOnly === null ? null : { x: xOnly, y: h / 2 };
|
|
886
|
+
}
|
|
887
|
+
if (tokens.length === 2) {
|
|
888
|
+
var first = tokens[0];
|
|
889
|
+
var second = tokens[1];
|
|
890
|
+
var x, y;
|
|
891
|
+
if (first === 'top' || first === 'bottom') {
|
|
892
|
+
x = axisValue(second, w, true);
|
|
893
|
+
y = axisValue(first, h, false);
|
|
894
|
+
} else {
|
|
895
|
+
x = axisValue(first, w, true);
|
|
896
|
+
y = axisValue(second, h, false);
|
|
897
|
+
}
|
|
898
|
+
if (x === null || y === null) return null;
|
|
899
|
+
return { x: x, y: y };
|
|
900
|
+
}
|
|
901
|
+
if (tokens.length !== 4) return null;
|
|
902
|
+
|
|
903
|
+
function edgeOffset(edge, offset) {
|
|
904
|
+
var horizontal = edge === 'left' || edge === 'right';
|
|
905
|
+
var vertical = edge === 'top' || edge === 'bottom';
|
|
906
|
+
if (!horizontal && !vertical) return null;
|
|
907
|
+
var dim = horizontal ? w : h;
|
|
908
|
+
var value = resolveLength(offset, dim);
|
|
909
|
+
if (value === null) return null;
|
|
910
|
+
return { axis: horizontal ? 'x' : 'y', value: edge === 'right' || edge === 'bottom' ? dim - value : value };
|
|
911
|
+
}
|
|
912
|
+
var firstPair = edgeOffset(tokens[0], tokens[1]);
|
|
913
|
+
var secondPair = edgeOffset(tokens[2], tokens[3]);
|
|
914
|
+
if (!firstPair || !secondPair || firstPair.axis === secondPair.axis) return null;
|
|
915
|
+
return firstPair.axis === 'x'
|
|
916
|
+
? { x: firstPair.value, y: secondPair.value }
|
|
917
|
+
: { x: secondPair.value, y: firstPair.value };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Single-axis radius: closest-side/farthest-side (distances to the two
|
|
921
|
+
// opposing edges along this axis) / percentage (of dim) / length /
|
|
922
|
+
// calc(). Returns \`null\` (never a guessed closest-side default) the
|
|
923
|
+
// moment the token is not one of these forms.
|
|
924
|
+
function resolveAxisRadius(token, centerRel, dim) {
|
|
925
|
+
token = (token || '').trim();
|
|
926
|
+
if (!token || token === 'closest-side') return Math.min(centerRel, dim - centerRel);
|
|
927
|
+
if (token === 'farthest-side') return Math.max(centerRel, dim - centerRel);
|
|
928
|
+
return resolveLength(token, dim);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// circle()'s single radius uses the CSS Shapes diagonal formula for '%'
|
|
932
|
+
// (sqrt(w^2+h^2)/sqrt(2)), not a per-axis percentage; a calc() radius
|
|
933
|
+
// resolves its own '%' terms against that same diagonal basis. Returns
|
|
934
|
+
// \`null\` (never a guessed closest-side default) when unresolvable.
|
|
935
|
+
function resolveCircleRadius(token, cx, cy, w, h) {
|
|
936
|
+
token = (token || '').trim();
|
|
937
|
+
if (!token || token === 'closest-side') return Math.min(cx, w - cx, cy, h - cy);
|
|
938
|
+
if (token === 'farthest-side') return Math.max(cx, w - cx, cy, h - cy);
|
|
939
|
+
var pctMatch = /^(-?[\\d.]+)%$/.exec(token);
|
|
940
|
+
if (pctMatch) return (parseFloat(pctMatch[1]) / 100) * (Math.sqrt(w * w + h * h) / Math.SQRT2);
|
|
941
|
+
var pxMatch = /^(-?[\\d.]+)px$/.exec(token);
|
|
942
|
+
if (pxMatch) return parseFloat(pxMatch[1]);
|
|
943
|
+
var numMatch = /^(-?[\\d.]+)$/.exec(token);
|
|
944
|
+
if (numMatch) return parseFloat(numMatch[1]);
|
|
945
|
+
if (/^calc\\(/.test(token) && token.charAt(token.length - 1) === ')') {
|
|
946
|
+
return evalCalcExpr(token.slice(5, -1), Math.sqrt(w * w + h * h) / Math.SQRT2);
|
|
947
|
+
}
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function splitShapeArgs(argsStr) {
|
|
952
|
+
var trimmed = argsStr.trim();
|
|
953
|
+
// Radius/axes are entirely optional ("circle(at 40px 40px)" is valid
|
|
954
|
+
// CSS — no leading space before "at" in that case), so check for a
|
|
955
|
+
// leading "at " separately from the more common "<radius> at <pos>" form.
|
|
956
|
+
if (/^at\s+/.test(trimmed)) {
|
|
957
|
+
return { radiusPart: '', posPart: trimmed.replace(/^at\s+/, '').trim() };
|
|
958
|
+
}
|
|
959
|
+
var atIdx = trimmed.indexOf(' at ');
|
|
960
|
+
return {
|
|
961
|
+
radiusPart: (atIdx >= 0 ? trimmed.slice(0, atIdx) : trimmed).trim(),
|
|
962
|
+
posPart: atIdx >= 0 ? trimmed.slice(atIdx + 4).trim() : '',
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Returns \`null\` (never a shape built from guessed coordinates) the
|
|
967
|
+
// moment the position or radius contains an unresolvable token.
|
|
968
|
+
function parseCircle(argsStr, box) {
|
|
969
|
+
var parts = splitShapeArgs(argsStr);
|
|
970
|
+
var pos = parsePosition(parts.posPart, box.width, box.height);
|
|
971
|
+
if (!pos) return null;
|
|
972
|
+
var r = resolveCircleRadius(parts.radiusPart, pos.x, pos.y, box.width, box.height);
|
|
973
|
+
if (r === null) return null;
|
|
974
|
+
return { type: 'circle', cx: box.left + pos.x, cy: box.top + pos.y, r: Math.max(0, r) };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function parseEllipse(argsStr, box) {
|
|
978
|
+
var parts = splitShapeArgs(argsStr);
|
|
979
|
+
var pos = parsePosition(parts.posPart, box.width, box.height);
|
|
980
|
+
if (!pos) return null;
|
|
981
|
+
var radiusTokens = splitTopLevel(parts.radiusPart);
|
|
982
|
+
var rx = resolveAxisRadius(radiusTokens[0], pos.x, box.width);
|
|
983
|
+
var ry = resolveAxisRadius(radiusTokens[1], pos.y, box.height);
|
|
984
|
+
if (rx === null || ry === null) return null;
|
|
985
|
+
return { type: 'ellipse', cx: box.left + pos.x, cy: box.top + pos.y, rx: Math.max(0, rx), ry: Math.max(0, ry) };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function parsePolygon(argsStr, box) {
|
|
989
|
+
var fillRule = 'nonzero';
|
|
990
|
+
var body = argsStr;
|
|
991
|
+
var fillMatch = /^\\s*(nonzero|evenodd)\\s*,/.exec(argsStr);
|
|
992
|
+
if (fillMatch) {
|
|
993
|
+
fillRule = fillMatch[1];
|
|
994
|
+
body = argsStr.slice(fillMatch[0].length);
|
|
995
|
+
}
|
|
996
|
+
var pairs = body.split(',');
|
|
997
|
+
var points = [];
|
|
998
|
+
for (var i = 0; i < pairs.length; i += 1) {
|
|
999
|
+
var tokens = splitTopLevel(pairs[i].trim());
|
|
1000
|
+
if (tokens.length < 2) continue;
|
|
1001
|
+
var xVal = resolveLength(tokens[0], box.width);
|
|
1002
|
+
var yVal = resolveLength(tokens[1], box.height);
|
|
1003
|
+
if (xVal === null || yVal === null) return null;
|
|
1004
|
+
points.push(box.left + xVal, box.top + yVal);
|
|
1005
|
+
}
|
|
1006
|
+
return { type: 'polygon', points: points, fillRule: fillRule };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Extracts the argument list of \`name(...)\` from a clip-path value —
|
|
1010
|
+
// but ONLY when \`name(\` opens a top-level function in the value, never
|
|
1011
|
+
// a bare substring match anywhere in the string. A clip-path value is
|
|
1012
|
+
// (per the CSS grammar this collector supports) either a single
|
|
1013
|
+
// \`<basic-shape>\` function optionally paired with a \`<geometry-box>\`
|
|
1014
|
+
// keyword — so splitting the value on TOP-LEVEL whitespace (via
|
|
1015
|
+
// \`splitTopLevel\`, the same depth-aware splitter used for a shape's own
|
|
1016
|
+
// arguments) yields at most one shape-function token; every other token
|
|
1017
|
+
// is a box keyword. Checking that token's own PREFIX (not an \`indexOf\`
|
|
1018
|
+
// anywhere in the raw string) is what keeps this from matching shape
|
|
1019
|
+
// text that only appears nested inside an unrelated top-level function's
|
|
1020
|
+
// arguments — most importantly \`url(\"#circle(20px at 50px 50px)\")\`, a
|
|
1021
|
+
// valid but UNSUPPORTED \`<clip-source>\` reference whose fragment text
|
|
1022
|
+
// merely resembles a circle() call. Once the matching token is found,
|
|
1023
|
+
// argument extraction still walks paren depth from \`name(\`'s opening
|
|
1024
|
+
// paren to its OWN matching close — never the naive \`[^)]*\` regex this
|
|
1025
|
+
// replaces, which truncates at the first \`)\` it sees and silently hands
|
|
1026
|
+
// a mangled/truncated argument string to the shape parser the moment an
|
|
1027
|
+
// argument itself contains parens (every computed \`calc(...)\`
|
|
1028
|
+
// edge-offset position does). Returns \`null\` when no top-level token
|
|
1029
|
+
// starts with \`name(\` or that token's parens never close.
|
|
1030
|
+
function extractFunctionArgs(str, name) {
|
|
1031
|
+
var prefix = name + '(';
|
|
1032
|
+
var tokens = splitTopLevel(str.trim());
|
|
1033
|
+
var target = null;
|
|
1034
|
+
for (var t = 0; t < tokens.length; t += 1) {
|
|
1035
|
+
if (tokens[t].indexOf(prefix) === 0) {
|
|
1036
|
+
target = tokens[t];
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (target === null) return null;
|
|
1041
|
+
var start = prefix.length;
|
|
1042
|
+
var depth = 1;
|
|
1043
|
+
var i = start;
|
|
1044
|
+
for (; i < target.length; i += 1) {
|
|
1045
|
+
if (target[i] === '(') depth += 1;
|
|
1046
|
+
else if (target[i] === ')') {
|
|
1047
|
+
depth -= 1;
|
|
1048
|
+
if (depth === 0) break;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (depth !== 0) return null;
|
|
1052
|
+
return target.slice(start, i);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function shapeBboxRect(shape) {
|
|
1056
|
+
if (shape.type === 'circle') {
|
|
1057
|
+
return { left: shape.cx - shape.r, top: shape.cy - shape.r, right: shape.cx + shape.r, bottom: shape.cy + shape.r };
|
|
1058
|
+
}
|
|
1059
|
+
if (shape.type === 'ellipse') {
|
|
1060
|
+
return { left: shape.cx - shape.rx, top: shape.cy - shape.ry, right: shape.cx + shape.rx, bottom: shape.cy + shape.ry };
|
|
1061
|
+
}
|
|
1062
|
+
var xs = [];
|
|
1063
|
+
var ys = [];
|
|
1064
|
+
for (var i = 0; i < shape.points.length; i += 2) {
|
|
1065
|
+
xs.push(shape.points[i]);
|
|
1066
|
+
ys.push(shape.points[i + 1]);
|
|
1067
|
+
}
|
|
1068
|
+
return { left: Math.min.apply(null, xs), top: Math.min.apply(null, ys), right: Math.max.apply(null, xs), bottom: Math.max.apply(null, ys) };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
var node = this.parentElement;
|
|
1072
|
+
var docEl = this.ownerDocument ? this.ownerDocument.documentElement : null;
|
|
1073
|
+
var clip = null;
|
|
1074
|
+
var shapes = [];
|
|
1075
|
+
var approximate = false;
|
|
1076
|
+
var hops = 0;
|
|
1077
|
+
while (node && node.nodeType === 1 && node !== docEl && hops < 100) {
|
|
1078
|
+
var style = getComputedStyle(node);
|
|
1079
|
+
var overflowClips = /hidden|auto|scroll|clip/.test(style.overflowX) || /hidden|auto|scroll|clip/.test(style.overflowY);
|
|
1080
|
+
var clipPath = style.clipPath;
|
|
1081
|
+
var hasClipPath = !!clipPath && clipPath !== 'none';
|
|
1082
|
+
if (overflowClips) {
|
|
1083
|
+
var ar = node.getBoundingClientRect();
|
|
1084
|
+
clip = intersectBox(clip, { left: ar.left, top: ar.top, right: ar.right, bottom: ar.bottom });
|
|
1085
|
+
}
|
|
1086
|
+
if (hasClipPath) {
|
|
1087
|
+
var refBox = computeReferenceBox(node, clipPath);
|
|
1088
|
+
// Paren-balanced argument extraction (not a naive \`[^)]*\` regex) —
|
|
1089
|
+
// required because a \`calc(...)\` argument (the form computed style
|
|
1090
|
+
// always uses for an edge-offset position) itself contains parens.
|
|
1091
|
+
var insetArgs = extractFunctionArgs(clipPath, 'inset');
|
|
1092
|
+
var circleArgs = extractFunctionArgs(clipPath, 'circle');
|
|
1093
|
+
var ellipseArgs = extractFunctionArgs(clipPath, 'ellipse');
|
|
1094
|
+
var polygonArgs = extractFunctionArgs(clipPath, 'polygon');
|
|
1095
|
+
var refBoxRect = { left: refBox.left, top: refBox.top, right: refBox.right, bottom: refBox.bottom };
|
|
1096
|
+
if (insetArgs !== null) {
|
|
1097
|
+
var insetBox = parseInsetRect(insetArgs, refBox);
|
|
1098
|
+
if (insetBox) {
|
|
1099
|
+
clip = intersectBox(clip, insetBox);
|
|
1100
|
+
} else {
|
|
1101
|
+
// A recognized inset() whose edge tokens this evaluator cannot
|
|
1102
|
+
// resolve (e.g. an operand outside the calc() px/%/number
|
|
1103
|
+
// grammar): do NOT silently fall back to guessed edges — mark
|
|
1104
|
+
// this ancestor's contribution approximate, same as path()/url().
|
|
1105
|
+
approximate = true;
|
|
1106
|
+
clip = intersectBox(clip, refBoxRect);
|
|
1107
|
+
}
|
|
1108
|
+
} else if (circleArgs !== null) {
|
|
1109
|
+
var circleShape = parseCircle(circleArgs, refBox);
|
|
1110
|
+
if (circleShape) {
|
|
1111
|
+
shapes.push(circleShape);
|
|
1112
|
+
clip = intersectBox(clip, shapeBboxRect(circleShape));
|
|
1113
|
+
} else {
|
|
1114
|
+
// A recognized circle() whose radius/position tokens this
|
|
1115
|
+
// evaluator cannot resolve (e.g. a computed calc() position term
|
|
1116
|
+
// outside the supported grammar): do NOT silently compute a
|
|
1117
|
+
// wrong shape from guessed coordinates — mark approximate.
|
|
1118
|
+
approximate = true;
|
|
1119
|
+
clip = intersectBox(clip, refBoxRect);
|
|
1120
|
+
}
|
|
1121
|
+
} else if (ellipseArgs !== null) {
|
|
1122
|
+
var ellipseShape = parseEllipse(ellipseArgs, refBox);
|
|
1123
|
+
if (ellipseShape) {
|
|
1124
|
+
shapes.push(ellipseShape);
|
|
1125
|
+
clip = intersectBox(clip, shapeBboxRect(ellipseShape));
|
|
1126
|
+
} else {
|
|
1127
|
+
approximate = true;
|
|
1128
|
+
clip = intersectBox(clip, refBoxRect);
|
|
1129
|
+
}
|
|
1130
|
+
} else if (polygonArgs !== null) {
|
|
1131
|
+
var polygonShape = parsePolygon(polygonArgs, refBox);
|
|
1132
|
+
if (polygonShape) {
|
|
1133
|
+
shapes.push(polygonShape);
|
|
1134
|
+
clip = intersectBox(clip, shapeBboxRect(polygonShape));
|
|
1135
|
+
} else {
|
|
1136
|
+
approximate = true;
|
|
1137
|
+
clip = intersectBox(clip, refBoxRect);
|
|
1138
|
+
}
|
|
1139
|
+
} else {
|
|
1140
|
+
// path() / url() / any other unrecognized clip-path form: cannot be
|
|
1141
|
+
// resolved to exact geometry. Do NOT silently treat this as
|
|
1142
|
+
// unclipped — fall back to the ancestor's own box as a conservative
|
|
1143
|
+
// bound (same as before) but flag it as approximate so the caller
|
|
1144
|
+
// never mistakes this for an exact mask.
|
|
1145
|
+
approximate = true;
|
|
1146
|
+
clip = intersectBox(clip, refBoxRect);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
node = node.parentElement;
|
|
1150
|
+
hops += 1;
|
|
1151
|
+
}
|
|
1152
|
+
if (!clip) return { rect: null, shapes: shapes, approximate: approximate };
|
|
1153
|
+
var width = Math.max(0, clip.right - clip.left);
|
|
1154
|
+
var height = Math.max(0, clip.bottom - clip.top);
|
|
1155
|
+
return { rect: { x: clip.left, y: clip.top, width: width, height: height }, shapes: shapes, approximate: approximate };
|
|
1156
|
+
}`;
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Resolves `nodeId` to a live `objectId` (`DOM.resolveNode`) and runs
|
|
1160
|
+
* {@link ANCESTOR_CLIP_FUNCTION} on it. Never throws overall, but a
|
|
1161
|
+
* resolve/call failure that genuinely prevents the walk from running
|
|
1162
|
+
* (`DOM.resolveNode` resolving without an `objectId`, `Runtime.callFunctionOn`
|
|
1163
|
+
* resolving without `result.value`, or either call throwing — detached
|
|
1164
|
+
* node, CDP hiccup) is marked honestly via {@link AncestorClipInfo.unavailable}
|
|
1165
|
+
* + `unavailableReason`, NOT silently coerced into the same
|
|
1166
|
+
* `{rect:null,shapes:[],approximate:false}` shape a genuine "no ancestor
|
|
1167
|
+
* clips this element" result produces (I-4/I-5) — that shape is reserved
|
|
1168
|
+
* for a walk that actually ran and found nothing to clip. This mirrors
|
|
1169
|
+
* `backendNodeId`'s `identityUnresolved: true` marker rather than a silent
|
|
1170
|
+
* default: clip info gets the same honesty treatment identity does.
|
|
1171
|
+
* The `DOM.resolveNode`-held `objectId` is released via
|
|
1172
|
+
* `Runtime.releaseObject` in a `finally` (unconditionally, mirroring
|
|
1173
|
+
* `animation.ts`'s held-object cleanup) so a per-element remote handle never
|
|
1174
|
+
* outlives this call.
|
|
1175
|
+
*/
|
|
1176
|
+
async function computeAncestorClip(client: CDPClient, nodeId: number): Promise<AncestorClipInfo> {
|
|
1177
|
+
const unavailable = (reason: AncestorClipUnavailableReason): AncestorClipInfo => ({
|
|
1178
|
+
rect: null,
|
|
1179
|
+
shapes: [],
|
|
1180
|
+
approximate: false,
|
|
1181
|
+
unavailable: true,
|
|
1182
|
+
unavailableReason: reason,
|
|
1183
|
+
});
|
|
1184
|
+
let objectId: string | undefined;
|
|
1185
|
+
try {
|
|
1186
|
+
const resolved = (await client.send('DOM.resolveNode', { nodeId })) as { object?: { objectId?: string } };
|
|
1187
|
+
objectId = resolved.object?.objectId;
|
|
1188
|
+
if (!objectId) return unavailable('resolve-node-no-object-id');
|
|
1189
|
+
const called = (await client.send('Runtime.callFunctionOn', {
|
|
1190
|
+
objectId,
|
|
1191
|
+
functionDeclaration: ANCESTOR_CLIP_FUNCTION,
|
|
1192
|
+
returnByValue: true,
|
|
1193
|
+
})) as { result?: { value?: AncestorClipInfo } };
|
|
1194
|
+
if (!called.result?.value) return unavailable('call-function-no-value');
|
|
1195
|
+
return called.result.value;
|
|
1196
|
+
} catch {
|
|
1197
|
+
return unavailable('resolve-or-call-threw');
|
|
1198
|
+
} finally {
|
|
1199
|
+
if (objectId) {
|
|
1200
|
+
try {
|
|
1201
|
+
await client.send('Runtime.releaseObject', { objectId });
|
|
1202
|
+
} catch {
|
|
1203
|
+
// Best-effort release — a failure here doesn't invalidate the clip
|
|
1204
|
+
// info already computed/returned above.
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// ============================================================================
|
|
1211
|
+
// Pure geometry/selector helpers
|
|
1212
|
+
// ============================================================================
|
|
1213
|
+
|
|
1214
|
+
function unionRect(rects: readonly Rect[]): Rect {
|
|
1215
|
+
// Loop instead of Math.min(...spread) — spreading a large rect list can
|
|
1216
|
+
// overflow the call stack.
|
|
1217
|
+
let minX = Infinity;
|
|
1218
|
+
let minY = Infinity;
|
|
1219
|
+
let maxX = -Infinity;
|
|
1220
|
+
let maxY = -Infinity;
|
|
1221
|
+
for (const r of rects) {
|
|
1222
|
+
if (r.x < minX) minX = r.x;
|
|
1223
|
+
if (r.y < minY) minY = r.y;
|
|
1224
|
+
if (r.x + r.width > maxX) maxX = r.x + r.width;
|
|
1225
|
+
if (r.y + r.height > maxY) maxY = r.y + r.height;
|
|
1226
|
+
}
|
|
1227
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** Axis-aligned rect intersection; zero width/height (never negative) when `a` and `b` don't overlap. */
|
|
1231
|
+
function intersectRect(a: Rect, b: Rect): Rect {
|
|
1232
|
+
const x0 = Math.max(a.x, b.x);
|
|
1233
|
+
const y0 = Math.max(a.y, b.y);
|
|
1234
|
+
const x1 = Math.min(a.x + a.width, b.x + b.width);
|
|
1235
|
+
const y1 = Math.min(a.y + a.height, b.y + b.height);
|
|
1236
|
+
return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function clampRectToPixels(
|
|
1240
|
+
rect: Rect,
|
|
1241
|
+
scaleX: number,
|
|
1242
|
+
scaleY: number,
|
|
1243
|
+
imgWidth: number,
|
|
1244
|
+
imgHeight: number,
|
|
1245
|
+
): PixelRect | null {
|
|
1246
|
+
const x0 = Math.max(0, Math.floor(rect.x * scaleX));
|
|
1247
|
+
const y0 = Math.max(0, Math.floor(rect.y * scaleY));
|
|
1248
|
+
const x1 = Math.min(imgWidth, Math.ceil((rect.x + rect.width) * scaleX));
|
|
1249
|
+
const y1 = Math.min(imgHeight, Math.ceil((rect.y + rect.height) * scaleY));
|
|
1250
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
1251
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Rasterizes a polygon mask over the clamped crop rect: a crop pixel is
|
|
1256
|
+
* on-mask iff its center — mapped back from screenshot-pixel space into CSS
|
|
1257
|
+
* viewport space via the same `scaleX`/`scaleY` used to derive the rect —
|
|
1258
|
+
* lies inside any of the element's content quads AND inside every ancestor
|
|
1259
|
+
* clip shape (`clipShapes`; an empty array is always satisfied — the
|
|
1260
|
+
* pre-existing behavior when no ancestor imposes an exact circle/ellipse/
|
|
1261
|
+
* polygon constraint). The 0.5 pixel-center offset keeps integer-aligned
|
|
1262
|
+
* axis-aligned quads (the common case) from hitting the point-in-polygon
|
|
1263
|
+
* boundary ambiguously.
|
|
1264
|
+
*/
|
|
1265
|
+
function buildQuadMask(
|
|
1266
|
+
pixelRect: PixelRect,
|
|
1267
|
+
quads: readonly Quad[],
|
|
1268
|
+
scaleX: number,
|
|
1269
|
+
scaleY: number,
|
|
1270
|
+
clipShapes: readonly ClipShape[],
|
|
1271
|
+
): Mask {
|
|
1272
|
+
const data = new Uint8Array(pixelRect.width * pixelRect.height);
|
|
1273
|
+
let count = 0;
|
|
1274
|
+
for (let row = 0; row < pixelRect.height; row += 1) {
|
|
1275
|
+
const cssY = (pixelRect.y + row + 0.5) / scaleY;
|
|
1276
|
+
for (let col = 0; col < pixelRect.width; col += 1) {
|
|
1277
|
+
const cssX = (pixelRect.x + col + 0.5) / scaleX;
|
|
1278
|
+
const inQuad = quads.some((quad) => pointInQuad(quad, cssX, cssY));
|
|
1279
|
+
const inClipShapes = clipShapes.every((shape) => pointInClipShape(shape, cssX, cssY));
|
|
1280
|
+
if (inQuad && inClipShapes) {
|
|
1281
|
+
data[row * pixelRect.width + col] = 1;
|
|
1282
|
+
count += 1;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return { data, count };
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** Even-odd ray-cast point-in-polygon over a quad's four corners. */
|
|
1290
|
+
function pointInQuad(quad: Quad, px: number, py: number): boolean {
|
|
1291
|
+
const xs = [quad[0], quad[2], quad[4], quad[6]];
|
|
1292
|
+
const ys = [quad[1], quad[3], quad[5], quad[7]];
|
|
1293
|
+
let inside = false;
|
|
1294
|
+
for (let i = 0, j = 3; i < 4; j = i, i += 1) {
|
|
1295
|
+
const xi = xs[i];
|
|
1296
|
+
const yi = ys[i];
|
|
1297
|
+
const xj = xs[j];
|
|
1298
|
+
const yj = ys[j];
|
|
1299
|
+
const intersects = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
|
1300
|
+
if (intersects) inside = !inside;
|
|
1301
|
+
}
|
|
1302
|
+
return inside;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Dispatches a point-in-shape test to the exact geometry for the given ancestor clip-path shape. */
|
|
1306
|
+
function pointInClipShape(shape: ClipShape, x: number, y: number): boolean {
|
|
1307
|
+
switch (shape.type) {
|
|
1308
|
+
case 'circle': {
|
|
1309
|
+
const dx = x - shape.cx;
|
|
1310
|
+
const dy = y - shape.cy;
|
|
1311
|
+
return dx * dx + dy * dy <= shape.r * shape.r;
|
|
1312
|
+
}
|
|
1313
|
+
case 'ellipse': {
|
|
1314
|
+
if (shape.rx <= 0 || shape.ry <= 0) return false;
|
|
1315
|
+
const dx = (x - shape.cx) / shape.rx;
|
|
1316
|
+
const dy = (y - shape.cy) / shape.ry;
|
|
1317
|
+
return dx * dx + dy * dy <= 1;
|
|
1318
|
+
}
|
|
1319
|
+
case 'polygon':
|
|
1320
|
+
return pointInPolygon(shape.points, shape.fillRule, x, y);
|
|
1321
|
+
default:
|
|
1322
|
+
return true;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Point-in-polygon over an arbitrary vertex list, honoring the CSS
|
|
1328
|
+
* `clip-path: polygon()` fill rule: `evenodd` (the same even-odd ray-cast
|
|
1329
|
+
* used for content quads) or `nonzero` (the default — a standard nonzero
|
|
1330
|
+
* winding-number test, needed because a self-intersecting or
|
|
1331
|
+
* differently-wound polygon can disagree with even-odd on which regions
|
|
1332
|
+
* are "inside").
|
|
1333
|
+
*/
|
|
1334
|
+
function pointInPolygon(points: readonly number[], fillRule: 'nonzero' | 'evenodd', px: number, py: number): boolean {
|
|
1335
|
+
const n = points.length / 2;
|
|
1336
|
+
if (n < 3) return false;
|
|
1337
|
+
if (fillRule === 'evenodd') {
|
|
1338
|
+
let inside = false;
|
|
1339
|
+
for (let i = 0, j = n - 1; i < n; j = i, i += 1) {
|
|
1340
|
+
const xi = points[i * 2];
|
|
1341
|
+
const yi = points[i * 2 + 1];
|
|
1342
|
+
const xj = points[j * 2];
|
|
1343
|
+
const yj = points[j * 2 + 1];
|
|
1344
|
+
const intersects = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
|
1345
|
+
if (intersects) inside = !inside;
|
|
1346
|
+
}
|
|
1347
|
+
return inside;
|
|
1348
|
+
}
|
|
1349
|
+
let winding = 0;
|
|
1350
|
+
for (let i = 0, j = n - 1; i < n; j = i, i += 1) {
|
|
1351
|
+
const xi = points[i * 2];
|
|
1352
|
+
const yi = points[i * 2 + 1];
|
|
1353
|
+
const xj = points[j * 2];
|
|
1354
|
+
const yj = points[j * 2 + 1];
|
|
1355
|
+
if (yi <= py) {
|
|
1356
|
+
if (yj > py && isLeftOfEdge(xi, yi, xj, yj, px, py) > 0) winding += 1;
|
|
1357
|
+
} else if (yj <= py && isLeftOfEdge(xi, yi, xj, yj, px, py) < 0) {
|
|
1358
|
+
winding -= 1;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return winding !== 0;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** Signed area (cross product) test: >0 when `(px,py)` is left of the directed edge `(x0,y0)->(x1,y1)`. */
|
|
1365
|
+
function isLeftOfEdge(x0: number, y0: number, x1: number, y1: number, px: number, py: number): number {
|
|
1366
|
+
return (x1 - x0) * (py - y0) - (px - x0) * (y1 - y0);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function flattenAttributes(attributes: readonly string[] | undefined): Map<string, string> {
|
|
1370
|
+
const map = new Map<string, string>();
|
|
1371
|
+
if (!attributes) return map;
|
|
1372
|
+
for (let i = 0; i + 1 < attributes.length; i += 2) {
|
|
1373
|
+
map.set(attributes[i], attributes[i + 1]);
|
|
1374
|
+
}
|
|
1375
|
+
return map;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* Best-effort `tag#id.class1.class2` selector. Structure is built from the
|
|
1380
|
+
* raw page-controlled attributes, then the whole string is routed through
|
|
1381
|
+
* the shared {@link sanitizeString} length cap — the single authority every
|
|
1382
|
+
* collector uses for page-controlled strings.
|
|
1383
|
+
*/
|
|
1384
|
+
function buildSelector(nodeName: string | undefined, attributes: readonly string[] | undefined): string | undefined {
|
|
1385
|
+
if (!nodeName) return undefined;
|
|
1386
|
+
const attrs = flattenAttributes(attributes);
|
|
1387
|
+
|
|
1388
|
+
let selector = nodeName.toLowerCase();
|
|
1389
|
+
const idAttr = attrs.get('id');
|
|
1390
|
+
if (idAttr) selector += `#${idAttr}`;
|
|
1391
|
+
const classAttr = attrs.get('class');
|
|
1392
|
+
if (classAttr) {
|
|
1393
|
+
for (const cls of classAttr.split(/\s+/).filter(Boolean).slice(0, 3)) {
|
|
1394
|
+
selector += `.${cls}`;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return sanitizeString(selector);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Stable, collision-free crop filename base: `index` guarantees uniqueness,
|
|
1402
|
+
* `backendNodeId ?? tag` is a stable identifier under our control, and the
|
|
1403
|
+
* shared {@link sanitizeFilenameSlug} replaces filename-unsafe characters
|
|
1404
|
+
* and caps length. Never derived from raw page attributes.
|
|
1405
|
+
*/
|
|
1406
|
+
function cropFileBase(index: number, entry: DescribedElement): string {
|
|
1407
|
+
const stableId = typeof entry.backendNodeId === 'number' ? String(entry.backendNodeId) : (entry.tag ?? 'el');
|
|
1408
|
+
const slug = entry.selector !== undefined ? sanitizeFilenameSlug(entry.selector) : '';
|
|
1409
|
+
return slug ? `${index}-${stableId}-${slug}` : `${index}-${stableId}`;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function round3(value: number): number {
|
|
1413
|
+
return Math.round(value * 1000) / 1000;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// ============================================================================
|
|
1417
|
+
// Raster helpers — crop, mask, encode, and derive per-crop facts
|
|
1418
|
+
// ============================================================================
|
|
1419
|
+
|
|
1420
|
+
function cropPixels(png: PNG, rect: PixelRect): RawImage {
|
|
1421
|
+
const out = Buffer.alloc(rect.width * rect.height * 4);
|
|
1422
|
+
for (let row = 0; row < rect.height; row += 1) {
|
|
1423
|
+
const srcStart = ((rect.y + row) * png.width + rect.x) * 4;
|
|
1424
|
+
const destStart = row * rect.width * 4;
|
|
1425
|
+
png.data.copy(out, destStart, srcStart, srcStart + rect.width * 4);
|
|
1426
|
+
}
|
|
1427
|
+
return { width: rect.width, height: rect.height, data: out };
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/** Returns a copy of `img` with every off-mask pixel zeroed to fully-transparent (0,0,0,0). */
|
|
1431
|
+
function applyMaskTransparent(img: RawImage, mask: Mask): RawImage {
|
|
1432
|
+
const out = Buffer.from(img.data);
|
|
1433
|
+
const n = pixelCount(img);
|
|
1434
|
+
for (let i = 0; i < n; i += 1) {
|
|
1435
|
+
if (mask.data[i] === 0) {
|
|
1436
|
+
const o = i * 4;
|
|
1437
|
+
out[o] = 0;
|
|
1438
|
+
out[o + 1] = 0;
|
|
1439
|
+
out[o + 2] = 0;
|
|
1440
|
+
out[o + 3] = 0;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return { width: img.width, height: img.height, data: out };
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function encodePng(img: RawImage): Buffer {
|
|
1447
|
+
const png = new PNG({ width: img.width, height: img.height });
|
|
1448
|
+
img.data.copy(png.data);
|
|
1449
|
+
return PNG.sync.write(png);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function pixelCount(img: RawImage): number {
|
|
1453
|
+
return img.width * img.height;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function averageColor(img: RawImage, mask: Mask): RGBColor {
|
|
1457
|
+
if (mask.count === 0) return { r: 0, g: 0, b: 0 };
|
|
1458
|
+
let r = 0;
|
|
1459
|
+
let g = 0;
|
|
1460
|
+
let b = 0;
|
|
1461
|
+
const n = pixelCount(img);
|
|
1462
|
+
for (let i = 0; i < n; i += 1) {
|
|
1463
|
+
if (mask.data[i] === 0) continue;
|
|
1464
|
+
const o = i * 4;
|
|
1465
|
+
r += img.data[o];
|
|
1466
|
+
g += img.data[o + 1];
|
|
1467
|
+
b += img.data[o + 2];
|
|
1468
|
+
}
|
|
1469
|
+
return { r: Math.round(r / mask.count), g: Math.round(g / mask.count), b: Math.round(b / mask.count) };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function medianColor(img: RawImage, mask: Mask): RGBColor {
|
|
1473
|
+
if (mask.count === 0) return { r: 0, g: 0, b: 0 };
|
|
1474
|
+
const rs = new Array<number>(mask.count);
|
|
1475
|
+
const gs = new Array<number>(mask.count);
|
|
1476
|
+
const bs = new Array<number>(mask.count);
|
|
1477
|
+
const n = pixelCount(img);
|
|
1478
|
+
let k = 0;
|
|
1479
|
+
for (let i = 0; i < n; i += 1) {
|
|
1480
|
+
if (mask.data[i] === 0) continue;
|
|
1481
|
+
const o = i * 4;
|
|
1482
|
+
rs[k] = img.data[o];
|
|
1483
|
+
gs[k] = img.data[o + 1];
|
|
1484
|
+
bs[k] = img.data[o + 2];
|
|
1485
|
+
k += 1;
|
|
1486
|
+
}
|
|
1487
|
+
rs.sort((a, b) => a - b);
|
|
1488
|
+
gs.sort((a, b) => a - b);
|
|
1489
|
+
bs.sort((a, b) => a - b);
|
|
1490
|
+
const mid = Math.floor(mask.count / 2);
|
|
1491
|
+
return { r: rs[mid], g: gs[mid], b: bs[mid] };
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/** Most-frequent color after quantizing to `DOMINANT_BUCKET`-wide buckets, reported as that bucket's own average. */
|
|
1495
|
+
function dominantColor(img: RawImage, mask: Mask): RGBColor {
|
|
1496
|
+
if (mask.count === 0) return { r: 0, g: 0, b: 0 };
|
|
1497
|
+
const buckets = new Map<string, { count: number; r: number; g: number; b: number }>();
|
|
1498
|
+
const n = pixelCount(img);
|
|
1499
|
+
for (let i = 0; i < n; i += 1) {
|
|
1500
|
+
if (mask.data[i] === 0) continue;
|
|
1501
|
+
const o = i * 4;
|
|
1502
|
+
const r = img.data[o];
|
|
1503
|
+
const g = img.data[o + 1];
|
|
1504
|
+
const b = img.data[o + 2];
|
|
1505
|
+
const key = `${Math.floor(r / DOMINANT_BUCKET)},${Math.floor(g / DOMINANT_BUCKET)},${Math.floor(b / DOMINANT_BUCKET)}`;
|
|
1506
|
+
const entry = buckets.get(key) ?? { count: 0, r: 0, g: 0, b: 0 };
|
|
1507
|
+
entry.count += 1;
|
|
1508
|
+
entry.r += r;
|
|
1509
|
+
entry.g += g;
|
|
1510
|
+
entry.b += b;
|
|
1511
|
+
buckets.set(key, entry);
|
|
1512
|
+
}
|
|
1513
|
+
let best: { count: number; r: number; g: number; b: number } | null = null;
|
|
1514
|
+
for (const entry of buckets.values()) {
|
|
1515
|
+
if (!best || entry.count > best.count) best = entry;
|
|
1516
|
+
}
|
|
1517
|
+
if (!best) return { r: 0, g: 0, b: 0 };
|
|
1518
|
+
return { r: Math.round(best.r / best.count), g: Math.round(best.g / best.count), b: Math.round(best.b / best.count) };
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function alphaFraction(img: RawImage, mask: Mask): number {
|
|
1522
|
+
if (mask.count === 0) return 0;
|
|
1523
|
+
let sum = 0;
|
|
1524
|
+
const n = pixelCount(img);
|
|
1525
|
+
for (let i = 0; i < n; i += 1) {
|
|
1526
|
+
if (mask.data[i] === 0) continue;
|
|
1527
|
+
sum += img.data[i * 4 + 3];
|
|
1528
|
+
}
|
|
1529
|
+
return sum / (mask.count * 255);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function visiblePixelFraction(img: RawImage, mask: Mask): number {
|
|
1533
|
+
if (mask.count === 0) return 0;
|
|
1534
|
+
let visible = 0;
|
|
1535
|
+
const n = pixelCount(img);
|
|
1536
|
+
for (let i = 0; i < n; i += 1) {
|
|
1537
|
+
if (mask.data[i] === 0) continue;
|
|
1538
|
+
if (img.data[i * 4 + 3] > 0) visible += 1;
|
|
1539
|
+
}
|
|
1540
|
+
return visible / mask.count;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* 8x8 average-hash (aHash): downsamples the ON-MASK crop pixels to a
|
|
1545
|
+
* `HASH_GRID`x`HASH_GRID` luminance grid, compares each cell to the grid
|
|
1546
|
+
* mean, and packs the resulting 64 bits into 16 hex chars. Off-mask pixels
|
|
1547
|
+
* are excluded from every cell's luminance so a rotated/disjoint element's
|
|
1548
|
+
* hash reflects only its own content. Deterministic for identical on-mask
|
|
1549
|
+
* pixel content — the "stable hash" this collector reports.
|
|
1550
|
+
*/
|
|
1551
|
+
function averageHash(img: RawImage, mask: Mask): string {
|
|
1552
|
+
const cells = new Array<number>(HASH_GRID * HASH_GRID).fill(0);
|
|
1553
|
+
const counts = new Array<number>(HASH_GRID * HASH_GRID).fill(0);
|
|
1554
|
+
|
|
1555
|
+
for (let y = 0; y < img.height; y += 1) {
|
|
1556
|
+
const cellY = Math.min(HASH_GRID - 1, Math.floor((y / img.height) * HASH_GRID));
|
|
1557
|
+
for (let x = 0; x < img.width; x += 1) {
|
|
1558
|
+
if (mask.data[y * img.width + x] === 0) continue;
|
|
1559
|
+
const cellX = Math.min(HASH_GRID - 1, Math.floor((x / img.width) * HASH_GRID));
|
|
1560
|
+
const o = (y * img.width + x) * 4;
|
|
1561
|
+
const lum = 0.299 * img.data[o] + 0.587 * img.data[o + 1] + 0.114 * img.data[o + 2];
|
|
1562
|
+
const idx = cellY * HASH_GRID + cellX;
|
|
1563
|
+
cells[idx] += lum;
|
|
1564
|
+
counts[idx] += 1;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const values = cells.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0));
|
|
1569
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
1570
|
+
|
|
1571
|
+
const bytes: number[] = [];
|
|
1572
|
+
let byte = 0;
|
|
1573
|
+
let bitCount = 0;
|
|
1574
|
+
for (const value of values) {
|
|
1575
|
+
byte = (byte << 1) | (value >= mean ? 1 : 0);
|
|
1576
|
+
bitCount += 1;
|
|
1577
|
+
if (bitCount === 8) {
|
|
1578
|
+
bytes.push(byte);
|
|
1579
|
+
byte = 0;
|
|
1580
|
+
bitCount = 0;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
1584
|
+
}
|