domotion-svg 0.2.2 → 0.3.1

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 +7 -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
package/FEATURES.md CHANGED
@@ -26,6 +26,7 @@ Each feature has a visual regression test that compares HTML-to-PNG with SVG-to-
26
26
  - [x] **border-solid**: Solid borders with color
27
27
  - [x] **border-radius**: Rounded corners
28
28
  - [x] **border-radius-pill**: Fully rounded (pill shape)
29
+ - [x] **inline-box-decoration-break**: wrapped inline backgrounds / borders paint per line-fragment (`slice` + `clone`); first/last fragment own the start/end edges in slice mode, every fragment paints a full box in clone mode
29
30
 
30
31
  ### Layout
31
32
  - [x] **layout-flex-row**: Horizontal flex layout with gap
package/README.md CHANGED
@@ -36,6 +36,35 @@ yourself to keep the first job's runtime down.
36
36
 
37
37
  ## Usage
38
38
 
39
+ The fastest way in is the `domotion` CLI — no TypeScript, no Playwright bring-up. Point it at a URL or HTML file:
40
+
41
+ ```bash
42
+ # Capture a URL as SVG.
43
+ domotion capture https://example.com -o example.svg
44
+
45
+ # Capture a local HTML file at a specific viewport, only the .hero region, optimized.
46
+ domotion capture ./demo.html \
47
+ --width 1200 --height 600 \
48
+ --selector ".hero" \
49
+ --optimize \
50
+ -o hero.svg
51
+
52
+ # Capture HTML piped on stdin.
53
+ cat demo.html | domotion capture - -o demo.svg
54
+ ```
55
+
56
+ For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
57
+
58
+ ```bash
59
+ domotion animate ./demo.json
60
+ ```
61
+
62
+ The config describes each frame (input URL or HTML file, duration, transition, optional pre-capture actions like `click` / `fill` / `scroll` / `hover`). See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
63
+
64
+ ### Scripting API
65
+
66
+ When you outgrow the CLI — custom interaction loops, programmatic frame composition, custom overlays — the same primitives are available as a library:
67
+
39
68
  ```ts
40
69
  import { captureElementTree, elementTreeToSvg, launchChromium, wrapSvg } from "domotion-svg";
41
70
 
@@ -137,15 +137,22 @@ export function generateAnimatedSvg(config) {
137
137
  const beforeStart = Math.max(0, startNum - 0.001).toFixed(3);
138
138
  const afterEnd = Math.min(100, endNum + 0.001).toFixed(3);
139
139
  // DM-599: cut already uses step-end on the opacity animation, so we
140
- // fold display into the same keyframes block — both snap together.
140
+ // fold visibility into the same keyframes block — both snap together.
141
+ // DM-641: this used to toggle `display`. The base `.f { display: none }`
142
+ // rule kept the element out of the render tree at t=0, and Chromium
143
+ // doesn't tick infinite animations on out-of-tree elements — so the
144
+ // 0% keyframe never ran and the frame stayed permanently hidden.
145
+ // Switching to `visibility` leaves the element in the render tree
146
+ // (still skips painting, which was the DM-599 goal) so the animation
147
+ // ticks normally.
141
148
  keyframes.push(`
142
149
  @keyframes fv-${i} {
143
- 0% { opacity: 0; display: none; }
144
- ${beforeStart}% { opacity: 0; display: none; }
145
- ${startNum.toFixed(3)}% { opacity: 1; display: inline; }
146
- ${endNum.toFixed(3)}% { opacity: 1; display: inline; }
147
- ${afterEnd}% { opacity: 0; display: none; }
148
- 100% { opacity: 0; display: none; }
150
+ 0% { opacity: 0; visibility: hidden; }
151
+ ${beforeStart}% { opacity: 0; visibility: hidden; }
152
+ ${startNum.toFixed(3)}% { opacity: 1; visibility: visible; }
153
+ ${endNum.toFixed(3)}% { opacity: 1; visibility: visible; }
154
+ ${afterEnd}% { opacity: 0; visibility: hidden; }
155
+ 100% { opacity: 0; visibility: hidden; }
149
156
  }
150
157
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
151
158
  }
