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
@@ -0,0 +1,49 @@
1
+ /**
2
+ * One path command from fontkit. Mirrors fontkit's internal `Path.commands[]`
3
+ * shape so callers can hand the raw fontkit output across without converting.
4
+ *
5
+ * moveTo (x, y)
6
+ * lineTo (x, y)
7
+ * quadraticCurveTo (cx, cy, x, y)
8
+ * bezierCurveTo (c1x, c1y, c2x, c2y, x, y)
9
+ * closePath ()
10
+ */
11
+ export interface PathCommand {
12
+ command: string;
13
+ args: number[];
14
+ }
15
+ /**
16
+ * Reset per-composition state. Call alongside `clearEmbeddedFonts` at the
17
+ * start of every `composeScrollSvg` / `elementTreeToSvg` invocation so
18
+ * glyphs from a prior composition don't leak into the new one.
19
+ */
20
+ export declare function clearEmbeddedFontBuilder(): void;
21
+ /**
22
+ * Record that a glyph is used by the current composition and return its
23
+ * placement coordinates: which CSS family to reference and which PUA
24
+ * codepoint to emit in the `<text>` content.
25
+ *
26
+ * Idempotent on (`instanceKey`, `glyphId`): repeated calls return the same
27
+ * cssFamily + puaCodepoint, so the same glyph can be referenced many times
28
+ * across the SVG and collapses to a single entry in the custom font.
29
+ *
30
+ * `instanceKey` must be stable per (font, axes-tuple). Two text runs that
31
+ * resolve to "Inter Variable" at `wght=450 opsz=30` share an instance key;
32
+ * a third run at `wght=540 opsz=24` gets its own.
33
+ */
34
+ export declare function trackGlyphInEmbedFont(instanceKey: string, unitsPerEm: number, ascender: number, descender: number, glyphId: number, pathCommands: PathCommand[], advanceWidth: number, variant?: {
35
+ italic: boolean;
36
+ weight: number;
37
+ }): {
38
+ cssFamily: string;
39
+ puaCodepoint: number;
40
+ } | null;
41
+ /**
42
+ * Serialise every tracked custom font as `@font-face` rules with embedded
43
+ * TTF bytes. Returns the joined CSS ready to drop into the SVG's `<style>`
44
+ * block. Empty string when no glyphs were registered.
45
+ */
46
+ export declare function getBuiltEmbeddedFontFaceCss(): string;
47
+ /** Test-only: inspect builder state for assertions. */
48
+ export declare function _builderRegistrySize(): number;
49
+ export declare function _builderGlyphsFor(instanceKey: string): number;
@@ -0,0 +1,149 @@
1
+ // DM-655: build custom standalone TTFs from extracted glyph outlines so the
2
+ // embedded-font render mode can emit `<text>` against any font Chrome paints
3
+ // with — webfonts, system fonts, variable-axis instances — not just CDN-
4
+ // fetched webfonts. Each tracked font becomes one `@font-face` whose `src:`
5
+ // is a `data:font/ttf;base64,…` URI containing JUST the glyphs the SVG uses,
6
+ // at their captured outlines and advances.
7
+ //
8
+ // Glyph addressing: every shaped glyph is assigned a sequential PUA codepoint
9
+ // (U+E000+). The `<text>` we emit contains the PUA stream, NOT the original
10
+ // codepoints — so the consumer browser performs zero shaping / kerning /
11
+ // ligature substitution and renders each glyph at its declared advance.
12
+ // fontkit already did the shaping at capture time, so this preserves
13
+ // contextual joining (Arabic init/medi/fina), ligatures (fi, ffi), and
14
+ // cluster reordering (Devanagari i-matra) without us having to ship any
15
+ // GSUB/GPOS rules in the custom font.
16
+ // opentype.js ships no type declarations; declare the minimal surface we
17
+ // use so callers stay strict-typed at the boundary.
18
+ import opentype from "opentype.js";
19
+ const ot = opentype;
20
+ const builderRegistry = new Map();
21
+ let builderIdCounter = 0;
22
+ /** PUA-A block: U+E000..U+F8FF (6400 codepoints). Plenty for typical SVGs. */
23
+ const PUA_START = 0xE000;
24
+ const PUA_END = 0xF8FF;
25
+ /**
26
+ * Reset per-composition state. Call alongside `clearEmbeddedFonts` at the
27
+ * start of every `composeScrollSvg` / `elementTreeToSvg` invocation so
28
+ * glyphs from a prior composition don't leak into the new one.
29
+ */
30
+ export function clearEmbeddedFontBuilder() {
31
+ builderRegistry.clear();
32
+ builderIdCounter = 0;
33
+ }
34
+ /**
35
+ * Record that a glyph is used by the current composition and return its
36
+ * placement coordinates: which CSS family to reference and which PUA
37
+ * codepoint to emit in the `<text>` content.
38
+ *
39
+ * Idempotent on (`instanceKey`, `glyphId`): repeated calls return the same
40
+ * cssFamily + puaCodepoint, so the same glyph can be referenced many times
41
+ * across the SVG and collapses to a single entry in the custom font.
42
+ *
43
+ * `instanceKey` must be stable per (font, axes-tuple). Two text runs that
44
+ * resolve to "Inter Variable" at `wght=450 opsz=30` share an instance key;
45
+ * a third run at `wght=540 opsz=24` gets its own.
46
+ */
47
+ export function trackGlyphInEmbedFont(instanceKey, unitsPerEm, ascender, descender, glyphId, pathCommands, advanceWidth, variant = { italic: false, weight: 400 }) {
48
+ let entry = builderRegistry.get(instanceKey);
49
+ if (entry == null) {
50
+ entry = {
51
+ cssFamily: `dmf${builderIdCounter++}`,
52
+ unitsPerEm,
53
+ ascender,
54
+ descender,
55
+ glyphs: new Map(),
56
+ puaForGlyphId: new Map(),
57
+ nextPua: PUA_START,
58
+ italic: variant.italic,
59
+ weight: variant.weight,
60
+ };
61
+ builderRegistry.set(instanceKey, entry);
62
+ }
63
+ const cached = entry.puaForGlyphId.get(glyphId);
64
+ if (cached != null)
65
+ return { cssFamily: entry.cssFamily, puaCodepoint: cached };
66
+ if (entry.nextPua > PUA_END) {
67
+ // Out of PUA-A slots. Caller falls through to paths-mode emission for
68
+ // this glyph; the (rare) over-6400-glyph case keeps rendering, just
69
+ // without the embedded-font fast path for the run that exceeded.
70
+ return null;
71
+ }
72
+ const pua = entry.nextPua++;
73
+ entry.puaForGlyphId.set(glyphId, pua);
74
+ const otPath = new ot.Path();
75
+ for (const cmd of pathCommands) {
76
+ switch (cmd.command) {
77
+ case "moveTo":
78
+ otPath.moveTo(cmd.args[0], cmd.args[1]);
79
+ break;
80
+ case "lineTo":
81
+ otPath.lineTo(cmd.args[0], cmd.args[1]);
82
+ break;
83
+ case "quadraticCurveTo":
84
+ otPath.quadraticCurveTo(cmd.args[0], cmd.args[1], cmd.args[2], cmd.args[3]);
85
+ break;
86
+ case "bezierCurveTo":
87
+ otPath.curveTo(cmd.args[0], cmd.args[1], cmd.args[2], cmd.args[3], cmd.args[4], cmd.args[5]);
88
+ break;
89
+ case "closePath":
90
+ otPath.close();
91
+ break;
92
+ }
93
+ }
94
+ entry.glyphs.set(glyphId, new ot.Glyph({
95
+ name: `g${glyphId}`,
96
+ unicode: pua,
97
+ advanceWidth,
98
+ path: otPath,
99
+ }));
100
+ return { cssFamily: entry.cssFamily, puaCodepoint: pua };
101
+ }
102
+ /**
103
+ * Serialise every tracked custom font as `@font-face` rules with embedded
104
+ * TTF bytes. Returns the joined CSS ready to drop into the SVG's `<style>`
105
+ * block. Empty string when no glyphs were registered.
106
+ */
107
+ export function getBuiltEmbeddedFontFaceCss() {
108
+ if (builderRegistry.size === 0)
109
+ return "";
110
+ const rules = [];
111
+ for (const entry of builderRegistry.values()) {
112
+ // Glyph 0 must be .notdef per OpenType spec — opentype.js enforces this
113
+ // by adding the first glyph as .notdef. Provide an empty .notdef so any
114
+ // codepoint the consumer might query that we DIDN'T embed (shouldn't
115
+ // happen in practice since we emit only PUA codepoints we registered)
116
+ // renders as a zero-width invisible glyph rather than tofu.
117
+ const notdef = new ot.Glyph({
118
+ name: ".notdef",
119
+ unicode: 0,
120
+ advanceWidth: Math.round(entry.unitsPerEm / 2),
121
+ path: new ot.Path(),
122
+ });
123
+ const allGlyphs = [notdef, ...entry.glyphs.values()];
124
+ const font = new ot.Font({
125
+ familyName: entry.cssFamily,
126
+ styleName: "Regular",
127
+ unitsPerEm: entry.unitsPerEm,
128
+ ascender: entry.ascender,
129
+ descender: entry.descender,
130
+ glyphs: allGlyphs,
131
+ });
132
+ const ttfBytes = Buffer.from(font.toArrayBuffer());
133
+ const b64 = ttfBytes.toString("base64");
134
+ // Emit explicit font-style / font-weight descriptors so the consumer
135
+ // browser matches this @font-face EXACTLY when the `<text>` element
136
+ // requests italic / bold. Without these the rule defaults to
137
+ // `font-style: normal; font-weight: 400` and Chromium synthesizes faux
138
+ // italic / faux bold on top of glyphs whose italic / bold shape is
139
+ // already baked into the custom TTF.
140
+ const styleDesc = entry.italic ? "italic" : "normal";
141
+ rules.push(`@font-face { font-family: "${entry.cssFamily}"; font-style: ${styleDesc}; font-weight: ${entry.weight}; src: url("data:font/ttf;base64,${b64}"); }`);
142
+ }
143
+ return rules.join("\n");
144
+ }
145
+ /** Test-only: inspect builder state for assertions. */
146
+ export function _builderRegistrySize() { return builderRegistry.size; }
147
+ export function _builderGlyphsFor(instanceKey) {
148
+ return builderRegistry.get(instanceKey)?.glyphs.size ?? 0;
149
+ }
@@ -757,28 +757,41 @@ function renderDatePicker(el, indent) {
757
757
  const parts = [];
758
758
  const t = el.styles.inputType ?? "date";
759
759
  const val = el.styles.inputValue ?? "";
760
+ // DM-723: Chrome's UA stylesheet sets date/time/month/week/datetime-local
761
+ // inputs to `font-family: monospace; font-size: ~13.333px` (small-control
762
+ // form-control metric). Read the captured font-size so we match Chrome's
763
+ // resolved metric instead of an under-scaled 11px literal — the previous
764
+ // hardcoded size emitted glyphs noticeably narrower than the expected paint.
765
+ const fontSize = parseFloat(el.styles.fontSize ?? "") || 13.333;
760
766
  const tx = el.x + 6;
761
- const ty = el.y + el.height / 2 + 4;
767
+ const ty = el.y + el.height / 2 + fontSize * 0.35;
768
+ // DM-731: pick up the input's resolved color so the value text matches
769
+ // the active color-scheme. Hardcoding `rgb(0,0,0)` made dark-mode date
770
+ // inputs render with invisible black text on a dark background. Falls
771
+ // back to black when the capture didn't supply a color.
772
+ const textFill = (el.styles.color != null && el.styles.color !== "")
773
+ ? el.styles.color
774
+ : "rgb(0,0,0)";
762
775
  // Chrome renders date inputs with an en-US-formatted display value: dates
763
776
  // as MM/DD/YYYY, times as hh:mm AM/PM, etc. The captured `inputValue` is
764
777
  // the canonical ISO form (`2026-04-21`). DM-263.
765
778
  const display = formatDateInputDisplay(t, val);
766
779
  if (display !== "") {
767
- // Chrome paints date input values in a tabular monospaced face; we route
768
- // through the system mono fallback so the segments don't kern.
769
- parts.push(`${indent}<text x="${r(tx)}" y="${r(ty)}" font-size="11" font-family="ui-monospace, Menlo, monospace" fill="rgb(0,0,0)">${display.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]))}</text>`);
780
+ parts.push(`${indent}<text x="${r(tx)}" y="${r(ty)}" font-size="${r(fontSize)}" font-family="ui-monospace, Menlo, monospace" fill="${textFill}">${display.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]))}</text>`);
770
781
  }
771
782
  // Picker icon on the right edge: calendar for date / month / week / datetime-local,
772
783
  // clock for time. Chrome paints these monochrome at ~14px in the input's
773
784
  // line-height. DM-263.
785
+ // DM-731: pass the input's text color through so the icon picks up the
786
+ // dark-mode color (was hardcoded to TRACK_FG light-mode constant).
774
787
  const cx = el.x + el.width - 12;
775
788
  const cy = el.y + el.height / 2;
776
789
  const iconSize = Math.min(11, el.height - 6);
777
790
  if (t === "time") {
778
- parts.push(renderClockIcon(indent, cx, cy, iconSize));
791
+ parts.push(renderClockIcon(indent, cx, cy, iconSize, textFill));
779
792
  }
780
793
  else {
781
- parts.push(renderCalendarIcon(indent, cx, cy, iconSize));
794
+ parts.push(renderCalendarIcon(indent, cx, cy, iconSize, textFill));
782
795
  }
783
796
  return parts.join("\n");
784
797
  }
