domotion-svg 0.2.2 → 0.3.2
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/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
|
@@ -22,6 +22,7 @@ import { createFontMetrics } from "./font-metrics.js";
|
|
|
22
22
|
import { createPlaceholderShown } from "./placeholder-shown.js";
|
|
23
23
|
import { createPseudoRules } from "./pseudo-rules.js";
|
|
24
24
|
import { createWarnings } from "./warnings.js";
|
|
25
|
+
import { createCounterStyleResolver } from "./walker/counter-style-resolver.js";
|
|
25
26
|
import { createListsCountersHandler } from "./walker/lists-counters.js";
|
|
26
27
|
import { createReplacedElementsHandler } from "./walker/replaced-elements.js";
|
|
27
28
|
import { createMasksClipsHandler } from "./walker/masks-clips.js";
|
|
@@ -39,19 +40,26 @@ export const captureScript = (args) => {
|
|
|
39
40
|
// returns the handles captureInner / the orchestration tail call. Renamed
|
|
40
41
|
// (e.g. `warnings: _warnings`) to keep captureInner's existing references
|
|
41
42
|
// unchanged.
|
|
42
|
-
const { normColor } = createColorNorm();
|
|
43
|
+
const { normColor, normGradientColors } = createColorNorm();
|
|
43
44
|
const { needsRaster, textNeedsRaster } = createEmojiDetect();
|
|
44
45
|
const { measureFontMetrics: _measureFontMetrics, substituteAliasedFamilies: _substituteAliasedFamilies } = createFontMetrics();
|
|
45
46
|
const { resolvePlaceholderShownBg: _resolvePlaceholderShownBg } = createPlaceholderShown();
|
|
46
47
|
const { resolvePseudo: _resolvePseudo, resolveCornerRadius: _resolveCornerRadius } = createPseudoRules();
|
|
47
48
|
const { warn, shortSelector, warnings: _warnings } = createWarnings();
|
|
48
|
-
|
|
49
|
+
// DM-770: counter-style map is populated by the pre-walk below (which
|
|
50
|
+
// reads @counter-style rules from document.styleSheets); declared here so
|
|
51
|
+
// the lists-counters and pseudo-content handlers close over the same
|
|
52
|
+
// object reference via the shared counter-style resolver.
|
|
53
|
+
const _counterStyles = {};
|
|
54
|
+
const { resolveCounterStyle, resolveCounterValue, isCustomCounterStyle } = createCounterStyleResolver({ counterStyles: _counterStyles });
|
|
55
|
+
const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle });
|
|
49
56
|
const { handleReplacedElement } = createReplacedElementsHandler({ vp });
|
|
50
|
-
const { discoverMasks, maskDefs: _maskDefs, maskRasters: _maskRasters } = createMasksClipsHandler({ vp, warn });
|
|
57
|
+
const { discoverMasks, discoverClipPaths, maskDefs: _maskDefs, maskRasters: _maskRasters, clipPathDefs: _clipPathDefs } = createMasksClipsHandler({ vp, warn });
|
|
51
58
|
const { captureFormControls } = createFormControlsHandler({ normColor, resolvePseudo: _resolvePseudo });
|
|
52
59
|
const { wrapWithFrozenTransform, threadFrozenTransform } = createTransformsHandler();
|
|
53
60
|
const { captureBordersBackgrounds } = createBordersBackgroundsHandler({
|
|
54
61
|
normColor,
|
|
62
|
+
normGradientColors,
|
|
55
63
|
resolvePlaceholderShownBg: _resolvePlaceholderShownBg,
|
|
56
64
|
resolveCornerRadius: _resolveCornerRadius,
|
|
57
65
|
});
|
|
@@ -60,6 +68,8 @@ export const captureScript = (args) => {
|
|
|
60
68
|
normColor,
|
|
61
69
|
measureFontMetrics: _measureFontMetrics,
|
|
62
70
|
textNeedsRaster,
|
|
71
|
+
resolveCounterValue,
|
|
72
|
+
isCustomCounterStyle,
|
|
63
73
|
});
|
|
64
74
|
const { captureInputValue } = createInputValueHandler({ vp, normColor, measureFontMetrics: _measureFontMetrics });
|
|
65
75
|
const { captureTextSegments } = createTextSegmentsHandler({ vp, measureFontMetrics: _measureFontMetrics, needsRaster });
|
|
@@ -107,6 +117,18 @@ export const captureScript = (args) => {
|
|
|
107
117
|
return null;
|
|
108
118
|
if ((cs.visibility === 'hidden' || cs.visibility === 'collapse') && !bordersOnlyCell)
|
|
109
119
|
return null;
|
|
120
|
+
// DM-750: `content-visibility: hidden` skips paint AND layout of the
|
|
121
|
+
// subtree; Chrome treats the element as a sized placeholder (driven by
|
|
122
|
+
// `contain-intrinsic-size`) with no visible children. `getBoundingClientRect`
|
|
123
|
+
// on the host still returns the placeholder box, but child rects would
|
|
124
|
+
// re-trigger layout if asked, producing rects that don't match what Chrome
|
|
125
|
+
// actually paints. Capture the host (so background / border / placeholder
|
|
126
|
+
// box land in the output) but drop the entire subtree's text + children.
|
|
127
|
+
// `content-visibility: auto` is handled implicitly — Chrome paints in-
|
|
128
|
+
// viewport `auto` sections normally, and the live-rect capture inherits
|
|
129
|
+
// that. Out-of-viewport `auto` sections are already culled by the captured
|
|
130
|
+
// viewport's bbox filter.
|
|
131
|
+
const _contentVisHidden = cs.contentVisibility === 'hidden';
|
|
110
132
|
// DM-580: standard accessibility "visually-hidden" / "sr-only" idioms.
|
|
111
133
|
// Chrome paints nothing for these (clipped to zero), but the DOM text is
|
|
112
134
|
// still present for screen readers. Without this filter the captured tree
|
|
@@ -168,6 +190,10 @@ export const captureScript = (args) => {
|
|
|
168
190
|
// Handler owns the maskDefs / maskRasters Maps that the orchestration
|
|
169
191
|
// tail consumes. See walker/masks-clips.ts.
|
|
170
192
|
discoverMasks(el, cs, sel);
|
|
193
|
+
// DM-826: clip-path: url("#id") same-document fragment refs. Sibling of
|
|
194
|
+
// the mask discovery above; collects inline <clipPath> defs the
|
|
195
|
+
// renderer copies into the output SVG. See docs/39.
|
|
196
|
+
discoverClipPaths(el, cs, sel);
|
|
171
197
|
if (cs.borderImageSource && cs.borderImageSource !== 'none') {
|
|
172
198
|
warn(sel, 'border-image', '9-slice composition pending (SK-466); border-image-source ignored');
|
|
173
199
|
}
|
|
@@ -207,7 +233,12 @@ export const captureScript = (args) => {
|
|
|
207
233
|
// padding box. The downstream text-segments assembler re-anchors
|
|
208
234
|
// seg.x/y against the captured text once shaping completes. See
|
|
209
235
|
// walker/pseudo-content.ts.
|
|
210
|
-
|
|
236
|
+
// DM-750: content-visibility:hidden hides the host's subtree, which
|
|
237
|
+
// includes generated content from ::before / ::after. Skip the pseudo
|
|
238
|
+
// capture too so the placeholder host is just an empty rect.
|
|
239
|
+
const _pcResult = _contentVisHidden
|
|
240
|
+
? { pseudoSegments: [], pseudoBoxes: [] }
|
|
241
|
+
: capturePseudoContent(el, cs, rect, _counterSnapshot);
|
|
211
242
|
const pseudoSegments = _pcResult.pseudoSegments;
|
|
212
243
|
const pseudoBoxes = _pcResult.pseudoBoxes;
|
|
213
244
|
// Skip text capture for elements where the child text is fallback content
|
|
@@ -223,7 +254,7 @@ export const captureScript = (args) => {
|
|
|
223
254
|
// (DM-246); listbox-mode selects synthesize all rows via
|
|
224
255
|
// styles.selectListboxOptions (DM-282).
|
|
225
256
|
const textIsHiddenFallback = tag === 'meter' || tag === 'progress' || tag === 'datalist' || tag === 'option' || tag === 'optgroup';
|
|
226
|
-
if (tag !== 'svg' && tag !== 'img' && !textIsHiddenFallback) {
|
|
257
|
+
if (tag !== 'svg' && tag !== 'img' && !textIsHiddenFallback && !_contentVisHidden) {
|
|
227
258
|
// Input / textarea value capture (incl. placeholder fallback, password
|
|
228
259
|
// masking, sub-pixel inputXOffsets probe, text-align shift). See
|
|
229
260
|
// walker/input-value.ts. When the handler `applied`, copy its locals
|
|
@@ -348,12 +379,47 @@ export const captureScript = (args) => {
|
|
|
348
379
|
// lost when the SVG is re-embedded outside the original cascade —
|
|
349
380
|
// computed style is resolved against the source DOM, not the clone.
|
|
350
381
|
const _bakeSvgAttrs = ['fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'opacity'];
|
|
382
|
+
// DM-720: SVG 2 promotes geometry properties (cx/cy/r/rx/ry/x/y/width/
|
|
383
|
+
// height/d) to CSS — modern Chrome resolves them from the cascade. When
|
|
384
|
+
// a fixture sets them entirely from CSS (no XML attrs on the element),
|
|
385
|
+
// the cloned subtree has no geometry and renders blank. Bake the
|
|
386
|
+
// computed values onto the clone so the emitted SVG stands on its own.
|
|
387
|
+
// We keep these in a separate list because (a) the per-tag applicability
|
|
388
|
+
// varies (circles want cx/cy/r, rects want x/y/width/height + rx/ry,
|
|
389
|
+
// paths want d) and (b) computed values need light normalisation
|
|
390
|
+
// (strip "px"; unwrap path("…") for d) before they're valid as XML
|
|
391
|
+
// presentation attributes.
|
|
392
|
+
const _bakeSvgGeomAttrs = ['cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'width', 'height', 'd'];
|
|
351
393
|
const _walkBake = (origNode, cloneNode) => {
|
|
352
394
|
if (origNode.nodeType !== 1)
|
|
353
395
|
return;
|
|
354
396
|
const ns = origNode.namespaceURI;
|
|
355
397
|
if (ns === 'http://www.w3.org/2000/svg' && origNode !== el) {
|
|
356
398
|
const ocs = window.getComputedStyle(origNode);
|
|
399
|
+
// DM-778: detect whether the source's `fill` / `stroke` was driven
|
|
400
|
+
// by `currentColor`. When the symbol is defined in a hidden <defs>
|
|
401
|
+
// <svg> and the polygon/polyline's CSS rule is `fill:
|
|
402
|
+
// currentColor` (or `stroke: currentColor`), getComputedStyle on
|
|
403
|
+
// that node resolves the value against the DEFS's cascade —
|
|
404
|
+
// typically the document body's color = black. If we baked that
|
|
405
|
+
// black literal onto the clone, every <use> consumer would paint
|
|
406
|
+
// the icon black regardless of its own host color. Probe by
|
|
407
|
+
// temporarily flipping `style.color` on the source: if `fill` /
|
|
408
|
+
// `stroke` follows, the value was driven by `currentColor` and we
|
|
409
|
+
// should preserve the keyword so `_substCurrentColor` can resolve
|
|
410
|
+
// it against the consumer's color later. Restore the source's
|
|
411
|
+
// inline color so the live page state isn't disturbed.
|
|
412
|
+
const _usesCurrentColor = (camel) => {
|
|
413
|
+
const baseVal = ocs[camel];
|
|
414
|
+
if (baseVal !== ocs.color)
|
|
415
|
+
return false;
|
|
416
|
+
const savedColor = origNode.style.color;
|
|
417
|
+
origNode.style.color = "rgb(1, 2, 3)";
|
|
418
|
+
const probeCs = window.getComputedStyle(origNode);
|
|
419
|
+
const matches = probeCs[camel] === probeCs.color;
|
|
420
|
+
origNode.style.color = savedColor;
|
|
421
|
+
return matches;
|
|
422
|
+
};
|
|
357
423
|
for (const attr of _bakeSvgAttrs) {
|
|
358
424
|
const camel = attr.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
359
425
|
const val = ocs[camel];
|
|
@@ -363,7 +429,54 @@ export const captureScript = (args) => {
|
|
|
363
429
|
// cascade and lose their resolution outside it, so we replace
|
|
364
430
|
// them with the resolved computed value.
|
|
365
431
|
if (val != null && val !== '' && !_hasConcreteAttr(origNode, attr)) {
|
|
366
|
-
|
|
432
|
+
// DM-778: preserve `currentColor` for `fill` / `stroke` when
|
|
433
|
+
// the source rule uses it, so the consumer's color cascades
|
|
434
|
+
// through the inlined symbol.
|
|
435
|
+
const preserveCurrent = (attr === "fill" || attr === "stroke") && _usesCurrentColor(camel);
|
|
436
|
+
cloneNode.setAttribute(attr, preserveCurrent ? "currentColor" : val);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// DM-720: bake CSS-driven geometry. Skip when the source has a
|
|
440
|
+
// concrete XML attr — Chrome's per-property precedence is "CSS wins
|
|
441
|
+
// over the presentation attribute" since SVG 2, but the computed
|
|
442
|
+
// value reflects that already, so writing it to the clone preserves
|
|
443
|
+
// the same painted geometry. Strip "px" suffixes and unwrap d's
|
|
444
|
+
// path() wrapper so the values parse as XML presentation attrs.
|
|
445
|
+
for (const gattr of _bakeSvgGeomAttrs) {
|
|
446
|
+
if (_hasConcreteAttr(origNode, gattr))
|
|
447
|
+
continue;
|
|
448
|
+
let gval = ocs.getPropertyValue(gattr);
|
|
449
|
+
if (gval == null)
|
|
450
|
+
continue;
|
|
451
|
+
gval = gval.trim();
|
|
452
|
+
if (gval === '' || gval === 'auto' || gval === 'none' || gval === 'normal')
|
|
453
|
+
continue;
|
|
454
|
+
if (gattr === 'd') {
|
|
455
|
+
// Computed `d` is wrapped as `path("M …")`. Unwrap to bare data.
|
|
456
|
+
const m = /^path\(\s*(?:"([^"]*)"|'([^']*)')\s*\)$/.exec(gval);
|
|
457
|
+
if (m)
|
|
458
|
+
gval = m[1] != null ? m[1] : m[2];
|
|
459
|
+
else
|
|
460
|
+
continue; // not a recognized path() form
|
|
461
|
+
}
|
|
462
|
+
else if (/^-?\d+(?:\.\d+)?px$/.test(gval)) {
|
|
463
|
+
gval = gval.slice(0, -2);
|
|
464
|
+
}
|
|
465
|
+
cloneNode.setAttribute(gattr, gval);
|
|
466
|
+
}
|
|
467
|
+
// DM-815: `<mask mask-type="…">` is a presentation attribute that
|
|
468
|
+
// CSS can override (e.g. `svg .alpha-test { mask-type: alpha }`).
|
|
469
|
+
// Bake the computed value as an attribute on cloned `<mask>` nodes
|
|
470
|
+
// so the emitted standalone SVG renders the mask with the
|
|
471
|
+
// intended semantics — without it, alpha-driven masks (gradient
|
|
472
|
+
// with stop-opacity transitions on solid black) decode as
|
|
473
|
+
// luminance and paint nothing.
|
|
474
|
+
if (origNode.tagName && origNode.tagName.toLowerCase() === 'mask') {
|
|
475
|
+
const mt = ocs.maskType || ocs.getPropertyValue('mask-type');
|
|
476
|
+
if (mt === 'alpha' || mt === 'luminance') {
|
|
477
|
+
if (!origNode.hasAttribute('mask-type') || origNode.getAttribute('mask-type') !== mt) {
|
|
478
|
+
cloneNode.setAttribute('mask-type', mt);
|
|
479
|
+
}
|
|
367
480
|
}
|
|
368
481
|
}
|
|
369
482
|
// DM-508: bake CSS-animated transforms at t=0. CSS animation /
|
|
@@ -378,20 +491,45 @@ export const captureScript = (args) => {
|
|
|
378
491
|
// origin px values are relative to the element's bounding box. We
|
|
379
492
|
// read those from getComputedStyle().transformOrigin and compose.
|
|
380
493
|
var transformVal = ocs.transform;
|
|
381
|
-
|
|
494
|
+
// DM-676: only bake when the SOURCE node has no static `transform=`
|
|
495
|
+
// attribute. The bake exists to capture CSS-animated transforms at
|
|
496
|
+
// t=0 (DM-508). When the node already has a literal `transform=`
|
|
497
|
+
// attribute, the existing attribute IS the source of truth — Chrome
|
|
498
|
+
// resolves it through transform-origin/transform-box at paint time,
|
|
499
|
+
// and the consumer browser will apply the same resolution. Baking
|
|
500
|
+
// a composed origin-anchored matrix on top double-applies the
|
|
501
|
+
// origin and shifts the rect.
|
|
502
|
+
var hasStaticTransformAttr = origNode.hasAttribute('transform') && !_isUnresolvedCssExpr(origNode.getAttribute('transform'));
|
|
503
|
+
if (!hasStaticTransformAttr && transformVal != null && transformVal !== '' && transformVal !== 'none') {
|
|
382
504
|
var transformOriginVal = ocs.transformOrigin || '0 0';
|
|
383
505
|
var originParts = transformOriginVal.trim().split(/\s+/);
|
|
384
506
|
var ox = parseFloat(originParts[0] || '0') || 0;
|
|
385
507
|
var oy = parseFloat(originParts[1] || '0') || 0;
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
508
|
+
// DM-752: route through `transform-box` to convert origin px values
|
|
509
|
+
// from the reference box's local coord space into SVG user space.
|
|
510
|
+
// Chrome's `getComputedStyle().transformOrigin` returns px values
|
|
511
|
+
// relative to the resolved transform-box:
|
|
512
|
+
// - `fill-box` (SVG default): bbox-local → add `bbox.x / bbox.y`.
|
|
513
|
+
// - `stroke-box`: stroke-bbox-local. Stroke-bbox is the geometry
|
|
514
|
+
// bbox extended by `stroke-width / 2` on each side, so
|
|
515
|
+
// stroke-bbox.x = bbox.x - sw/2, stroke-bbox.y = bbox.y - sw/2.
|
|
516
|
+
// - `view-box`: already in viewBox / user space coords; no shift.
|
|
517
|
+
// - `content-box` / `border-box`: HTML-only; SVG-side bake doesn't
|
|
518
|
+
// hit these (the HTML transform path applies them separately).
|
|
519
|
+
// Without this, `transform-box: view-box` rotated around the wrong
|
|
520
|
+
// anchor (the rect's bbox top-left instead of the viewBox center)
|
|
521
|
+
// and `transform-box: stroke-box` was off by `stroke-width / 2`.
|
|
522
|
+
var transformBoxVal = ocs.transformBox || 'fill-box';
|
|
390
523
|
try {
|
|
391
|
-
if (typeof origNode.getBBox === 'function') {
|
|
524
|
+
if (typeof origNode.getBBox === 'function' && transformBoxVal !== 'view-box') {
|
|
392
525
|
var bbox = origNode.getBBox();
|
|
393
526
|
ox += bbox.x;
|
|
394
527
|
oy += bbox.y;
|
|
528
|
+
if (transformBoxVal === 'stroke-box') {
|
|
529
|
+
var swPx = parseFloat(ocs.strokeWidth || '0') || 0;
|
|
530
|
+
ox -= swPx / 2;
|
|
531
|
+
oy -= swPx / 2;
|
|
532
|
+
}
|
|
395
533
|
}
|
|
396
534
|
}
|
|
397
535
|
catch (e) { /* element not yet in render tree, fall through */ }
|
|
@@ -469,22 +607,22 @@ export const captureScript = (args) => {
|
|
|
469
607
|
// symbol target — we let SVG do the math.
|
|
470
608
|
var vb = target.getAttribute('viewBox') || '';
|
|
471
609
|
var par = target.getAttribute('preserveAspectRatio') || '';
|
|
472
|
-
|
|
610
|
+
var innerSvg = document.createElementNS(_svgNS, 'svg');
|
|
473
611
|
if (ux !== 0)
|
|
474
|
-
|
|
612
|
+
innerSvg.setAttribute('x', String(ux));
|
|
475
613
|
if (uy !== 0)
|
|
476
|
-
|
|
614
|
+
innerSvg.setAttribute('y', String(uy));
|
|
477
615
|
if (uw != null)
|
|
478
|
-
|
|
616
|
+
innerSvg.setAttribute('width', uw);
|
|
479
617
|
if (uh != null)
|
|
480
|
-
|
|
618
|
+
innerSvg.setAttribute('height', uh);
|
|
481
619
|
if (vb !== '')
|
|
482
|
-
|
|
620
|
+
innerSvg.setAttribute('viewBox', vb);
|
|
483
621
|
if (par !== '')
|
|
484
|
-
|
|
622
|
+
innerSvg.setAttribute('preserveAspectRatio', par);
|
|
485
623
|
for (var ci = 0; ci < target.children.length; ci++) {
|
|
486
624
|
var clonedChild = target.children[ci].cloneNode(true);
|
|
487
|
-
|
|
625
|
+
innerSvg.appendChild(clonedChild);
|
|
488
626
|
// DM-508: bake t=0 computed styles on the inlined subtree.
|
|
489
627
|
// The hidden-defs symbol's children carry CSS animations whose
|
|
490
628
|
// computed values (transform, fill, opacity, etc.) reflect the
|
|
@@ -492,13 +630,40 @@ export const captureScript = (args) => {
|
|
|
492
630
|
// original DOM as source captures those values.
|
|
493
631
|
_walkBake(target.children[ci], clonedChild);
|
|
494
632
|
}
|
|
633
|
+
// DM-778: thread the <use>'s own transform around the inlined
|
|
634
|
+
// nested <svg>. Per SVG 2 §5.6 the use's `transform` attribute
|
|
635
|
+
// applies to the inlined shadow tree; SVG's `<svg>` element does
|
|
636
|
+
// not directly take a `transform` attribute in legacy SVG 1.1
|
|
637
|
+
// renderers, so wrap in a `<g transform>` to be safe. Without
|
|
638
|
+
// this the `<use href="#badge" transform="scale(0.6)">` form in
|
|
639
|
+
// `07-deep-svg-use-href` rendered the badge at full size,
|
|
640
|
+
// duplicating the un-scaled pill on top of the in-place pill.
|
|
641
|
+
var useTransformAttrSym = useEl.getAttribute('transform') || '';
|
|
642
|
+
if (useTransformAttrSym !== '') {
|
|
643
|
+
replacement = document.createElementNS(_svgNS, 'g');
|
|
644
|
+
replacement.setAttribute('transform', useTransformAttrSym);
|
|
645
|
+
replacement.appendChild(innerSvg);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
replacement = innerSvg;
|
|
649
|
+
}
|
|
495
650
|
}
|
|
496
651
|
else {
|
|
497
|
-
// <g>, <path>, <circle>, <svg>, etc. — wrap in <g
|
|
498
|
-
//
|
|
652
|
+
// <g>, <path>, <circle>, <svg>, etc. — wrap in <g transform>.
|
|
653
|
+
// Per SVG 2 §5.6 the `<use>` element's own `transform` attribute
|
|
654
|
+
// applies to the inlined shadow tree, with the use's x/y
|
|
655
|
+
// translate happening INSIDE that transform. So compose:
|
|
656
|
+
// composedTransform = useTransform + translate(x, y)
|
|
657
|
+
// Skip pieces that are no-ops to keep the markup tidy. Without
|
|
658
|
+
// this, `<use transform="scale(1.2)" x="80" y="150">` would
|
|
659
|
+
// inline as plain `translate(80, 150)` and the scale would
|
|
660
|
+
// silently disappear (DM-675).
|
|
499
661
|
replacement = document.createElementNS(_svgNS, 'g');
|
|
500
|
-
|
|
501
|
-
|
|
662
|
+
var useTransformAttr = useEl.getAttribute('transform') || '';
|
|
663
|
+
var translatePart = (ux !== 0 || uy !== 0) ? ('translate(' + ux + ',' + uy + ')') : '';
|
|
664
|
+
var composedTransform = (useTransformAttr + ' ' + translatePart).trim();
|
|
665
|
+
if (composedTransform !== '') {
|
|
666
|
+
replacement.setAttribute('transform', composedTransform);
|
|
502
667
|
}
|
|
503
668
|
var clonedTarget = target.cloneNode(true);
|
|
504
669
|
// Drop the id on the clone — keeping it would create a duplicate
|
|
@@ -510,6 +675,28 @@ export const captureScript = (args) => {
|
|
|
510
675
|
replacement.appendChild(clonedTarget);
|
|
511
676
|
// DM-508: bake t=0 computed styles on the inlined target subtree.
|
|
512
677
|
_walkBake(target, clonedTarget);
|
|
678
|
+
// When the target itself is an `<svg>` (the framer.com toolbar
|
|
679
|
+
// pattern: `<use href="#svgID">` → `<svg viewBox="0 0 20 20"
|
|
680
|
+
// id="svgID"><path .../></svg>` living in a hidden defs container
|
|
681
|
+
// with `width: 0; height: 0`), the bake above writes `width="0"
|
|
682
|
+
// height="0"` onto the cloned svg from the source's computed
|
|
683
|
+
// style. That collapses the inlined inner viewport and the icon
|
|
684
|
+
// paints nothing inside its parent — even though Chrome paints
|
|
685
|
+
// it correctly because the live `<use>` consumer's viewport
|
|
686
|
+
// (the outer svg inside the page's regular flow) gives the icon
|
|
687
|
+
// its 14×14 / 20×20 space. Strip baked zero width/height on the
|
|
688
|
+
// cloned target so the nested svg defaults to 100%/100% of its
|
|
689
|
+
// parent viewport, matching Chrome's behavior. Don't touch non-
|
|
690
|
+
// zero baked values — those came from a legitimately-sized source
|
|
691
|
+
// and reflect Chrome's intent.
|
|
692
|
+
if (clonedTarget.tagName && clonedTarget.tagName.toLowerCase() === 'svg' && clonedTarget.removeAttribute) {
|
|
693
|
+
if (!_hasConcreteAttr(target, 'width') && /^0(?:\.0+)?$/.test(clonedTarget.getAttribute('width') || '')) {
|
|
694
|
+
clonedTarget.removeAttribute('width');
|
|
695
|
+
}
|
|
696
|
+
if (!_hasConcreteAttr(target, 'height') && /^0(?:\.0+)?$/.test(clonedTarget.getAttribute('height') || '')) {
|
|
697
|
+
clonedTarget.removeAttribute('height');
|
|
698
|
+
}
|
|
699
|
+
}
|
|
513
700
|
}
|
|
514
701
|
// Carry over any presentation attrs from the <use> element. CSS
|
|
515
702
|
// spec: attributes on <use> override the same attribute on the
|
|
@@ -551,22 +738,30 @@ export const captureScript = (args) => {
|
|
|
551
738
|
svgContent = clone.outerHTML;
|
|
552
739
|
}
|
|
553
740
|
const children = [];
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
// <select> renders its own listbox/dropdown via the form-control
|
|
561
|
-
// synth; recursively capturing <option>/<optgroup> children would
|
|
562
|
-
// emit their own background rects and stack them on top of the
|
|
563
|
-
// synth output, hiding the option text. Skip them. (DM-355)
|
|
564
|
-
if (tag === 'select' && (child.tagName.toLowerCase() === 'option' || child.tagName.toLowerCase() === 'optgroup'))
|
|
565
|
-
continue;
|
|
566
|
-
const c = capture(child);
|
|
567
|
-
if (c)
|
|
568
|
-
children.push(c);
|
|
741
|
+
// DM-750: see the `content-visibility: hidden` note above — capture the
|
|
742
|
+
// host's own box (background / border / placeholder) but drop the subtree
|
|
743
|
+
// entirely. Skip the whole `for (child of el.children)` loop so neither
|
|
744
|
+
// children nor their text gets pushed.
|
|
745
|
+
if (_contentVisHidden) {
|
|
746
|
+
// fall through to the rest of the capture with `children = []`.
|
|
569
747
|
}
|
|
748
|
+
else
|
|
749
|
+
for (const child of el.children) {
|
|
750
|
+
// Closed <details> hides non-<summary> children visually. getBoundingClientRect
|
|
751
|
+
// still returns their rects and cs.display isn't 'none', so we explicitly
|
|
752
|
+
// skip non-summary children when the parent details is closed.
|
|
753
|
+
if (tag === 'details' && !el.open && child.tagName.toLowerCase() !== 'summary')
|
|
754
|
+
continue;
|
|
755
|
+
// <select> renders its own listbox/dropdown via the form-control
|
|
756
|
+
// synth; recursively capturing <option>/<optgroup> children would
|
|
757
|
+
// emit their own background rects and stack them on top of the
|
|
758
|
+
// synth output, hiding the option text. Skip them. (DM-355)
|
|
759
|
+
if (tag === 'select' && (child.tagName.toLowerCase() === 'option' || child.tagName.toLowerCase() === 'optgroup'))
|
|
760
|
+
continue;
|
|
761
|
+
const c = capture(child);
|
|
762
|
+
if (c)
|
|
763
|
+
children.push(c);
|
|
764
|
+
}
|
|
570
765
|
const _animId = el.dataset != null ? el.dataset.domotionAnim : undefined;
|
|
571
766
|
// <fieldset> with a top-aligned <legend>: Chrome's UA fieldset paints its
|
|
572
767
|
// top border at the legend's vertical center, with a notch cut in the
|
|
@@ -613,6 +808,12 @@ export const captureScript = (args) => {
|
|
|
613
808
|
...captureBordersBackgrounds(el, cs, tag, rect, isPlaceholderCapture),
|
|
614
809
|
overflowX: cs.overflowX,
|
|
615
810
|
overflowY: cs.overflowY,
|
|
811
|
+
// DM-761: `overflow-clip-margin` extends the overflow clip outward
|
|
812
|
+
// from a reference box (content / padding / border) by a length.
|
|
813
|
+
// Only meaningful for `overflow: clip`; `hidden` ignores it. Captured
|
|
814
|
+
// as the resolved string ("20px" / "content-box 12px") so the renderer
|
|
815
|
+
// can parse the reference-box keyword + length together.
|
|
816
|
+
overflowClipMargin: cs.overflowClipMargin || undefined,
|
|
616
817
|
scrollbarGutter: cs.scrollbarGutter || 'auto',
|
|
617
818
|
scrollWidth: el.scrollWidth,
|
|
618
819
|
scrollHeight: el.scrollHeight,
|
|
@@ -633,6 +834,44 @@ export const captureScript = (args) => {
|
|
|
633
834
|
maskPosition: cs.maskPosition || cs.webkitMaskPosition || '0% 0%',
|
|
634
835
|
maskRepeat: cs.maskRepeat || cs.webkitMaskRepeat || 'repeat',
|
|
635
836
|
maskComposite: cs.maskComposite || cs.webkitMaskComposite || 'add',
|
|
837
|
+
maskClip: cs.maskClip || cs.webkitMaskClip || 'border-box',
|
|
838
|
+
// DM-758: `mask-border-source` / legacy `-webkit-mask-box-image`. Chrome
|
|
839
|
+
// exposes only the legacy webkit name; modern `maskBorderSource`
|
|
840
|
+
// returns undefined. Capture source + slice / width / outset so the
|
|
841
|
+
// renderer can decide whether to route through the simplified
|
|
842
|
+
// full-element mask path (only safe when width / outset both `0`).
|
|
843
|
+
maskBorderSource: cs.webkitMaskBoxImageSource && cs.webkitMaskBoxImageSource !== 'none'
|
|
844
|
+
? cs.webkitMaskBoxImageSource
|
|
845
|
+
: undefined,
|
|
846
|
+
maskBorderSlice: cs.webkitMaskBoxImageSlice || undefined,
|
|
847
|
+
maskBorderWidth: cs.webkitMaskBoxImageWidth || undefined,
|
|
848
|
+
maskBorderOutset: cs.webkitMaskBoxImageOutset || undefined,
|
|
849
|
+
// DM-793: legacy `-webkit-mask-box-image-repeat` keyword (stretch /
|
|
850
|
+
// repeat / round / space) per axis. Mirrors `border-image-repeat`.
|
|
851
|
+
maskBorderRepeat: cs.webkitMaskBoxImageRepeat || undefined,
|
|
852
|
+
// DM-793: intrinsic dimensions of the mask-border-source raster /
|
|
853
|
+
// SVG asset. Same probe pattern as `borderImageIntrinsic*` — a
|
|
854
|
+
// detached `<img>` resolves the URL against the document base and
|
|
855
|
+
// reports `naturalWidth` / `naturalHeight` for raster sources and
|
|
856
|
+
// the `<svg width/height>` attributes (or viewBox-derived size) for
|
|
857
|
+
// SVG sources. Captured at capture time so the renderer can compute
|
|
858
|
+
// 9-slice source rects without re-fetching the asset.
|
|
859
|
+
maskBorderIntrinsicWidth: (function () {
|
|
860
|
+
var _m = /^url\((?:"|')?([^"')]+)/.exec(cs.webkitMaskBoxImageSource || '');
|
|
861
|
+
if (_m == null)
|
|
862
|
+
return undefined;
|
|
863
|
+
var _img = new Image();
|
|
864
|
+
_img.src = _m[1];
|
|
865
|
+
return _img.naturalWidth || undefined;
|
|
866
|
+
})(),
|
|
867
|
+
maskBorderIntrinsicHeight: (function () {
|
|
868
|
+
var _m = /^url\((?:"|')?([^"')]+)/.exec(cs.webkitMaskBoxImageSource || '');
|
|
869
|
+
if (_m == null)
|
|
870
|
+
return undefined;
|
|
871
|
+
var _img = new Image();
|
|
872
|
+
_img.src = _m[1];
|
|
873
|
+
return _img.naturalHeight || undefined;
|
|
874
|
+
})(),
|
|
636
875
|
listStyleType: cs.listStyleType,
|
|
637
876
|
listStyleImage: cs.listStyleImage,
|
|
638
877
|
display: cs.display,
|
|
@@ -686,7 +925,7 @@ export const captureScript = (args) => {
|
|
|
686
925
|
var _fs = parseFloat(cs.fontSize);
|
|
687
926
|
if (!isFinite(_fs))
|
|
688
927
|
return cs.fontSize;
|
|
689
|
-
var _s =
|
|
928
|
+
var _s = _scaleMag(el);
|
|
690
929
|
if (_s === 1)
|
|
691
930
|
return cs.fontSize;
|
|
692
931
|
return (_fs * _s).toFixed(4) + 'px';
|
|
@@ -738,8 +977,8 @@ export const captureScript = (args) => {
|
|
|
738
977
|
// captured fontSize. Otherwise the renderer's baseline math reads
|
|
739
978
|
// unscaled ascent values, and glyphs sit too far below their captured
|
|
740
979
|
// bbox top inside a `transform: scale(<1)` container.
|
|
741
|
-
fontAscent: fontAscent != null ? fontAscent * (
|
|
742
|
-
fontDescent: fontDescent != null ? fontDescent * (
|
|
980
|
+
fontAscent: fontAscent != null ? fontAscent * _scaleMag(el) : fontAscent,
|
|
981
|
+
fontDescent: fontDescent != null ? fontDescent * _scaleMag(el) : fontDescent,
|
|
743
982
|
inputXOffsets,
|
|
744
983
|
textImageUri, textImageScale,
|
|
745
984
|
// Placeholder metadata (SK-1097 / SK-1100 / SK-1099): captured in
|
|
@@ -752,7 +991,108 @@ export const captureScript = (args) => {
|
|
|
752
991
|
// SK-1108 / SK-1128: textarea soft-wrap + writing-mode != horizontal-tb
|
|
753
992
|
// content-box raster rect — see walker/text-segments.ts.
|
|
754
993
|
elementRaster: computeElementRaster(el, cs, tag, rect, vp),
|
|
994
|
+
// DM-680: per-axis cumulative ancestor scale, exposed ONLY when
|
|
995
|
+
// anisotropic (sx ≠ sy within a small epsilon). The geometric mean is
|
|
996
|
+
// already folded into fontSize / fontAscent / fontDescent above, so
|
|
997
|
+
// the renderer's text-emission path only needs to apply a per-axis
|
|
998
|
+
// correction transform when the two axes diverge. Emitting these on
|
|
999
|
+
// every transformed element would add noise to the captured tree.
|
|
1000
|
+
...(function () {
|
|
1001
|
+
const _s = _scaleXY(el);
|
|
1002
|
+
const _sx = _s[0], _sy = _s[1];
|
|
1003
|
+
if (Math.abs(_sx - _sy) > 1e-4)
|
|
1004
|
+
return { cumScaleX: _sx, cumScaleY: _sy };
|
|
1005
|
+
return {};
|
|
1006
|
+
})(),
|
|
755
1007
|
};
|
|
1008
|
+
// Elements that fragment into multiple paint boxes need per-fragment
|
|
1009
|
+
// paint of background + border, not a single rect covering the bbox
|
|
1010
|
+
// (the bbox is the union of every fragment and produces an over-wide /
|
|
1011
|
+
// over-tall shape that paints across the gap between fragments).
|
|
1012
|
+
// Trigger when:
|
|
1013
|
+
// 1. The element has a non-transparent background OR a non-zero border
|
|
1014
|
+
// width on any side, AND
|
|
1015
|
+
// 2. `el.getClientRects()` returns more than one rect, AND
|
|
1016
|
+
// 3. The element is either
|
|
1017
|
+
// (a) `display: inline` and wrapped onto multiple lines, OR
|
|
1018
|
+
// (b) DM-754: block-level (block / list-item / flex / grid /
|
|
1019
|
+
// flow-root) inside a multi-column container ancestor —
|
|
1020
|
+
// `column-count > 1` or `column-width: <length>` — where a
|
|
1021
|
+
// tall block fragments at the column boundary.
|
|
1022
|
+
// Without the `display` / ancestor-column guard we'd trip on table cells
|
|
1023
|
+
// and other layouts where Chrome legitimately reports multiple client
|
|
1024
|
+
// rects for an axis-aligned bbox (e.g. SVG paint shapes); restrict to
|
|
1025
|
+
// the two known fragmentation cases.
|
|
1026
|
+
//
|
|
1027
|
+
// The renderer reads `inlineFragments`, detects axis from frag geometry
|
|
1028
|
+
// (block-axis when fragments stack vertically, inline-axis when they
|
|
1029
|
+
// stack horizontally), and walks per-fragment with the right
|
|
1030
|
+
// `box-decoration-break` slice/clone semantics for that axis.
|
|
1031
|
+
{
|
|
1032
|
+
var _bgC = _captured.styles.backgroundColor;
|
|
1033
|
+
var _hasBg = _bgC != null && _bgC !== '' && _bgC !== 'transparent' && _bgC !== 'rgba(0, 0, 0, 0)';
|
|
1034
|
+
var _hasBgImage = _captured.styles.backgroundImage != null
|
|
1035
|
+
&& _captured.styles.backgroundImage !== '' && _captured.styles.backgroundImage !== 'none';
|
|
1036
|
+
var _btw = parseFloat(_captured.styles.borderTopWidth || '0') || 0;
|
|
1037
|
+
var _brw = parseFloat(_captured.styles.borderRightWidth || '0') || 0;
|
|
1038
|
+
var _bbw = parseFloat(_captured.styles.borderBottomWidth || '0') || 0;
|
|
1039
|
+
var _blw = parseFloat(_captured.styles.borderLeftWidth || '0') || 0;
|
|
1040
|
+
var _hasBorder = _btw > 0 || _brw > 0 || _bbw > 0 || _blw > 0;
|
|
1041
|
+
var _hasPaint = _hasBg || _hasBgImage || _hasBorder;
|
|
1042
|
+
var _isInline = cs.display === 'inline';
|
|
1043
|
+
var _isBlockLevel = !_isInline && (cs.display === 'block' || cs.display === 'list-item' || cs.display === 'flex'
|
|
1044
|
+
|| cs.display === 'grid' || cs.display === 'flow-root'
|
|
1045
|
+
|| cs.display === 'inline-block' || cs.display === 'inline-flex' || cs.display === 'inline-grid');
|
|
1046
|
+
var _inMultiColumn = false;
|
|
1047
|
+
if (_isBlockLevel && _hasPaint) {
|
|
1048
|
+
// Walk ancestors looking for a multi-column container. `column-count`
|
|
1049
|
+
// is the most common; `column-width: <length>` also creates columns.
|
|
1050
|
+
// Stop at <body> (no column container above that level in practice).
|
|
1051
|
+
var _a = el.parentElement;
|
|
1052
|
+
while (_a != null) {
|
|
1053
|
+
var _ac = window.getComputedStyle(_a);
|
|
1054
|
+
var _cc = parseInt(_ac.columnCount, 10);
|
|
1055
|
+
var _cw = _ac.columnWidth;
|
|
1056
|
+
if ((Number.isFinite(_cc) && _cc > 1) || (_cw != null && _cw !== 'auto' && _cw !== '' && _cw !== 'normal')) {
|
|
1057
|
+
_inMultiColumn = true;
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
if (_a === document.body)
|
|
1061
|
+
break;
|
|
1062
|
+
_a = _a.parentElement;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (_hasPaint && (_isInline || _inMultiColumn)) {
|
|
1066
|
+
var _cr = el.getClientRects();
|
|
1067
|
+
if (_cr != null && _cr.length > 1) {
|
|
1068
|
+
var _frags = [];
|
|
1069
|
+
for (var _ci = 0; _ci < _cr.length; _ci++) {
|
|
1070
|
+
var _f = _cr[_ci];
|
|
1071
|
+
// Skip zero-area fragments — Chrome occasionally emits these for
|
|
1072
|
+
// empty trailing inline runs.
|
|
1073
|
+
if (_f.width <= 0 || _f.height <= 0)
|
|
1074
|
+
continue;
|
|
1075
|
+
_frags.push({
|
|
1076
|
+
x: _f.left - vp.x,
|
|
1077
|
+
y: _f.top - vp.y,
|
|
1078
|
+
width: _f.width,
|
|
1079
|
+
height: _f.height,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
if (_frags.length > 1) {
|
|
1083
|
+
_captured.inlineFragments = _frags;
|
|
1084
|
+
// DM-754: stash the fragmentation axis derived from `display`.
|
|
1085
|
+
// Inline-wrap (e.g. `<span>` wrapping across line boxes) slices
|
|
1086
|
+
// horizontally — first owns the left side, last owns the right.
|
|
1087
|
+
// Block-level fragmentation inside a multi-column container
|
|
1088
|
+
// slices vertically — first owns the top, last owns the bottom.
|
|
1089
|
+
// Both axes produce vertically-stacked frag rects so we can't
|
|
1090
|
+
// distinguish them geometrically at render time.
|
|
1091
|
+
_captured.fragmentAxis = _isInline ? 'inline' : 'block';
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
756
1096
|
// DM-450: hidden/collapsed table cell — keep the cell's box + borders so
|
|
757
1097
|
// shared edges of the collapsed table grid still paint, but suppress
|
|
758
1098
|
// text, children, and background fill (per CSS visibility:hidden).
|
|
@@ -852,13 +1192,19 @@ export const captureScript = (args) => {
|
|
|
852
1192
|
// sqrt(|a*d|) which is exact for pure scale and 1 for pure rotation — the
|
|
853
1193
|
// error grows for combined rotate+scale but no real-world fixture exercises
|
|
854
1194
|
// that on text-bearing elements. Translations contribute scale=1.
|
|
1195
|
+
// DM-680: cumulative ancestor scale is captured PER AXIS (sx, sy). The
|
|
1196
|
+
// map value is `[sx, sy]`. Geometric-mean magnitude is still used to
|
|
1197
|
+
// pre-scale fontSize / fontAscent / fontDescent (so the SVG re-rasterizer
|
|
1198
|
+
// sees Chrome-equivalent font metrics in the common uniform case). When
|
|
1199
|
+
// the scale is anisotropic (sx ≠ sy, e.g. `transform: scale(1.3, 0.8)`),
|
|
1200
|
+
// we also expose `cumScaleX` / `cumScaleY` on the captured element so the
|
|
1201
|
+
// renderer can wrap text in a correction `<g transform="scale(cx, cy)">`
|
|
1202
|
+
// pivoted around the text origin — matching how Chrome paints glyphs
|
|
1203
|
+
// into the post-transform device space with per-axis scaling.
|
|
855
1204
|
const _cumulativeScale = new Map();
|
|
856
1205
|
const _computeOwnScale = (_tt) => {
|
|
857
1206
|
if (_tt == null || _tt === 'none' || _tt === '')
|
|
858
|
-
return 1;
|
|
859
|
-
// matrix(a, b, c, d, e, f) — a/d are the scale-along-x / scale-along-y
|
|
860
|
-
// diagonal entries. matrix3d(...) downgrades to its 2D submatrix elements
|
|
861
|
-
// m11/m22 (indexes 0 / 5) — same as a / d.
|
|
1207
|
+
return [1, 1];
|
|
862
1208
|
const _m2 = /^matrix\(\s*([-\d.eE+]+)\s*,\s*([-\d.eE+]+)\s*,\s*([-\d.eE+]+)\s*,\s*([-\d.eE+]+)/.exec(_tt);
|
|
863
1209
|
let _sa = 1, _sd = 1;
|
|
864
1210
|
if (_m2 != null) {
|
|
@@ -874,28 +1220,56 @@ export const captureScript = (args) => {
|
|
|
874
1220
|
}
|
|
875
1221
|
}
|
|
876
1222
|
if (!isFinite(_sa) || !isFinite(_sd))
|
|
877
|
-
return 1;
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
return _s > 0 ? _s : 1;
|
|
1223
|
+
return [1, 1];
|
|
1224
|
+
const _sx = Math.abs(_sa) > 0 ? Math.abs(_sa) : 1;
|
|
1225
|
+
const _sy = Math.abs(_sd) > 0 ? Math.abs(_sd) : 1;
|
|
1226
|
+
return [_sx, _sy];
|
|
882
1227
|
};
|
|
883
|
-
// Map every transformed element to its own scale, then walk descendants
|
|
884
|
-
// multiplying. Doing this top-down via parentNode + memoization avoids the
|
|
885
|
-
// O(n*depth) cost of querying ancestors per element.
|
|
886
1228
|
for (let _si = 0; _si < _allEls.length; _si++) {
|
|
887
1229
|
const _el = _allEls[_si];
|
|
888
|
-
let
|
|
1230
|
+
let _cumX = 1, _cumY = 1;
|
|
889
1231
|
const _pe = _el.parentElement;
|
|
890
|
-
if (_pe != null && _cumulativeScale.has(_pe))
|
|
891
|
-
|
|
892
|
-
|
|
1232
|
+
if (_pe != null && _cumulativeScale.has(_pe)) {
|
|
1233
|
+
const _p = _cumulativeScale.get(_pe);
|
|
1234
|
+
_cumX = _p[0];
|
|
1235
|
+
_cumY = _p[1];
|
|
1236
|
+
}
|
|
1237
|
+
const _ownCs = getComputedStyle(_el);
|
|
1238
|
+
const _ownT = _ownCs.transform;
|
|
893
1239
|
if (_ownT != null && _ownT !== 'none' && _ownT !== '') {
|
|
894
|
-
|
|
1240
|
+
const _own = _computeOwnScale(_ownT);
|
|
1241
|
+
_cumX *= _own[0];
|
|
1242
|
+
_cumY *= _own[1];
|
|
1243
|
+
}
|
|
1244
|
+
// DM-755: CSS `zoom` is a legacy WebKit / IE property that Chrome still
|
|
1245
|
+
// honors as a real layout-affecting scaler. `getComputedStyle().zoom`
|
|
1246
|
+
// returns the resolved factor as a string ("1", "0.5", "2", "1.5" for
|
|
1247
|
+
// 150%, "reset"); `getBoundingClientRect()` already includes the zoom
|
|
1248
|
+
// in coordinates, but `getComputedStyle()` returns `fontSize` /
|
|
1249
|
+
// `padding` etc. in PRE-zoom CSS pixels. Folding zoom into the same
|
|
1250
|
+
// cumulative scale that handles `transform: scale()` re-uses the
|
|
1251
|
+
// downstream `fontSize × cum` and `cumScaleX / cumScaleY` correction
|
|
1252
|
+
// wrappers — text inside a `zoom: 2` box gets painted at 2× the
|
|
1253
|
+
// captured font size, matching Chrome's effective paint.
|
|
1254
|
+
const _ownZ = parseFloat(_ownCs.zoom);
|
|
1255
|
+
if (Number.isFinite(_ownZ) && _ownZ > 0 && _ownZ !== 1) {
|
|
1256
|
+
_cumX *= _ownZ;
|
|
1257
|
+
_cumY *= _ownZ;
|
|
895
1258
|
}
|
|
896
|
-
if (
|
|
897
|
-
_cumulativeScale.set(_el,
|
|
1259
|
+
if (_cumX !== 1 || _cumY !== 1)
|
|
1260
|
+
_cumulativeScale.set(_el, [_cumX, _cumY]);
|
|
898
1261
|
}
|
|
1262
|
+
// Helper: read the per-axis scale for an element, defaulting to [1, 1].
|
|
1263
|
+
const _scaleXY = (el) => _cumulativeScale.get(el) || [1, 1];
|
|
1264
|
+
// Helper: geometric-mean magnitude (the value the old single-scalar code
|
|
1265
|
+
// used). Drives fontSize / fontAscent / fontDescent pre-scaling so the
|
|
1266
|
+
// SVG re-rasterizer sees Chrome-equivalent font metrics in the uniform
|
|
1267
|
+
// case; the renderer applies a per-axis correction transform on top when
|
|
1268
|
+
// sx ≠ sy.
|
|
1269
|
+
const _scaleMag = (el) => {
|
|
1270
|
+
const s = _scaleXY(el);
|
|
1271
|
+
return Math.sqrt(s[0] * s[1]);
|
|
1272
|
+
};
|
|
899
1273
|
// CSS counters pre-walk (DM-357). Walk the document in DOM order,
|
|
900
1274
|
// applying counter-reset / counter-set / counter-increment per the
|
|
901
1275
|
// computed style of each element, and snapshot the active counter
|
|
@@ -944,20 +1318,27 @@ export const captureScript = (args) => {
|
|
|
944
1318
|
_activeScopes.push(scope);
|
|
945
1319
|
owned.push(scope);
|
|
946
1320
|
});
|
|
947
|
-
|
|
1321
|
+
// DM-705 / DM-706: CSS Lists 3 §2.3 ("Properties on a single element are
|
|
1322
|
+
// processed in the order reset, increment, set") — increment runs BEFORE
|
|
1323
|
+
// set. Our previous order (reset, set, increment) made
|
|
1324
|
+
// `counter-set: section 99` followed by an implicit `counter-increment:
|
|
1325
|
+
// section` paint as "100." instead of Chrome's "99." for the
|
|
1326
|
+
// `.restart` h2 in `24-counters.html`. Same off-by-one (always +1) in
|
|
1327
|
+
// `24-deep-counter-scope.html`.
|
|
1328
|
+
_parseCounterDecl(cs.counterIncrement, 1).forEach(({ name, value }) => {
|
|
948
1329
|
const s = _findInnermost(name);
|
|
949
1330
|
if (s)
|
|
950
|
-
s.value
|
|
1331
|
+
s.value += value;
|
|
951
1332
|
else {
|
|
952
1333
|
const ns = { name, value, owner: el };
|
|
953
1334
|
_activeScopes.push(ns);
|
|
954
1335
|
owned.push(ns);
|
|
955
1336
|
}
|
|
956
1337
|
});
|
|
957
|
-
_parseCounterDecl(cs.
|
|
1338
|
+
_parseCounterDecl(cs.counterSet, 0).forEach(({ name, value }) => {
|
|
958
1339
|
const s = _findInnermost(name);
|
|
959
1340
|
if (s)
|
|
960
|
-
s.value
|
|
1341
|
+
s.value = value;
|
|
961
1342
|
else {
|
|
962
1343
|
const ns = { name, value, owner: el };
|
|
963
1344
|
_activeScopes.push(ns);
|
|
@@ -977,6 +1358,148 @@ export const captureScript = (args) => {
|
|
|
977
1358
|
}
|
|
978
1359
|
}
|
|
979
1360
|
_counterPreWalk(root);
|
|
1361
|
+
// DM-770: collect `@counter-style` rule definitions from all stylesheets
|
|
1362
|
+
// so the lists-counters walker can resolve `list-style-type: <custom-name>`
|
|
1363
|
+
// to the right symbol per system (cyclic / fixed / numeric / alphabetic /
|
|
1364
|
+
// symbolic / additive) plus prefix / suffix / pad / negative / range /
|
|
1365
|
+
// fallback / extends descriptors. Chrome doesn't expose the resolved
|
|
1366
|
+
// marker string via `getComputedStyle(li, '::marker').content` (returns
|
|
1367
|
+
// "normal" even when the resolved marker is a custom symbol) so we
|
|
1368
|
+
// re-implement the resolution algorithm against the captured rule map.
|
|
1369
|
+
function _parseStringList(s) {
|
|
1370
|
+
// CSS string list — sequence of "double-quoted" strings (CSS escapes any
|
|
1371
|
+
// quote char). Whitespace-separated. Returns array of unescaped strings.
|
|
1372
|
+
const out = [];
|
|
1373
|
+
let i = 0;
|
|
1374
|
+
while (i < s.length) {
|
|
1375
|
+
while (i < s.length && /\s/.test(s[i]))
|
|
1376
|
+
i++;
|
|
1377
|
+
if (i >= s.length)
|
|
1378
|
+
break;
|
|
1379
|
+
const q = s[i];
|
|
1380
|
+
if (q !== '"' && q !== "'") {
|
|
1381
|
+
// Unquoted identifier (used by symbol shortcuts in some browsers).
|
|
1382
|
+
let j = i;
|
|
1383
|
+
while (j < s.length && !/\s/.test(s[j]))
|
|
1384
|
+
j++;
|
|
1385
|
+
out.push(s.slice(i, j));
|
|
1386
|
+
i = j;
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
let j = i + 1;
|
|
1390
|
+
let val = '';
|
|
1391
|
+
while (j < s.length && s[j] !== q) {
|
|
1392
|
+
if (s[j] === '\\' && j + 1 < s.length) {
|
|
1393
|
+
// CSS escape: \HHHHHH (hex) or \char.
|
|
1394
|
+
const hex = /^\\([0-9a-fA-F]{1,6})\s?/.exec(s.slice(j));
|
|
1395
|
+
if (hex != null) {
|
|
1396
|
+
val += String.fromCodePoint(parseInt(hex[1], 16));
|
|
1397
|
+
j += hex[0].length;
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
val += s[j + 1];
|
|
1401
|
+
j += 2;
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
val += s[j];
|
|
1405
|
+
j++;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
out.push(val);
|
|
1409
|
+
i = j + 1;
|
|
1410
|
+
}
|
|
1411
|
+
return out;
|
|
1412
|
+
}
|
|
1413
|
+
function _parseAdditiveSymbols(s) {
|
|
1414
|
+
// `additive-symbols: 10 "X", 9 "IX", 5 "V", ...`
|
|
1415
|
+
// Comma-separated weight + symbol pairs. Returns array sorted by weight
|
|
1416
|
+
// descending (largest first — required by the additive algorithm).
|
|
1417
|
+
const out = [];
|
|
1418
|
+
for (const tok of s.split(',')) {
|
|
1419
|
+
const m = /(-?\d+)\s+(.+)/.exec(tok.trim());
|
|
1420
|
+
if (m == null)
|
|
1421
|
+
continue;
|
|
1422
|
+
const weight = parseInt(m[1], 10);
|
|
1423
|
+
const sym = _parseStringList(m[2])[0] ?? '';
|
|
1424
|
+
out.push({ weight, sym });
|
|
1425
|
+
}
|
|
1426
|
+
out.sort((a, b) => b.weight - a.weight);
|
|
1427
|
+
return out;
|
|
1428
|
+
}
|
|
1429
|
+
function _walkRulesForCounterStyles(rules) {
|
|
1430
|
+
for (let i = 0; i < rules.length; i++) {
|
|
1431
|
+
const rule = rules[i];
|
|
1432
|
+
// CSSCounterStyleRule.type === 11. Also covered by `instanceof
|
|
1433
|
+
// CSSCounterStyleRule` in modern browsers — both forms work.
|
|
1434
|
+
if (rule.type === 11 || (window.CSSCounterStyleRule != null && rule instanceof window.CSSCounterStyleRule)) {
|
|
1435
|
+
const name = rule.name;
|
|
1436
|
+
if (!name)
|
|
1437
|
+
continue;
|
|
1438
|
+
let extendsName;
|
|
1439
|
+
let sys = rule.system || 'symbolic';
|
|
1440
|
+
// `system: extends upper-roman` → sys == "extends upper-roman".
|
|
1441
|
+
const extMatch = /^extends\s+(\S+)/.exec(sys);
|
|
1442
|
+
if (extMatch) {
|
|
1443
|
+
extendsName = extMatch[1];
|
|
1444
|
+
sys = 'extends';
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
// `system: cyclic`, `system: fixed [N]`, etc. Strip the keyword.
|
|
1448
|
+
const sysMatch = /^(cyclic|numeric|alphabetic|symbolic|fixed|additive)\b/.exec(sys);
|
|
1449
|
+
sys = sysMatch ? sysMatch[1] : 'symbolic';
|
|
1450
|
+
}
|
|
1451
|
+
const symbols = rule.symbols ? _parseStringList(rule.symbols) : [];
|
|
1452
|
+
const additiveSymbols = rule.additiveSymbols ? _parseAdditiveSymbols(rule.additiveSymbols) : [];
|
|
1453
|
+
const prefix = rule.prefix ? (_parseStringList(rule.prefix)[0] ?? '') : '';
|
|
1454
|
+
// Default suffix is ". " for most systems per the CSS spec; Chrome
|
|
1455
|
+
// returns the empty string when no `suffix` descriptor is set. Treat
|
|
1456
|
+
// empty as default.
|
|
1457
|
+
const suffix = rule.suffix ? (_parseStringList(rule.suffix)[0] ?? '. ') : '. ';
|
|
1458
|
+
const negativeRaw = rule.negative;
|
|
1459
|
+
let negPrefix = '-';
|
|
1460
|
+
let negSuffix = '';
|
|
1461
|
+
if (negativeRaw) {
|
|
1462
|
+
const nlist = _parseStringList(negativeRaw);
|
|
1463
|
+
negPrefix = nlist[0] ?? '-';
|
|
1464
|
+
if (nlist.length > 1)
|
|
1465
|
+
negSuffix = nlist[1];
|
|
1466
|
+
}
|
|
1467
|
+
let padLen = 0;
|
|
1468
|
+
let padSym = '';
|
|
1469
|
+
if (rule.pad) {
|
|
1470
|
+
const pm = /^\s*(\d+)\s+(.+)$/.exec(rule.pad);
|
|
1471
|
+
if (pm != null) {
|
|
1472
|
+
padLen = parseInt(pm[1], 10);
|
|
1473
|
+
padSym = _parseStringList(pm[2])[0] ?? '';
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
let rangeLo = -Infinity;
|
|
1477
|
+
let rangeHi = Infinity;
|
|
1478
|
+
if (rule.range && rule.range !== 'auto') {
|
|
1479
|
+
// "infinite infinite" or "1 39" or "-3 5" etc.
|
|
1480
|
+
const rm = /(-?\d+|infinite)\s+(-?\d+|infinite)/.exec(rule.range);
|
|
1481
|
+
if (rm != null) {
|
|
1482
|
+
rangeLo = rm[1] === 'infinite' ? -Infinity : parseInt(rm[1], 10);
|
|
1483
|
+
rangeHi = rm[2] === 'infinite' ? Infinity : parseInt(rm[2], 10);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
const fallback = rule.fallback || 'decimal';
|
|
1487
|
+
_counterStyles[name] = { system: sys, symbols, additiveSymbols, prefix, suffix, negPrefix, negSuffix, padLen, padSym, rangeLo, rangeHi, fallback, extendsName };
|
|
1488
|
+
}
|
|
1489
|
+
else if (rule.cssRules) {
|
|
1490
|
+
// @media / @supports / @layer — walk nested rule lists.
|
|
1491
|
+
_walkRulesForCounterStyles(rule.cssRules);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
1496
|
+
try {
|
|
1497
|
+
_walkRulesForCounterStyles(sheet.cssRules);
|
|
1498
|
+
}
|
|
1499
|
+
catch (e) {
|
|
1500
|
+
// CORS-protected stylesheets throw on .cssRules access. Skip silently.
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
980
1503
|
const result = [];
|
|
981
1504
|
// Capture the root element itself when it has visible border or background
|
|
982
1505
|
// (DM-362: <body style="border:3px solid pink"> was not rendering because
|
|
@@ -1020,6 +1543,11 @@ export const captureScript = (args) => {
|
|
|
1020
1543
|
if (_maskDefs.size > 0 && result.length > 0) {
|
|
1021
1544
|
result[0].maskDefs = Array.from(_maskDefs.values());
|
|
1022
1545
|
}
|
|
1546
|
+
// DM-826: same shape as maskDefs above — top-level collection of inline
|
|
1547
|
+
// <clipPath> defs the renderer emits into the output SVG. See docs/39.
|
|
1548
|
+
if (_clipPathDefs.size > 0 && result.length > 0) {
|
|
1549
|
+
result[0].clipPathDefs = Array.from(_clipPathDefs.values());
|
|
1550
|
+
}
|
|
1023
1551
|
// DM-494: attach mask raster references (mask-image: element(#id)). Skip
|
|
1024
1552
|
// null entries (display:none / zero-area / not-found targets). The post-
|
|
1025
1553
|
// capture rasterize pass on the Node side fills in dataUri.
|