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
@@ -8,6 +8,16 @@ import { r, esc } from "./format.js";
8
8
  import { parseColor, type RGBA } from "./colors.js";
9
9
  import type { CapturedElement } from "../capture/types.js";
10
10
  import { embedResizedDataUri } from "../capture/embed.js";
11
+ import { parseGradient, buildLinearGradientDef, buildRadialGradientDef } from "./gradients.js";
12
+
13
+ /** `border-image-repeat` per-axis keyword. */
14
+ type BorderImageRepeat = "stretch" | "repeat" | "round" | "space";
15
+ const BORDER_IMAGE_REPEATS = new Set<string>(["stretch", "repeat", "round", "space"]);
16
+
17
+ function normalizeBorderImageRepeat(raw: string | undefined): BorderImageRepeat {
18
+ if (raw != null && BORDER_IMAGE_REPEATS.has(raw)) return raw as BorderImageRepeat;
19
+ return "stretch";
20
+ }
11
21
 
12
22
  /** Per-corner border-radius axis-pair (h = horizontal, v = vertical).
13
23
  * An elliptical corner has h ≠ v; a circular corner has h = v. */
@@ -97,6 +107,28 @@ export function insetCornerRadii(c: CornerRadii, top: number, right: number, bot
97
107
  return { tl, tr, br, bl, uniform };
98
108
  }
99
109
 
110
+ /** Grow each corner radius outward by `spread` for an OUTSET box-shadow shape.
111
+ * Per CSS Backgrounds 3 §6.4 and Chromium's `FloatRoundedRect::Outset`, a
112
+ * corner whose source radius is zero STAYS sharp through any spread — only
113
+ * pre-curved corners grow. A naive `corner + spread` produces visibly
114
+ * rounded shadow corners on a sharp-cornered box (e.g. concentric outlines
115
+ * built from `box-shadow: 0 0 0 Npx`). Use this for outset shadow shapes;
116
+ * the dual inset case is already covered by `insetCornerRadii` shrinking to
117
+ * zero when the border eats past the radius. */
118
+ export function outsetCornerRadiiForShadow(c: CornerRadii, spread: number): CornerRadii {
119
+ const grow = (p: CornerRadiusPair): CornerRadiusPair => ({
120
+ h: p.h > 0 ? Math.max(0, p.h + spread) : 0,
121
+ v: p.v > 0 ? Math.max(0, p.v + spread) : 0,
122
+ });
123
+ const tl = grow(c.tl);
124
+ const tr = grow(c.tr);
125
+ const br = grow(c.br);
126
+ const bl = grow(c.bl);
127
+ const uniform = tl.h === tl.v && tl.h === tr.h && tl.h === tr.v
128
+ && tl.h === br.h && tl.h === br.v && tl.h === bl.h && tl.h === bl.v;
129
+ return { tl, tr, br, bl, uniform };
130
+ }
131
+
100
132
  /** Emit an SVG path `d` attribute for a rounded rectangle with per-corner radii.
101
133
  * Path goes clockwise from the top-left, using elliptical arc commands at each
102
134
  * corner. Zero-radius corners collapse to a sharp 90° join. */
