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
@@ -40,7 +40,7 @@
40
40
  // - **borderImageIntrinsicWidth / Height**: same pattern for border-
41
41
  // image-source url().
42
42
 
43
- export const createBordersBackgroundsHandler = ({ normColor, resolvePlaceholderShownBg, resolveCornerRadius }) => {
43
+ export const createBordersBackgroundsHandler = ({ normColor, normGradientColors, resolvePlaceholderShownBg, resolveCornerRadius }) => {
44
44
  const isUaColorBorder = (tag, el, cs, side) =>
45
45
  tag === 'input' && el.type === 'color'
46
46
  && normColor(cs[side], cs.color).replace(/\s+/g, '') === 'rgb(0,0,0)';
@@ -87,10 +87,25 @@ export const createBordersBackgroundsHandler = ({ normColor, resolvePlaceholderS
87
87
  layers.push(bgImage.slice(start));
88
88
 
89
89
  return layers.map((layer) => {
90
+ // DM-759: `image-set(url(...) 1x, url(...) 2x, ...)` layers wrap their
91
+ // url() candidates inside the function call; the top-level regex
92
+ // below only matches a bare `url(...)` at layer start. Probe the
93
+ // FIRST url() inside the image-set instead so the renderer's `cover`
94
+ // / `contain` math has the right aspect ratio. Picking any candidate
95
+ // works because Chrome's image-set candidates are conventionally the
96
+ // SAME image at different resolutions / formats, so the intrinsic
97
+ // aspect is consistent across them; the absolute scale may differ
98
+ // by the 1x/2x factor but `cover` / `contain` are aspect-driven.
99
+ let searchLayer = layer;
100
+ const imgSet = /^\s*(?:-webkit-)?image-set\(\s*([\s\S]+)\s*\)\s*$/i.exec(layer);
101
+ if (imgSet != null) searchLayer = imgSet[1];
90
102
  // Match all three url() forms: "...", '...', and bare. Data: URLs
91
103
  // with embedded HTML attribute quotes (escaped as \") were silently
92
104
  // truncated by a prior single-regex implementation. DM-308.
93
- const u = /^\s*url\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^)\s]+))\s*\)/.exec(layer);
105
+ // For image-set candidates the url() may appear anywhere in the
106
+ // inner string, so anchor at the start of the SEARCH LAYER but
107
+ // allow leading whitespace.
108
+ const u = /\burl\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^)\s]+))\s*\)/.exec(searchLayer);
94
109
  if (u == null) return null;
95
110
  const raw = u[1] || u[2] || u[3] || '';
96
111
  if (raw === '') return null;
