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.
Files changed (115) hide show
  1. package/FEATURES.md +1 -0
  2. package/README.md +29 -0
  3. package/dist/animation/animator.js +25 -14
  4. package/dist/animation/animator.test.js +54 -21
  5. package/dist/animation/cursor-overlay.js +0 -2
  6. package/dist/capture/emoji.js +29 -18
  7. package/dist/capture/index.js +5 -4
  8. package/dist/capture/script/color-norm.d.ts +1 -0
  9. package/dist/capture/script/color-norm.js +43 -1
  10. package/dist/capture/script/emoji-detect.js +14 -0
  11. package/dist/capture/script/index.js +593 -65
  12. package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
  13. package/dist/capture/script/walker/borders-backgrounds.js +123 -7
  14. package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
  15. package/dist/capture/script/walker/counter-style-resolver.js +218 -0
  16. package/dist/capture/script/walker/input-value.js +14 -1
  17. package/dist/capture/script/walker/lists-counters.d.ts +3 -1
  18. package/dist/capture/script/walker/lists-counters.js +22 -2
  19. package/dist/capture/script/walker/masks-clips.d.ts +2 -0
  20. package/dist/capture/script/walker/masks-clips.js +41 -1
  21. package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
  22. package/dist/capture/script/walker/pseudo-content.js +301 -61
  23. package/dist/capture/script/walker/pseudo-inject.js +20 -0
  24. package/dist/capture/script/walker/text-segments.js +98 -4
  25. package/dist/capture/script/walker/transforms.d.ts +1 -0
  26. package/dist/capture/script/walker/transforms.js +16 -0
  27. package/dist/capture/script.generated.js +1 -1
  28. package/dist/capture/types.d.ts +213 -2
  29. package/dist/cli/animate.js +151 -15
  30. package/dist/mask.test.js +12 -7
  31. package/dist/render/borders.d.ts +9 -13
  32. package/dist/render/borders.js +379 -14
  33. package/dist/render/element-tree-to-svg.d.ts +11 -12
  34. package/dist/render/element-tree-to-svg.js +2046 -241
  35. package/dist/render/embedded-font-builder.d.ts +49 -0
  36. package/dist/render/embedded-font-builder.js +149 -0
  37. package/dist/render/form-controls.js +45 -24
  38. package/dist/render/gradients.d.ts +15 -0
  39. package/dist/render/gradients.js +103 -2
  40. package/dist/render/gradients.test.js +34 -0
  41. package/dist/render/text-to-path.d.ts +38 -1
  42. package/dist/render/text-to-path.js +654 -29
  43. package/dist/render/text-to-path.test.js +230 -9
  44. package/dist/render/text.d.ts +14 -0
  45. package/dist/render/text.js +344 -40
  46. package/dist/scroll/composer.d.ts +26 -0
  47. package/dist/scroll/composer.js +199 -11
  48. package/dist/scroll/composer.test.js +293 -16
  49. package/dist/scroll/executor.d.ts +3 -1
  50. package/dist/scroll/executor.js +15 -6
  51. package/dist/scroll/executor.test.js +25 -0
  52. package/dist/scroll/hoist-fixed.d.ts +48 -0
  53. package/dist/scroll/hoist-fixed.js +85 -0
  54. package/dist/scroll/hoist-fixed.test.d.ts +1 -0
  55. package/dist/scroll/hoist-fixed.test.js +103 -0
  56. package/dist/scroll/hoist-sticky.d.ts +45 -0
  57. package/dist/scroll/hoist-sticky.js +157 -0
  58. package/dist/scroll/hoist-sticky.test.d.ts +1 -0
  59. package/dist/scroll/hoist-sticky.test.js +154 -0
  60. package/dist/scroll/pattern.d.ts +22 -5
  61. package/dist/scroll/pattern.js +55 -7
  62. package/dist/scroll/pattern.test.js +48 -1
  63. package/dist/tree-ops/frame-merge.d.ts +10 -0
  64. package/dist/tree-ops/frame-merge.js +23 -5
  65. package/dist/tree-ops/frame-merge.test.js +45 -0
  66. package/dist/tree-ops/tree-diff.js +1 -1
  67. package/dist/tree-ops/viewbox-culling.js +32 -18
  68. package/dist/tree-ops/viewbox-culling.test.js +40 -6
  69. package/package.json +8 -2
  70. package/src/animation/animator.test.ts +56 -21
  71. package/src/animation/animator.ts +25 -14
  72. package/src/animation/cursor-overlay.ts +0 -2
  73. package/src/capture/emoji.ts +28 -18
  74. package/src/capture/index.ts +15 -14
  75. package/src/capture/script/color-norm.ts +38 -1
  76. package/src/capture/script/emoji-detect.ts +14 -0
  77. package/src/capture/script/index.ts +555 -48
  78. package/src/capture/script/walker/borders-backgrounds.ts +114 -7
  79. package/src/capture/script/walker/counter-style-resolver.ts +184 -0
  80. package/src/capture/script/walker/input-value.ts +14 -1
  81. package/src/capture/script/walker/lists-counters.ts +24 -2
  82. package/src/capture/script/walker/masks-clips.ts +40 -1
  83. package/src/capture/script/walker/pseudo-content.ts +297 -55
  84. package/src/capture/script/walker/pseudo-inject.ts +20 -0
  85. package/src/capture/script/walker/text-segments.ts +93 -4
  86. package/src/capture/script/walker/transforms.ts +14 -0
  87. package/src/capture/script.generated.ts +1 -1
  88. package/src/capture/types.ts +202 -2
  89. package/src/cli/animate.ts +135 -15
  90. package/src/mask.test.ts +12 -7
  91. package/src/render/borders.ts +383 -17
  92. package/src/render/element-tree-to-svg.ts +2051 -238
  93. package/src/render/embedded-font-builder.ts +221 -0
  94. package/src/render/form-controls.ts +45 -24
  95. package/src/render/gradients.test.ts +46 -0
  96. package/src/render/gradients.ts +94 -2
  97. package/src/render/opentype.js.d.ts +7 -0
  98. package/src/render/text-to-path.test.ts +246 -9
  99. package/src/render/text-to-path.ts +702 -31
  100. package/src/render/text.ts +344 -40
  101. package/src/scroll/composer.test.ts +322 -16
  102. package/src/scroll/composer.ts +246 -13
  103. package/src/scroll/executor.test.ts +27 -0
  104. package/src/scroll/executor.ts +19 -10
  105. package/src/scroll/hoist-fixed.test.ts +117 -0
  106. package/src/scroll/hoist-fixed.ts +95 -0
  107. package/src/scroll/hoist-sticky.test.ts +173 -0
  108. package/src/scroll/hoist-sticky.ts +193 -0
  109. package/src/scroll/pattern.test.ts +58 -1
  110. package/src/scroll/pattern.ts +71 -8
  111. package/src/tree-ops/frame-merge.test.ts +51 -0
  112. package/src/tree-ops/frame-merge.ts +24 -6
  113. package/src/tree-ops/tree-diff.ts +3 -1
  114. package/src/tree-ops/viewbox-culling.test.ts +42 -6
  115. 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