@@ -195,6 +227,228 @@ export function injectSvgSize(svgHtml: string, w: number, h: number): string {
195
227
  * Returns { svg, usedIds }. usedIds indicates how many clipIdx values were
196
228
  * consumed so the caller can keep its own counter in sync.
197
229
  */
230
+ /**
231
+ * Render a `border-image-source` that's a CSS gradient as a proper 9-slice.
232
+ *
233
+ * Per CSS Images 3, a gradient used as `border-image-source` has the size of
234
+ * the border-image-area (= border-box ± `border-image-outset`). The 9-slice
235
+ * algorithm then applies just like for a raster source: corners stretched,
236
+ * edges tiled per `border-image-repeat`, optional fill center.
237
+ *
238
+ * Implementation: build a single `<linearGradient>` / `<radialGradient>` def
239
+ * positioned in source space `(0, 0) - (natW, natH)` where natW = boxW,
240
+ * natH = boxH. Each slot emits an inner `<svg x dx y dy width dw height dh
241
+ * viewBox="sx sy sw sh" preserveAspectRatio="none">` containing a `<rect
242
+ * width="natW" height="natH" fill="url(#g)" />`. The viewBox maps the source
243
+ * slice rect onto the destination slot; the gradient comes along because its
244
+ * `userSpaceOnUse` coordinates are interpreted in the viewBox space. Tiled
245
+ * edges (`repeat` / `round` / `space`) wrap that inner `<svg>` in a
246
+ * `<pattern>`. The single-def-per-element keeps SVG output small and matches
247
+ * how the URL path reuses one source asset.
248
+ */
249
+ function renderBorderImageGradient(
250
+ el: CapturedElement,
251
+ indent: string,
252
+ idPrefix: string,
253
+ defsParts: string[],
254
+ clipIdx: number,
255
+ src: string,
256
+ ): { svg: string; usedIds: number } {
257
+ const grad = parseGradient(src);
258
+ if (grad == null) return { svg: "", usedIds: 0 };
259
+ if (grad.kind !== "linear" && grad.kind !== "radial") return { svg: "", usedIds: 0 };
260
+
261
+ const sliceRaw = el.styles.borderImageSlice ?? "100%";
262
+ const fillCenter = /\bfill\b/i.test(sliceRaw);
263
+ const bwTop = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
264
+ const bwRight = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
265
+ const bwBottom = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
266
+ const bwLeft = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
267
+
268
+ // Outsets: default 0. Same parsing as URL path.
269
+ const outsetTokens = (el.styles.borderImageOutset ?? "0").trim().split(/\s+/);
270
+ const parseOutset = (tok: string | undefined, basis: number, borderW: number): number => {
271
+ if (tok == null || tok === "") return 0;
272
+ if (/%$/.test(tok)) return (parseFloat(tok) / 100) * basis;
273
+ if (/(px|em|rem|pt|pc|cm|mm|in|Q)$/.test(tok)) return parseFloat(tok) || 0;
274
+ const n = parseFloat(tok);
275
+ return Number.isFinite(n) ? n * borderW : 0;
276
+ };
277
+ const ot = parseOutset(outsetTokens[0], el.height, bwTop);
278
+ const or_ = parseOutset(outsetTokens[1] ?? outsetTokens[0], el.width, bwRight);
279
+ const ob = parseOutset(outsetTokens[2] ?? outsetTokens[0], el.height, bwBottom);
280
+ const ol = parseOutset(outsetTokens[3] ?? outsetTokens[1] ?? outsetTokens[0], el.width, bwLeft);
281
+
282
+ // Border-image-width per side; default = element's border-width. Same as URL path.
283
+ const parseBorderImageLen = (tok: string | undefined, basis: number, borderW: number): number => {
284
+ if (tok == null || tok === "" || tok === "auto") return borderW;
285
+ if (/%$/.test(tok)) return (parseFloat(tok) / 100) * basis;
286
+ if (/(px|em|rem|pt|pc|cm|mm|in|Q)$/.test(tok)) return parseFloat(tok) || 0;
287
+ const n = parseFloat(tok);
288
+ return Number.isFinite(n) ? n * borderW : borderW;
289
+ };
290
+ const widthTokens = (el.styles.borderImageWidth ?? "").trim().split(/\s+/);
291
+ const wt = parseBorderImageLen(widthTokens[0], el.height, bwTop);
292
+ const wr = parseBorderImageLen(widthTokens[1] ?? widthTokens[0], el.width, bwRight);
293
+ const wb = parseBorderImageLen(widthTokens[2] ?? widthTokens[0], el.height, bwBottom);
294
+ const wl = parseBorderImageLen(widthTokens[3] ?? widthTokens[1] ?? widthTokens[0], el.width, bwLeft);
295
+
296
+ const boxX = el.x - ol;
297
+ const boxY = el.y - ot;
298
+ const boxW = el.width + ol + or_;
299
+ const boxH = el.height + ot + ob;
300
+ if (boxW <= 0 || boxH <= 0) return { svg: "", usedIds: 0 };
301
+
302
+ // Gradient sources have the size of the border-image-area.
303
+ const natW = boxW;
304
+ const natH = boxH;
305
+
306
+ // Slice: numbers = source pixels, percentages = of source dims, optional `fill`.
307
+ const sliceTokens = sliceRaw.replace(/\bfill\b/i, "").trim().split(/\s+/);
308
+ const sliceNums = sliceTokens.map((t) => {
309
+ if (/%$/.test(t)) return { pct: parseFloat(t) };
310
+ return { px: parseFloat(t) };
311
+ });
312
+ const resolveSlice = (tok: { pct?: number; px?: number }, basis: number): number => {
313
+ if (tok.pct != null) return (tok.pct / 100) * basis;
314
+ return tok.px ?? 0;
315
+ };
316
+ const st = resolveSlice(sliceNums[0] ?? { px: 0 }, natH);
317
+ const sr = resolveSlice(sliceNums[1] ?? sliceNums[0] ?? { px: 0 }, natW);
318
+ const sb = resolveSlice(sliceNums[2] ?? sliceNums[0] ?? { px: 0 }, natH);
319
+ const sl = resolveSlice(sliceNums[3] ?? sliceNums[1] ?? sliceNums[0] ?? { px: 0 }, natW);
320
+
321
+ // Repeat policy per axis.
322
+ const repeatTokens = (el.styles.borderImageRepeat ?? "stretch").trim().split(/\s+/);
323
+ const rH = normalizeBorderImageRepeat((repeatTokens[0] ?? "stretch").toLowerCase());
324
+ const rV = repeatTokens[1] != null && repeatTokens[1] !== "" ? normalizeBorderImageRepeat(repeatTokens[1].toLowerCase()) : rH;
325
+
326
+ // Gradient def in source space (0, 0) - (natW, natH). Positioned at the
327
+ // border-image-area's element-absolute origin (boxX, boxY) so the inner
328
+ // <svg viewBox> remap below lands the gradient on the correct destination
329
+ // coordinates. Each <rect> inside an inner <svg viewBox="sx sy sw sh">
330
+ // paints the slice region by drawing the full natW × natH rect — the
331
+ // viewBox + preserveAspectRatio="none" map source slice → destination slot.
332
+ const gid = `${idPrefix}big${clipIdx}`;
333
+ let usedIds = 1;
334
+ const gradRect = { x: boxX, y: boxY, w: natW, h: natH };
335
+ const def = grad.kind === "linear"
336
+ ? buildLinearGradientDef(grad, gid, gradRect)
337
+ : buildRadialGradientDef(grad, gid, gradRect);
338
+ defsParts.push(def);
339
+
340
+ // Slot geometry in element-absolute coords.
341
+ const x0 = boxX, x1 = boxX + wl, x2 = boxX + boxW - wr, x3 = boxX + boxW;
342
+ const y0 = boxY, y1 = boxY + wt, y2 = boxY + boxH - wb, y3 = boxY + boxH;
343
+ // Source regions in source pixels (NB: corner rects + edge / center rects).
344
+ const sxL = 0, sxR = natW - sr, sxC = sl, sxW_C = natW - sl - sr;
345
+ const syT = 0, syB = natH - sb, syC = st, syH_C = natH - st - sb;
346
+
347
+ const parts: string[] = [];
348
+
349
+ // Inner <svg viewBox> that paints the source slice rect (sx, sy, sw, sh)
350
+ // into the destination slot (dx, dy, dw, dh). The gradient is positioned
351
+ // in source-space coords (boxX..boxX+natW, boxY..boxY+natH); to keep it
352
+ // aligned through the viewBox mapping, the viewBox is offset to start at
353
+ // (boxX + sx, boxY + sy) — so the gradient's userSpaceOnUse coordinates
354
+ // line up with the source rect we're sampling. Then a single <rect>
355
+ // covering (boxX, boxY) - (boxX+natW, boxY+natH) lets the gradient
356
+ // evaluate across the full source space; the viewBox crops to the slice.
357
+ const innerSvgForSlot = (
358
+ dx: number, dy: number, dw: number, dh: number,
359
+ sx: number, sy: number, sw: number, sh: number,
360
+ ): string => {
361
+ return `<svg x="${r(dx)}" y="${r(dy)}" width="${r(dw)}" height="${r(dh)}" viewBox="${r(boxX + sx)} ${r(boxY + sy)} ${r(sw)} ${r(sh)}" preserveAspectRatio="none"><rect x="${r(boxX)}" y="${r(boxY)}" width="${r(natW)}" height="${r(natH)}" fill="url(#${gid})" /></svg>`;
362
+ };
363
+
364
+ const emitStretchedSlot = (
365
+ dx: number, dy: number, dw: number, dh: number,
366
+ sx: number, sy: number, sw: number, sh: number,
367
+ ): void => {
368
+ if (dw <= 0 || dh <= 0 || sw <= 0 || sh <= 0) return;
369
+ parts.push(`${indent}${innerSvgForSlot(dx, dy, dw, dh, sx, sy, sw, sh)}`);
370
+ };
371
+
372
+ // Tiled edges: wrap the inner <svg> in a <pattern> sized to one tile, then
373
+ // fill the destination rect with that pattern. round / space tile-count
374
+ // logic mirrors the URL path's `emitTiledSliceEdge`. For `space`, the
375
+ // pattern cell is the slot/N and the inner <svg> is centered inside the
376
+ // cell so each end has a half-gap; transparent gap is automatic because
377
+ // the inner <svg> is smaller than the pattern cell.
378
+ const emitTiledEdgeSlot = (
379
+ dx: number, dy: number, dw: number, dh: number,
380
+ sx: number, sy: number, sw: number, sh: number,
381
+ axis: "x" | "y", mode: "repeat" | "round" | "space",
382
+ ): void => {
383
+ if (dw <= 0 || dh <= 0 || sw <= 0 || sh <= 0) return;
384
+ let tileW: number, tileH: number;
385
+ if (axis === "x") {
386
+ tileH = dh;
387
+ tileW = sw * (dh / sh);
388
+ if (mode === "round") {
389
+ const count = Math.max(1, Math.round(dw / tileW));
390
+ tileW = dw / count;
391
+ }
392
+ } else {
393
+ tileW = dw;
394
+ tileH = sh * (dw / sw);
395
+ if (mode === "round") {
396
+ const count = Math.max(1, Math.round(dh / tileH));
397
+ tileH = dh / count;
398
+ }
399
+ }
400
+ let patternW = tileW, patternH = tileH;
401
+ let tileOffX = 0, tileOffY = 0;
402
+ if (mode === "space") {
403
+ if (axis === "x") {
404
+ const count = Math.floor(dw / tileW);
405
+ if (count <= 0) return;
406
+ patternW = dw / count;
407
+ tileOffX = (patternW - tileW) / 2;
408
+ } else {
409
+ const count = Math.floor(dh / tileH);
410
+ if (count <= 0) return;
411
+ patternH = dh / count;
412
+ tileOffY = (patternH - tileH) / 2;
413
+ }
414
+ }
415
+ const patId = `${idPrefix}bip${clipIdx + usedIds}`;
416
+ usedIds++;
417
+ const inner = innerSvgForSlot(tileOffX, tileOffY, tileW, tileH, sx, sy, sw, sh);
418
+ defsParts.push(`<pattern id="${patId}" patternUnits="userSpaceOnUse" x="${r(dx)}" y="${r(dy)}" width="${r(patternW)}" height="${r(patternH)}">${inner}</pattern>`);
419
+ parts.push(`${indent}<rect x="${r(dx)}" y="${r(dy)}" width="${r(dw)}" height="${r(dh)}" fill="url(#${patId})" />`);
420
+ };
421
+
422
+ // 4 corners — always stretched.
423
+ emitStretchedSlot(x0, y0, wl, wt, sxL, syT, sl, st); // NW
424
+ emitStretchedSlot(x2, y0, wr, wt, sxR, syT, sr, st); // NE
425
+ emitStretchedSlot(x0, y2, wl, wb, sxL, syB, sl, sb); // SW
426
+ emitStretchedSlot(x2, y2, wr, wb, sxR, syB, sr, sb); // SE
427
+ // Top + Bottom edges.
428
+ if (rH === "stretch") {
429
+ emitStretchedSlot(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st);
430
+ emitStretchedSlot(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb);
431
+ } else {
432
+ emitTiledEdgeSlot(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st, "x", rH);
433
+ emitTiledEdgeSlot(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb, "x", rH);
434
+ }
435
+ // Left + Right edges.
436
+ if (rV === "stretch") {
437
+ emitStretchedSlot(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C);
438
+ emitStretchedSlot(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C);
439
+ } else {
440
+ emitTiledEdgeSlot(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C, "y", rV);
441
+ emitTiledEdgeSlot(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C, "y", rV);
442
+ }
443
+ // Center — only when `fill`.
444
+ if (fillCenter) {
445
+ emitStretchedSlot(x1, y1, x2 - x1, y2 - y1, sxC, syC, sxW_C, syH_C);
446
+ }
447
+
448
+ if (parts.length === 0) return { svg: "", usedIds: 0 };
449
+ return { svg: parts.join("\n"), usedIds };
450
+ }
451
+
198
452
  export function renderBorderImage(
199
453
  el: CapturedElement,
200
454
  indent: string,
@@ -205,8 +459,25 @@ export function renderBorderImage(
205
459
  const src = el.styles.borderImageSource;
206
460
  if (src == null || src === "none" || src === "") return { svg: "", usedIds: 0 };
207
461
 
462
+ // DM-722: CSS gradient as `border-image-source`. The 9-slice machinery
463
+ // below is built around a fixed-size raster source. Per CSS Images 3, a
464
+ // gradient used as `border-image-source` resolves to a concrete-size image
465
+ // equal to the border-image-area (= border-box ± `border-image-outset`).
466
+ // For the common `border-image: <grad> 1` case (slice 1, stretch — the
467
+ // fixture's `.gradient-border` panel), emit a single "border ring" path
468
+ // (outer rect minus inner rect via even-odd fill rule) filled with the
469
+ // gradient scoped to the full border-image-area. This matches Chrome's
470
+ // paint because slice 1 + stretch effectively maps a continuous gradient
471
+ // along all four sides — exactly what painting the whole area with the
472
+ // gradient and clipping to the border donut produces. Slice values other
473
+ // than `1` or `1 fill` (with non-degenerate edge tiling) fall through
474
+ // unsupported for gradient sources; the rasterise-during-capture path is
475
+ // tracked separately for that.
208
476
  const urlMatch = /^url\((?:"|')?([^"')]+)(?:"|')?\)$/i.exec(src);
209
- if (urlMatch == null) return { svg: "", usedIds: 0 };
477
+ if (urlMatch == null) {
478
+ if (!/-gradient\(/i.test(src)) return { svg: "", usedIds: 0 };
479
+ return renderBorderImageGradient(el, indent, idPrefix, defsParts, clipIdx, src);
480
+ }
210
481
  const url = urlMatch[1];
211
482
  const natW = el.styles.borderImageIntrinsicWidth ?? 0;
212
483
  const natH = el.styles.borderImageIntrinsicHeight ?? 0;
@@ -275,8 +546,8 @@ export function renderBorderImage(
275
546
 
276
547
  // Repeat policy per axis (tokens order: H V; fallback: single token applies to both).
277
548
  const repeatTokens = (el.styles.borderImageRepeat ?? "stretch").trim().split(/\s+/);
278
- const rH = (repeatTokens[0] || "stretch").toLowerCase();
279
- const rV = (repeatTokens[1] || rH).toLowerCase();
549
+ const rH = normalizeBorderImageRepeat((repeatTokens[0] ?? "stretch").toLowerCase());
550
+ const rV = repeatTokens[1] != null && repeatTokens[1] !== "" ? normalizeBorderImageRepeat(repeatTokens[1].toLowerCase()) : rH;
280
551
 
281
552
  // Slot geometry (in element-absolute coords).
282
553
  const x0 = boxX, x1 = boxX + wl, x2 = boxX + boxW - wr, x3 = boxX + boxW;
@@ -356,16 +627,29 @@ export function renderBorderImage(
356
627
  tileH = dhSlot / count;
357
628
  }
358
629
  }
359
- // space: pad between tiles. Approximated here as a constant gap; exact
360
- // placement depends on count math Chrome uses. Close-enough fallback.
630
+ // DM-795: `space` tiles the source N whole times with equal gaps between
631
+ // tiles AND half-gaps at each end (CSS Images 3 §6.1.3). Compute N =
632
+ // floor(slot / tile); if N === 0 the tile is too big for the slot and
633
+ // the spec says no border is drawn for that side, so bail. Otherwise set
634
+ // `patternW = dwSlot / N` so cells span the slot evenly, and offset the
635
+ // pattern start by half a gap. The `<image>` inside the cell needs a
636
+ // `<clipPath>` clipped to the slice region (0, 0, tileW, tileH) —
637
+ // otherwise the image extends past the slice into the gap, painting
638
+ // source pixels beyond the slice region instead of transparent gap.
361
639
  let patternW = tileW, patternH = tileH;
640
+ let patternX = dxSlot, patternY = dySlot;
362
641
  if (mode === "space") {
363
642
  if (axis === "x") {
364
- const count = Math.max(1, Math.floor(dwSlot / tileW));
643
+ const count = Math.floor(dwSlot / tileW);
644
+ if (count <= 0) return;
365
645
  patternW = dwSlot / count;
646
+ // Per spec, half-gap at each end: shift pattern start by `(patternW − tileW) / 2`.
647
+ patternX = dxSlot + (patternW - tileW) / 2;
366
648
  } else {
367
- const count = Math.max(1, Math.floor(dhSlot / tileH));
649
+ const count = Math.floor(dhSlot / tileH);
650
+ if (count <= 0) return;
368
651
  patternH = dhSlot / count;
652
+ patternY = dySlot + (patternH - tileH) / 2;
369
653
  }
370
654
  }
371
655
  const patId = `${idPrefix}bip${clipIdx + usedIds}`;
@@ -377,7 +661,17 @@ export function renderBorderImage(
377
661
  const inImgY = -sy * imgScaleY;
378
662
  const inImgW = natW * imgScaleX;
379
663
  const inImgH = natH * imgScaleY;
380
- defsParts.push(`<pattern id="${patId}" patternUnits="userSpaceOnUse" x="${r(dxSlot)}" y="${r(dySlot)}" width="${r(patternW)}" height="${r(patternH)}"><image href="${esc(embedResizedDataUri(url, inImgW, inImgH))}" x="${r(inImgX)}" y="${r(inImgY)}" width="${r(inImgW)}" height="${r(inImgH)}" preserveAspectRatio="none" /></pattern>`);
664
+ // DM-795: clip the image to the slice region so `space` mode shows
665
+ // transparent gaps between tiles instead of bleeding adjacent source
666
+ // pixels into the gap. The clipPath is scoped to the pattern cell at
667
+ // (0, 0) - (tileW, tileH) and references the image inside the pattern.
668
+ const clipBgId = `${idPrefix}bic${clipIdx + usedIds}`;
669
+ usedIds++;
670
+ const clipDef = mode === "space"
671
+ ? `<clipPath id="${clipBgId}"><rect x="0" y="0" width="${r(tileW)}" height="${r(tileH)}" /></clipPath>`
672
+ : "";
673
+ const imgClip = mode === "space" ? ` clip-path="url(#${clipBgId})"` : "";
674
+ defsParts.push(`<pattern id="${patId}" patternUnits="userSpaceOnUse" x="${r(patternX)}" y="${r(patternY)}" width="${r(patternW)}" height="${r(patternH)}">${clipDef}<image href="${esc(embedResizedDataUri(url, inImgW, inImgH))}" x="${r(inImgX)}" y="${r(inImgY)}" width="${r(inImgW)}" height="${r(inImgH)}" preserveAspectRatio="none"${imgClip} /></pattern>`);
381
675
  parts.push(`${indent}<rect x="${r(dxSlot)}" y="${r(dySlot)}" width="${r(dwSlot)}" height="${r(dhSlot)}" fill="url(#${patId})" />`);
382
676
  };
383
677
 
@@ -392,24 +686,96 @@ export function renderBorderImage(
392
686
  emitStretchedSlice(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st);
393
687
  emitStretchedSlice(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb);
394
688
  } else {
395
- emitTiledSliceEdge(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st, "x", rH as "repeat" | "round" | "space");
396
- emitTiledSliceEdge(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb, "x", rH as "repeat" | "round" | "space");
689
+ emitTiledSliceEdge(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st, "x", rH);
690
+ emitTiledSliceEdge(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb, "x", rH);
397
691
  }
398
692
  // Left + Right edges (vertical axis).
399
693
  if (rV === "stretch") {
400
694
  emitStretchedSlice(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C);
401
695
  emitStretchedSlice(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C);
402
696
  } else {
403
- emitTiledSliceEdge(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C, "y", rV as "repeat" | "round" | "space");
404
- emitTiledSliceEdge(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C, "y", rV as "repeat" | "round" | "space");
697
+ emitTiledSliceEdge(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C, "y", rV);
698
+ emitTiledSliceEdge(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C, "y", rV);
405
699
  }
406
- // Center (only if 'fill').
700
+ // Center (only if `fill`). Per CSS Backgrounds 3 §6.1.3 the middle slice
701
+ // is tiled in both directions when `border-image-repeat` is non-stretch,
702
+ // using the SAME tile sizing as the corresponding edge — horizontal axis
703
+ // matches the top edge derivation (tileW_natural = sxW_C × wt / st),
704
+ // vertical matches the left edge derivation (tileH_natural = syH_C × wl / sl).
705
+ // Single stretched <image> for stretch×stretch; otherwise a 2D <pattern>.
407
706
  if (fillCenter) {
707
+ const dwCenter = x2 - x1;
708
+ const dhCenter = y2 - y1;
408
709
  if (rH === "stretch" && rV === "stretch") {
409
- emitStretchedSlice(x1, y1, x2 - x1, y2 - y1, sxC, syC, sxW_C, syH_C);
410
- } else {
411
- // Center repeat is uncommon; fall back to stretch for simplicity.
412
- emitStretchedSlice(x1, y1, x2 - x1, y2 - y1, sxC, syC, sxW_C, syH_C);
710
+ emitStretchedSlice(x1, y1, dwCenter, dhCenter, sxC, syC, sxW_C, syH_C);
711
+ } else if (dwCenter > 0 && dhCenter > 0 && sxW_C > 0 && syH_C > 0 && st > 0 && sl > 0) {
712
+ // Per-axis tile size.
713
+ const tileWNatural = sxW_C * (wt / st);
714
+ const tileHNatural = syH_C * (wl / sl);
715
+ let tileW: number, tileH: number;
716
+ let patternW: number, patternH: number;
717
+ let tileOffX = 0, tileOffY = 0;
718
+ // Horizontal.
719
+ if (rH === "stretch") {
720
+ tileW = dwCenter;
721
+ patternW = dwCenter;
722
+ } else if (rH === "round") {
723
+ const count = Math.max(1, Math.round(dwCenter / tileWNatural));
724
+ tileW = dwCenter / count;
725
+ patternW = tileW;
726
+ } else if (rH === "space") {
727
+ const count = Math.floor(dwCenter / tileWNatural);
728
+ if (count <= 0) { tileW = 0; patternW = 0; } else {
729
+ tileW = tileWNatural;
730
+ patternW = dwCenter / count;
731
+ tileOffX = (patternW - tileW) / 2;
732
+ }
733
+ } else { // "repeat"
734
+ tileW = tileWNatural;
735
+ patternW = tileWNatural;
736
+ }
737
+ // Vertical.
738
+ if (rV === "stretch") {
739
+ tileH = dhCenter;
740
+ patternH = dhCenter;
741
+ } else if (rV === "round") {
742
+ const count = Math.max(1, Math.round(dhCenter / tileHNatural));
743
+ tileH = dhCenter / count;
744
+ patternH = tileH;
745
+ } else if (rV === "space") {
746
+ const count = Math.floor(dhCenter / tileHNatural);
747
+ if (count <= 0) { tileH = 0; patternH = 0; } else {
748
+ tileH = tileHNatural;
749
+ patternH = dhCenter / count;
750
+ tileOffY = (patternH - tileH) / 2;
751
+ }
752
+ } else {
753
+ tileH = tileHNatural;
754
+ patternH = tileHNatural;
755
+ }
756
+ if (tileW > 0 && tileH > 0 && patternW > 0 && patternH > 0) {
757
+ const imgScaleX = tileW / sxW_C;
758
+ const imgScaleY = tileH / syH_C;
759
+ const inImgX = -sxC * imgScaleX + tileOffX;
760
+ const inImgY = -syC * imgScaleY + tileOffY;
761
+ const inImgW = natW * imgScaleX;
762
+ const inImgH = natH * imgScaleY;
763
+ const patId = `${idPrefix}bipc${clipIdx + usedIds}`;
764
+ usedIds++;
765
+ // For `space` mode, clip the image to the visible tile region so
766
+ // gaps stay transparent (mirrors the edge-tile fix from DM-795).
767
+ const needsClip = rH === "space" || rV === "space";
768
+ let clipDef = "";
769
+ let imgClip = "";
770
+ if (needsClip) {
771
+ const clipBgId = `${idPrefix}bicc${clipIdx + usedIds}`;
772
+ usedIds++;
773
+ clipDef = `<clipPath id="${clipBgId}"><rect x="${r(tileOffX)}" y="${r(tileOffY)}" width="${r(tileW)}" height="${r(tileH)}" /></clipPath>`;
774
+ imgClip = ` clip-path="url(#${clipBgId})"`;
775
+ }
776
+ defsParts.push(`<pattern id="${patId}" patternUnits="userSpaceOnUse" x="${r(x1)}" y="${r(y1)}" width="${r(patternW)}" height="${r(patternH)}">${clipDef}<image href="${esc(embedResizedDataUri(url, inImgW, inImgH))}" x="${r(inImgX)}" y="${r(inImgY)}" width="${r(inImgW)}" height="${r(inImgH)}" preserveAspectRatio="none"${imgClip} /></pattern>`);
777
+ parts.push(`${indent}<rect x="${r(x1)}" y="${r(y1)}" width="${r(dwCenter)}" height="${r(dhCenter)}" fill="url(#${patId})" />`);
778
+ }
413
779
  }
414
780
  }
415
781