domotion-svg 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/capture/backdrop-isolation.d.ts +14 -0
- package/dist/capture/backdrop-isolation.js +49 -0
- package/dist/capture/emoji.js +130 -0
- package/dist/capture/index.d.ts +3 -1
- package/dist/capture/index.js +2 -2
- package/dist/capture/script/emoji-detect.js +22 -1
- package/dist/capture/script/font-feature-values.d.ts +2 -0
- package/dist/capture/script/font-feature-values.js +62 -0
- package/dist/capture/script/index.js +176 -42
- package/dist/capture/script/walker/form-controls.d.ts +1 -19
- package/dist/capture/script/walker/form-controls.js +34 -3
- package/dist/capture/script/walker/fragmentation.js +60 -0
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +2 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +2 -0
- package/dist/capture/script/walker/pseudo-content.js +57 -38
- package/dist/capture/script/walker/pseudo-inject.js +4 -2
- package/dist/capture/script/walker/text-segments.d.ts +10 -0
- package/dist/capture/script/walker/text-segments.js +82 -49
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +80 -3
- package/dist/cli/capture.js +4 -2
- package/dist/cli/common.d.ts +6 -1
- package/dist/cli/common.js +7 -3
- package/dist/cli/index.js +3 -0
- package/dist/render/element-tree-to-svg.js +111 -60
- package/dist/render/embedded-font-builder.d.ts +24 -0
- package/dist/render/embedded-font-builder.js +73 -6
- package/dist/render/font-resolution.d.ts +6 -2
- package/dist/render/font-resolution.js +32 -11
- package/dist/render/form-controls.js +34 -10
- package/dist/render/gradient-defs.js +113 -32
- package/dist/render/gradients.js +35 -27
- package/dist/render/harfbuzz-shaper.js +4 -4
- package/dist/render/helper-acquire.js +1 -1
- package/dist/render/list-marker-geometry.d.ts +24 -0
- package/dist/render/list-marker-geometry.js +57 -0
- package/dist/render/script-segmentation.js +19 -9
- package/dist/render/text-to-path.d.ts +13 -3
- package/dist/render/text-to-path.js +156 -41
- package/dist/render/text.d.ts +9 -0
- package/dist/render/text.js +101 -68
- package/dist/render/vertical-text.d.ts +17 -9
- package/dist/render/vertical-text.js +81 -81
- package/dist/templates/builtin/kinetic-text.d.ts +1 -1
- package/dist/templates/render.js +1 -1
- package/dist/templates/types.d.ts +2 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -79,6 +79,12 @@ cat demo.html | domotion capture - -o demo.svg
|
|
|
79
79
|
domotion capture https://example.com --scroll "down:bottom/8s" -o scroll.svg
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
Navigation waits for the page's `load` event, then Domotion applies its normal
|
|
83
|
+
font/image/paint readiness checks. Pages with a finite request chain can opt
|
|
84
|
+
into Playwright's stricter network-idle heuristic with `--network-idle`; it is
|
|
85
|
+
off by default because analytics, long polling, and streaming requests may
|
|
86
|
+
never become idle.
|
|
87
|
+
|
|
82
88
|
Same-origin `<iframe>` content is recursed into the capture as native, selectable SVG rather than flattened to a screenshot; opt into cross-origin frames you trust with `--cross-origin-frames "<hosts>"`.
|
|
83
89
|
|
|
84
90
|
For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SnapshotNode {
|
|
2
|
+
backendNodeId: number;
|
|
3
|
+
parentIndex: number;
|
|
4
|
+
attributes: string[];
|
|
5
|
+
bounds?: [number, number, number, number];
|
|
6
|
+
paintOrder?: number;
|
|
7
|
+
layoutOrder?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface IsolationPlan {
|
|
10
|
+
targetBackendNodeId: number;
|
|
11
|
+
hideBackendNodeIds: number[];
|
|
12
|
+
}
|
|
13
|
+
/** Pure CDP snapshot planner; DOM mutation and restoration stay in emoji.ts. */
|
|
14
|
+
export declare function planBackdropIsolation(nodes: SnapshotNode[], token: string): IsolationPlan | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function overlaps(a, b) {
|
|
2
|
+
return a[0] < b[0] + b[2] && a[0] + a[2] > b[0]
|
|
3
|
+
&& a[1] < b[1] + b[3] && a[1] + a[3] > b[1];
|
|
4
|
+
}
|
|
5
|
+
function hasToken(node, token) {
|
|
6
|
+
for (let i = 0; i + 1 < node.attributes.length; i += 2) {
|
|
7
|
+
if (node.attributes[i] === "data-domotion-backdrop-raster" && node.attributes[i + 1] === token)
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
function isAncestor(nodes, ancestor, child) {
|
|
13
|
+
for (let i = child; i >= 0; i = nodes[i]?.parentIndex ?? -1)
|
|
14
|
+
if (i === ancestor)
|
|
15
|
+
return true;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
/** Pure CDP snapshot planner; DOM mutation and restoration stay in emoji.ts. */
|
|
19
|
+
export function planBackdropIsolation(nodes, token) {
|
|
20
|
+
const target = nodes.findIndex((node) => hasToken(node, token));
|
|
21
|
+
if (target < 0)
|
|
22
|
+
return null;
|
|
23
|
+
const targetNode = nodes[target];
|
|
24
|
+
if (targetNode.bounds == null || targetNode.paintOrder == null)
|
|
25
|
+
return null;
|
|
26
|
+
const hide = [];
|
|
27
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
28
|
+
const node = nodes[i];
|
|
29
|
+
if (i === target || node.bounds == null || node.paintOrder == null)
|
|
30
|
+
continue;
|
|
31
|
+
const paintsLater = node.paintOrder > targetNode.paintOrder
|
|
32
|
+
|| (node.paintOrder === targetNode.paintOrder
|
|
33
|
+
&& node.layoutOrder != null && targetNode.layoutOrder != null
|
|
34
|
+
&& node.layoutOrder > targetNode.layoutOrder);
|
|
35
|
+
if (!paintsLater || !overlaps(node.bounds, targetNode.bounds))
|
|
36
|
+
continue;
|
|
37
|
+
if (isAncestor(nodes, i, target) || isAncestor(nodes, target, i))
|
|
38
|
+
continue;
|
|
39
|
+
// If an already-selected later ancestor hides this node, do not resolve and
|
|
40
|
+
// mutate the descendant too. Snapshot order is parent-before-child.
|
|
41
|
+
if (hide.some((id) => {
|
|
42
|
+
const ancestor = nodes.findIndex((n) => n.backendNodeId === id);
|
|
43
|
+
return ancestor >= 0 && isAncestor(nodes, ancestor, i);
|
|
44
|
+
}))
|
|
45
|
+
continue;
|
|
46
|
+
hide.push(node.backendNodeId);
|
|
47
|
+
}
|
|
48
|
+
return { targetBackendNodeId: targetNode.backendNodeId, hideBackendNodeIds: hide };
|
|
49
|
+
}
|
package/dist/capture/emoji.js
CHANGED
|
@@ -20,6 +20,7 @@ import * as fontkit from "fontkit";
|
|
|
20
20
|
import sharp from "sharp";
|
|
21
21
|
import { clipRectForScreenshot } from "./clip-rect.js";
|
|
22
22
|
import { forEachElement } from "../tree-ops/for-each-element.js";
|
|
23
|
+
import { planBackdropIsolation } from "./backdrop-isolation.js";
|
|
23
24
|
const APPLE_COLOR_EMOJI_PATH = "/System/Library/Fonts/Apple Color Emoji.ttc";
|
|
24
25
|
let _aceFont = null;
|
|
25
26
|
let _aceFontLoaded = false;
|
|
@@ -382,7 +383,119 @@ async function calibrateSbixOverlays(page, viewport, jobs) {
|
|
|
382
383
|
catch { /* leave this overlay as stamped */ }
|
|
383
384
|
}
|
|
384
385
|
}
|
|
386
|
+
async function rasterizeBackdropFilters(page, tree, viewport) {
|
|
387
|
+
const targets = [];
|
|
388
|
+
forEachElement(tree, (el) => { if (el.backdropFilterRaster?.token != null)
|
|
389
|
+
targets.push(el.backdropFilterRaster); });
|
|
390
|
+
if (targets.length === 0)
|
|
391
|
+
return;
|
|
392
|
+
let cdp;
|
|
393
|
+
try {
|
|
394
|
+
cdp = await page.context().newCDPSession(page);
|
|
395
|
+
const snap = await cdp.send("DOMSnapshot.captureSnapshot", {
|
|
396
|
+
computedStyles: [], includePaintOrder: true, includeDOMRects: true,
|
|
397
|
+
});
|
|
398
|
+
const doc = snap.documents?.[0];
|
|
399
|
+
const strings = snap.strings ?? [];
|
|
400
|
+
const paintByNode = new Map();
|
|
401
|
+
for (let i = 0; i < (doc?.layout?.nodeIndex?.length ?? 0); i++) {
|
|
402
|
+
const nodeIndex = doc.layout.nodeIndex[i];
|
|
403
|
+
const bounds = doc.layout.bounds[i];
|
|
404
|
+
const paintOrder = doc.layout.paintOrders?.[i];
|
|
405
|
+
if (paintOrder != null && !paintByNode.has(nodeIndex))
|
|
406
|
+
paintByNode.set(nodeIndex, { bounds, paintOrder, layoutOrder: i });
|
|
407
|
+
}
|
|
408
|
+
const attributesByNode = new Map();
|
|
409
|
+
const rareAttributes = doc?.nodes?.attributes;
|
|
410
|
+
if (Array.isArray(rareAttributes)) {
|
|
411
|
+
for (let i = 0; i < rareAttributes.length; i++)
|
|
412
|
+
attributesByNode.set(i, rareAttributes[i]);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
for (let i = 0; i < (rareAttributes?.index?.length ?? 0); i++) {
|
|
416
|
+
attributesByNode.set(rareAttributes.index[i], rareAttributes.value[i]);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const nodes = (doc?.nodes?.backendNodeId ?? []).map((backendNodeId, i) => {
|
|
420
|
+
const attrIndexes = attributesByNode.get(i) ?? [];
|
|
421
|
+
const attributes = attrIndexes.map((idx) => strings[idx]);
|
|
422
|
+
const layout = paintByNode.get(i);
|
|
423
|
+
return {
|
|
424
|
+
backendNodeId,
|
|
425
|
+
parentIndex: doc.nodes.parentIndex?.[i] ?? -1,
|
|
426
|
+
attributes,
|
|
427
|
+
bounds: layout?.bounds,
|
|
428
|
+
paintOrder: layout?.paintOrder,
|
|
429
|
+
layoutOrder: layout?.layoutOrder,
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
for (const target of targets) {
|
|
433
|
+
const plan = planBackdropIsolation(nodes, target.token);
|
|
434
|
+
const restores = [];
|
|
435
|
+
try {
|
|
436
|
+
if (plan != null) {
|
|
437
|
+
for (const backendNodeId of plan.hideBackendNodeIds) {
|
|
438
|
+
try {
|
|
439
|
+
const resolved = await cdp.send("DOM.resolveNode", { backendNodeId });
|
|
440
|
+
const objectId = resolved.object?.objectId;
|
|
441
|
+
if (objectId == null)
|
|
442
|
+
continue;
|
|
443
|
+
const changed = await cdp.send("Runtime.callFunctionOn", {
|
|
444
|
+
objectId,
|
|
445
|
+
functionDeclaration: "function(){const v=this.style.getPropertyValue('visibility');const p=this.style.getPropertyPriority('visibility');this.style.setProperty('visibility','hidden','important');return {v,p};}",
|
|
446
|
+
returnByValue: true,
|
|
447
|
+
});
|
|
448
|
+
restores.push({ objectId, value: changed.result?.value?.v ?? "", priority: changed.result?.value?.p ?? "" });
|
|
449
|
+
}
|
|
450
|
+
catch { /* conservative fallback: leave this node painted */ }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const clip = clipRectForScreenshot(target, viewport);
|
|
454
|
+
const buf = await page.screenshot({ clip, omitBackground: true, type: "png" });
|
|
455
|
+
target.dataUri = `data:image/png;base64,${Buffer.from(buf).toString("base64")}`;
|
|
456
|
+
target.x = clip.x - viewport.x;
|
|
457
|
+
target.y = clip.y - viewport.y;
|
|
458
|
+
target.width = clip.width;
|
|
459
|
+
target.height = clip.height;
|
|
460
|
+
}
|
|
461
|
+
catch { /* retain vector fallback if screenshot itself fails */ }
|
|
462
|
+
finally {
|
|
463
|
+
for (let i = restores.length - 1; i >= 0; i--) {
|
|
464
|
+
const restore = restores[i];
|
|
465
|
+
try {
|
|
466
|
+
await cdp.send("Runtime.callFunctionOn", {
|
|
467
|
+
objectId: restore.objectId,
|
|
468
|
+
functionDeclaration: "function(v,p){if(v==='')this.style.removeProperty('visibility');else this.style.setProperty('visibility',v,p);}",
|
|
469
|
+
arguments: [{ value: restore.value }, { value: restore.priority }],
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
catch { /* page teardown */ }
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// DOMSnapshot is Chromium-only and mapping can fail for pseudo/fragments.
|
|
479
|
+
// Fall back to the original full-page crop for every unresolved target.
|
|
480
|
+
for (const target of targets) {
|
|
481
|
+
try {
|
|
482
|
+
const clip = clipRectForScreenshot(target, viewport);
|
|
483
|
+
const buf = await page.screenshot({ clip, omitBackground: true, type: "png" });
|
|
484
|
+
target.dataUri = `data:image/png;base64,${Buffer.from(buf).toString("base64")}`;
|
|
485
|
+
}
|
|
486
|
+
catch { /* leave dataUri absent */ }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
await cdp?.detach().catch(() => undefined);
|
|
491
|
+
await page.evaluate(() => {
|
|
492
|
+
for (const el of document.querySelectorAll("[data-domotion-backdrop-raster]"))
|
|
493
|
+
el.removeAttribute("data-domotion-backdrop-raster");
|
|
494
|
+
}).catch(() => undefined);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
385
497
|
export async function rasterizeBitmapGlyphs(page, tree, viewport) {
|
|
498
|
+
await rasterizeBackdropFilters(page, tree, viewport);
|
|
386
499
|
// Two kinds of candidates share the pipeline:
|
|
387
500
|
// - Segment-level rasterRect (SK-1058): the whole pseudo text is a color-
|
|
388
501
|
// bitmap run; renderer emits one <image> and skips the text path.
|
|
@@ -392,6 +505,23 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
|
|
|
392
505
|
const candidates = [];
|
|
393
506
|
const sbixAligns = [];
|
|
394
507
|
forEachElement(tree, (el) => {
|
|
508
|
+
if (el.transformSubtreeRaster != null) {
|
|
509
|
+
const tr = el.transformSubtreeRaster;
|
|
510
|
+
candidates.push({
|
|
511
|
+
rect: { x: tr.x, y: tr.y, width: tr.width, height: tr.height },
|
|
512
|
+
key: `transform-subtree|${tr.x}|${tr.y}|${tr.width}x${tr.height}`,
|
|
513
|
+
setDataUri: (uri) => { tr.dataUri = uri; },
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
if (el.nativeControlRaster != null) {
|
|
517
|
+
const nr = el.nativeControlRaster;
|
|
518
|
+
candidates.push({
|
|
519
|
+
rect: nr,
|
|
520
|
+
key: `native-control|${el.tag}|${el.styles.inputType ?? ''}|${nr.x}|${nr.y}|${nr.width}x${nr.height}`,
|
|
521
|
+
setDataUri: (uri) => { nr.dataUri = uri; },
|
|
522
|
+
snapRectToClip: true,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
395
525
|
// Element-level raster (SK-1108): textarea content region, too
|
|
396
526
|
// involved to word-wrap in the path pipeline. Key on text+size+color so
|
|
397
527
|
// identical textareas dedupe to one screenshot.
|
package/dist/capture/index.d.ts
CHANGED
|
@@ -139,7 +139,9 @@ export declare class DemoRecorder {
|
|
|
139
139
|
constructor(baseUrl: string, opts: CaptureOptions);
|
|
140
140
|
init(opts: CaptureOptions): Promise<void>;
|
|
141
141
|
/** Navigate to a URL and capture the visible DOM as SVG. */
|
|
142
|
-
captureUrl(path: string, waitMs?: number, idPrefix?: string
|
|
142
|
+
captureUrl(path: string, waitMs?: number, idPrefix?: string, opts?: {
|
|
143
|
+
networkIdle?: boolean;
|
|
144
|
+
}): Promise<string>;
|
|
143
145
|
/**
|
|
144
146
|
* Shared post-capture pipeline (DM-1434): self-contained remote-image
|
|
145
147
|
* embedding + optional resize + conic-gradient rasterization, then reset the
|
package/dist/capture/index.js
CHANGED
|
@@ -191,10 +191,10 @@ export class DemoRecorder {
|
|
|
191
191
|
this.page.setDefaultNavigationTimeout(90_000);
|
|
192
192
|
}
|
|
193
193
|
/** Navigate to a URL and capture the visible DOM as SVG. */
|
|
194
|
-
async captureUrl(path, waitMs = 800, idPrefix = "") {
|
|
194
|
+
async captureUrl(path, waitMs = 800, idPrefix = "", opts) {
|
|
195
195
|
if (this.page == null)
|
|
196
196
|
throw new Error("Call init() first");
|
|
197
|
-
await this.page.goto(`${this.baseUrl}${path}`, { waitUntil: "networkidle" });
|
|
197
|
+
await this.page.goto(`${this.baseUrl}${path}`, { waitUntil: opts?.networkIdle === true ? "networkidle" : "load" });
|
|
198
198
|
await this.page.waitForTimeout(waitMs);
|
|
199
199
|
return this.captureCurrent(idPrefix);
|
|
200
200
|
}
|
|
@@ -338,6 +338,23 @@ export const createEmojiDetect = () => {
|
|
|
338
338
|
// emoji over Chrome's text glyph.
|
|
339
339
|
if (emojiPresentation26.has(cp))
|
|
340
340
|
return isColorGlyph(cp, font);
|
|
341
|
+
// DM-2167: Emoji_Presentation is not confined to the 2600 block or the
|
|
342
|
+
// supplementary emoji planes. Misc Technical contains default-emoji
|
|
343
|
+
// characters such as WATCH, HOURGLASS and the media-control buttons
|
|
344
|
+
// (U+231A/U+231B/U+23E9..U+23F3). Linux Chromium routes these through
|
|
345
|
+
// Noto Color Emoji, but the old hand-partitioned ranges left them on the
|
|
346
|
+
// outline path, where a CBDT-only face has no vector glyph and vanished.
|
|
347
|
+
// Keep this cascade-sensitive: a leading monochrome face must still win.
|
|
348
|
+
if (cp >= 0x2300 && cp <= 0x23FF
|
|
349
|
+
&& RE_EMOJI_PRESENTATION.test(String.fromCodePoint(cp)))
|
|
350
|
+
return isColorGlyph(cp, font);
|
|
351
|
+
// The Mahjong Tiles and Playing Cards blocks are another gap below the
|
|
352
|
+
// historical U+1F300 supplementary-plane floor. Chromium paints MAHJONG
|
|
353
|
+
// TILE RED DRAGON and PLAYING CARD BLACK JOKER from the color font when
|
|
354
|
+
// that is the resolved cascade face; keep text-font coverage authoritative.
|
|
355
|
+
if (cp >= 0x1F000 && cp <= 0x1F0FF
|
|
356
|
+
&& RE_EMOJI_PRESENTATION.test(String.fromCodePoint(cp)))
|
|
357
|
+
return isColorGlyph(cp, font);
|
|
341
358
|
// U+FE0F (Variation Selector-16) after a base emoji codepoint requests
|
|
342
359
|
// emoji presentation — Chrome paints the colorful glyph instead of the
|
|
343
360
|
// text-mode path glyph. DM-278.
|
|
@@ -425,8 +442,12 @@ export const createEmojiDetect = () => {
|
|
|
425
442
|
// ⭐, ⭕). Cascade-dependent — Chrome paints text when the stack reaches a
|
|
426
443
|
// monochrome symbol/math font first (Apple Symbols / STIX Two Math), color
|
|
427
444
|
// otherwise. See `emojiPresentation2B` above.
|
|
445
|
+
// Some color-font glyphs in this set are intentionally achromatic (the
|
|
446
|
+
// black/white square pair). A saturation-only probe mistakes those bitmap
|
|
447
|
+
// glyphs for text. Color fonts ignore CSS fill while monochrome symbol
|
|
448
|
+
// fonts inherit it, so fill invariance supplies the missing discriminator.
|
|
428
449
|
if (emojiPresentation2B.has(cp))
|
|
429
|
-
return isColorGlyph(cp, font);
|
|
450
|
+
return isColorGlyph(cp, font) || ignoresFillColor(cp, font);
|
|
430
451
|
// DM-1167: the ONLY two codepoints in the Misc Symbols & Pictographs block
|
|
431
452
|
// (U+1F300-1F5FF) that a macOS text font also covers monochrome are
|
|
432
453
|
// 🌐 U+1F310 (GLOBE WITH MERIDIANS) and 🎤 U+1F3A4 (MICROPHONE) — Apple
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/** Collect Blink's CSSOM view of author @font-feature-values rules. */
|
|
3
|
+
export function collectFontFeatureValues(doc) {
|
|
4
|
+
const families = {};
|
|
5
|
+
const categories = ["annotation", "ornaments", "stylistic", "swash", "characterVariant", "styleset"];
|
|
6
|
+
function familyNames(css) {
|
|
7
|
+
const out = [];
|
|
8
|
+
let token = "", quote = "";
|
|
9
|
+
for (const ch of css) {
|
|
10
|
+
if (quote) {
|
|
11
|
+
if (ch === quote)
|
|
12
|
+
quote = "";
|
|
13
|
+
else
|
|
14
|
+
token += ch;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (ch === "\"" || ch === "'") {
|
|
18
|
+
quote = ch;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (ch === ",") {
|
|
22
|
+
if (token.trim())
|
|
23
|
+
out.push(token.trim().toLowerCase());
|
|
24
|
+
token = "";
|
|
25
|
+
}
|
|
26
|
+
else
|
|
27
|
+
token += ch;
|
|
28
|
+
}
|
|
29
|
+
if (token.trim())
|
|
30
|
+
out.push(token.trim().toLowerCase());
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function visit(rules) {
|
|
34
|
+
if (!rules)
|
|
35
|
+
return;
|
|
36
|
+
for (const rule of rules) {
|
|
37
|
+
if (typeof rule.fontFamily === "string" && rule.stylistic != null) {
|
|
38
|
+
for (const family of familyNames(rule.fontFamily)) {
|
|
39
|
+
const table = families[family] || (families[family] = {});
|
|
40
|
+
for (const category of categories) {
|
|
41
|
+
const entries = Array.from(rule[category].entries());
|
|
42
|
+
if (!entries.length)
|
|
43
|
+
continue;
|
|
44
|
+
const aliases = table[category] || (table[category] = {});
|
|
45
|
+
for (const [name, values] of entries)
|
|
46
|
+
aliases[name] = Array.from(values);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (rule.cssRules) {
|
|
51
|
+
visit(rule.cssRules);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const sheet of doc.styleSheets) {
|
|
56
|
+
try {
|
|
57
|
+
visit(sheet.cssRules);
|
|
58
|
+
}
|
|
59
|
+
catch { /* cross-origin sheet */ }
|
|
60
|
+
}
|
|
61
|
+
return families;
|
|
62
|
+
}
|
|
@@ -22,6 +22,7 @@ import { createDottedCircleDetect } from "./dotted-circle-detect.js";
|
|
|
22
22
|
import { createFontMetrics } from "./font-metrics.js";
|
|
23
23
|
import { createPlaceholderShown } from "./placeholder-shown.js";
|
|
24
24
|
import { createFontFamilyDefault } from "./font-family-default.js";
|
|
25
|
+
import { collectFontFeatureValues } from "./font-feature-values.js";
|
|
25
26
|
import { createPseudoRules } from "./pseudo-rules.js";
|
|
26
27
|
import { createWarnings } from "./warnings.js";
|
|
27
28
|
import { createCounterStyleResolver } from "./walker/counter-style-resolver.js";
|
|
@@ -48,6 +49,7 @@ const captureDocumentTree = (args) => {
|
|
|
48
49
|
// `--cross-origin-frames` value, passed in as `args.cof`). null in the
|
|
49
50
|
// default (Phase 1) configuration — only same-origin frames recurse then.
|
|
50
51
|
const _crossOriginAllow = parseCrossOriginAllowlist(args.cof);
|
|
52
|
+
let _backdropRasterSeq = 0;
|
|
51
53
|
// Wire up per-concern helpers. Each factory closes over its own state and
|
|
52
54
|
// returns the handles captureInner / the orchestration tail call. Renamed
|
|
53
55
|
// (e.g. `warnings: _warnings`) to keep captureInner's existing references
|
|
@@ -58,6 +60,15 @@ const captureDocumentTree = (args) => {
|
|
|
58
60
|
const { measureFontMetrics: _measureFontMetrics, substituteAliasedFamilies: _substituteAliasedFamilies } = createFontMetrics();
|
|
59
61
|
const { resolvePlaceholderShownBg: _resolvePlaceholderShownBg } = createPlaceholderShown();
|
|
60
62
|
const { familyIsUADefault: _familyIsUADefault } = createFontFamilyDefault();
|
|
63
|
+
const _fontFeatureValuesByDocument = new WeakMap();
|
|
64
|
+
const _fontFeatureValuesFor = (doc) => {
|
|
65
|
+
let tables = _fontFeatureValuesByDocument.get(doc);
|
|
66
|
+
if (tables == null) {
|
|
67
|
+
tables = collectFontFeatureValues(doc);
|
|
68
|
+
_fontFeatureValuesByDocument.set(doc, tables);
|
|
69
|
+
}
|
|
70
|
+
return tables;
|
|
71
|
+
};
|
|
61
72
|
const { resolvePseudo: _resolvePseudo, resolveCornerRadius: _resolveCornerRadius } = createPseudoRules();
|
|
62
73
|
const { warn, shortSelector, warnings: _warnings } = createWarnings();
|
|
63
74
|
// DM-770: counter-style map is populated by the pre-walk below (which
|
|
@@ -69,7 +80,7 @@ const captureDocumentTree = (args) => {
|
|
|
69
80
|
// against a recursed iframe's own document (`_runCounterStylePrewalk(doc)`).
|
|
70
81
|
const _runCounterStylePrewalk = createCounterStylePrewalk({ counterStyles: _counterStyles });
|
|
71
82
|
const { resolveCounterStyle, resolveCounterValue, isCustomCounterStyle } = createCounterStyleResolver({ counterStyles: _counterStyles });
|
|
72
|
-
const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle });
|
|
83
|
+
const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle, measureFontMetrics: _measureFontMetrics });
|
|
73
84
|
const { handleReplacedElement } = createReplacedElementsHandler({ vp });
|
|
74
85
|
const { discoverMasks, discoverClipPaths, discoverFilters, maskDefs: _maskDefs, maskRasters: _maskRasters, clipPathDefs: _clipPathDefs, filterDefs: _filterDefs } = createMasksClipsHandler({ vp, warn });
|
|
75
86
|
const { captureFormControls } = createFormControlsHandler({ normColor, resolvePseudo: _resolvePseudo });
|
|
@@ -206,7 +217,7 @@ const captureDocumentTree = (args) => {
|
|
|
206
217
|
// them so the fidelity gaps are self-documenting.
|
|
207
218
|
const sel = shortSelector(el);
|
|
208
219
|
if (cs.transform && cs.transform.startsWith('matrix3d')) {
|
|
209
|
-
warn(sel, 'transform-3d', '
|
|
220
|
+
warn(sel, 'transform-3d', 'non-affine 3D rendering context captured from Chromium as a bitmap snapshot');
|
|
210
221
|
}
|
|
211
222
|
if (cs.backdropFilter && cs.backdropFilter !== 'none') {
|
|
212
223
|
warn(sel, 'backdrop-filter', 'approximated via a frosted-glass background fallback for the transparent-backdrop case (doc 19); no true backdrop blur');
|
|
@@ -612,6 +623,11 @@ const captureDocumentTree = (args) => {
|
|
|
612
623
|
fontStretch: cs.fontStretch,
|
|
613
624
|
fontVariationSettings: cs.fontVariationSettings,
|
|
614
625
|
fontFeatureSettings: cs.fontFeatureSettings,
|
|
626
|
+
fontVariantAlternates: cs.fontVariantAlternates,
|
|
627
|
+
// The alias table is document-global but only alternate-bearing nodes
|
|
628
|
+
// can consume it; omit it from the common element shape to avoid
|
|
629
|
+
// repeating author rule data throughout the serialized tree.
|
|
630
|
+
fontFeatureValues: cs.fontVariantAlternates && cs.fontVariantAlternates !== 'normal' ? _fontFeatureValuesFor(el.ownerDocument) : undefined,
|
|
615
631
|
// CSS font-variant-caps. 'small-caps' / 'all-small-caps' route to
|
|
616
632
|
// the OpenType smcp feature; renderer applies synthesized small-caps
|
|
617
633
|
// when the active font lacks smcp (Helvetica, Times, etc.). DM-361.
|
|
@@ -711,6 +727,83 @@ const captureDocumentTree = (args) => {
|
|
|
711
727
|
// SK-1108 / SK-1128: textarea soft-wrap + writing-mode != horizontal-tb
|
|
712
728
|
// content-box raster rect — see walker/text-segments.ts.
|
|
713
729
|
elementRaster: computeElementRaster(el, cs, tag, rect, vp),
|
|
730
|
+
// DM-2150: SVG transforms are affine and cannot reproduce CSS
|
|
731
|
+
// perspective or preserve-3d flattening. Capture the top-level 3D
|
|
732
|
+
// rendering context as Chromium composited it. Descendant matrix3d
|
|
733
|
+
// nodes remain part of this one bitmap rather than being stamped
|
|
734
|
+
// independently. Include overflowing faces by unioning live descendant
|
|
735
|
+
// client rects before the walker freezes any child transforms.
|
|
736
|
+
transformSubtreeRaster: (function () {
|
|
737
|
+
const is3dRoot = cs.transformStyle === 'preserve-3d'
|
|
738
|
+
|| (cs.perspective != null && cs.perspective !== '' && cs.perspective !== 'none');
|
|
739
|
+
if (!is3dRoot)
|
|
740
|
+
return undefined;
|
|
741
|
+
let p = el.parentElement;
|
|
742
|
+
while (p != null) {
|
|
743
|
+
const pcs = getComputedStyle(p);
|
|
744
|
+
if (pcs.transformStyle === 'preserve-3d'
|
|
745
|
+
|| (pcs.perspective != null && pcs.perspective !== '' && pcs.perspective !== 'none'))
|
|
746
|
+
return undefined;
|
|
747
|
+
p = p.parentElement;
|
|
748
|
+
}
|
|
749
|
+
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
|
|
750
|
+
const descendants = el.getElementsByTagName('*');
|
|
751
|
+
for (let i = 0; i < descendants.length; i++) {
|
|
752
|
+
const dr = descendants[i].getBoundingClientRect();
|
|
753
|
+
if (dr.width <= 0 || dr.height <= 0)
|
|
754
|
+
continue;
|
|
755
|
+
left = Math.min(left, dr.left);
|
|
756
|
+
top = Math.min(top, dr.top);
|
|
757
|
+
right = Math.max(right, dr.right);
|
|
758
|
+
bottom = Math.max(bottom, dr.bottom);
|
|
759
|
+
}
|
|
760
|
+
return { x: left - vp.x, y: top - vp.y, width: right - left, height: bottom - top };
|
|
761
|
+
})(),
|
|
762
|
+
// DM-2149: `appearance:auto` controls are painted by Blink's platform
|
|
763
|
+
// LayoutTheme (including native shadow-DOM parts), so a single hardcoded
|
|
764
|
+
// SVG geometry/palette cannot match macOS, Windows, and Linux. Preserve
|
|
765
|
+
// author-owned `appearance:none` controls as vectors; snapshot only the
|
|
766
|
+
// native-themed host rectangle from the same Chromium doing capture.
|
|
767
|
+
nativeControlRaster: (function () {
|
|
768
|
+
const nativeTag = tag === 'input' || tag === 'select' || tag === 'textarea'
|
|
769
|
+
|| tag === 'button' || tag === 'progress' || tag === 'meter';
|
|
770
|
+
if (!nativeTag || cs.appearance === 'none' || rect.width <= 0 || rect.height <= 0)
|
|
771
|
+
return undefined;
|
|
772
|
+
// Author outlines and validation-state focus rings paint outside the
|
|
773
|
+
// host border box even though the themed control itself is native.
|
|
774
|
+
// Blink's outline visual overflow is width + positive offset; include
|
|
775
|
+
// that surface in the same snapshot instead of clipping it at `rect`.
|
|
776
|
+
const outlineWidth = parseFloat(cs.outlineWidth) || 0;
|
|
777
|
+
const outlineOffset = parseFloat(cs.outlineOffset) || 0;
|
|
778
|
+
const expand = cs.outlineStyle !== 'none' && cs.outlineStyle !== 'hidden'
|
|
779
|
+
? Math.max(0, outlineWidth + outlineOffset)
|
|
780
|
+
: 0;
|
|
781
|
+
// Skia AA coverage may extend one device-independent pixel past the
|
|
782
|
+
// layout border box (notably the lower edge of rounded author borders
|
|
783
|
+
// on otherwise native inputs). Preserve that visual-overflow fringe;
|
|
784
|
+
// the screenshot and emitted <image> use this same rect, so no scaling
|
|
785
|
+
// or fixture geometry is introduced.
|
|
786
|
+
const paintOverflow = 1;
|
|
787
|
+
const rasterExpand = expand + paintOverflow;
|
|
788
|
+
return {
|
|
789
|
+
x: rect.left - vp.x - rasterExpand,
|
|
790
|
+
y: rect.top - vp.y - rasterExpand,
|
|
791
|
+
width: rect.width + rasterExpand * 2,
|
|
792
|
+
height: rect.height + rasterExpand * 2,
|
|
793
|
+
};
|
|
794
|
+
})(),
|
|
795
|
+
// DM-2171: backdrop-filter samples already-painted content behind this
|
|
796
|
+
// element through a distinct Blink effect node. An img-rendered SVG has
|
|
797
|
+
// no equivalent input surface, so preserve Chromium's composited pixels
|
|
798
|
+
// for the complete isolation subtree at its paint-order position.
|
|
799
|
+
backdropFilterRaster: (function () {
|
|
800
|
+
const value = cs.backdropFilter || cs.webkitBackdropFilter || '';
|
|
801
|
+
if (value === '' || value === 'none' || rect.width <= 0 || rect.height <= 0)
|
|
802
|
+
return undefined;
|
|
803
|
+
const token = 'bf' + (_backdropRasterSeq++);
|
|
804
|
+
el.setAttribute('data-domotion-backdrop-raster', token);
|
|
805
|
+
return { x: rect.left - vp.x, y: rect.top - vp.y, width: rect.width, height: rect.height, token };
|
|
806
|
+
})(),
|
|
714
807
|
// DM-680: per-axis cumulative ancestor scale, exposed ONLY when
|
|
715
808
|
// anisotropic (sx ≠ sy within a small epsilon). The geometric mean is
|
|
716
809
|
// already folded into fontSize / fontAscent / fontDescent above, so
|
|
@@ -1516,23 +1609,72 @@ const captureDocumentTree = (args) => {
|
|
|
1516
1609
|
}
|
|
1517
1610
|
return out;
|
|
1518
1611
|
}
|
|
1519
|
-
//
|
|
1520
|
-
|
|
1612
|
+
// Blink keeps one stack per counter name. A counter introduced on an
|
|
1613
|
+
// element remains visible to later siblings because its originating
|
|
1614
|
+
// element's parent is still an ancestor of those siblings.
|
|
1615
|
+
const _counterStacks = new Map();
|
|
1616
|
+
const _isAncestorOrSelf = (ancestor, node) => ancestor === node || ancestor.contains(node);
|
|
1617
|
+
function _stack(name) {
|
|
1618
|
+
let stack = _counterStacks.get(name);
|
|
1619
|
+
if (stack == null) {
|
|
1620
|
+
stack = [];
|
|
1621
|
+
_counterStacks.set(name, stack);
|
|
1622
|
+
}
|
|
1623
|
+
return stack;
|
|
1624
|
+
}
|
|
1625
|
+
function _removeStale(name, el) {
|
|
1626
|
+
const stack = _stack(name);
|
|
1627
|
+
while (stack.length > 0) {
|
|
1628
|
+
const parent = stack[stack.length - 1].scopeParent;
|
|
1629
|
+
if (parent == null || _isAncestorOrSelf(parent, el))
|
|
1630
|
+
break;
|
|
1631
|
+
stack.pop();
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1521
1634
|
function _findInnermost(name) {
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1635
|
+
const stack = _stack(name);
|
|
1636
|
+
return stack.length ? stack[stack.length - 1] : null;
|
|
1637
|
+
}
|
|
1638
|
+
function _snapshotCounters() {
|
|
1639
|
+
const result = [];
|
|
1640
|
+
for (const [name, stack] of _counterStacks)
|
|
1641
|
+
for (const entry of stack)
|
|
1642
|
+
result.push({ name, value: entry.value });
|
|
1643
|
+
return result;
|
|
1644
|
+
}
|
|
1645
|
+
function _applyCounterStyle(owner, scopeParent, style) {
|
|
1646
|
+
const touched = new Set();
|
|
1647
|
+
const resets = _parseCounterDecl(style.counterReset, 0);
|
|
1648
|
+
const increments = _parseCounterDecl(style.counterIncrement, 1);
|
|
1649
|
+
const sets = _parseCounterDecl(style.counterSet, 0);
|
|
1650
|
+
for (const item of [...resets, ...increments, ...sets])
|
|
1651
|
+
touched.add(item.name);
|
|
1652
|
+
for (const name of touched)
|
|
1653
|
+
_removeStale(name, owner);
|
|
1654
|
+
for (const { name, value } of resets) {
|
|
1655
|
+
const stack = _stack(name);
|
|
1656
|
+
if (stack.length && stack[stack.length - 1].scopeParent === scopeParent)
|
|
1657
|
+
stack.pop();
|
|
1658
|
+
stack.push({ name, value, owner, scopeParent });
|
|
1525
1659
|
}
|
|
1526
|
-
|
|
1660
|
+
for (const { name, value } of increments) {
|
|
1661
|
+
const current = _findInnermost(name);
|
|
1662
|
+
if (current)
|
|
1663
|
+
current.value += value;
|
|
1664
|
+
else
|
|
1665
|
+
_stack(name).push({ name, value, owner, scopeParent });
|
|
1666
|
+
}
|
|
1667
|
+
for (const { name, value } of sets) {
|
|
1668
|
+
const current = _findInnermost(name);
|
|
1669
|
+
if (current)
|
|
1670
|
+
current.value = value;
|
|
1671
|
+
else
|
|
1672
|
+
_stack(name).push({ name, value, owner, scopeParent });
|
|
1673
|
+
}
|
|
1674
|
+
return touched;
|
|
1527
1675
|
}
|
|
1528
1676
|
function _counterPreWalk(el) {
|
|
1529
1677
|
const cs = window.getComputedStyle(el);
|
|
1530
|
-
const owned = [];
|
|
1531
|
-
_parseCounterDecl(cs.counterReset, 0).forEach(({ name, value }) => {
|
|
1532
|
-
const scope = { name, value, owner: el };
|
|
1533
|
-
_activeScopes.push(scope);
|
|
1534
|
-
owned.push(scope);
|
|
1535
|
-
});
|
|
1536
1678
|
// DM-705 / DM-706: CSS Lists 3 §2.3 ("Properties on a single element are
|
|
1537
1679
|
// processed in the order reset, increment, set") — increment runs BEFORE
|
|
1538
1680
|
// set. Our previous order (reset, set, increment) made
|
|
@@ -1540,36 +1682,28 @@ const captureDocumentTree = (args) => {
|
|
|
1540
1682
|
// section` paint as "100." instead of Chrome's "99." for the
|
|
1541
1683
|
// `.restart` h2 in `24-counters.html`. Same off-by-one (always +1) in
|
|
1542
1684
|
// `24-deep-counter-scope.html`.
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
const ns = { name, value, owner: el };
|
|
1549
|
-
_activeScopes.push(ns);
|
|
1550
|
-
owned.push(ns);
|
|
1551
|
-
}
|
|
1552
|
-
});
|
|
1553
|
-
_parseCounterDecl(cs.counterSet, 0).forEach(({ name, value }) => {
|
|
1554
|
-
const s = _findInnermost(name);
|
|
1555
|
-
if (s)
|
|
1556
|
-
s.value = value;
|
|
1557
|
-
else {
|
|
1558
|
-
const ns = { name, value, owner: el };
|
|
1559
|
-
_activeScopes.push(ns);
|
|
1560
|
-
owned.push(ns);
|
|
1561
|
-
}
|
|
1562
|
-
});
|
|
1563
|
-
// Snapshot the active scopes (shallow copy of name+value pairs).
|
|
1564
|
-
_counterSnapshot.set(el, _activeScopes.map((s) => ({ name: s.name, value: s.value })));
|
|
1685
|
+
const touched = _applyCounterStyle(el, el.parentElement, cs);
|
|
1686
|
+
const beforeStyle = window.getComputedStyle(el, '::before');
|
|
1687
|
+
for (const name of _applyCounterStyle(el, el, beforeStyle))
|
|
1688
|
+
touched.add(name);
|
|
1689
|
+
const snapshots = { element: _snapshotCounters(), '::before': _snapshotCounters(), '::after': null };
|
|
1565
1690
|
for (const child of el.children)
|
|
1566
1691
|
_counterPreWalk(child);
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1692
|
+
const afterStyle = window.getComputedStyle(el, '::after');
|
|
1693
|
+
for (const name of _applyCounterStyle(el, el, afterStyle))
|
|
1694
|
+
touched.add(name);
|
|
1695
|
+
snapshots['::after'] = _snapshotCounters();
|
|
1696
|
+
_counterSnapshot.set(el, snapshots);
|
|
1697
|
+
// Match CountersAttachmentContext::RemoveCounterIfAncestorExists: a
|
|
1698
|
+
// descendant-origin counter cannot remain atop an ancestor counter after
|
|
1699
|
+
// leaving its originating element.
|
|
1700
|
+
for (const name of touched) {
|
|
1701
|
+
const stack = _stack(name);
|
|
1702
|
+
if (stack.length < 2 || stack[stack.length - 1].owner !== el)
|
|
1703
|
+
continue;
|
|
1704
|
+
const previous = stack[stack.length - 2].owner;
|
|
1705
|
+
if (previous instanceof Element && previous.contains(el))
|
|
1706
|
+
stack.pop();
|
|
1573
1707
|
}
|
|
1574
1708
|
}
|
|
1575
1709
|
_counterPreWalk(root);
|