@@ -837,19 +850,21 @@ function formatDateInputDisplay(type, val) {
837
850
  }
838
851
  return val;
839
852
  }
840
- function renderCalendarIcon(indent, cx, cy, size) {
853
+ function renderCalendarIcon(indent, cx, cy, size, strokeOverride) {
841
854
  // Simple calendar glyph: rounded rect with two top "binders" and a grid line.
842
855
  const w = size;
843
856
  const h = size;
844
857
  const x = cx - w / 2;
845
858
  const y = cy - h / 2;
846
- const stroke = TRACK_FG;
859
+ // DM-731: caller supplies the stroke color so the icon picks up the
860
+ // active color-scheme (was hardcoded to the light-mode `TRACK_FG`).
861
+ const stroke = strokeOverride ?? TRACK_FG;
847
862
  return `${indent}<g fill="none" stroke="${stroke}" stroke-width="1" stroke-linecap="round"><rect x="${r(x + 0.5)}" y="${r(y + 1.5)}" width="${r(w - 1)}" height="${r(h - 2)}" rx="1" /><line x1="${r(x + 0.5)}" y1="${r(y + 4)}" x2="${r(x + w - 0.5)}" y2="${r(y + 4)}" /><line x1="${r(x + 3)}" y1="${r(y + 0.5)}" x2="${r(x + 3)}" y2="${r(y + 2.5)}" /><line x1="${r(x + w - 3)}" y1="${r(y + 0.5)}" x2="${r(x + w - 3)}" y2="${r(y + 2.5)}" /></g>`;
848
863
  }
849
- function renderClockIcon(indent, cx, cy, size) {
864
+ function renderClockIcon(indent, cx, cy, size, strokeOverride) {
850
865
  // Simple clock glyph: circle with two hands.
851
866
  const r1 = size / 2;
852
- const stroke = TRACK_FG;
867
+ const stroke = strokeOverride ?? TRACK_FG;
853
868
  return `${indent}<g fill="none" stroke="${stroke}" stroke-width="1" stroke-linecap="round"><circle cx="${r(cx)}" cy="${r(cy)}" r="${r(r1 - 0.5)}" /><line x1="${r(cx)}" y1="${r(cy)}" x2="${r(cx)}" y2="${r(cy - r1 * 0.55)}" /><line x1="${r(cx)}" y1="${r(cy)}" x2="${r(cx + r1 * 0.4)}" y2="${r(cy)}" /></g>`;
854
869
  }
855
870
  /**
@@ -1120,24 +1135,30 @@ function renderDetailsMarker(el, indent) {
1120
1135
  // previous 0.6em multiplier produced a triangle that read visibly small
1121
1136
  // vs Chrome's painted output.
1122
1137
  const size = Math.max(8, fontSizePx * 0.7);
1123
- const lineH = parseFloat(el.styles.lineHeight ?? "") || fontSizePx * 1.5;
1124
- // Position: marker sits inside the summary at its content-start, which
1125
- // is el.x + paddingL + borderL. Offset by half the marker size so the
1126
- // glyph's center sits ~half-marker-width past the summary's left edge,
1127
- // matching Chrome's painted offset (DM-448).
1138
+ // DM-746: prefer the captured <summary> child's actual y / height when
1139
+ // available that's the line box Chrome paints the marker into. The
1140
+ // previous fallback computed `cy` from `el.y + paddingT + borderT +
1141
+ // lineH/2` with `lineH = parseFloat(lineHeight) || fontSize*1.5`, which
1142
+ // overshoots by ~3 px when lineHeight is "normal" (Chrome's "normal"
1143
+ // resolves to ~1.15× fontSize via the font's intrinsic ascent+descent
1144
+ // +linegap, not the 1.5× literal). The drift was visible as a downward
1145
+ // shift of the disclosure triangle on `niche-command-invokers`.
1146
+ const summaryChild = el.children?.find((c) => c.tag === "summary");
1128
1147
  const padL = parseFloat(el.styles.paddingLeft ?? "") || 0;
1129
1148
  const brL = parseFloat(el.styles.borderLeftWidth ?? "") || 0;
1130
1149
  const padT = parseFloat(el.styles.paddingTop ?? "") || 0;
1131
1150
  const brT = parseFloat(el.styles.borderTopWidth ?? "") || 0;
1132
- const cx = el.x + padL + brL + size / 2;
1133
- // Vertical center: the summary is the first child of <details>; its
1134
- // first line-box top sits at el.y + paddingTop + borderTop, and its
1135
- // center is half a line-height below that. The previous `cy = el.y +
1136
- // lineH/2` ignored the details element's own padding/border, leaving
1137
- // the triangle painted ~paddingTop pixels above where Chrome paints
1138
- // it (DM-448 user feedback: 'disclosure arrow positions still
1139
- // incorrect').
1140
- const cy = el.y + padT + brT + lineH / 2;
1151
+ // Position: marker sits inside the summary at its content-start, which
1152
+ // is el.x + paddingL + borderL. Offset by half the marker size so the
1153
+ // glyph's center sits ~half-marker-width past the summary's left edge,
1154
+ // matching Chrome's painted offset (DM-448).
1155
+ const cx = (summaryChild != null ? summaryChild.x : el.x + padL + brL) + size / 2;
1156
+ // Vertical center: the summary is the first child of <details>; use its
1157
+ // captured line-box center when available. Fallback computes from the
1158
+ // details' padding/border + a corrected line-height ratio.
1159
+ const cy = summaryChild != null
1160
+ ? summaryChild.y + summaryChild.height / 2
1161
+ : el.y + padT + brT + (parseFloat(el.styles.lineHeight ?? "") || fontSizePx * 1.15) / 2;
1141
1162
  const open = el.styles.detailsOpen === true;
1142
1163
  // Use the summary's text color when captured, else dark gray.
1143
1164
  const fill = (el.styles.color != null && el.styles.color !== "")
@@ -104,6 +104,21 @@ export interface ConicGradient {
104
104
  export type AnyGradient = LinearGradient | RadialGradient | ConicGradient;
105
105
  /** Try every supported gradient type. Returns the first that parses or null. */
106
106
  export declare function parseGradient(text: string | undefined | null): AnyGradient | null;
107
+ /**
108
+ * Normalize legacy `-webkit-gradient(linear, ...)` syntax (still emitted by
109
+ * Chromium's computed-style serializer for old CSS that uses it — e.g. the
110
+ * Slashdot mobile header) into the modern `linear-gradient(...)` form so the
111
+ * regular parser can consume it. Returns null when the input isn't a legacy
112
+ * linear webkit-gradient.
113
+ *
114
+ * Grammar (legacy):
115
+ * -webkit-gradient(linear, <p1>, <p2>, from(<c>), [color-stop(<pos>, <c>)...], to(<c>))
116
+ * where <pN> is either a percentage pair `0% 0%` or a side keyword pair like
117
+ * `left top` / `right bottom`. Only axis-aligned cases (vertical / horizontal)
118
+ * are handled — diagonal legacy-syntax gradients are rare and would need a
119
+ * separate angle solve.
120
+ */
121
+ export declare function convertLegacyWebkitGradient(text: string | undefined | null): string | null;
107
122
  /** Parse `linear-gradient(...)` or `repeating-linear-gradient(...)` text. */
108
123
  export declare function parseLinearGradient(text: string | undefined | null): LinearGradient | null;
109
124
  /**
@@ -14,7 +14,108 @@
14
14
  */
15
15
  /** Try every supported gradient type. Returns the first that parses or null. */
16
16
  export function parseGradient(text) {
17
- return parseLinearGradient(text) ?? parseRadialGradient(text) ?? parseConicGradient(text);
17
+ const normalized = convertLegacyWebkitGradient(text) ?? text;
18
+ return parseLinearGradient(normalized) ?? parseRadialGradient(normalized) ?? parseConicGradient(normalized);
19
+ }
20
+ /**
21
+ * Normalize legacy `-webkit-gradient(linear, ...)` syntax (still emitted by
22
+ * Chromium's computed-style serializer for old CSS that uses it — e.g. the
23
+ * Slashdot mobile header) into the modern `linear-gradient(...)` form so the
24
+ * regular parser can consume it. Returns null when the input isn't a legacy
25
+ * linear webkit-gradient.
26
+ *
27
+ * Grammar (legacy):
28
+ * -webkit-gradient(linear, <p1>, <p2>, from(<c>), [color-stop(<pos>, <c>)...], to(<c>))
29
+ * where <pN> is either a percentage pair `0% 0%` or a side keyword pair like
30
+ * `left top` / `right bottom`. Only axis-aligned cases (vertical / horizontal)
31
+ * are handled — diagonal legacy-syntax gradients are rare and would need a
32
+ * separate angle solve.
33
+ */
34
+ export function convertLegacyWebkitGradient(text) {
35
+ if (text == null)
36
+ return null;
37
+ const trimmed = text.trim();
38
+ const m = /^-webkit-gradient\s*\(\s*linear\s*,\s*([\s\S]+)\)\s*$/i.exec(trimmed);
39
+ if (m == null)
40
+ return null;
41
+ const parts = splitTopLevelCommas(m[1]).map((t) => t.trim()).filter((t) => t !== "");
42
+ if (parts.length < 4)
43
+ return null;
44
+ const p1 = parsePointToFracPair(parts[0]);
45
+ const p2 = parsePointToFracPair(parts[1]);
46
+ if (p1 == null || p2 == null)
47
+ return null;
48
+ const dx = p2.x - p1.x;
49
+ const dy = p2.y - p1.y;
50
+ // Only axis-aligned axes (vertical or horizontal). Skip diagonal cases.
51
+ let angleDeg;
52
+ if (Math.abs(dx) < 1e-6 && Math.abs(dy) > 1e-6) {
53
+ angleDeg = dy > 0 ? 180 : 0;
54
+ }
55
+ else if (Math.abs(dy) < 1e-6 && Math.abs(dx) > 1e-6) {
56
+ angleDeg = dx > 0 ? 90 : 270;
57
+ }
58
+ else {
59
+ return null;
60
+ }
61
+ const stopExprs = [];
62
+ for (let i = 2; i < parts.length; i++) {
63
+ const t = parts[i];
64
+ const fromMatch = /^from\s*\(\s*([\s\S]+?)\s*\)\s*$/i.exec(t);
65
+ const toMatch = /^to\s*\(\s*([\s\S]+?)\s*\)\s*$/i.exec(t);
66
+ const csMatch = /^color-stop\s*\(\s*([^,]+)\s*,\s*([\s\S]+?)\s*\)\s*$/i.exec(t);
67
+ if (fromMatch != null) {
68
+ stopExprs.push(`${fromMatch[1].trim()} 0%`);
69
+ }
70
+ else if (toMatch != null) {
71
+ stopExprs.push(`${toMatch[1].trim()} 100%`);
72
+ }
73
+ else if (csMatch != null) {
74
+ const pos = csMatch[1].trim();
75
+ // CSS color-stop accepts a 0..1 number or a percentage; normalize to %.
76
+ const pct = /^[0-9.]+%$/.test(pos) ? pos : `${parseFloat(pos) * 100}%`;
77
+ stopExprs.push(`${csMatch[2].trim()} ${pct}`);
78
+ }
79
+ else {
80
+ return null;
81
+ }
82
+ }
83
+ if (stopExprs.length < 2)
84
+ return null;
85
+ return `linear-gradient(${angleDeg}deg, ${stopExprs.join(", ")})`;
86
+ }
87
+ /**
88
+ * Parse a legacy webkit-gradient point token like `0% 0%`, `left top`, or
89
+ * `right bottom` into fractional 0..1 coordinates. Returns null on diagonal
90
+ * or unrecognised inputs (caller fails fast in that case).
91
+ */
92
+ function parsePointToFracPair(tok) {
93
+ const lower = tok.toLowerCase().trim();
94
+ // Keyword form: "left top", "right", "center bottom", etc.
95
+ const KW = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 };
96
+ const words = lower.split(/\s+/);
97
+ if (words.length > 0 && words.every((w) => w in KW)) {
98
+ // Determine which keyword maps to x vs y. "top/bottom" → y, "left/right" → x.
99
+ let x = 0.5, y = 0.5;
100
+ for (const w of words) {
101
+ if (w === "left" || w === "right")
102
+ x = KW[w];
103
+ else if (w === "top" || w === "bottom")
104
+ y = KW[w];
105
+ else if (w === "center") { /* leave default */ }
106
+ }
107
+ return { x, y };
108
+ }
109
+ // Percentage / pixel pair: "0% 0%", "50% 100%".
110
+ const m = /^([\d.]+)(%|px)\s+([\d.]+)(%|px)$/.exec(lower);
111
+ if (m != null) {
112
+ const ux = m[2] === "%" ? parseFloat(m[1]) / 100 : NaN;
113
+ const uy = m[4] === "%" ? parseFloat(m[3]) / 100 : NaN;
114
+ if (Number.isFinite(ux) && Number.isFinite(uy))
115
+ return { x: ux, y: uy };
116
+ // px-pair: can't resolve without rect dims. Skip — uncommon in real CSS.
117
+ }
118
+ return null;
18
119
  }
19
120
  /** Parse `linear-gradient(...)` or `repeating-linear-gradient(...)` text. */
20
121
  export function parseLinearGradient(text) {
@@ -709,7 +810,7 @@ function resolveStops(stops, gradientLineLength, opts) {
709
810
  function tileRepeatingStops(stops) {
710
811
  if (stops.length < 2)
711
812
  return stops;
712
- const sorted = stops.filter((s) => s.offset != null);
813
+ const sorted = stops.filter((s) => typeof s.offset === "number" && Number.isFinite(s.offset));
713
814
  if (sorted.length < 2)
714
815
  return stops;
715
816
  const tileStart = sorted[0].offset;
@@ -1,5 +1,39 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { buildLinearGradientDef, parseConicGradient, parseGradient, parseLinearGradient } from "./gradients.js";
3
+ describe("convertLegacyWebkitGradient: legacy -webkit-gradient(linear, ...)", () => {
4
+ it("vertical top-to-bottom from()/to() form (slashdot mobile header)", () => {
5
+ // Slashdot's mobile header background, as Chromium serializes it.
6
+ const g = parseGradient("-webkit-gradient(linear, 0% 0%, 0% 100%, from(rgb(0, 0, 0)), to(rgb(32, 32, 32)))");
7
+ expect(g).not.toBeNull();
8
+ expect(g.kind).toBe("linear");
9
+ const lg = g;
10
+ expect(lg.angleDeg).toBe(180);
11
+ expect(lg.stops).toHaveLength(2);
12
+ expect(lg.stops[0].color).toBe("rgb(0, 0, 0)");
13
+ expect(lg.stops[0].offset).toBe(0);
14
+ expect(lg.stops[1].color).toBe("rgb(32, 32, 32)");
15
+ expect(lg.stops[1].offset).toBe(1);
16
+ });
17
+ it("horizontal with side keyword endpoints", () => {
18
+ const g = parseGradient("-webkit-gradient(linear, left top, right top, from(red), to(blue))");
19
+ expect(g).not.toBeNull();
20
+ expect(g.kind).toBe("linear");
21
+ expect(g.angleDeg).toBe(90);
22
+ });
23
+ it("intermediate color-stop() entries pass through", () => {
24
+ const g = parseGradient("-webkit-gradient(linear, 0% 0%, 0% 100%, from(red), color-stop(0.5, green), to(blue))");
25
+ expect(g).not.toBeNull();
26
+ const lg = g;
27
+ expect(lg.stops).toHaveLength(3);
28
+ expect(lg.stops[1].color).toBe("green");
29
+ expect(lg.stops[1].offset).toBe(0.5);
30
+ });
31
+ it("diagonal legacy webkit-gradient is skipped (returns null)", () => {
32
+ // Diagonal endpoints aren't axis-aligned; rare in real CSS — we don't
33
+ // try to solve a general angle from the endpoint pair.
34
+ expect(parseGradient("-webkit-gradient(linear, 0% 0%, 100% 100%, from(red), to(blue))")).toBeNull();
35
+ });
36
+ });
3
37
  describe("parseLinearGradient: repeating support (DM-275)", () => {
4
38
  it("parses repeating-linear-gradient with the repeating flag set", () => {
5
39
  const g = parseLinearGradient("repeating-linear-gradient(90deg, red 0%, blue 10%)");
@@ -39,6 +39,17 @@ interface FontInstance {
39
39
  yStrikeoutSize?: number;
40
40
  };
41
41
  }
42
+ export type RenderTextMode = "paths" | "embedded-font";
43
+ export declare function setRenderTextMode(mode: RenderTextMode): void;
44
+ export declare function getRenderTextMode(): RenderTextMode;
45
+ export declare function clearEmbeddedFonts(): void;
46
+ /**
47
+ * Emit one `@font-face` rule per font the embedded-font path registered
48
+ * during this render pass. Returns the CSS to inject into the SVG's
49
+ * `<style>` block (or `<defs><style>`). Empty string when no fonts were
50
+ * registered (e.g. `renderText: "paths"`).
51
+ */
52
+ export declare function getEmbeddedFontFaceCss(): string;
42
53
  /**
43
54
  * Open a webfont buffer with fontkit and register it under the given family
44
55
  * name (case-insensitive). `weight` is a CSS numeric weight (100-900); 400
@@ -210,7 +221,16 @@ features?: string[],
210
221
  * (PingFang TC / HK / MO, or Hiragino Kaku for `ja`). DM-394. */
211
222
  lang?: string,
212
223
  /** Author-set `font-variation-settings` axis overrides. DM-578. */
213
- variationSettings?: Record<string, number>): string | null;
224
+ variationSettings?: Record<string, number>,
225
+ /** DM-719: `-webkit-text-stroke-width` (px). When > 0, the emitted text
226
+ * group gets a `stroke` attribute so each glyph paints with an outline. */
227
+ textStrokeWidth?: number,
228
+ /** DM-719: `-webkit-text-stroke-color`. Required when `textStrokeWidth > 0`. */
229
+ textStrokeColor?: string,
230
+ /** DM-719: `paint-order` (e.g. "stroke fill"). When `stroke fill`, the
231
+ * stroke paints UNDER the fill so half the stroke is covered, eliminating
232
+ * the chunky fill-on-stroke look at large widths. */
233
+ paintOrder?: string): string | null;
214
234
  /**
215
235
  * Check if text-to-path conversion is available for a font family.
216
236
  */
@@ -263,6 +283,23 @@ underlineOffsetCss?: string): DecorationMetrics;
263
283
  * below baseline). Returns `[]` when font isn't resolvable, no glyphs cross
264
284
  * the rect, or shaping throws. (DM-446.)
265
285
  */
286
+ /**
287
+ * Measure the right-side-bearing (rsb) of the last non-whitespace glyph in
288
+ * `text` when shaped through fontkit at the given font / size / weight /
289
+ * style. rsb = `advanceWidth − bbox.maxX`, in CSS px (already scaled by
290
+ * `fontSize / unitsPerEm`).
291
+ *
292
+ * Used by the list-marker emit path (DM-790): SVG `<text text-anchor="end">`
293
+ * places the anchor at the last glyph's advance-end, not its visible-right
294
+ * edge. To land the visible right at the target (Chromium's `el.x − 7` per
295
+ * `kCMarkerPaddingPx`), we have to shift the anchor right by `rsb`.
296
+ *
297
+ * Returns 0 when the font isn't resolvable, when shaping fails, when the
298
+ * text is empty / whitespace-only, or when the last glyph has no bbox. The
299
+ * built-in marker path's previous empirical 3 px offset for `.` is now
300
+ * font-metric-derived through this helper too.
301
+ */
302
+ export declare function measureLastGlyphRsb(text: string, fontSize: number, fontFamily: string, fontWeight: string | number, fontStyle?: string): number;
266
303
  export declare function computeSkipInkGaps(text: string, fontSize: number, fontFamily: string, fontWeight: string | number, fontStyle?: string, decorationCenterYRel?: number, decorationThickness?: number, features?: string[],
267
304
  /** Chromium-measured run width — when set, intercepts are scaled to match
268
305
  * so gaps line up with the painted glyph positions even when fontkit's