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
@@ -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
- const { captureListsCounters } = createListsCountersHandler({ normColor });
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
- const _pcResult = capturePseudoContent(el, cs, rect, _counterSnapshot);
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
- cloneNode.setAttribute(attr, val);
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
- if (transformVal != null && transformVal !== '' && transformVal !== 'none') {
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
- // For transform-box: fill-box (Chrome default for SVG since CSS
389
- // Transforms 2), origin coords are relative to the element's bbox.
390
- // We need them in the parent's user space add the bbox origin.
391
- // getBBox() works on rendered SVG nodes.
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
- replacement = document.createElementNS(_svgNS, 'svg');
466
- if (ux !== 0) replacement.setAttribute('x', String(ux));
467
- if (uy !== 0) replacement.setAttribute('y', String(uy));
468
- if (uw != null) replacement.setAttribute('width', uw);
469
- if (uh != null) replacement.setAttribute('height', uh);
470
- if (vb !== '') replacement.setAttribute('viewBox', vb);
471
- if (par !== '') replacement.setAttribute('preserveAspectRatio', 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
- replacement.appendChild(clonedChild);
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 translate(x,y)>.
484
- // Skip translate when ux/uy are zero to keep the markup tidy.
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
- if (ux !== 0 || uy !== 0) {
487
- replacement.setAttribute('transform', 'translate(' + ux + ',' + uy + ')');
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 = _cumulativeScale.get(el) || 1;
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 * (_cumulativeScale.get(el) || 1) : fontAscent,
719
- fontDescent: fontDescent != null ? fontDescent * (_cumulativeScale.get(el) || 1) : 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
- // Geometric mean of x-scale + y-scale magnitudes. Exact for uniform
849
- // scale; reasonable approximation for non-uniform scale on text.
850
- const _s = Math.sqrt(Math.abs(_sa * _sd));
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 _cum = 1;
1190
+ let _cumX = 1, _cumY = 1;
859
1191
  const _pe = _el.parentElement;
860
- if (_pe != null && _cumulativeScale.has(_pe)) _cum = _cumulativeScale.get(_pe);
861
- const _ownT = getComputedStyle(_el).transform;
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
- _cum *= _computeOwnScale(_ownT);
1199
+ const _own = _computeOwnScale(_ownT);
1200
+ _cumX *= _own[0]; _cumY *= _own[1];
864
1201
  }
865
- if (_cum !== 1) _cumulativeScale.set(_el, _cum);
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
- _parseCounterDecl(cs.counterSet, 0).forEach(({name, value}) => {
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 = 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.counterIncrement, 1).forEach(({name, value}) => {
1287
+ _parseCounterDecl(cs.counterSet, 0).forEach(({name, value}) => {
919
1288
  const s = _findInnermost(name);
920
- if (s) s.value += 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.