@@ -111,6 +126,48 @@ export const createBordersBackgroundsHandler = ({ normColor, resolvePlaceholderS
111
126
  return img[dim] || undefined;
112
127
  };
113
128
 
129
+ // DM-690: CSS 2.1 §17.6.2.1 — `border-style: hidden` has the highest
130
+ // precedence in `border-collapse: collapse` mode and SUPPRESSES the
131
+ // neighbor cell's matching border too. Walk the table grid neighbors and
132
+ // return per-side flags so the caller can rewrite this cell's border-side
133
+ // styles to `'hidden'` (which the renderer already skips). Scope: simple
134
+ // tables (no rowspan / colspan); good enough for the bug-report fixture
135
+ // `04-deep-border-conflict` and the common-case marketing tables.
136
+ const cellHiddenNeighbors = (el, tag, cs) => {
137
+ const out = { top: false, right: false, bottom: false, left: false };
138
+ if ((tag !== 'td' && tag !== 'th') || cs.borderCollapse !== 'collapse') return out;
139
+ const tr = el.parentElement;
140
+ if (tr == null || tr.tagName !== 'TR') return out;
141
+ const rowCells = Array.from(tr.children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
142
+ const colIdx = rowCells.indexOf(el);
143
+ const hiddenSide = (cell, side) => {
144
+ if (cell == null) return false;
145
+ const cs2 = getComputedStyle(cell);
146
+ return cs2['border' + side + 'Style'] === 'hidden';
147
+ };
148
+ if (hiddenSide(rowCells[colIdx - 1], 'Right')) out.left = true;
149
+ if (hiddenSide(rowCells[colIdx + 1], 'Left')) out.right = true;
150
+ // Resolve the surrounding table to walk row-neighbors across
151
+ // thead/tbody/tfoot sections.
152
+ let table = tr.parentElement;
153
+ while (table != null && table.tagName !== 'TABLE') table = table.parentElement;
154
+ if (table != null) {
155
+ const allRows = Array.from(table.querySelectorAll('tr')).filter((t) => t.closest('table') === table);
156
+ const rowIdx = allRows.indexOf(tr);
157
+ const above = allRows[rowIdx - 1];
158
+ const below = allRows[rowIdx + 1];
159
+ if (above != null) {
160
+ const aboveCells = Array.from(above.children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
161
+ if (hiddenSide(aboveCells[colIdx], 'Bottom')) out.top = true;
162
+ }
163
+ if (below != null) {
164
+ const belowCells = Array.from(below.children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
165
+ if (hiddenSide(belowCells[colIdx], 'Top')) out.bottom = true;
166
+ }
167
+ }
168
+ return out;
169
+ };
170
+
114
171
  const captureBordersBackgrounds = (el, cs, tag, rect, isPlaceholderCapture) => ({
115
172
  backgroundColor: (function () {
116
173
  if (isPlaceholderCapture) {
@@ -130,25 +187,72 @@ export const createBordersBackgroundsHandler = ({ normColor, resolvePlaceholderS
130
187
  borderRightWidth: cs.borderRightWidth,
131
188
  borderBottomWidth: cs.borderBottomWidth,
132
189
  borderLeftWidth: cs.borderLeftWidth,
133
- borderTopStyle: cs.borderTopStyle,
134
- borderRightStyle: cs.borderRightStyle,
135
- borderBottomStyle: cs.borderBottomStyle,
136
- borderLeftStyle: cs.borderLeftStyle,
190
+ // DM-690: when an adjacent collapsed-table cell declares its matching
191
+ // side as `border-style: hidden`, CSS 2.1 §17.6.2.1 says we MUST treat
192
+ // our side as hidden too (precedence: `hidden > widest > ...`). Override
193
+ // here at capture time so the renderer's existing `style === 'hidden'`
194
+ // skip suppresses the paint on this side. (Cells whose OWN side is
195
+ // already hidden are unaffected — they pass through.)
196
+ ...(function () {
197
+ const hn = cellHiddenNeighbors(el, tag, cs);
198
+ return {
199
+ borderTopStyle: hn.top ? 'hidden' : cs.borderTopStyle,
200
+ borderRightStyle: hn.right ? 'hidden' : cs.borderRightStyle,
201
+ borderBottomStyle: hn.bottom ? 'hidden' : cs.borderBottomStyle,
202
+ borderLeftStyle: hn.left ? 'hidden' : cs.borderLeftStyle,
203
+ };
204
+ })(),
137
205
  borderTopColor: tintedBorderColor(tag, el, cs, 'borderTopColor'),
138
206
  borderRightColor: tintedBorderColor(tag, el, cs, 'borderRightColor'),
139
207
  borderBottomColor: tintedBorderColor(tag, el, cs, 'borderBottomColor'),
140
208
  borderLeftColor: tintedBorderColor(tag, el, cs, 'borderLeftColor'),
141
209
  borderCollapse: cs.borderCollapse,
142
210
  frostedBgFallback: computeFrostedBgFallback(cs),
143
- backgroundImage: cs.backgroundImage,
211
+ backgroundImage: normGradientColors(cs.backgroundImage, cs.color),
144
212
  backgroundSize: cs.backgroundSize,
145
213
  backgroundPosition: cs.backgroundPosition,
146
214
  backgroundRepeat: cs.backgroundRepeat,
147
215
  backgroundClip: cs.backgroundClip,
216
+ backgroundBlendMode: cs.backgroundBlendMode,
148
217
  // DM-462: -webkit-text-fill-color is the property that actually makes
149
218
  // the headline text transparent in the background-clip:text idiom
150
219
  // (cs.color may still report a normal value).
151
220
  webkitTextFillColor: cs.webkitTextFillColor || cs.WebkitTextFillColor || undefined,
221
+ // DM-749: Stripe's keynote-speaker headline pattern — a span with
222
+ // `background-image: <gradient>; background-clip: text; -webkit-text-
223
+ // fill-color: transparent` wraps a child div that holds the actual
224
+ // text. The gradient is on the parent but Chrome lets it paint through
225
+ // the child's glyphs because background-clip: text masks the gradient
226
+ // by the union of all descendant text shapes. When the element's own
227
+ // bg-image is none AND its text-fill-color is transparent AND an
228
+ // ancestor has background-clip: text with a gradient, capture that
229
+ // ancestor's gradient so the renderer can use it as the glyph fill.
230
+ inheritedTextFillGradient: (function () {
231
+ const ownTfc = cs.webkitTextFillColor || cs.WebkitTextFillColor || '';
232
+ // Only meaningful when our own text is transparent.
233
+ if (!/^(rgba\(0[^)]*?,\s*0\)|transparent)$/i.test(ownTfc.trim())) return undefined;
234
+ // Walk up at most 8 ancestors looking for `background-clip: text`
235
+ // + a non-none `background-image`. 8 covers the Stripe hds-heading
236
+ // depth-of-2 nesting comfortably without scanning the whole tree.
237
+ let p = el.parentElement;
238
+ let depth = 0;
239
+ while (p != null && depth < 8) {
240
+ const pcs = window.getComputedStyle(p);
241
+ const bc = (pcs.backgroundClip || '') + ' ' + (pcs.webkitBackgroundClip || '');
242
+ if (/\btext\b/i.test(bc) && pcs.backgroundImage && pcs.backgroundImage !== 'none' && pcs.backgroundImage !== '') {
243
+ return pcs.backgroundImage;
244
+ }
245
+ p = p.parentElement;
246
+ depth++;
247
+ }
248
+ return undefined;
249
+ })(),
250
+ // DM-719: `-webkit-text-stroke-width` / `-webkit-text-stroke-color` paint a
251
+ // stroke around each glyph outline. Captured so the renderer can add a
252
+ // `stroke` attribute to the text-path emission.
253
+ webkitTextStrokeWidth: cs.webkitTextStrokeWidth || cs.WebkitTextStrokeWidth || undefined,
254
+ webkitTextStrokeColor: cs.webkitTextStrokeColor || cs.WebkitTextStrokeColor || undefined,
255
+ paintOrder: cs.paintOrder || undefined,
152
256
  backgroundOrigin: cs.backgroundOrigin,
153
257
  backgroundAttachment: cs.backgroundAttachment,
154
258
  backgroundIntrinsic: computeBackgroundIntrinsic(cs),
@@ -164,6 +268,9 @@ export const createBordersBackgroundsHandler = ({ normColor, resolvePlaceholderS
164
268
  outlineColor: normColor(cs.outlineColor),
165
269
  outlineOffset: cs.outlineOffset,
166
270
  boxShadow: cs.boxShadow,
271
+ // box-decoration-break: 'slice' (default) vs 'clone'. Drives per-fragment
272
+ // paint of wrapped inline elements; see CapturedElement.inlineFragments.
273
+ boxDecorationBreak: cs.boxDecorationBreak || cs.webkitBoxDecorationBreak || 'slice',
167
274
  });
168
275
 
169
276
  return { captureBordersBackgrounds };
@@ -0,0 +1,184 @@
1
+ // @ts-nocheck
2
+ //
3
+ // DM-770 / DM-788: resolves a custom @counter-style name + 1-based index to
4
+ // the marker symbol string per the CSS Counter Styles algorithms (cyclic,
5
+ // fixed, numeric, alphabetic, symbolic, additive) plus prefix / suffix /
6
+ // pad / negative / range / fallback / extends descriptors.
7
+ //
8
+ // Used by:
9
+ // - lists-counters.ts to resolve `list-style-type: <custom-name>` markers
10
+ // on <li> elements (DM-770)
11
+ // - pseudo-content.ts to resolve `counter(name, custom-style)` /
12
+ // `counters(name, sep, custom-style)` inside `::before` / `::after`
13
+ // `content` declarations (DM-788)
14
+ //
15
+ // The counter-style rule map is populated by `_walkRulesForCounterStyles` in
16
+ // the orchestrator pre-walk (`src/capture/script/index.ts`); both walkers
17
+ // close over the same object reference so a single sweep of styleSheets is
18
+ // shared across the capture.
19
+ //
20
+ // Bundled into the page-context capture script via the index.ts orchestrator;
21
+ // no runtime imports of its own.
22
+
23
+ export const createCounterStyleResolver = ({ counterStyles }) => {
24
+ // Built-in counter-style names whose algorithm the render side already
25
+ // covers inside `formatListMarker`. When an `extends` / `fallback` chain
26
+ // bottoms out at one of these, return the formatted symbol so the caller
27
+ // can stamp it directly without recursing through this resolver.
28
+ const BUILTINS = new Set([
29
+ 'decimal', 'decimal-leading-zero',
30
+ 'lower-alpha', 'lower-latin', 'upper-alpha', 'upper-latin',
31
+ 'lower-roman', 'upper-roman',
32
+ 'lower-greek',
33
+ 'disc', 'circle', 'square', 'none',
34
+ ]);
35
+
36
+ // Marker-context resolution (used by list-item ::marker). Returns the full
37
+ // string with prefix + pad + value + suffix wrapping per the CSS spec.
38
+ const resolveCounterStyle = (name, n) => _resolve(name, n, 0, true);
39
+ // Counter-function-context resolution (used by `counter()` / `counters()`
40
+ // inside `content`). Per Chrome's paint, the function returns only the
41
+ // pad-formatted value: prefix / suffix are NOT included. Matches DM-788
42
+ // empirical probe.
43
+ const resolveCounterValue = (name, n) => _resolve(name, n, 0, false);
44
+ const isCustomCounterStyle = (name) => counterStyles[name] != null;
45
+
46
+ const _resolve = (name, n, depth, wrap) => {
47
+ if (depth > 16) return null; // fallback / extends loop guard
48
+ if (BUILTINS.has(name)) return _formatBuiltin(name, n);
49
+ const def = counterStyles[name];
50
+ if (def == null) return _formatBuiltin('decimal', n);
51
+ if (def.system === 'extends' && def.extendsName != null) {
52
+ const childSym = _resolve(def.extendsName, n, depth + 1, wrap);
53
+ if (childSym == null) return null;
54
+ const padded = _applyPad(childSym, def.padLen, def.padSym);
55
+ return wrap ? (def.prefix + padded + def.suffix) : padded;
56
+ }
57
+ if (n < def.rangeLo || n > def.rangeHi) {
58
+ return _resolve(def.fallback ?? 'decimal', n, depth + 1, wrap);
59
+ }
60
+ const negative = n < 0;
61
+ const abs = Math.abs(n);
62
+ let core = null;
63
+ switch (def.system) {
64
+ case 'cyclic':
65
+ if (def.symbols.length === 0) break;
66
+ core = def.symbols[((abs - 1) % def.symbols.length + def.symbols.length) % def.symbols.length];
67
+ break;
68
+ case 'fixed': {
69
+ const idx0 = abs - 1;
70
+ if (idx0 >= 0 && idx0 < def.symbols.length) core = def.symbols[idx0];
71
+ break;
72
+ }
73
+ case 'numeric': {
74
+ if (def.symbols.length < 2) break;
75
+ if (abs === 0) { core = def.symbols[0]; break; }
76
+ const base = def.symbols.length;
77
+ let v = abs;
78
+ let s = '';
79
+ while (v > 0) { s = def.symbols[v % base] + s; v = Math.floor(v / base); }
80
+ core = s;
81
+ break;
82
+ }
83
+ case 'alphabetic': {
84
+ if (def.symbols.length === 0 || abs <= 0) break;
85
+ const base = def.symbols.length;
86
+ let v = abs;
87
+ let s = '';
88
+ while (v > 0) { v--; s = def.symbols[v % base] + s; v = Math.floor(v / base); }
89
+ core = s;
90
+ break;
91
+ }
92
+ case 'symbolic': {
93
+ if (def.symbols.length === 0 || abs <= 0) break;
94
+ const base = def.symbols.length;
95
+ const copies = Math.ceil(abs / base);
96
+ const sym = def.symbols[(abs - 1) % base];
97
+ core = sym.repeat(copies);
98
+ break;
99
+ }
100
+ case 'additive': {
101
+ if (def.additiveSymbols.length === 0) break;
102
+ if (abs === 0) {
103
+ const zero = def.additiveSymbols.find((s) => s.weight === 0);
104
+ core = zero ? zero.sym : null;
105
+ } else {
106
+ let v = abs;
107
+ let s = '';
108
+ for (const { weight, sym } of def.additiveSymbols) {
109
+ if (weight <= 0) continue;
110
+ const count = Math.floor(v / weight);
111
+ for (let i = 0; i < count; i++) s += sym;
112
+ v -= count * weight;
113
+ }
114
+ core = v === 0 ? s : null;
115
+ }
116
+ break;
117
+ }
118
+ }
119
+ if (core == null) return _resolve(def.fallback ?? 'decimal', n, depth + 1, wrap);
120
+ const padded = _applyPad(core, def.padLen, def.padSym);
121
+ const sign = negative ? def.negPrefix : '';
122
+ const signTail = negative ? def.negSuffix : '';
123
+ return wrap
124
+ ? (def.prefix + sign + padded + signTail + def.suffix)
125
+ : (sign + padded + signTail);
126
+ };
127
+
128
+ const _applyPad = (s, len, sym) => {
129
+ if (!len || !sym) return s;
130
+ while ([...s].length < len) s = sym + s;
131
+ return s;
132
+ };
133
+
134
+ const _formatBuiltin = (type, n) => {
135
+ switch (type) {
136
+ case 'decimal': return String(n);
137
+ case 'decimal-leading-zero': return n < 10 && n >= 0 ? '0' + n : String(n);
138
+ case 'lower-alpha':
139
+ case 'lower-latin':
140
+ return _alphaMarker(n, false);
141
+ case 'upper-alpha':
142
+ case 'upper-latin':
143
+ return _alphaMarker(n, true);
144
+ case 'lower-roman': return _romanMarker(n).toLowerCase();
145
+ case 'upper-roman': return _romanMarker(n);
146
+ case 'lower-greek': return _greekMarker(n);
147
+ case 'disc':
148
+ case 'circle':
149
+ case 'square':
150
+ case 'none':
151
+ return null;
152
+ default: return String(n);
153
+ }
154
+ };
155
+ const _alphaMarker = (n, upper) => {
156
+ if (n <= 0) return String(n);
157
+ const base = upper ? 65 : 97;
158
+ let s = '';
159
+ let v = n;
160
+ while (v > 0) { v--; s = String.fromCharCode(base + (v % 26)) + s; v = Math.floor(v / 26); }
161
+ return s;
162
+ };
163
+ const _greekMarker = (n) => {
164
+ if (n <= 0) return String(n);
165
+ const greek = 'αβγδεζηθικλμνξοπρστυφχψω';
166
+ let s = '';
167
+ let v = n;
168
+ while (v > 0) { v--; s = greek.charAt(v % 24) + s; v = Math.floor(v / 24); }
169
+ return s;
170
+ };
171
+ const _romanMarker = (n) => {
172
+ if (n <= 0 || n >= 4000) return String(n);
173
+ const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
174
+ const syms = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];
175
+ let s = '';
176
+ let v = n;
177
+ for (let i = 0; i < vals.length; i++) {
178
+ while (v >= vals[i]) { s += syms[i]; v -= vals[i]; }
179
+ }
180
+ return s;
181
+ };
182
+
183
+ return { resolveCounterStyle, resolveCounterValue, isCustomCounterStyle };
184
+ };
@@ -93,9 +93,22 @@ export const createInputValueHandler = ({ vp, normColor, measureFontMetrics }) =
93
93
  // + ascent), so without this adjustment the text shows up at the top
94
94
  // of the button instead of centered. Surfaced by framer-mobile-fold's
95
95
  // "Okay" button (display: flex; align-items: center; height: 45px).
96
+ //
97
+ // DM-666: `<input type="submit" | "button" | "reset">` are button-type
98
+ // inputs whose value text Chrome ALWAYS centers vertically inside the
99
+ // content box — this is the UA-stylesheet `appearance: button`
100
+ // behaviour, independent of `display` / `align-items`. Google's
101
+ // homepage "Google Search" / "I'm Feeling Lucky" inputs are exactly
102
+ // this case: `display: inline-block`, no flex, 36-px tall with 14-px
103
+ // text — the value sits dead-centre in Chrome but anchored to
104
+ // content-top in the captured tree pre-fix. Extending the centring to
105
+ // these button types here keeps the renderer's textTop = line-box-top
106
+ // contract intact.
96
107
  const display = cs.display;
97
108
  const isFlexLike = display === 'flex' || display === 'inline-flex' || display === 'grid' || display === 'inline-grid';
98
- if (isFlexLike && cs.alignItems === 'center') {
109
+ const isButtonInput = tag === 'input'
110
+ && (inputType === 'submit' || inputType === 'button' || inputType === 'reset');
111
+ if ((isFlexLike && cs.alignItems === 'center') || isButtonInput) {
99
112
  const contentH = rect.height - bt - bb - pt - pb;
100
113
  if (contentH > textHeight + 0.5) {
101
114
  textTop = (rect.top - vp.y + bt + pt) + (contentH - textHeight) / 2;
@@ -14,7 +14,8 @@
14
14
  // outer-page environment exposes `document` / `window` / `Image` without the
15
15
  // project's tsconfig DOM lib applying.
16
16
 
17
- export const createListsCountersHandler = ({ normColor }) => {
17
+ export const createListsCountersHandler = ({ normColor, resolveCounterStyle, isCustomCounterStyle }) => {
18
+
18
19
  const captureListsCounters = (el, cs, tag) => {
19
20
  // CSS treats any element with display:list-item as a list item — the tag
20
21
  // alone isn't enough. An <li> with display:inline-block (e.g. a horizontal
@@ -72,6 +73,27 @@ export const createListsCountersHandler = ({ normColor }) => {
72
73
  // else the values come back equal to the element's own font and are
73
74
  // quietly ignored at render time, so leave them undefined.
74
75
  const markerCs = isListItem ? window.getComputedStyle(el, '::marker') : null;
76
+ let markerContent = markerCs ? markerCs.content : undefined;
77
+
78
+ // DM-770: if list-style-type names a custom @counter-style and the
79
+ // ::marker pseudo doesn't already define a `content` override, resolve
80
+ // the marker symbol from the captured rule definitions. Chrome's CSSOM
81
+ // returns the resolved `::marker { content }` as the literal `"normal"`
82
+ // even when the painted marker is a custom symbol, so we re-implement
83
+ // the resolution algorithm against the captured rule map.
84
+ if (isListItem && resolveCounterStyle != null && listItemIndex != null) {
85
+ const lsType = cs.listStyleType;
86
+ const isCustom = lsType != null && isCustomCounterStyle != null && isCustomCounterStyle(lsType);
87
+ const noAuthorContent = markerContent == null || markerContent === '' || markerContent === 'normal';
88
+ if (isCustom && noAuthorContent) {
89
+ const resolved = resolveCounterStyle(lsType, listItemIndex);
90
+ if (resolved != null) {
91
+ // Wrap as a CSS-string so the render-time `rawContent` parser
92
+ // (which strips surrounding quotes) accepts it.
93
+ markerContent = '"' + resolved.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
94
+ }
95
+ }
96
+ }
75
97
 
76
98
  return {
77
99
  listMarkerIntrinsic,
@@ -79,7 +101,7 @@ export const createListsCountersHandler = ({ normColor }) => {
79
101
  markerColor: markerCs ? normColor(markerCs.color) : undefined,
80
102
  markerFontWeight: markerCs ? markerCs.fontWeight : undefined,
81
103
  markerFontSize: markerCs ? markerCs.fontSize : undefined,
82
- markerContent: markerCs ? markerCs.content : undefined,
104
+ markerContent,
83
105
  markerFontFamily: markerCs ? markerCs.fontFamily : undefined,
84
106
  };
85
107
  };
@@ -39,6 +39,7 @@
39
39
  export const createMasksClipsHandler = ({ vp, warn }) => {
40
40
  const maskDefs = new Map();
41
41
  const maskRasters = new Map();
42
+ const clipPathDefs = new Map();
42
43
  let maskRasterIdx = 0;
43
44
 
44
45
  const discoverMasks = (el, cs, sel) => {
@@ -138,5 +139,43 @@ export const createMasksClipsHandler = ({ vp, warn }) => {
138
139
  }
139
140
  };
140
141
 
141
- return { discoverMasks, maskDefs, maskRasters };
142
+ // DM-826: clip-path: url("#id") same-document fragment ref. Resolves the
143
+ // fragment to an inline `<clipPath>` element and stashes its outerHTML so
144
+ // the renderer can emit it into the output SVG `<defs>`. See
145
+ // `docs/39-clip-path-fragment-references.md`.
146
+ //
147
+ // Scope (initial cut): same-document `<clipPath>` defs with
148
+ // `clipPathUnits="objectBoundingBox"` or the default `userSpaceOnUse`. The
149
+ // emitted def carries `clipPathUnits` through verbatim — SVG handles
150
+ // objectBoundingBox auto-scaling natively. userSpaceOnUse refs are recorded
151
+ // but the renderer currently passes coordinates through unchanged (best-
152
+ // effort; faithful support needs per-element translation, deferred).
153
+ const discoverClipPaths = (el, cs, sel) => {
154
+ const cp = cs.clipPath;
155
+ if (!cp || cp === 'none' || cp === '') return;
156
+ // Strip an optional <geometry-box> keyword (`padding-box` / `border-box` /
157
+ // …) before the url(...) check — `clip-path: url(#id) padding-box` is
158
+ // valid per CSS Masking 1 §3.1. The renderer's geo-box handling is
159
+ // shape-side; here we only care about the url() form.
160
+ const cpShape = cp.replace(/\b(?:content-box|padding-box|border-box|margin-box|fill-box|stroke-box|view-box)\b/i, '').trim();
161
+ const fragMatch = /^url\(\s*(?:"|')?#([^"')\s]+)(?:"|')?\s*\)$/i.exec(cpShape);
162
+ if (fragMatch != null) {
163
+ const fragId = fragMatch[1];
164
+ if (!clipPathDefs.has(fragId)) {
165
+ const target = document.getElementById(fragId);
166
+ if (target != null && target.tagName.toLowerCase() === 'clippath') {
167
+ clipPathDefs.set(fragId, { id: fragId, outerHTML: target.outerHTML });
168
+ } else {
169
+ warn(sel, 'clip-path', 'clip-path fragment "#' + fragId + '" did not resolve to an inline <clipPath> element');
170
+ }
171
+ }
172
+ return;
173
+ }
174
+ const extFragMatch = /^url\(\s*(?:"|')?[^"')#]+#[^"')\s]+(?:"|')?\s*\)$/i.exec(cpShape);
175
+ if (extFragMatch != null) {
176
+ warn(sel, 'clip-path', 'external-file SVG fragment refs (url("./file.svg#id")) are not yet emitted');
177
+ }
178
+ };
179
+
180
+ return { discoverMasks, discoverClipPaths, maskDefs, maskRasters, clipPathDefs };
142
181
  };