@@ -216,7 +223,7 @@ export function generateAnimatedSvg(config) {
216
223
  </defs>
217
224
  <style>
218
225
  :root { --scene-dur: ${totalSec.toFixed(2)}s; }
219
- .f { opacity: 0; display: none; }
226
+ .f { opacity: 0; visibility: hidden; }
220
227
  ${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
221
228
  </style>
222
229
  <g clip-path="url(#viewport-clip)">
@@ -314,18 +321,22 @@ function pct(ms, total) {
314
321
  * latter and the unmerged-path keyframes feed either form.
315
322
  */
316
323
  function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
324
+ // DM-641: kept the function name for callers but the toggle is now on
325
+ // `visibility`, not `display`, for the same reason as `fv-${i}` above —
326
+ // animating `display` away from an element starting `display: none` never
327
+ // ticks in Chromium.
317
328
  const start = parseFloat(String(visibleStartPct));
318
329
  const end = parseFloat(String(visibleEndPct));
319
330
  const startMinus = Math.max(0, start - 0.01).toFixed(3);
320
331
  const endPlus = Math.min(100, end + 0.01).toFixed(3);
321
332
  return `
322
333
  @keyframes ${name} {
323
- 0% { display: none; }
324
- ${startMinus}% { display: none; }
325
- ${start.toFixed(3)}% { display: inline; }
326
- ${end.toFixed(3)}% { display: inline; }
327
- ${endPlus}% { display: none; }
328
- 100% { display: none; }
334
+ 0% { visibility: hidden; }
335
+ ${startMinus}% { visibility: hidden; }
336
+ ${start.toFixed(3)}% { visibility: visible; }
337
+ ${end.toFixed(3)}% { visibility: visible; }
338
+ ${endPlus}% { visibility: hidden; }
339
+ 100% { visibility: hidden; }
329
340
  }`;
330
341
  }
331
342
  /**
@@ -172,12 +172,15 @@ describe("animator", () => {
172
172
  expect(svg).toContain("transform: translateY(240px)");
173
173
  expect(svg).toContain("transform: translateY(0px)");
174
174
  });
175
- it("DM-599: push-left frame gets a paired fd-N display animation alongside fv-N", () => {
175
+ it("DM-599/DM-641: push-left frame gets a paired fd-N visibility animation alongside fv-N", () => {
176
176
  // push-left is unmergeable (the merge fast path only takes crossfade/cut),
177
177
  // so it goes through the unmerged emit path that emits per-frame fv-/fp-
178
178
  // blocks. The DM-599 optimization adds an fd-N keyframes block that
179
- // toggles `display: none inline` so the frame is dropped from paint
180
- // outside its show window.
179
+ // toggles a paint-skip property so the frame is dropped from paint
180
+ // outside its show window. DM-641 changed that toggle from `display`
181
+ // (which parks the CSS-animations engine when an element starts out of
182
+ // the render tree) to `visibility` (which keeps the element rendered
183
+ // but skips painting it).
181
184
  const svg = generateAnimatedSvg({
182
185
  width: 100, height: 100,
183
186
  frames: [
@@ -190,17 +193,20 @@ describe("animator", () => {
190
193
  expect(svg).toMatch(/@keyframes fd-1\s*{/);
191
194
  // … alongside the existing fv-N opacity block.
192
195
  expect(svg).toMatch(/@keyframes fv-0\s*{/);
193
- // The keyframes flip display between none and inline.
194
- expect(svg).toMatch(/display:\s*none/);
195
- expect(svg).toMatch(/display:\s*inline/);
196
+ // The keyframes flip visibility (DM-641 formerly display).
197
+ expect(svg).toMatch(/visibility:\s*hidden/);
198
+ expect(svg).toMatch(/visibility:\s*visible/);
199
+ // DM-641: must NOT emit display toggles anywhere — those broke the
200
+ // infinite animation in Chromium.
201
+ expect(svg).not.toMatch(/display:\s*none/);
202
+ expect(svg).not.toMatch(/display:\s*inline/);
196
203
  // The frame's CSS rule lists BOTH animations, with the fd one tagged
197
- // step-end so the display flip is instant (not snap-at-50% of segment).
204
+ // step-end so the visibility flip is instant.
198
205
  expect(svg).toMatch(/\.f-0\s*{\s*animation:[^}]*fv-0[^}]*,[^}]*fd-0[^}]*step-end/);
199
- // The base .f rule sets display:none so frames start hidden until the
200
- // keyframe flips them in.
201
- expect(svg).toMatch(/\.f\s*{[^}]*display:\s*none/);
206
+ // The base .f rule sets visibility:hidden (was display:none pre-DM-641).
207
+ expect(svg).toMatch(/\.f\s*{[^}]*visibility:\s*hidden/);
202
208
  });
203
- it("DM-599: cut frames fold display into fv-N (same step-end timing)", () => {
209
+ it("DM-599/DM-641: cut frames fold visibility into fv-N (same step-end timing)", () => {
204
210
  // Three explicit `cut` frames — the all-mergeable check trips and these
205
211
  // route through the MERGE pipeline. But a non-mergeable transition mixed
206
212
  // in (e.g. push-left) would route this through the unmerged path. We
@@ -213,19 +219,19 @@ describe("animator", () => {
213
219
  { svgContent: `<rect fill="green"/>`, duration: 1000 },
214
220
  ],
215
221
  });
216
- // The "cut" frame's fv-N keyframes carry the display toggle inline (no
217
- // separate fd-N block) since cut already uses step-end on fv-N.
222
+ // The "cut" frame's fv-N keyframes carry the visibility toggle inline
223
+ // (no separate fd-N block) since cut already uses step-end on fv-N.
218
224
  const fv1Match = svg.match(/@keyframes fv-1\s*{[\s\S]*?\n\s*}/);
219
225
  expect(fv1Match).not.toBeNull();
220
- expect(fv1Match[0]).toMatch(/display:\s*none/);
221
- expect(fv1Match[0]).toMatch(/display:\s*inline/);
226
+ expect(fv1Match[0]).toMatch(/visibility:\s*hidden/);
227
+ expect(fv1Match[0]).toMatch(/visibility:\s*visible/);
222
228
  // The "cut" frame uses ONLY fv-1 (no fd-1 — it's folded in).
223
229
  expect(svg).not.toMatch(/@keyframes fd-1\s*{/);
224
230
  });
225
- it("DM-599: merged-path keyframes emit display alongside opacity", () => {
231
+ it("DM-599/DM-641: merged-path keyframes emit visibility alongside opacity", () => {
226
232
  // Two crossfade frames with different content route through the merge
227
233
  // pipeline. Per-element visibility classes (tN) now toggle BOTH opacity
228
- // and display so the browser can skip painting hidden elements.
234
+ // and visibility so the browser can skip painting hidden elements.
229
235
  const svg = generateAnimatedSvg({
230
236
  width: 100, height: 100,
231
237
  frames: [
@@ -233,12 +239,39 @@ describe("animator", () => {
233
239
  { svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
234
240
  ],
235
241
  });
236
- // Each tN keyframe stop with opacity:1 also has display:inline; each
237
- // opacity:0 stop has display:none.
242
+ // Each tN keyframe stop with opacity:1 also has visibility:visible; each
243
+ // opacity:0 stop has visibility:hidden.
238
244
  const tN = svg.match(/@keyframes t\d+\s*{[\s\S]*?\n\s*}/);
239
245
  expect(tN).not.toBeNull();
240
- expect(tN[0]).toMatch(/opacity:\s*1;\s*display:\s*inline/);
241
- expect(tN[0]).toMatch(/opacity:\s*0;\s*display:\s*none/);
246
+ expect(tN[0]).toMatch(/opacity:\s*1;\s*visibility:\s*visible/);
247
+ expect(tN[0]).toMatch(/opacity:\s*0;\s*visibility:\s*hidden/);
248
+ });
249
+ it("DM-641: never emits `display: none` keyframes (would park Chromium's animation engine)", () => {
250
+ // Regression. The repro from the ticket: a multi-frame animation with
251
+ // `cut` transitions produced `@keyframes fv-0 { 0% { opacity:0; display:none } … }`
252
+ // plus `.f { display: none }`, which Chromium would never tick — so
253
+ // EVERY frame stayed permanently hidden when the SVG was loaded into a
254
+ // browser. The fix swapped both sites onto `visibility`. This test pins
255
+ // the fix on every code path that emits keyframes for the animator.
256
+ const cutSvg = generateAnimatedSvg({
257
+ width: 100, height: 100,
258
+ frames: [
259
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
260
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
261
+ { svgContent: `<rect/>`, duration: 1000 },
262
+ ],
263
+ });
264
+ expect(cutSvg).not.toMatch(/display:\s*none/);
265
+ expect(cutSvg).not.toMatch(/display:\s*inline/);
266
+ const pushSvg = generateAnimatedSvg({
267
+ width: 100, height: 100,
268
+ frames: [
269
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "push-left", duration: 100 } },
270
+ { svgContent: `<rect/>`, duration: 1000 },
271
+ ],
272
+ });
273
+ expect(pushSvg).not.toMatch(/display:\s*none/);
274
+ expect(pushSvg).not.toMatch(/display:\s*inline/);
242
275
  });
243
276
  it("cut transition: timeline boundary is exactly at the frame edge", () => {
244
277
  // For two frames each held 1000ms with cut transitions and no overlap,
@@ -114,13 +114,11 @@ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
114
114
  return { x: curX + ev.by.dx, y: curY + ev.by.dy };
115
115
  if (ev.selector != null) {
116
116
  if (resolveSelector == null) {
117
- // eslint-disable-next-line no-console
118
117
  console.warn(`cursor-overlay: selector "${ev.selector}" used but no resolveSelector provided; skipping`);
119
118
  return null;
120
119
  }
121
120
  const rect = resolveSelector(ev.selector, frameIndex);
122
121
  if (rect == null) {
123
- // eslint-disable-next-line no-console
124
122
  console.warn(`cursor-overlay: selector "${ev.selector}" matched no element in frame ${frameIndex}; skipping`);
125
123
  return null;
126
124
  }
@@ -31,7 +31,12 @@ function loadAppleColorEmojiFont() {
31
31
  return null;
32
32
  try {
33
33
  const opened = fontkit.openSync(APPLE_COLOR_EMOJI_PATH);
34
- _aceFont = opened.fonts != null ? opened.fonts[0] : opened;
34
+ if (opened == null) {
35
+ _aceFont = null;
36
+ }
37
+ else {
38
+ _aceFont = opened.fonts != null ? opened.fonts[0] : opened;
39
+ }
35
40
  }
36
41
  catch {
37
42
  _aceFont = null;
@@ -139,23 +144,29 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
139
144
  const sbixPng = extractEmojiBitmap(cp, g.rect.width);
140
145
  if (sbixPng != null) {
141
146
  g.dataUri = `data:image/png;base64,${sbixPng.toString("base64")}`;
142
- // sbix bitmaps are square. The captured rect uses the line-box
143
- // height (typically the advance width for emoji) which is
144
- // shorter than the advance width Range.getBoundingClientRect
145
- // returns the line box, not the painted ink. Painting the
146
- // square bitmap into a non-square rect with preserveAspectRatio
147
- // ="none" squishes the glyph vertically (DM-438: smiley emoji
148
- // rendered 20×17 instead of 20×20). Chrome paints the bitmap
149
- // at em-square aspect with the BOTTOM aligned to the line-box
150
- // bottom, so extend the rect upward to a square. Scoped to
151
- // sbix-fed glyphs (Apple Color Emoji on macOS) so DM-401/411
152
- // /414's flag-emoji regressiondriven by page.screenshot's
153
- // already-rectangular bitmaps does not return.
154
- if (g.rect.width > g.rect.height) {
155
- const bottom = g.rect.y + g.rect.height;
156
- g.rect.height = g.rect.width;
157
- g.rect.y = bottom - g.rect.width;
158
- }
147
+ // sbix bitmaps are square (em-square sized). Chrome paints
148
+ // them centered horizontally on the glyph advance and bottom-
149
+ // aligned to the line-box. The captured rect spans the
150
+ // typographic line-box (advance × line-height), which is
151
+ // bigger than the em-square on either axis when letter-spacing
152
+ // or line-height add slack. DM-438: smiley rendered 20×17 in a
153
+ // 20-wide / 17-tall rect (height shorter than width) fixed
154
+ // by extending the rect upward to a 20×20 square. DM-801: at
155
+ // font-size 48 with letter-spacing 8, the rect was 56×63
156
+ // (width INCLUDES letter-spacing, height bigger than em-
157
+ // square), so emoji painted as 56×63 vertically stretched
158
+ // hearts and wide smileys. Snap to fontSize × fontSize
159
+ // centered horizontally on the rect's advance and bottom-
160
+ // aligned vertically; falls back to max(w,h) when fontSize
161
+ // isn't carried on the segment (only the SVG path needs it,
162
+ // not the existing screenshot path which already round-trips
163
+ // a rectangular PNG).
164
+ const elFs = parseFloat(el.styles.fontSize ?? "") || 0;
165
+ const fs = seg.fontSize ?? (elFs > 0 ? elFs : Math.max(g.rect.width, g.rect.height));
166
+ g.rect.x += (g.rect.width - fs) / 2;
167
+ g.rect.y += (g.rect.height - fs) / 2;
168
+ g.rect.width = fs;
169
+ g.rect.height = fs;
159
170
  continue;
160
171
  }
161
172
  }
@@ -435,7 +435,7 @@ export async function discoverAndRegisterWebfonts(page, observedFontUrls = []) {
435
435
  // ran in this scenario registers under fontkit's internal `familyName`,
436
436
  // which for license-protected fonts (Sohne) is a copyright string —
437
437
  // unmatchable against the CSS-declared `font-family: sohne-var` query.
438
- for (const sheetUrl of fromPage.crossOriginSheetUrls ?? []) {
438
+ for (const sheetUrl of fromPage.crossOriginSheetUrls) {
439
439
  let cssText;
440
440
  try {
441
441
  const resp = await page.context().request.get(sheetUrl);
@@ -482,10 +482,9 @@ export async function discoverAndRegisterWebfonts(page, observedFontUrls = []) {
482
482
  // (e.g. an alias whose width happened to disagree with all candidates
483
483
  // due to layout shaping that the simple sample didn't exercise).
484
484
  const declaredWeight = parseWeightDescriptor(item.weight);
485
- const declaredStyle = String(item.style ?? "normal").toLowerCase();
485
+ const declaredStyle = item.style.toLowerCase();
486
486
  const declaredItalic = declaredStyle !== "" && declaredStyle !== "normal";
487
- const resolved = item.resolvedLocalName;
488
- const candidates = resolved != null ? [resolved] : item.localNames;
487
+ const candidates = item.resolvedLocalName != null ? [item.resolvedLocalName] : item.localNames;
489
488
  for (const localName of candidates) {
490
489
  const key = systemFontKeyForLocalName(localName);
491
490
  if (key != null) {
@@ -592,6 +591,8 @@ async function readFontMetadata(buf) {
592
591
  const fontkit = await import("fontkit");
593
592
  try {
594
593
  const f = fontkit.create(buf);
594
+ if (f == null)
595
+ return null;
595
596
  const family = f.familyName ?? "";
596
597
  if (family === "")
597
598
  return null;
@@ -1,3 +1,4 @@
1
1
  export declare const createColorNorm: () => {
2
2
  normColor: (c: any, elColor: any) => any;
3
+ normGradientColors: (text: any, elColor: any) => any;
3
4
  };
@@ -40,5 +40,47 @@ export const createColorNorm = () => {
40
40
  catch (e) { /* fall through */ }
41
41
  return c;
42
42
  };