- const { captureListsCounters } = createListsCountersHandler({ normColor });
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
- const _pcResult = capturePseudoContent(el, cs, rect, _counterSnapshot);
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
- cloneNode.setAttribute(attr, val);
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
- if (transformVal != null && transformVal !== '' && transformVal !== 'none') {
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
- // For transform-box: fill-box (Chrome default for SVG since CSS
387
- // Transforms 2), origin coords are relative to the element's bbox.
388
- // We need them in the parent's user space add the bbox origin.
389
- // getBBox() works on rendered SVG nodes.
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
- replacement = document.createElementNS(_svgNS, 'svg');
610
+ var innerSvg = document.createElementNS(_svgNS, 'svg');
473
611
  if (ux !== 0)
474
- replacement.setAttribute('x', String(ux));
612
+ innerSvg.setAttribute('x', String(ux));
475
613
  if (uy !== 0)
476
- replacement.setAttribute('y', String(uy));
614
+ innerSvg.setAttribute('y', String(uy));
477
615
  if (uw != null)
478
- replacement.setAttribute('width', uw);
616
+ innerSvg.setAttribute('width', uw);
479
617
  if (uh != null)
480
- replacement.setAttribute('height', uh);
618
+ innerSvg.setAttribute('height', uh);
481
619
  if (vb !== '')
482
- replacement.setAttribute('viewBox', vb);
620
+ innerSvg.setAttribute('viewBox', vb);
483
621
  if (par !== '')
484
- replacement.setAttribute('preserveAspectRatio', par);
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
- replacement.appendChild(clonedChild);
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 translate(x,y)>.
498
- // Skip translate when ux/uy are zero to keep the markup tidy.
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
- if (ux !== 0 || uy !== 0) {
501
- replacement.setAttribute('transform', 'translate(' + ux + ',' + uy + ')');
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
- for (const child of el.children) {
555
- // Closed <details> hides non-<summary> children visually. getBoundingClientRect
556
- // still returns their rects and cs.display isn't 'none', so we explicitly
557
- // skip non-summary children when the parent details is closed.
558
- if (tag === 'details' && !el.open && child.tagName.toLowerCase() !== 'summary')
559
- continue;
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 = _cumulativeScale.get(el) || 1;
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 * (_cumulativeScale.get(el) || 1) : fontAscent,
742
- fontDescent: fontDescent != null ? fontDescent * (_cumulativeScale.get(el) || 1) : 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
- // Geometric mean of x-scale + y-scale magnitudes. Exact for uniform
879
- // scale; reasonable approximation for non-uniform scale on text.
880
- const _s = Math.sqrt(Math.abs(_sa * _sd));
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 _cum = 1;
1230
+ let _cumX = 1, _cumY = 1;
889
1231
  const _pe = _el.parentElement;
890
- if (_pe != null && _cumulativeScale.has(_pe))
891
- _cum = _cumulativeScale.get(_pe);
892
- const _ownT = getComputedStyle(_el).transform;
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
- _cum *= _computeOwnScale(_ownT);
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 (_cum !== 1)
897
- _cumulativeScale.set(_el, _cum);
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
- _parseCounterDecl(cs.counterSet, 0).forEach(({ name, value }) => {
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 = 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.counterIncrement, 1).forEach(({ name, value }) => {
1338
+ _parseCounterDecl(cs.counterSet, 0).forEach(({ name, value }) => {
958
1339
  const s = _findInnermost(name);
959
1340
  if (s)
960
- s.value += 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.