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
|
@@ -23,6 +23,7 @@ import { createFontMetrics } from "./font-metrics.js";
|
|
|
23
23
|
import { createPlaceholderShown } from "./placeholder-shown.js";
|
|
24
24
|
import { createPseudoRules } from "./pseudo-rules.js";
|
|
25
25
|
import { createWarnings } from "./warnings.js";
|
|
26
|
+
import { createCounterStyleResolver } from "./walker/counter-style-resolver.js";
|
|
26
27
|
import { createListsCountersHandler } from "./walker/lists-counters.js";
|
|
27
28
|
import { createReplacedElementsHandler } from "./walker/replaced-elements.js";
|
|
28
29
|
import { createMasksClipsHandler } from "./walker/masks-clips.js";
|
|
@@ -43,19 +44,26 @@ export const captureScript =
|
|
|
43
44
|
// returns the handles captureInner / the orchestration tail call. Renamed
|
|
44
45
|
// (e.g. `warnings: _warnings`) to keep captureInner's existing references
|
|
45
46
|
// unchanged.
|
|
46
|
-
const { normColor } = createColorNorm();
|
|
47
|
+
const { normColor, normGradientColors } = createColorNorm();
|
|
47
48
|
const { needsRaster, textNeedsRaster } = createEmojiDetect();
|
|
48
49
|
const { measureFontMetrics: _measureFontMetrics, substituteAliasedFamilies: _substituteAliasedFamilies } = createFontMetrics();
|
|
49
50
|
const { resolvePlaceholderShownBg: _resolvePlaceholderShownBg } = createPlaceholderShown();
|
|
50
51
|
const { resolvePseudo: _resolvePseudo, resolveCornerRadius: _resolveCornerRadius } = createPseudoRules();
|
|
51
52
|
const { warn, shortSelector, warnings: _warnings } = createWarnings();
|
|
52
|
-
|
|
53
|
+
// DM-770: counter-style map is populated by the pre-walk below (which
|
|
54
|
+
// reads @counter-style rules from document.styleSheets); declared here so
|
|
55
|
+
// the lists-counters and pseudo-content handlers close over the same
|
|
56
|
+
// object reference via the shared counter-style resolver.
|
|
57
|
+
const _counterStyles = {};
|
|
58
|
+
const { resolveCounterStyle, resolveCounterValue, isCustomCounterStyle } = createCounterStyleResolver({ counterStyles: _counterStyles });
|
|
59
|
+
const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle });
|
|
53
60
|
const { handleReplacedElement } = createReplacedElementsHandler({ vp });
|
|
54
|
-
const { discoverMasks, maskDefs: _maskDefs, maskRasters: _maskRasters } = createMasksClipsHandler({ vp, warn });
|
|
61
|
+
const { discoverMasks, discoverClipPaths, maskDefs: _maskDefs, maskRasters: _maskRasters, clipPathDefs: _clipPathDefs } = createMasksClipsHandler({ vp, warn });
|
|
55
62
|
const { captureFormControls } = createFormControlsHandler({ normColor, resolvePseudo: _resolvePseudo });
|
|
56
63
|
const { wrapWithFrozenTransform, threadFrozenTransform } = createTransformsHandler();
|
|
57
64
|
const { captureBordersBackgrounds } = createBordersBackgroundsHandler({
|
|
58
65
|
normColor,
|
|
66
|
+
normGradientColors,
|
|
59
67
|
resolvePlaceholderShownBg: _resolvePlaceholderShownBg,
|
|
60
68
|
resolveCornerRadius: _resolveCornerRadius,
|
|
61
69
|
});
|
|
@@ -64,6 +72,8 @@ export const captureScript =
|
|
|
64
72
|
normColor,
|
|
65
73
|
measureFontMetrics: _measureFontMetrics,
|
|
66
74
|
textNeedsRaster,
|
|
75
|
+
resolveCounterValue,
|
|
76
|
+
isCustomCounterStyle,
|
|
67
77
|
});
|
|
68
78
|
const { captureInputValue } = createInputValueHandler({ vp, normColor, measureFontMetrics: _measureFontMetrics });
|
|
69
79
|
const { captureTextSegments } = createTextSegmentsHandler({ vp, measureFontMetrics: _measureFontMetrics, needsRaster });
|
|
@@ -111,6 +121,19 @@ export const captureScript =
|
|
|
111
121
|
if (cs.display === 'none') return null;
|
|
112
122
|
if ((cs.visibility === 'hidden' || cs.visibility === 'collapse') && !bordersOnlyCell) return null;
|
|
113
123
|
|
|
124
|
+
// DM-750: `content-visibility: hidden` skips paint AND layout of the
|
|
125
|
+
// subtree; Chrome treats the element as a sized placeholder (driven by
|
|
126
|
+
// `contain-intrinsic-size`) with no visible children. `getBoundingClientRect`
|
|
127
|
+
// on the host still returns the placeholder box, but child rects would
|
|
128
|
+
// re-trigger layout if asked, producing rects that don't match what Chrome
|
|
129
|
+
// actually paints. Capture the host (so background / border / placeholder
|
|
130
|
+
// box land in the output) but drop the entire subtree's text + children.
|
|
131
|
+
// `content-visibility: auto` is handled implicitly — Chrome paints in-
|
|
132
|
+
// viewport `auto` sections normally, and the live-rect capture inherits
|
|
133
|
+
// that. Out-of-viewport `auto` sections are already culled by the captured
|
|
134
|
+
// viewport's bbox filter.
|
|
135
|
+
const _contentVisHidden = cs.contentVisibility === 'hidden';
|
|
136
|
+
|
|
114
137
|
// DM-580: standard accessibility "visually-hidden" / "sr-only" idioms.
|
|
115
138
|
// Chrome paints nothing for these (clipped to zero), but the DOM text is
|
|
116
139
|
// still present for screen readers. Without this filter the captured tree
|
|
@@ -173,6 +196,10 @@ export const captureScript =
|
|
|
173
196
|
// Handler owns the maskDefs / maskRasters Maps that the orchestration
|
|
174
197
|
// tail consumes. See walker/masks-clips.ts.
|
|
175
198
|
discoverMasks(el, cs, sel);
|
|
199
|
+
// DM-826: clip-path: url("#id") same-document fragment refs. Sibling of
|
|
200
|
+
// the mask discovery above; collects inline <clipPath> defs the
|
|
201
|
+
// renderer copies into the output SVG. See docs/39.
|
|
202
|
+
discoverClipPaths(el, cs, sel);
|
|
176
203
|
if (cs.borderImageSource && cs.borderImageSource !== 'none') {
|
|
177
204
|
warn(sel, 'border-image', '9-slice composition pending (SK-466); border-image-source ignored');
|
|
178
205
|
}
|
|
@@ -213,7 +240,12 @@ export const captureScript =
|
|
|
213
240
|
// padding box. The downstream text-segments assembler re-anchors
|
|
214
241
|
// seg.x/y against the captured text once shaping completes. See
|
|
215
242
|
// walker/pseudo-content.ts.
|
|
216
|
-
|
|
243
|
+
// DM-750: content-visibility:hidden hides the host's subtree, which
|
|
244
|
+
// includes generated content from ::before / ::after. Skip the pseudo
|
|
245
|
+
// capture too so the placeholder host is just an empty rect.
|
|
246
|
+
const _pcResult = _contentVisHidden
|
|
247
|
+
? { pseudoSegments: [], pseudoBoxes: [] }
|
|
248
|
+
: capturePseudoContent(el, cs, rect, _counterSnapshot);
|
|
217
249
|
const pseudoSegments = _pcResult.pseudoSegments;
|
|
218
250
|
const pseudoBoxes = _pcResult.pseudoBoxes;
|
|
219
251
|
|
|
@@ -230,7 +262,7 @@ export const captureScript =
|
|
|
230
262
|
// (DM-246); listbox-mode selects synthesize all rows via
|
|
231
263
|
// styles.selectListboxOptions (DM-282).
|
|
232
264
|
const textIsHiddenFallback = tag === 'meter' || tag === 'progress' || tag === 'datalist' || tag === 'option' || tag === 'optgroup';
|
|
233
|
-
if (tag !== 'svg' && tag !== 'img' && !textIsHiddenFallback) {
|
|
265
|
+
if (tag !== 'svg' && tag !== 'img' && !textIsHiddenFallback && !_contentVisHidden) {
|
|
234
266
|
// Input / textarea value capture (incl. placeholder fallback, password
|
|
235
267
|
// masking, sub-pixel inputXOffsets probe, text-align shift). See
|
|
236
268
|
// walker/input-value.ts. When the handler `applied`, copy its locals
|
|
@@ -351,11 +383,45 @@ export const captureScript =
|
|
|
351
383
|
// lost when the SVG is re-embedded outside the original cascade —
|
|
352
384
|
// computed style is resolved against the source DOM, not the clone.
|
|
353
385
|
const _bakeSvgAttrs = ['fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'opacity'];
|
|
386
|
+
// DM-720: SVG 2 promotes geometry properties (cx/cy/r/rx/ry/x/y/width/
|
|
387
|
+
// height/d) to CSS — modern Chrome resolves them from the cascade. When
|
|
388
|
+
// a fixture sets them entirely from CSS (no XML attrs on the element),
|
|
389
|
+
// the cloned subtree has no geometry and renders blank. Bake the
|
|
390
|
+
// computed values onto the clone so the emitted SVG stands on its own.
|
|
391
|
+
// We keep these in a separate list because (a) the per-tag applicability
|
|
392
|
+
// varies (circles want cx/cy/r, rects want x/y/width/height + rx/ry,
|
|
393
|
+
// paths want d) and (b) computed values need light normalisation
|
|
394
|
+
// (strip "px"; unwrap path("…") for d) before they're valid as XML
|
|
395
|
+
// presentation attributes.
|
|
396
|
+
const _bakeSvgGeomAttrs = ['cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'width', 'height', 'd'];
|
|
354
397
|
const _walkBake = (origNode, cloneNode) => {
|
|
355
398
|
if (origNode.nodeType !== 1) return;
|
|
356
399
|
const ns = origNode.namespaceURI;
|
|
357
400
|
if (ns === 'http://www.w3.org/2000/svg' && origNode !== el) {
|
|
358
401
|
const ocs = window.getComputedStyle(origNode);
|
|
402
|
+
// DM-778: detect whether the source's `fill` / `stroke` was driven
|
|
403
|
+
// by `currentColor`. When the symbol is defined in a hidden <defs>
|
|
404
|
+
// <svg> and the polygon/polyline's CSS rule is `fill:
|
|
405
|
+
// currentColor` (or `stroke: currentColor`), getComputedStyle on
|
|
406
|
+
// that node resolves the value against the DEFS's cascade —
|
|
407
|
+
// typically the document body's color = black. If we baked that
|
|
408
|
+
// black literal onto the clone, every <use> consumer would paint
|
|
409
|
+
// the icon black regardless of its own host color. Probe by
|
|
410
|
+
// temporarily flipping `style.color` on the source: if `fill` /
|
|
411
|
+
// `stroke` follows, the value was driven by `currentColor` and we
|
|
412
|
+
// should preserve the keyword so `_substCurrentColor` can resolve
|
|
413
|
+
// it against the consumer's color later. Restore the source's
|
|
414
|
+
// inline color so the live page state isn't disturbed.
|
|
415
|
+
const _usesCurrentColor = (camel) => {
|
|
416
|
+
const baseVal = ocs[camel];
|
|
417
|
+
if (baseVal !== ocs.color) return false;
|
|
418
|
+
const savedColor = origNode.style.color;
|
|
419
|
+
origNode.style.color = "rgb(1, 2, 3)";
|
|
420
|
+
const probeCs = window.getComputedStyle(origNode);
|
|
421
|
+
const matches = probeCs[camel] === probeCs.color;
|
|
422
|
+
origNode.style.color = savedColor;
|
|
423
|
+
return matches;
|
|
424
|
+
};
|
|
359
425
|
for (const attr of _bakeSvgAttrs) {
|
|
360
426
|
const camel = attr.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
361
427
|
const val = ocs[camel];
|
|
@@ -365,7 +431,48 @@ export const captureScript =
|
|
|
365
431
|
// cascade and lose their resolution outside it, so we replace
|
|
366
432
|
// them with the resolved computed value.
|
|
367
433
|
if (val != null && val !== '' && !_hasConcreteAttr(origNode, attr)) {
|
|
368
|
-
|
|
434
|
+
// DM-778: preserve `currentColor` for `fill` / `stroke` when
|
|
435
|
+
// the source rule uses it, so the consumer's color cascades
|
|
436
|
+
// through the inlined symbol.
|
|
437
|
+
const preserveCurrent = (attr === "fill" || attr === "stroke") && _usesCurrentColor(camel);
|
|
438
|
+
cloneNode.setAttribute(attr, preserveCurrent ? "currentColor" : val);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// DM-720: bake CSS-driven geometry. Skip when the source has a
|
|
442
|
+
// concrete XML attr — Chrome's per-property precedence is "CSS wins
|
|
443
|
+
// over the presentation attribute" since SVG 2, but the computed
|
|
444
|
+
// value reflects that already, so writing it to the clone preserves
|
|
445
|
+
// the same painted geometry. Strip "px" suffixes and unwrap d's
|
|
446
|
+
// path() wrapper so the values parse as XML presentation attrs.
|
|
447
|
+
for (const gattr of _bakeSvgGeomAttrs) {
|
|
448
|
+
if (_hasConcreteAttr(origNode, gattr)) continue;
|
|
449
|
+
let gval = ocs.getPropertyValue(gattr);
|
|
450
|
+
if (gval == null) continue;
|
|
451
|
+
gval = gval.trim();
|
|
452
|
+
if (gval === '' || gval === 'auto' || gval === 'none' || gval === 'normal') continue;
|
|
453
|
+
if (gattr === 'd') {
|
|
454
|
+
// Computed `d` is wrapped as `path("M …")`. Unwrap to bare data.
|
|
455
|
+
const m = /^path\(\s*(?:"([^"]*)"|'([^']*)')\s*\)$/.exec(gval);
|
|
456
|
+
if (m) gval = m[1] != null ? m[1] : m[2];
|
|
457
|
+
else continue; // not a recognized path() form
|
|
458
|
+
} else if (/^-?\d+(?:\.\d+)?px$/.test(gval)) {
|
|
459
|
+
gval = gval.slice(0, -2);
|
|
460
|
+
}
|
|
461
|
+
cloneNode.setAttribute(gattr, gval);
|
|
462
|
+
}
|
|
463
|
+
// DM-815: `<mask mask-type="…">` is a presentation attribute that
|
|
464
|
+
// CSS can override (e.g. `svg .alpha-test { mask-type: alpha }`).
|
|
465
|
+
// Bake the computed value as an attribute on cloned `<mask>` nodes
|
|
466
|
+
// so the emitted standalone SVG renders the mask with the
|
|
467
|
+
// intended semantics — without it, alpha-driven masks (gradient
|
|
468
|
+
// with stop-opacity transitions on solid black) decode as
|
|
469
|
+
// luminance and paint nothing.
|
|
470
|
+
if (origNode.tagName && origNode.tagName.toLowerCase() === 'mask') {
|
|
471
|
+
const mt = ocs.maskType || ocs.getPropertyValue('mask-type');
|
|
472
|
+
if (mt === 'alpha' || mt === 'luminance') {
|
|
473
|
+
if (!origNode.hasAttribute('mask-type') || origNode.getAttribute('mask-type') !== mt) {
|
|
474
|
+
cloneNode.setAttribute('mask-type', mt);
|
|
475
|
+
}
|
|
369
476
|
}
|
|
370
477
|
}
|
|
371
478
|
// DM-508: bake CSS-animated transforms at t=0. CSS animation /
|
|
@@ -380,20 +487,45 @@ export const captureScript =
|
|
|
380
487
|
// origin px values are relative to the element's bounding box. We
|
|
381
488
|
// read those from getComputedStyle().transformOrigin and compose.
|
|
382
489
|
var transformVal = ocs.transform;
|
|
383
|
-
|
|
490
|
+
// DM-676: only bake when the SOURCE node has no static `transform=`
|
|
491
|
+
// attribute. The bake exists to capture CSS-animated transforms at
|
|
492
|
+
// t=0 (DM-508). When the node already has a literal `transform=`
|
|
493
|
+
// attribute, the existing attribute IS the source of truth — Chrome
|
|
494
|
+
// resolves it through transform-origin/transform-box at paint time,
|
|
495
|
+
// and the consumer browser will apply the same resolution. Baking
|
|
496
|
+
// a composed origin-anchored matrix on top double-applies the
|
|
497
|
+
// origin and shifts the rect.
|
|
498
|
+
var hasStaticTransformAttr = origNode.hasAttribute('transform') && !_isUnresolvedCssExpr(origNode.getAttribute('transform'));
|
|
499
|
+
if (!hasStaticTransformAttr && transformVal != null && transformVal !== '' && transformVal !== 'none') {
|
|
384
500
|
var transformOriginVal = ocs.transformOrigin || '0 0';
|
|
385
501
|
var originParts = transformOriginVal.trim().split(/\s+/);
|
|
386
502
|
var ox = parseFloat(originParts[0] || '0') || 0;
|
|
387
503
|
var oy = parseFloat(originParts[1] || '0') || 0;
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
504
|
+
// DM-752: route through `transform-box` to convert origin px values
|
|
505
|
+
// from the reference box's local coord space into SVG user space.
|
|
506
|
+
// Chrome's `getComputedStyle().transformOrigin` returns px values
|
|
507
|
+
// relative to the resolved transform-box:
|
|
508
|
+
// - `fill-box` (SVG default): bbox-local → add `bbox.x / bbox.y`.
|
|
509
|
+
// - `stroke-box`: stroke-bbox-local. Stroke-bbox is the geometry
|
|
510
|
+
// bbox extended by `stroke-width / 2` on each side, so
|
|
511
|
+
// stroke-bbox.x = bbox.x - sw/2, stroke-bbox.y = bbox.y - sw/2.
|
|
512
|
+
// - `view-box`: already in viewBox / user space coords; no shift.
|
|
513
|
+
// - `content-box` / `border-box`: HTML-only; SVG-side bake doesn't
|
|
514
|
+
// hit these (the HTML transform path applies them separately).
|
|
515
|
+
// Without this, `transform-box: view-box` rotated around the wrong
|
|
516
|
+
// anchor (the rect's bbox top-left instead of the viewBox center)
|
|
517
|
+
// and `transform-box: stroke-box` was off by `stroke-width / 2`.
|
|
518
|
+
var transformBoxVal = ocs.transformBox || 'fill-box';
|
|
392
519
|
try {
|
|
393
|
-
if (typeof origNode.getBBox === 'function') {
|
|
520
|
+
if (typeof origNode.getBBox === 'function' && transformBoxVal !== 'view-box') {
|
|
394
521
|
var bbox = origNode.getBBox();
|
|
395
522
|
ox += bbox.x;
|
|
396
523
|
oy += bbox.y;
|
|
524
|
+
if (transformBoxVal === 'stroke-box') {
|
|
525
|
+
var swPx = parseFloat(ocs.strokeWidth || '0') || 0;
|
|
526
|
+
ox -= swPx / 2;
|
|
527
|
+
oy -= swPx / 2;
|
|
528
|
+
}
|
|
397
529
|
}
|
|
398
530
|
} catch (e) { /* element not yet in render tree, fall through */ }
|
|
399
531
|
var composed;
|
|
@@ -462,16 +594,16 @@ export const captureScript =
|
|
|
462
594
|
// symbol target — we let SVG do the math.
|
|
463
595
|
var vb = target.getAttribute('viewBox') || '';
|
|
464
596
|
var par = target.getAttribute('preserveAspectRatio') || '';
|
|
465
|
-
|
|
466
|
-
if (ux !== 0)
|
|
467
|
-
if (uy !== 0)
|
|
468
|
-
if (uw != null)
|
|
469
|
-
if (uh != null)
|
|
470
|
-
if (vb !== '')
|
|
471
|
-
if (par !== '')
|
|
597
|
+
var innerSvg = document.createElementNS(_svgNS, 'svg');
|
|
598
|
+
if (ux !== 0) innerSvg.setAttribute('x', String(ux));
|
|
599
|
+
if (uy !== 0) innerSvg.setAttribute('y', String(uy));
|
|
600
|
+
if (uw != null) innerSvg.setAttribute('width', uw);
|
|
601
|
+
if (uh != null) innerSvg.setAttribute('height', uh);
|
|
602
|
+
if (vb !== '') innerSvg.setAttribute('viewBox', vb);
|
|
603
|
+
if (par !== '') innerSvg.setAttribute('preserveAspectRatio', par);
|
|
472
604
|
for (var ci = 0; ci < target.children.length; ci++) {
|
|
473
605
|
var clonedChild = target.children[ci].cloneNode(true);
|
|
474
|
-
|
|
606
|
+
innerSvg.appendChild(clonedChild);
|
|
475
607
|
// DM-508: bake t=0 computed styles on the inlined subtree.
|
|
476
608
|
// The hidden-defs symbol's children carry CSS animations whose
|
|
477
609
|
// computed values (transform, fill, opacity, etc.) reflect the
|
|
@@ -479,12 +611,38 @@ export const captureScript =
|
|
|
479
611
|
// original DOM as source captures those values.
|
|
480
612
|
_walkBake(target.children[ci], clonedChild);
|
|
481
613
|
}
|
|
614
|
+
// DM-778: thread the <use>'s own transform around the inlined
|
|
615
|
+
// nested <svg>. Per SVG 2 §5.6 the use's `transform` attribute
|
|
616
|
+
// applies to the inlined shadow tree; SVG's `<svg>` element does
|
|
617
|
+
// not directly take a `transform` attribute in legacy SVG 1.1
|
|
618
|
+
// renderers, so wrap in a `<g transform>` to be safe. Without
|
|
619
|
+
// this the `<use href="#badge" transform="scale(0.6)">` form in
|
|
620
|
+
// `07-deep-svg-use-href` rendered the badge at full size,
|
|
621
|
+
// duplicating the un-scaled pill on top of the in-place pill.
|
|
622
|
+
var useTransformAttrSym = useEl.getAttribute('transform') || '';
|
|
623
|
+
if (useTransformAttrSym !== '') {
|
|
624
|
+
replacement = document.createElementNS(_svgNS, 'g');
|
|
625
|
+
replacement.setAttribute('transform', useTransformAttrSym);
|
|
626
|
+
replacement.appendChild(innerSvg);
|
|
627
|
+
} else {
|
|
628
|
+
replacement = innerSvg;
|
|
629
|
+
}
|
|
482
630
|
} else {
|
|
483
|
-
// <g>, <path>, <circle>, <svg>, etc. — wrap in <g
|
|
484
|
-
//
|
|
631
|
+
// <g>, <path>, <circle>, <svg>, etc. — wrap in <g transform>.
|
|
632
|
+
// Per SVG 2 §5.6 the `<use>` element's own `transform` attribute
|
|
633
|
+
// applies to the inlined shadow tree, with the use's x/y
|
|
634
|
+
// translate happening INSIDE that transform. So compose:
|
|
635
|
+
// composedTransform = useTransform + translate(x, y)
|
|
636
|
+
// Skip pieces that are no-ops to keep the markup tidy. Without
|
|
637
|
+
// this, `<use transform="scale(1.2)" x="80" y="150">` would
|
|
638
|
+
// inline as plain `translate(80, 150)` and the scale would
|
|
639
|
+
// silently disappear (DM-675).
|
|
485
640
|
replacement = document.createElementNS(_svgNS, 'g');
|
|
486
|
-
|
|
487
|
-
|
|
641
|
+
var useTransformAttr = useEl.getAttribute('transform') || '';
|
|
642
|
+
var translatePart = (ux !== 0 || uy !== 0) ? ('translate(' + ux + ',' + uy + ')') : '';
|
|
643
|
+
var composedTransform = (useTransformAttr + ' ' + translatePart).trim();
|
|
644
|
+
if (composedTransform !== '') {
|
|
645
|
+
replacement.setAttribute('transform', composedTransform);
|
|
488
646
|
}
|
|
489
647
|
var clonedTarget = target.cloneNode(true);
|
|
490
648
|
// Drop the id on the clone — keeping it would create a duplicate
|
|
@@ -495,6 +653,28 @@ export const captureScript =
|
|
|
495
653
|
replacement.appendChild(clonedTarget);
|
|
496
654
|
// DM-508: bake t=0 computed styles on the inlined target subtree.
|
|
497
655
|
_walkBake(target, clonedTarget);
|
|
656
|
+
// When the target itself is an `<svg>` (the framer.com toolbar
|
|
657
|
+
// pattern: `<use href="#svgID">` → `<svg viewBox="0 0 20 20"
|
|
658
|
+
// id="svgID"><path .../></svg>` living in a hidden defs container
|
|
659
|
+
// with `width: 0; height: 0`), the bake above writes `width="0"
|
|
660
|
+
// height="0"` onto the cloned svg from the source's computed
|
|
661
|
+
// style. That collapses the inlined inner viewport and the icon
|
|
662
|
+
// paints nothing inside its parent — even though Chrome paints
|
|
663
|
+
// it correctly because the live `<use>` consumer's viewport
|
|
664
|
+
// (the outer svg inside the page's regular flow) gives the icon
|
|
665
|
+
// its 14×14 / 20×20 space. Strip baked zero width/height on the
|
|
666
|
+
// cloned target so the nested svg defaults to 100%/100% of its
|
|
667
|
+
// parent viewport, matching Chrome's behavior. Don't touch non-
|
|
668
|
+
// zero baked values — those came from a legitimately-sized source
|
|
669
|
+
// and reflect Chrome's intent.
|
|
670
|
+
if (clonedTarget.tagName && clonedTarget.tagName.toLowerCase() === 'svg' && clonedTarget.removeAttribute) {
|
|
671
|
+
if (!_hasConcreteAttr(target, 'width') && /^0(?:\.0+)?$/.test(clonedTarget.getAttribute('width') || '')) {
|
|
672
|
+
clonedTarget.removeAttribute('width');
|
|
673
|
+
}
|
|
674
|
+
if (!_hasConcreteAttr(target, 'height') && /^0(?:\.0+)?$/.test(clonedTarget.getAttribute('height') || '')) {
|
|
675
|
+
clonedTarget.removeAttribute('height');
|
|
676
|
+
}
|
|
677
|
+
}
|
|
498
678
|
}
|
|
499
679
|
// Carry over any presentation attrs from the <use> element. CSS
|
|
500
680
|
// spec: attributes on <use> override the same attribute on the
|
|
@@ -532,6 +712,13 @@ export const captureScript =
|
|
|
532
712
|
}
|
|
533
713
|
|
|
534
714
|
const children = [];
|
|
715
|
+
// DM-750: see the `content-visibility: hidden` note above — capture the
|
|
716
|
+
// host's own box (background / border / placeholder) but drop the subtree
|
|
717
|
+
// entirely. Skip the whole `for (child of el.children)` loop so neither
|
|
718
|
+
// children nor their text gets pushed.
|
|
719
|
+
if (_contentVisHidden) {
|
|
720
|
+
// fall through to the rest of the capture with `children = []`.
|
|
721
|
+
} else
|
|
535
722
|
for (const child of el.children) {
|
|
536
723
|
// Closed <details> hides non-<summary> children visually. getBoundingClientRect
|
|
537
724
|
// still returns their rects and cs.display isn't 'none', so we explicitly
|
|
@@ -593,6 +780,12 @@ export const captureScript =
|
|
|
593
780
|
...captureBordersBackgrounds(el, cs, tag, rect, isPlaceholderCapture),
|
|
594
781
|
overflowX: cs.overflowX,
|
|
595
782
|
overflowY: cs.overflowY,
|
|
783
|
+
// DM-761: `overflow-clip-margin` extends the overflow clip outward
|
|
784
|
+
// from a reference box (content / padding / border) by a length.
|
|
785
|
+
// Only meaningful for `overflow: clip`; `hidden` ignores it. Captured
|
|
786
|
+
// as the resolved string ("20px" / "content-box 12px") so the renderer
|
|
787
|
+
// can parse the reference-box keyword + length together.
|
|
788
|
+
overflowClipMargin: cs.overflowClipMargin || undefined,
|
|
596
789
|
scrollbarGutter: cs.scrollbarGutter || 'auto',
|
|
597
790
|
scrollWidth: el.scrollWidth,
|
|
598
791
|
scrollHeight: el.scrollHeight,
|
|
@@ -613,6 +806,42 @@ export const captureScript =
|
|
|
613
806
|
maskPosition: cs.maskPosition || cs.webkitMaskPosition || '0% 0%',
|
|
614
807
|
maskRepeat: cs.maskRepeat || cs.webkitMaskRepeat || 'repeat',
|
|
615
808
|
maskComposite: cs.maskComposite || cs.webkitMaskComposite || 'add',
|
|
809
|
+
maskClip: cs.maskClip || cs.webkitMaskClip || 'border-box',
|
|
810
|
+
// DM-758: `mask-border-source` / legacy `-webkit-mask-box-image`. Chrome
|
|
811
|
+
// exposes only the legacy webkit name; modern `maskBorderSource`
|
|
812
|
+
// returns undefined. Capture source + slice / width / outset so the
|
|
813
|
+
// renderer can decide whether to route through the simplified
|
|
814
|
+
// full-element mask path (only safe when width / outset both `0`).
|
|
815
|
+
maskBorderSource: cs.webkitMaskBoxImageSource && cs.webkitMaskBoxImageSource !== 'none'
|
|
816
|
+
? cs.webkitMaskBoxImageSource
|
|
817
|
+
: undefined,
|
|
818
|
+
maskBorderSlice: cs.webkitMaskBoxImageSlice || undefined,
|
|
819
|
+
maskBorderWidth: cs.webkitMaskBoxImageWidth || undefined,
|
|
820
|
+
maskBorderOutset: cs.webkitMaskBoxImageOutset || undefined,
|
|
821
|
+
// DM-793: legacy `-webkit-mask-box-image-repeat` keyword (stretch /
|
|
822
|
+
// repeat / round / space) per axis. Mirrors `border-image-repeat`.
|
|
823
|
+
maskBorderRepeat: cs.webkitMaskBoxImageRepeat || undefined,
|
|
824
|
+
// DM-793: intrinsic dimensions of the mask-border-source raster /
|
|
825
|
+
// SVG asset. Same probe pattern as `borderImageIntrinsic*` — a
|
|
826
|
+
// detached `<img>` resolves the URL against the document base and
|
|
827
|
+
// reports `naturalWidth` / `naturalHeight` for raster sources and
|
|
828
|
+
// the `<svg width/height>` attributes (or viewBox-derived size) for
|
|
829
|
+
// SVG sources. Captured at capture time so the renderer can compute
|
|
830
|
+
// 9-slice source rects without re-fetching the asset.
|
|
831
|
+
maskBorderIntrinsicWidth: (function() {
|
|
832
|
+
var _m = /^url\((?:"|')?([^"')]+)/.exec(cs.webkitMaskBoxImageSource || '');
|
|
833
|
+
if (_m == null) return undefined;
|
|
834
|
+
var _img = new Image();
|
|
835
|
+
_img.src = _m[1];
|
|
836
|
+
return _img.naturalWidth || undefined;
|
|
837
|
+
})(),
|
|
838
|
+
maskBorderIntrinsicHeight: (function() {
|
|
839
|
+
var _m = /^url\((?:"|')?([^"')]+)/.exec(cs.webkitMaskBoxImageSource || '');
|
|
840
|
+
if (_m == null) return undefined;
|
|
841
|
+
var _img = new Image();
|
|
842
|
+
_img.src = _m[1];
|
|
843
|
+
return _img.naturalHeight || undefined;
|
|
844
|
+
})(),
|
|
616
845
|
listStyleType: cs.listStyleType,
|
|
617
846
|
listStyleImage: cs.listStyleImage,
|
|
618
847
|
display: cs.display,
|
|
@@ -665,7 +894,7 @@ export const captureScript =
|
|
|
665
894
|
fontSize: (function() {
|
|
666
895
|
var _fs = parseFloat(cs.fontSize);
|
|
667
896
|
if (!isFinite(_fs)) return cs.fontSize;
|
|
668
|
-
var _s =
|
|
897
|
+
var _s = _scaleMag(el);
|
|
669
898
|
if (_s === 1) return cs.fontSize;
|
|
670
899
|
return (_fs * _s).toFixed(4) + 'px';
|
|
671
900
|
})(),
|
|
@@ -715,8 +944,8 @@ export const captureScript =
|
|
|
715
944
|
// captured fontSize. Otherwise the renderer's baseline math reads
|
|
716
945
|
// unscaled ascent values, and glyphs sit too far below their captured
|
|
717
946
|
// bbox top inside a `transform: scale(<1)` container.
|
|
718
|
-
fontAscent: fontAscent != null ? fontAscent * (
|
|
719
|
-
fontDescent: fontDescent != null ? fontDescent * (
|
|
947
|
+
fontAscent: fontAscent != null ? fontAscent * _scaleMag(el) : fontAscent,
|
|
948
|
+
fontDescent: fontDescent != null ? fontDescent * _scaleMag(el) : fontDescent,
|
|
720
949
|
inputXOffsets,
|
|
721
950
|
textImageUri, textImageScale,
|
|
722
951
|
// Placeholder metadata (SK-1097 / SK-1100 / SK-1099): captured in
|
|
@@ -729,7 +958,108 @@ export const captureScript =
|
|
|
729
958
|
// SK-1108 / SK-1128: textarea soft-wrap + writing-mode != horizontal-tb
|
|
730
959
|
// content-box raster rect — see walker/text-segments.ts.
|
|
731
960
|
elementRaster: computeElementRaster(el, cs, tag, rect, vp),
|
|
961
|
+
// DM-680: per-axis cumulative ancestor scale, exposed ONLY when
|
|
962
|
+
// anisotropic (sx ≠ sy within a small epsilon). The geometric mean is
|
|
963
|
+
// already folded into fontSize / fontAscent / fontDescent above, so
|
|
964
|
+
// the renderer's text-emission path only needs to apply a per-axis
|
|
965
|
+
// correction transform when the two axes diverge. Emitting these on
|
|
966
|
+
// every transformed element would add noise to the captured tree.
|
|
967
|
+
...(function () {
|
|
968
|
+
const _s = _scaleXY(el);
|
|
969
|
+
const _sx = _s[0], _sy = _s[1];
|
|
970
|
+
if (Math.abs(_sx - _sy) > 1e-4) return { cumScaleX: _sx, cumScaleY: _sy };
|
|
971
|
+
return {};
|
|
972
|
+
})(),
|
|
732
973
|
};
|
|
974
|
+
// Elements that fragment into multiple paint boxes need per-fragment
|
|
975
|
+
// paint of background + border, not a single rect covering the bbox
|
|
976
|
+
// (the bbox is the union of every fragment and produces an over-wide /
|
|
977
|
+
// over-tall shape that paints across the gap between fragments).
|
|
978
|
+
// Trigger when:
|
|
979
|
+
// 1. The element has a non-transparent background OR a non-zero border
|
|
980
|
+
// width on any side, AND
|
|
981
|
+
// 2. `el.getClientRects()` returns more than one rect, AND
|
|
982
|
+
// 3. The element is either
|
|
983
|
+
// (a) `display: inline` and wrapped onto multiple lines, OR
|
|
984
|
+
// (b) DM-754: block-level (block / list-item / flex / grid /
|
|
985
|
+
// flow-root) inside a multi-column container ancestor —
|
|
986
|
+
// `column-count > 1` or `column-width: <length>` — where a
|
|
987
|
+
// tall block fragments at the column boundary.
|
|
988
|
+
// Without the `display` / ancestor-column guard we'd trip on table cells
|
|
989
|
+
// and other layouts where Chrome legitimately reports multiple client
|
|
990
|
+
// rects for an axis-aligned bbox (e.g. SVG paint shapes); restrict to
|
|
991
|
+
// the two known fragmentation cases.
|
|
992
|
+
//
|
|
993
|
+
// The renderer reads `inlineFragments`, detects axis from frag geometry
|
|
994
|
+
// (block-axis when fragments stack vertically, inline-axis when they
|
|
995
|
+
// stack horizontally), and walks per-fragment with the right
|
|
996
|
+
// `box-decoration-break` slice/clone semantics for that axis.
|
|
997
|
+
{
|
|
998
|
+
var _bgC = _captured.styles.backgroundColor;
|
|
999
|
+
var _hasBg = _bgC != null && _bgC !== '' && _bgC !== 'transparent' && _bgC !== 'rgba(0, 0, 0, 0)';
|
|
1000
|
+
var _hasBgImage = _captured.styles.backgroundImage != null
|
|
1001
|
+
&& _captured.styles.backgroundImage !== '' && _captured.styles.backgroundImage !== 'none';
|
|
1002
|
+
var _btw = parseFloat(_captured.styles.borderTopWidth || '0') || 0;
|
|
1003
|
+
var _brw = parseFloat(_captured.styles.borderRightWidth || '0') || 0;
|
|
1004
|
+
var _bbw = parseFloat(_captured.styles.borderBottomWidth || '0') || 0;
|
|
1005
|
+
var _blw = parseFloat(_captured.styles.borderLeftWidth || '0') || 0;
|
|
1006
|
+
var _hasBorder = _btw > 0 || _brw > 0 || _bbw > 0 || _blw > 0;
|
|
1007
|
+
var _hasPaint = _hasBg || _hasBgImage || _hasBorder;
|
|
1008
|
+
var _isInline = cs.display === 'inline';
|
|
1009
|
+
var _isBlockLevel = !_isInline && (
|
|
1010
|
+
cs.display === 'block' || cs.display === 'list-item' || cs.display === 'flex'
|
|
1011
|
+
|| cs.display === 'grid' || cs.display === 'flow-root'
|
|
1012
|
+
|| cs.display === 'inline-block' || cs.display === 'inline-flex' || cs.display === 'inline-grid'
|
|
1013
|
+
);
|
|
1014
|
+
var _inMultiColumn = false;
|
|
1015
|
+
if (_isBlockLevel && _hasPaint) {
|
|
1016
|
+
// Walk ancestors looking for a multi-column container. `column-count`
|
|
1017
|
+
// is the most common; `column-width: <length>` also creates columns.
|
|
1018
|
+
// Stop at <body> (no column container above that level in practice).
|
|
1019
|
+
var _a = el.parentElement;
|
|
1020
|
+
while (_a != null) {
|
|
1021
|
+
var _ac = window.getComputedStyle(_a);
|
|
1022
|
+
var _cc = parseInt(_ac.columnCount, 10);
|
|
1023
|
+
var _cw = _ac.columnWidth;
|
|
1024
|
+
if ((Number.isFinite(_cc) && _cc > 1) || (_cw != null && _cw !== 'auto' && _cw !== '' && _cw !== 'normal')) {
|
|
1025
|
+
_inMultiColumn = true;
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
1028
|
+
if (_a === document.body) break;
|
|
1029
|
+
_a = _a.parentElement;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (_hasPaint && (_isInline || _inMultiColumn)) {
|
|
1033
|
+
var _cr = el.getClientRects();
|
|
1034
|
+
if (_cr != null && _cr.length > 1) {
|
|
1035
|
+
var _frags = [];
|
|
1036
|
+
for (var _ci = 0; _ci < _cr.length; _ci++) {
|
|
1037
|
+
var _f = _cr[_ci];
|
|
1038
|
+
// Skip zero-area fragments — Chrome occasionally emits these for
|
|
1039
|
+
// empty trailing inline runs.
|
|
1040
|
+
if (_f.width <= 0 || _f.height <= 0) continue;
|
|
1041
|
+
_frags.push({
|
|
1042
|
+
x: _f.left - vp.x,
|
|
1043
|
+
y: _f.top - vp.y,
|
|
1044
|
+
width: _f.width,
|
|
1045
|
+
height: _f.height,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
if (_frags.length > 1) {
|
|
1049
|
+
_captured.inlineFragments = _frags;
|
|
1050
|
+
// DM-754: stash the fragmentation axis derived from `display`.
|
|
1051
|
+
// Inline-wrap (e.g. `<span>` wrapping across line boxes) slices
|
|
1052
|
+
// horizontally — first owns the left side, last owns the right.
|
|
1053
|
+
// Block-level fragmentation inside a multi-column container
|
|
1054
|
+
// slices vertically — first owns the top, last owns the bottom.
|
|
1055
|
+
// Both axes produce vertically-stacked frag rects so we can't
|
|
1056
|
+
// distinguish them geometrically at render time.
|
|
1057
|
+
_captured.fragmentAxis = _isInline ? 'inline' : 'block';
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
733
1063
|
// DM-450: hidden/collapsed table cell — keep the cell's box + borders so
|
|
734
1064
|
// shared edges of the collapsed table grid still paint, but suppress
|
|
735
1065
|
// text, children, and background fill (per CSS visibility:hidden).
|
|
@@ -828,12 +1158,18 @@ export const captureScript =
|
|
|
828
1158
|
// sqrt(|a*d|) which is exact for pure scale and 1 for pure rotation — the
|
|
829
1159
|
// error grows for combined rotate+scale but no real-world fixture exercises
|
|
830
1160
|
// that on text-bearing elements. Translations contribute scale=1.
|
|
1161
|
+
// DM-680: cumulative ancestor scale is captured PER AXIS (sx, sy). The
|
|
1162
|
+
// map value is `[sx, sy]`. Geometric-mean magnitude is still used to
|
|
1163
|
+
// pre-scale fontSize / fontAscent / fontDescent (so the SVG re-rasterizer
|
|
1164
|
+
// sees Chrome-equivalent font metrics in the common uniform case). When
|
|
1165
|
+
// the scale is anisotropic (sx ≠ sy, e.g. `transform: scale(1.3, 0.8)`),
|
|
1166
|
+
// we also expose `cumScaleX` / `cumScaleY` on the captured element so the
|
|
1167
|
+
// renderer can wrap text in a correction `<g transform="scale(cx, cy)">`
|
|
1168
|
+
// pivoted around the text origin — matching how Chrome paints glyphs
|
|
1169
|
+
// into the post-transform device space with per-axis scaling.
|
|
831
1170
|
const _cumulativeScale = new Map();
|
|
832
1171
|
const _computeOwnScale = (_tt) => {
|
|
833
|
-
if (_tt == null || _tt === 'none' || _tt === '') return 1;
|
|
834
|
-
// matrix(a, b, c, d, e, f) — a/d are the scale-along-x / scale-along-y
|
|
835
|
-
// diagonal entries. matrix3d(...) downgrades to its 2D submatrix elements
|
|
836
|
-
// m11/m22 (indexes 0 / 5) — same as a / d.
|
|
1172
|
+
if (_tt == null || _tt === 'none' || _tt === '') return [1, 1];
|
|
837
1173
|
const _m2 = /^matrix\(\s*([-\d.eE+]+)\s*,\s*([-\d.eE+]+)\s*,\s*([-\d.eE+]+)\s*,\s*([-\d.eE+]+)/.exec(_tt);
|
|
838
1174
|
let _sa = 1, _sd = 1;
|
|
839
1175
|
if (_m2 != null) { _sa = parseFloat(_m2[1]); _sd = parseFloat(_m2[4]); }
|
|
@@ -844,26 +1180,52 @@ export const captureScript =
|
|
|
844
1180
|
_sa = parseFloat(_parts[0]); _sd = parseFloat(_parts[5]);
|
|
845
1181
|
}
|
|
846
1182
|
}
|
|
847
|
-
if (!isFinite(_sa) || !isFinite(_sd)) return 1;
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
return _s > 0 ? _s : 1;
|
|
1183
|
+
if (!isFinite(_sa) || !isFinite(_sd)) return [1, 1];
|
|
1184
|
+
const _sx = Math.abs(_sa) > 0 ? Math.abs(_sa) : 1;
|
|
1185
|
+
const _sy = Math.abs(_sd) > 0 ? Math.abs(_sd) : 1;
|
|
1186
|
+
return [_sx, _sy];
|
|
852
1187
|
};
|
|
853
|
-
// Map every transformed element to its own scale, then walk descendants
|
|
854
|
-
// multiplying. Doing this top-down via parentNode + memoization avoids the
|
|
855
|
-
// O(n*depth) cost of querying ancestors per element.
|
|
856
1188
|
for (let _si = 0; _si < _allEls.length; _si++) {
|
|
857
1189
|
const _el = _allEls[_si];
|
|
858
|
-
let
|
|
1190
|
+
let _cumX = 1, _cumY = 1;
|
|
859
1191
|
const _pe = _el.parentElement;
|
|
860
|
-
if (_pe != null && _cumulativeScale.has(_pe))
|
|
861
|
-
|
|
1192
|
+
if (_pe != null && _cumulativeScale.has(_pe)) {
|
|
1193
|
+
const _p = _cumulativeScale.get(_pe);
|
|
1194
|
+
_cumX = _p[0]; _cumY = _p[1];
|
|
1195
|
+
}
|
|
1196
|
+
const _ownCs = getComputedStyle(_el);
|
|
1197
|
+
const _ownT = _ownCs.transform;
|
|
862
1198
|
if (_ownT != null && _ownT !== 'none' && _ownT !== '') {
|
|
863
|
-
|
|
1199
|
+
const _own = _computeOwnScale(_ownT);
|
|
1200
|
+
_cumX *= _own[0]; _cumY *= _own[1];
|
|
864
1201
|
}
|
|
865
|
-
|
|
1202
|
+
// DM-755: CSS `zoom` is a legacy WebKit / IE property that Chrome still
|
|
1203
|
+
// honors as a real layout-affecting scaler. `getComputedStyle().zoom`
|
|
1204
|
+
// returns the resolved factor as a string ("1", "0.5", "2", "1.5" for
|
|
1205
|
+
// 150%, "reset"); `getBoundingClientRect()` already includes the zoom
|
|
1206
|
+
// in coordinates, but `getComputedStyle()` returns `fontSize` /
|
|
1207
|
+
// `padding` etc. in PRE-zoom CSS pixels. Folding zoom into the same
|
|
1208
|
+
// cumulative scale that handles `transform: scale()` re-uses the
|
|
1209
|
+
// downstream `fontSize × cum` and `cumScaleX / cumScaleY` correction
|
|
1210
|
+
// wrappers — text inside a `zoom: 2` box gets painted at 2× the
|
|
1211
|
+
// captured font size, matching Chrome's effective paint.
|
|
1212
|
+
const _ownZ = parseFloat(_ownCs.zoom);
|
|
1213
|
+
if (Number.isFinite(_ownZ) && _ownZ > 0 && _ownZ !== 1) {
|
|
1214
|
+
_cumX *= _ownZ; _cumY *= _ownZ;
|
|
1215
|
+
}
|
|
1216
|
+
if (_cumX !== 1 || _cumY !== 1) _cumulativeScale.set(_el, [_cumX, _cumY]);
|
|
866
1217
|
}
|
|
1218
|
+
// Helper: read the per-axis scale for an element, defaulting to [1, 1].
|
|
1219
|
+
const _scaleXY = (el) => _cumulativeScale.get(el) || [1, 1];
|
|
1220
|
+
// Helper: geometric-mean magnitude (the value the old single-scalar code
|
|
1221
|
+
// used). Drives fontSize / fontAscent / fontDescent pre-scaling so the
|
|
1222
|
+
// SVG re-rasterizer sees Chrome-equivalent font metrics in the uniform
|
|
1223
|
+
// case; the renderer applies a per-axis correction transform on top when
|
|
1224
|
+
// sx ≠ sy.
|
|
1225
|
+
const _scaleMag = (el) => {
|
|
1226
|
+
const s = _scaleXY(el);
|
|
1227
|
+
return Math.sqrt(s[0] * s[1]);
|
|
1228
|
+
};
|
|
867
1229
|
|
|
868
1230
|
// CSS counters pre-walk (DM-357). Walk the document in DOM order,
|
|
869
1231
|
// applying counter-reset / counter-set / counter-increment per the
|
|
@@ -910,14 +1272,21 @@ export const captureScript =
|
|
|
910
1272
|
_activeScopes.push(scope);
|
|
911
1273
|
owned.push(scope);
|
|
912
1274
|
});
|
|
913
|
-
|
|
1275
|
+
// DM-705 / DM-706: CSS Lists 3 §2.3 ("Properties on a single element are
|
|
1276
|
+
// processed in the order reset, increment, set") — increment runs BEFORE
|
|
1277
|
+
// set. Our previous order (reset, set, increment) made
|
|
1278
|
+
// `counter-set: section 99` followed by an implicit `counter-increment:
|
|
1279
|
+
// section` paint as "100." instead of Chrome's "99." for the
|
|
1280
|
+
// `.restart` h2 in `24-counters.html`. Same off-by-one (always +1) in
|
|
1281
|
+
// `24-deep-counter-scope.html`.
|
|
1282
|
+
_parseCounterDecl(cs.counterIncrement, 1).forEach(({name, value}) => {
|
|
914
1283
|
const s = _findInnermost(name);
|
|
915
|
-
if (s) s.value
|
|
1284
|
+
if (s) s.value += value;
|
|
916
1285
|
else { const ns = { name, value, owner: el }; _activeScopes.push(ns); owned.push(ns); }
|
|
917
1286
|
});
|
|
918
|
-
_parseCounterDecl(cs.
|
|
1287
|
+
_parseCounterDecl(cs.counterSet, 0).forEach(({name, value}) => {
|
|
919
1288
|
const s = _findInnermost(name);
|
|
920
|
-
if (s) s.value
|
|
1289
|
+
if (s) s.value = value;
|
|
921
1290
|
else { const ns = { name, value, owner: el }; _activeScopes.push(ns); owned.push(ns); }
|
|
922
1291
|
});
|
|
923
1292
|
// Snapshot the active scopes (shallow copy of name+value pairs).
|
|
@@ -933,6 +1302,139 @@ export const captureScript =
|
|
|
933
1302
|
}
|
|
934
1303
|
_counterPreWalk(root);
|
|
935
1304
|
|
|
1305
|
+
// DM-770: collect `@counter-style` rule definitions from all stylesheets
|
|
1306
|
+
// so the lists-counters walker can resolve `list-style-type: <custom-name>`
|
|
1307
|
+
// to the right symbol per system (cyclic / fixed / numeric / alphabetic /
|
|
1308
|
+
// symbolic / additive) plus prefix / suffix / pad / negative / range /
|
|
1309
|
+
// fallback / extends descriptors. Chrome doesn't expose the resolved
|
|
1310
|
+
// marker string via `getComputedStyle(li, '::marker').content` (returns
|
|
1311
|
+
// "normal" even when the resolved marker is a custom symbol) so we
|
|
1312
|
+
// re-implement the resolution algorithm against the captured rule map.
|
|
1313
|
+
function _parseStringList(s) {
|
|
1314
|
+
// CSS string list — sequence of "double-quoted" strings (CSS escapes any
|
|
1315
|
+
// quote char). Whitespace-separated. Returns array of unescaped strings.
|
|
1316
|
+
const out = [];
|
|
1317
|
+
let i = 0;
|
|
1318
|
+
while (i < s.length) {
|
|
1319
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
1320
|
+
if (i >= s.length) break;
|
|
1321
|
+
const q = s[i];
|
|
1322
|
+
if (q !== '"' && q !== "'") {
|
|
1323
|
+
// Unquoted identifier (used by symbol shortcuts in some browsers).
|
|
1324
|
+
let j = i;
|
|
1325
|
+
while (j < s.length && !/\s/.test(s[j])) j++;
|
|
1326
|
+
out.push(s.slice(i, j));
|
|
1327
|
+
i = j;
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
let j = i + 1;
|
|
1331
|
+
let val = '';
|
|
1332
|
+
while (j < s.length && s[j] !== q) {
|
|
1333
|
+
if (s[j] === '\\' && j + 1 < s.length) {
|
|
1334
|
+
// CSS escape: \HHHHHH (hex) or \char.
|
|
1335
|
+
const hex = /^\\([0-9a-fA-F]{1,6})\s?/.exec(s.slice(j));
|
|
1336
|
+
if (hex != null) {
|
|
1337
|
+
val += String.fromCodePoint(parseInt(hex[1], 16));
|
|
1338
|
+
j += hex[0].length;
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
val += s[j + 1];
|
|
1342
|
+
j += 2;
|
|
1343
|
+
} else {
|
|
1344
|
+
val += s[j];
|
|
1345
|
+
j++;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
out.push(val);
|
|
1349
|
+
i = j + 1;
|
|
1350
|
+
}
|
|
1351
|
+
return out;
|
|
1352
|
+
}
|
|
1353
|
+
function _parseAdditiveSymbols(s) {
|
|
1354
|
+
// `additive-symbols: 10 "X", 9 "IX", 5 "V", ...`
|
|
1355
|
+
// Comma-separated weight + symbol pairs. Returns array sorted by weight
|
|
1356
|
+
// descending (largest first — required by the additive algorithm).
|
|
1357
|
+
const out = [];
|
|
1358
|
+
for (const tok of s.split(',')) {
|
|
1359
|
+
const m = /(-?\d+)\s+(.+)/.exec(tok.trim());
|
|
1360
|
+
if (m == null) continue;
|
|
1361
|
+
const weight = parseInt(m[1], 10);
|
|
1362
|
+
const sym = _parseStringList(m[2])[0] ?? '';
|
|
1363
|
+
out.push({ weight, sym });
|
|
1364
|
+
}
|
|
1365
|
+
out.sort((a, b) => b.weight - a.weight);
|
|
1366
|
+
return out;
|
|
1367
|
+
}
|
|
1368
|
+
function _walkRulesForCounterStyles(rules) {
|
|
1369
|
+
for (let i = 0; i < rules.length; i++) {
|
|
1370
|
+
const rule = rules[i];
|
|
1371
|
+
// CSSCounterStyleRule.type === 11. Also covered by `instanceof
|
|
1372
|
+
// CSSCounterStyleRule` in modern browsers — both forms work.
|
|
1373
|
+
if (rule.type === 11 || (window.CSSCounterStyleRule != null && rule instanceof window.CSSCounterStyleRule)) {
|
|
1374
|
+
const name = rule.name;
|
|
1375
|
+
if (!name) continue;
|
|
1376
|
+
let extendsName;
|
|
1377
|
+
let sys = rule.system || 'symbolic';
|
|
1378
|
+
// `system: extends upper-roman` → sys == "extends upper-roman".
|
|
1379
|
+
const extMatch = /^extends\s+(\S+)/.exec(sys);
|
|
1380
|
+
if (extMatch) {
|
|
1381
|
+
extendsName = extMatch[1];
|
|
1382
|
+
sys = 'extends';
|
|
1383
|
+
} else {
|
|
1384
|
+
// `system: cyclic`, `system: fixed [N]`, etc. Strip the keyword.
|
|
1385
|
+
const sysMatch = /^(cyclic|numeric|alphabetic|symbolic|fixed|additive)\b/.exec(sys);
|
|
1386
|
+
sys = sysMatch ? sysMatch[1] : 'symbolic';
|
|
1387
|
+
}
|
|
1388
|
+
const symbols = rule.symbols ? _parseStringList(rule.symbols) : [];
|
|
1389
|
+
const additiveSymbols = rule.additiveSymbols ? _parseAdditiveSymbols(rule.additiveSymbols) : [];
|
|
1390
|
+
const prefix = rule.prefix ? (_parseStringList(rule.prefix)[0] ?? '') : '';
|
|
1391
|
+
// Default suffix is ". " for most systems per the CSS spec; Chrome
|
|
1392
|
+
// returns the empty string when no `suffix` descriptor is set. Treat
|
|
1393
|
+
// empty as default.
|
|
1394
|
+
const suffix = rule.suffix ? (_parseStringList(rule.suffix)[0] ?? '. ') : '. ';
|
|
1395
|
+
const negativeRaw = rule.negative;
|
|
1396
|
+
let negPrefix = '-';
|
|
1397
|
+
let negSuffix = '';
|
|
1398
|
+
if (negativeRaw) {
|
|
1399
|
+
const nlist = _parseStringList(negativeRaw);
|
|
1400
|
+
negPrefix = nlist[0] ?? '-';
|
|
1401
|
+
if (nlist.length > 1) negSuffix = nlist[1];
|
|
1402
|
+
}
|
|
1403
|
+
let padLen = 0;
|
|
1404
|
+
let padSym = '';
|
|
1405
|
+
if (rule.pad) {
|
|
1406
|
+
const pm = /^\s*(\d+)\s+(.+)$/.exec(rule.pad);
|
|
1407
|
+
if (pm != null) {
|
|
1408
|
+
padLen = parseInt(pm[1], 10);
|
|
1409
|
+
padSym = _parseStringList(pm[2])[0] ?? '';
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
let rangeLo = -Infinity;
|
|
1413
|
+
let rangeHi = Infinity;
|
|
1414
|
+
if (rule.range && rule.range !== 'auto') {
|
|
1415
|
+
// "infinite infinite" or "1 39" or "-3 5" etc.
|
|
1416
|
+
const rm = /(-?\d+|infinite)\s+(-?\d+|infinite)/.exec(rule.range);
|
|
1417
|
+
if (rm != null) {
|
|
1418
|
+
rangeLo = rm[1] === 'infinite' ? -Infinity : parseInt(rm[1], 10);
|
|
1419
|
+
rangeHi = rm[2] === 'infinite' ? Infinity : parseInt(rm[2], 10);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const fallback = rule.fallback || 'decimal';
|
|
1423
|
+
_counterStyles[name] = { system: sys, symbols, additiveSymbols, prefix, suffix, negPrefix, negSuffix, padLen, padSym, rangeLo, rangeHi, fallback, extendsName };
|
|
1424
|
+
} else if (rule.cssRules) {
|
|
1425
|
+
// @media / @supports / @layer — walk nested rule lists.
|
|
1426
|
+
_walkRulesForCounterStyles(rule.cssRules);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
1431
|
+
try {
|
|
1432
|
+
_walkRulesForCounterStyles(sheet.cssRules);
|
|
1433
|
+
} catch (e) {
|
|
1434
|
+
// CORS-protected stylesheets throw on .cssRules access. Skip silently.
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
936
1438
|
const result = [];
|
|
937
1439
|
// Capture the root element itself when it has visible border or background
|
|
938
1440
|
// (DM-362: <body style="border:3px solid pink"> was not rendering because
|
|
@@ -973,6 +1475,11 @@ export const captureScript =
|
|
|
973
1475
|
if (_maskDefs.size > 0 && result.length > 0) {
|
|
974
1476
|
result[0].maskDefs = Array.from(_maskDefs.values());
|
|
975
1477
|
}
|
|
1478
|
+
// DM-826: same shape as maskDefs above — top-level collection of inline
|
|
1479
|
+
// <clipPath> defs the renderer emits into the output SVG. See docs/39.
|
|
1480
|
+
if (_clipPathDefs.size > 0 && result.length > 0) {
|
|
1481
|
+
result[0].clipPathDefs = Array.from(_clipPathDefs.values());
|
|
1482
|
+
}
|
|
976
1483
|
// DM-494: attach mask raster references (mask-image: element(#id)). Skip
|
|
977
1484
|
// null entries (display:none / zero-area / not-found targets). The post-
|
|
978
1485
|
// capture rasterize pass on the Node side fills in dataUri.
|