43
- return { normColor };
43
+ // DM-800: Chromium retains wide-gamut color functions verbatim inside
44
+ // computed gradient stops (e.g. `linear-gradient(90deg, oklch(0.89 0.04
45
+ // 264), …)`). The render-side `parseColor` doesn't speak oklch/lab/lch/
46
+ // oklab/hwb and falls back to black for any stop it can't decode,
47
+ // collapsing the tinted-gradient strip to mostly-black bars. Walk the
48
+ // gradient text and replace each wide-gamut color call with its
49
+ // normColor-resolved form so the renderer only sees rgb()/color(srgb).
50
+ // `color-mix(...)` doesn't appear here because Chromium pre-resolves it
51
+ // inside gradients to its target color space (e.g. `color-mix(in oklch,
52
+ // red, blue)` serializes as `oklch(...)`), but we match it defensively in
53
+ // case future Chromium versions change that.
54
+ const normGradientColors = (text, elColor) => {
55
+ if (text == null || text === '' || text === 'none')
56
+ return text;
57
+ // Match a color-function identifier followed by a balanced (...) group.
58
+ const fnRe = /\b(oklch|oklab|lab|lch|hwb|hsl|hsla|color|color-mix)\(/gi;
59
+ var out = '';
60
+ var i = 0;
61
+ while (i < text.length) {
62
+ fnRe.lastIndex = i;
63
+ const m = fnRe.exec(text);
64
+ if (m == null) {
65
+ out += text.slice(i);
66
+ break;
67
+ }
68
+ out += text.slice(i, m.index);
69
+ // Walk forward consuming balanced parens.
70
+ var depth = 1;
71
+ var j = m.index + m[0].length;
72
+ while (j < text.length && depth > 0) {
73
+ const ch = text[j++];
74
+ if (ch === '(')
75
+ depth++;
76
+ else if (ch === ')')
77
+ depth--;
78
+ }
79
+ const call = text.slice(m.index, j);
80
+ out += normColor(call, elColor);
81
+ i = j;
82
+ }
83
+ return out;
84
+ };
85
+ return { normColor, normGradientColors };
44
86
  };
@@ -24,6 +24,12 @@ export const createEmojiDetect = () => {
24
24
  const rasterCps = new Set([
25
25
  0x2713, 0x2714, 0x2716, 0x2717, 0x2728, 0x2753, 0x2754, 0x2755, 0x2757,
26
26
  0x274C, 0x274E, 0x2795, 0x2796, 0x2797, 0x27A1, 0x27B0, 0x27BF,
27
+ // DM-728: U+2B?? "Miscellaneous Symbols and Arrows" block with default
28
+ // emoji presentation per Unicode emoji-data — Chrome paints these as
29
+ // Apple Color Emoji glyphs without needing the U+FE0F variation
30
+ // selector. The fixture's ⭐ U+2B50 in `20-deep-font-palette.html` was
31
+ // painting as a hollow tofu before this list was extended.
32
+ 0x2B05, 0x2B06, 0x2B07, 0x2B1B, 0x2B1C, 0x2B50, 0x2B55,
27
33
  ]);
28
34
  // Codepoints in the U+2600-26FF Misc Symbols block with EmojiPresentation=Yes
29
35
  // per Unicode emoji-data: Chrome paints these as color emoji by default
@@ -49,6 +55,14 @@ export const createEmojiDetect = () => {
49
55
  0x2696, 0x2697, 0x2699, 0x269B, 0x269C, 0x26A0, 0x26A7, 0x26B0,
50
56
  0x26B1, 0x26C8, 0x26CF, 0x26D1, 0x26D3, 0x26E9, 0x26F0, 0x26F1,
51
57
  0x26F4, 0x26F7, 0x26F8, 0x26F9,
58
+ // DM-728: Dingbats block (U+27??) codepoints with text-default
59
+ // presentation that flip to color emoji when paired with U+FE0F. The
60
+ // fixture's ❤️ (U+2764 + U+FE0F) heart was painting as a small black
61
+ // monochrome glyph before this entry was added; with it, the VS-16
62
+ // pairing routes through the raster overlay path so Apple Color Emoji
63
+ // paints the red heart Chrome shows.
64
+ 0x2702, 0x2708, 0x2709, 0x270C, 0x270D, 0x270F, 0x2712, 0x2716,
65
+ 0x2733, 0x2734, 0x2744, 0x2747, 0x2763, 0x2764,
52
66
  ]);
53
67
  const needsRaster = (cp, nextCp) => {
54
68
  if (rasterCps.has(cp))