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
@@ -6,6 +6,13 @@
6
6
  import { r, esc } from "./format.js";
7
7
  import { parseColor } from "./colors.js";
8
8
  import { embedResizedDataUri } from "../capture/embed.js";
9
+ import { parseGradient, buildLinearGradientDef, buildRadialGradientDef } from "./gradients.js";
10
+ const BORDER_IMAGE_REPEATS = new Set(["stretch", "repeat", "round", "space"]);
11
+ function normalizeBorderImageRepeat(raw) {
12
+ if (raw != null && BORDER_IMAGE_REPEATS.has(raw))
13
+ return raw;
14
+ return "stretch";
15
+ }
9
16
  /** Parse a captured "h v" axis-pair (e.g. "30px 30px" or "50px 20px"). */
10
17
  function _parsePair(v) {
11
18
  if (!v)
@@ -73,6 +80,27 @@ export function insetCornerRadii(c, top, right, bottom, left) {
73
80
  && tl.h === br.h && tl.h === br.v && tl.h === bl.h && tl.h === bl.v;
74
81
  return { tl, tr, br, bl, uniform };
75
82
  }
83
+ /** Grow each corner radius outward by `spread` for an OUTSET box-shadow shape.
84
+ * Per CSS Backgrounds 3 §6.4 and Chromium's `FloatRoundedRect::Outset`, a
85
+ * corner whose source radius is zero STAYS sharp through any spread — only
86
+ * pre-curved corners grow. A naive `corner + spread` produces visibly
87
+ * rounded shadow corners on a sharp-cornered box (e.g. concentric outlines
88
+ * built from `box-shadow: 0 0 0 Npx`). Use this for outset shadow shapes;
89
+ * the dual inset case is already covered by `insetCornerRadii` shrinking to
90
+ * zero when the border eats past the radius. */
91
+ export function outsetCornerRadiiForShadow(c, spread) {
92
+ const grow = (p) => ({
93
+ h: p.h > 0 ? Math.max(0, p.h + spread) : 0,
94
+ v: p.v > 0 ? Math.max(0, p.v + spread) : 0,
95
+ });
96
+ const tl = grow(c.tl);
97
+ const tr = grow(c.tr);
98
+ const br = grow(c.br);
99
+ const bl = grow(c.bl);
100
+ const uniform = tl.h === tl.v && tl.h === tr.h && tl.h === tr.v
101
+ && tl.h === br.h && tl.h === br.v && tl.h === bl.h && tl.h === bl.v;
102
+ return { tl, tr, br, bl, uniform };
103
+ }
76
104
  /** Emit an SVG path `d` attribute for a rounded rectangle with per-corner radii.
77
105
  * Path goes clockwise from the top-left, using elliptical arc commands at each
78
106
  * corner. Zero-radius corners collapse to a sharp 90° join. */
@@ -168,13 +196,239 @@ export function injectSvgSize(svgHtml, w, h) {
168
196
  * Returns { svg, usedIds }. usedIds indicates how many clipIdx values were
169
197
  * consumed so the caller can keep its own counter in sync.
170
198
  */
199
+ /**
200
+ * Render a `border-image-source` that's a CSS gradient as a proper 9-slice.
201
+ *
202
+ * Per CSS Images 3, a gradient used as `border-image-source` has the size of
203
+ * the border-image-area (= border-box ± `border-image-outset`). The 9-slice
204
+ * algorithm then applies just like for a raster source: corners stretched,
205
+ * edges tiled per `border-image-repeat`, optional fill center.
206
+ *
207
+ * Implementation: build a single `<linearGradient>` / `<radialGradient>` def
208
+ * positioned in source space `(0, 0) - (natW, natH)` where natW = boxW,
209
+ * natH = boxH. Each slot emits an inner `<svg x dx y dy width dw height dh
210
+ * viewBox="sx sy sw sh" preserveAspectRatio="none">` containing a `<rect
211
+ * width="natW" height="natH" fill="url(#g)" />`. The viewBox maps the source
212
+ * slice rect onto the destination slot; the gradient comes along because its
213
+ * `userSpaceOnUse` coordinates are interpreted in the viewBox space. Tiled
214
+ * edges (`repeat` / `round` / `space`) wrap that inner `<svg>` in a
215
+ * `<pattern>`. The single-def-per-element keeps SVG output small and matches
216
+ * how the URL path reuses one source asset.
217
+ */
218
+ function renderBorderImageGradient(el, indent, idPrefix, defsParts, clipIdx, src) {
219
+ const grad = parseGradient(src);
220
+ if (grad == null)
221
+ return { svg: "", usedIds: 0 };
222
+ if (grad.kind !== "linear" && grad.kind !== "radial")
223
+ return { svg: "", usedIds: 0 };
224
+ const sliceRaw = el.styles.borderImageSlice ?? "100%";
225
+ const fillCenter = /\bfill\b/i.test(sliceRaw);
226
+ const bwTop = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
227
+ const bwRight = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
228
+ const bwBottom = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
229
+ const bwLeft = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
230
+ // Outsets: default 0. Same parsing as URL path.
231
+ const outsetTokens = (el.styles.borderImageOutset ?? "0").trim().split(/\s+/);
232
+ const parseOutset = (tok, basis, borderW) => {
233
+ if (tok == null || tok === "")
234
+ return 0;
235
+ if (/%$/.test(tok))
236
+ return (parseFloat(tok) / 100) * basis;
237
+ if (/(px|em|rem|pt|pc|cm|mm|in|Q)$/.test(tok))
238
+ return parseFloat(tok) || 0;
239
+ const n = parseFloat(tok);
240
+ return Number.isFinite(n) ? n * borderW : 0;
241
+ };
242
+ const ot = parseOutset(outsetTokens[0], el.height, bwTop);
243
+ const or_ = parseOutset(outsetTokens[1] ?? outsetTokens[0], el.width, bwRight);
244
+ const ob = parseOutset(outsetTokens[2] ?? outsetTokens[0], el.height, bwBottom);
245
+ const ol = parseOutset(outsetTokens[3] ?? outsetTokens[1] ?? outsetTokens[0], el.width, bwLeft);
246
+ // Border-image-width per side; default = element's border-width. Same as URL path.
247
+ const parseBorderImageLen = (tok, basis, borderW) => {
248
+ if (tok == null || tok === "" || tok === "auto")
249
+ return borderW;
250
+ if (/%$/.test(tok))
251
+ return (parseFloat(tok) / 100) * basis;
252
+ if (/(px|em|rem|pt|pc|cm|mm|in|Q)$/.test(tok))
253
+ return parseFloat(tok) || 0;
254
+ const n = parseFloat(tok);
255
+ return Number.isFinite(n) ? n * borderW : borderW;
256
+ };
257
+ const widthTokens = (el.styles.borderImageWidth ?? "").trim().split(/\s+/);
258
+ const wt = parseBorderImageLen(widthTokens[0], el.height, bwTop);
259
+ const wr = parseBorderImageLen(widthTokens[1] ?? widthTokens[0], el.width, bwRight);
260
+ const wb = parseBorderImageLen(widthTokens[2] ?? widthTokens[0], el.height, bwBottom);
261
+ const wl = parseBorderImageLen(widthTokens[3] ?? widthTokens[1] ?? widthTokens[0], el.width, bwLeft);
262
+ const boxX = el.x - ol;
263
+ const boxY = el.y - ot;
264
+ const boxW = el.width + ol + or_;
265
+ const boxH = el.height + ot + ob;
266
+ if (boxW <= 0 || boxH <= 0)
267
+ return { svg: "", usedIds: 0 };
268
+ // Gradient sources have the size of the border-image-area.
269
+ const natW = boxW;
270
+ const natH = boxH;
271
+ // Slice: numbers = source pixels, percentages = of source dims, optional `fill`.
272
+ const sliceTokens = sliceRaw.replace(/\bfill\b/i, "").trim().split(/\s+/);
273
+ const sliceNums = sliceTokens.map((t) => {
274
+ if (/%$/.test(t))
275
+ return { pct: parseFloat(t) };
276
+ return { px: parseFloat(t) };
277
+ });
278
+ const resolveSlice = (tok, basis) => {
279
+ if (tok.pct != null)
280
+ return (tok.pct / 100) * basis;
281
+ return tok.px ?? 0;
282
+ };
283
+ const st = resolveSlice(sliceNums[0] ?? { px: 0 }, natH);
284
+ const sr = resolveSlice(sliceNums[1] ?? sliceNums[0] ?? { px: 0 }, natW);
285
+ const sb = resolveSlice(sliceNums[2] ?? sliceNums[0] ?? { px: 0 }, natH);
286
+ const sl = resolveSlice(sliceNums[3] ?? sliceNums[1] ?? sliceNums[0] ?? { px: 0 }, natW);
287
+ // Repeat policy per axis.
288
+ const repeatTokens = (el.styles.borderImageRepeat ?? "stretch").trim().split(/\s+/);
289
+ const rH = normalizeBorderImageRepeat((repeatTokens[0] ?? "stretch").toLowerCase());
290
+ const rV = repeatTokens[1] != null && repeatTokens[1] !== "" ? normalizeBorderImageRepeat(repeatTokens[1].toLowerCase()) : rH;
291
+ // Gradient def in source space (0, 0) - (natW, natH). Positioned at the
292
+ // border-image-area's element-absolute origin (boxX, boxY) so the inner
293
+ // <svg viewBox> remap below lands the gradient on the correct destination
294
+ // coordinates. Each <rect> inside an inner <svg viewBox="sx sy sw sh">
295
+ // paints the slice region by drawing the full natW × natH rect — the
296
+ // viewBox + preserveAspectRatio="none" map source slice → destination slot.
297
+ const gid = `${idPrefix}big${clipIdx}`;
298
+ let usedIds = 1;
299
+ const gradRect = { x: boxX, y: boxY, w: natW, h: natH };
300
+ const def = grad.kind === "linear"
301
+ ? buildLinearGradientDef(grad, gid, gradRect)
302
+ : buildRadialGradientDef(grad, gid, gradRect);
303
+ defsParts.push(def);
304
+ // Slot geometry in element-absolute coords.
305
+ const x0 = boxX, x1 = boxX + wl, x2 = boxX + boxW - wr, x3 = boxX + boxW;
306
+ const y0 = boxY, y1 = boxY + wt, y2 = boxY + boxH - wb, y3 = boxY + boxH;
307
+ // Source regions in source pixels (NB: corner rects + edge / center rects).
308
+ const sxL = 0, sxR = natW - sr, sxC = sl, sxW_C = natW - sl - sr;
309
+ const syT = 0, syB = natH - sb, syC = st, syH_C = natH - st - sb;
310
+ const parts = [];
311
+ // Inner <svg viewBox> that paints the source slice rect (sx, sy, sw, sh)
312
+ // into the destination slot (dx, dy, dw, dh). The gradient is positioned
313
+ // in source-space coords (boxX..boxX+natW, boxY..boxY+natH); to keep it
314
+ // aligned through the viewBox mapping, the viewBox is offset to start at
315
+ // (boxX + sx, boxY + sy) — so the gradient's userSpaceOnUse coordinates
316
+ // line up with the source rect we're sampling. Then a single <rect>
317
+ // covering (boxX, boxY) - (boxX+natW, boxY+natH) lets the gradient
318
+ // evaluate across the full source space; the viewBox crops to the slice.
319
+ const innerSvgForSlot = (dx, dy, dw, dh, sx, sy, sw, sh) => {
320
+ 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>`;
321
+ };
322
+ const emitStretchedSlot = (dx, dy, dw, dh, sx, sy, sw, sh) => {
323
+ if (dw <= 0 || dh <= 0 || sw <= 0 || sh <= 0)
324
+ return;
325
+ parts.push(`${indent}${innerSvgForSlot(dx, dy, dw, dh, sx, sy, sw, sh)}`);
326
+ };
327
+ // Tiled edges: wrap the inner <svg> in a <pattern> sized to one tile, then
328
+ // fill the destination rect with that pattern. round / space tile-count
329
+ // logic mirrors the URL path's `emitTiledSliceEdge`. For `space`, the
330
+ // pattern cell is the slot/N and the inner <svg> is centered inside the
331
+ // cell so each end has a half-gap; transparent gap is automatic because
332
+ // the inner <svg> is smaller than the pattern cell.
333
+ const emitTiledEdgeSlot = (dx, dy, dw, dh, sx, sy, sw, sh, axis, mode) => {
334
+ if (dw <= 0 || dh <= 0 || sw <= 0 || sh <= 0)
335
+ return;
336
+ let tileW, tileH;
337
+ if (axis === "x") {
338
+ tileH = dh;
339
+ tileW = sw * (dh / sh);
340
+ if (mode === "round") {
341
+ const count = Math.max(1, Math.round(dw / tileW));
342
+ tileW = dw / count;
343
+ }
344
+ }
345
+ else {
346
+ tileW = dw;
347
+ tileH = sh * (dw / sw);
348
+ if (mode === "round") {
349
+ const count = Math.max(1, Math.round(dh / tileH));
350
+ tileH = dh / count;
351
+ }
352
+ }
353
+ let patternW = tileW, patternH = tileH;
354
+ let tileOffX = 0, tileOffY = 0;
355
+ if (mode === "space") {
356
+ if (axis === "x") {
357
+ const count = Math.floor(dw / tileW);
358
+ if (count <= 0)
359
+ return;
360
+ patternW = dw / count;
361
+ tileOffX = (patternW - tileW) / 2;
362
+ }
363
+ else {
364
+ const count = Math.floor(dh / tileH);
365
+ if (count <= 0)
366
+ return;
367
+ patternH = dh / count;
368
+ tileOffY = (patternH - tileH) / 2;
369
+ }
370
+ }
371
+ const patId = `${idPrefix}bip${clipIdx + usedIds}`;
372
+ usedIds++;
373
+ const inner = innerSvgForSlot(tileOffX, tileOffY, tileW, tileH, sx, sy, sw, sh);
374
+ defsParts.push(`<pattern id="${patId}" patternUnits="userSpaceOnUse" x="${r(dx)}" y="${r(dy)}" width="${r(patternW)}" height="${r(patternH)}">${inner}</pattern>`);
375
+ parts.push(`${indent}<rect x="${r(dx)}" y="${r(dy)}" width="${r(dw)}" height="${r(dh)}" fill="url(#${patId})" />`);
376
+ };
377
+ // 4 corners — always stretched.
378
+ emitStretchedSlot(x0, y0, wl, wt, sxL, syT, sl, st); // NW
379
+ emitStretchedSlot(x2, y0, wr, wt, sxR, syT, sr, st); // NE
380
+ emitStretchedSlot(x0, y2, wl, wb, sxL, syB, sl, sb); // SW
381
+ emitStretchedSlot(x2, y2, wr, wb, sxR, syB, sr, sb); // SE
382
+ // Top + Bottom edges.
383
+ if (rH === "stretch") {
384
+ emitStretchedSlot(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st);
385
+ emitStretchedSlot(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb);
386
+ }
387
+ else {
388
+ emitTiledEdgeSlot(x1, y0, x2 - x1, wt, sxC, syT, sxW_C, st, "x", rH);
389
+ emitTiledEdgeSlot(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb, "x", rH);
390
+ }
391
+ // Left + Right edges.
392
+ if (rV === "stretch") {
393
+ emitStretchedSlot(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C);
394
+ emitStretchedSlot(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C);
395
+ }
396
+ else {
397
+ emitTiledEdgeSlot(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C, "y", rV);
398
+ emitTiledEdgeSlot(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C, "y", rV);
399
+ }
400
+ // Center — only when `fill`.
401
+ if (fillCenter) {
402
+ emitStretchedSlot(x1, y1, x2 - x1, y2 - y1, sxC, syC, sxW_C, syH_C);
403
+ }
404
+ if (parts.length === 0)
405
+ return { svg: "", usedIds: 0 };
406
+ return { svg: parts.join("\n"), usedIds };
407
+ }
171
408
  export function renderBorderImage(el, indent, idPrefix, defsParts, clipIdx) {
172
409
  const src = el.styles.borderImageSource;
173
410
  if (src == null || src === "none" || src === "")
174
411
  return { svg: "", usedIds: 0 };
412
+ // DM-722: CSS gradient as `border-image-source`. The 9-slice machinery
413
+ // below is built around a fixed-size raster source. Per CSS Images 3, a
414
+ // gradient used as `border-image-source` resolves to a concrete-size image
415
+ // equal to the border-image-area (= border-box ± `border-image-outset`).
416
+ // For the common `border-image: <grad> 1` case (slice 1, stretch — the
417
+ // fixture's `.gradient-border` panel), emit a single "border ring" path
418
+ // (outer rect minus inner rect via even-odd fill rule) filled with the
419
+ // gradient scoped to the full border-image-area. This matches Chrome's
420
+ // paint because slice 1 + stretch effectively maps a continuous gradient
421
+ // along all four sides — exactly what painting the whole area with the
422
+ // gradient and clipping to the border donut produces. Slice values other
423
+ // than `1` or `1 fill` (with non-degenerate edge tiling) fall through
424
+ // unsupported for gradient sources; the rasterise-during-capture path is
425
+ // tracked separately for that.
175
426
  const urlMatch = /^url\((?:"|')?([^"')]+)(?:"|')?\)$/i.exec(src);
176
- if (urlMatch == null)
177
- return { svg: "", usedIds: 0 };
427
+ if (urlMatch == null) {
428
+ if (!/-gradient\(/i.test(src))
429
+ return { svg: "", usedIds: 0 };
430
+ return renderBorderImageGradient(el, indent, idPrefix, defsParts, clipIdx, src);
431
+ }
178
432
  const url = urlMatch[1];
179
433
  const natW = el.styles.borderImageIntrinsicWidth ?? 0;
180
434
  const natH = el.styles.borderImageIntrinsicHeight ?? 0;
@@ -246,8 +500,8 @@ export function renderBorderImage(el, indent, idPrefix, defsParts, clipIdx) {
246
500
  const boxH = el.height + ot + ob;
247
501
  // Repeat policy per axis (tokens order: H V; fallback: single token applies to both).
248
502
  const repeatTokens = (el.styles.borderImageRepeat ?? "stretch").trim().split(/\s+/);
249
- const rH = (repeatTokens[0] || "stretch").toLowerCase();
250
- const rV = (repeatTokens[1] || rH).toLowerCase();
503
+ const rH = normalizeBorderImageRepeat((repeatTokens[0] ?? "stretch").toLowerCase());
504
+ const rV = repeatTokens[1] != null && repeatTokens[1] !== "" ? normalizeBorderImageRepeat(repeatTokens[1].toLowerCase()) : rH;
251
505
  // Slot geometry (in element-absolute coords).
252
506
  const x0 = boxX, x1 = boxX + wl, x2 = boxX + boxW - wr, x3 = boxX + boxW;
253
507
  const y0 = boxY, y1 = boxY + wt, y2 = boxY + boxH - wb, y3 = boxY + boxH;
@@ -304,17 +558,32 @@ export function renderBorderImage(el, indent, idPrefix, defsParts, clipIdx) {
304
558
  tileH = dhSlot / count;
305
559
  }
306
560
  }
307
- // space: pad between tiles. Approximated here as a constant gap; exact
308
- // placement depends on count math Chrome uses. Close-enough fallback.
561
+ // DM-795: `space` tiles the source N whole times with equal gaps between
562
+ // tiles AND half-gaps at each end (CSS Images 3 §6.1.3). Compute N =
563
+ // floor(slot / tile); if N === 0 the tile is too big for the slot and
564
+ // the spec says no border is drawn for that side, so bail. Otherwise set
565
+ // `patternW = dwSlot / N` so cells span the slot evenly, and offset the
566
+ // pattern start by half a gap. The `<image>` inside the cell needs a
567
+ // `<clipPath>` clipped to the slice region (0, 0, tileW, tileH) —
568
+ // otherwise the image extends past the slice into the gap, painting
569
+ // source pixels beyond the slice region instead of transparent gap.
309
570
  let patternW = tileW, patternH = tileH;
571
+ let patternX = dxSlot, patternY = dySlot;
310
572
  if (mode === "space") {
311
573
  if (axis === "x") {
312
- const count = Math.max(1, Math.floor(dwSlot / tileW));
574
+ const count = Math.floor(dwSlot / tileW);
575
+ if (count <= 0)
576
+ return;
313
577
  patternW = dwSlot / count;
578
+ // Per spec, half-gap at each end: shift pattern start by `(patternW − tileW) / 2`.
579
+ patternX = dxSlot + (patternW - tileW) / 2;
314
580
  }
315
581
  else {
316
- const count = Math.max(1, Math.floor(dhSlot / tileH));
582
+ const count = Math.floor(dhSlot / tileH);
583
+ if (count <= 0)
584
+ return;
317
585
  patternH = dhSlot / count;
586
+ patternY = dySlot + (patternH - tileH) / 2;
318
587
  }
319
588
  }
320
589
  const patId = `${idPrefix}bip${clipIdx + usedIds}`;
@@ -326,7 +595,17 @@ export function renderBorderImage(el, indent, idPrefix, defsParts, clipIdx) {
326
595
  const inImgY = -sy * imgScaleY;
327
596
  const inImgW = natW * imgScaleX;
328
597
  const inImgH = natH * imgScaleY;
329
- 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>`);
598
+ // DM-795: clip the image to the slice region so `space` mode shows
599
+ // transparent gaps between tiles instead of bleeding adjacent source
600
+ // pixels into the gap. The clipPath is scoped to the pattern cell at
601
+ // (0, 0) - (tileW, tileH) and references the image inside the pattern.
602
+ const clipBgId = `${idPrefix}bic${clipIdx + usedIds}`;
603
+ usedIds++;
604
+ const clipDef = mode === "space"
605
+ ? `<clipPath id="${clipBgId}"><rect x="0" y="0" width="${r(tileW)}" height="${r(tileH)}" /></clipPath>`
606
+ : "";
607
+ const imgClip = mode === "space" ? ` clip-path="url(#${clipBgId})"` : "";
608
+ 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>`);
330
609
  parts.push(`${indent}<rect x="${r(dxSlot)}" y="${r(dySlot)}" width="${r(dwSlot)}" height="${r(dhSlot)}" fill="url(#${patId})" />`);
331
610
  };
332
611
  // Corners: always stretched (CSS spec).
@@ -352,14 +631,100 @@ export function renderBorderImage(el, indent, idPrefix, defsParts, clipIdx) {
352
631
  emitTiledSliceEdge(x0, y1, wl, y2 - y1, sxL, syC, sl, syH_C, "y", rV);
353
632
  emitTiledSliceEdge(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C, "y", rV);
354
633
  }
355
- // Center (only if 'fill').
634
+ // Center (only if `fill`). Per CSS Backgrounds 3 §6.1.3 the middle slice
635
+ // is tiled in both directions when `border-image-repeat` is non-stretch,
636
+ // using the SAME tile sizing as the corresponding edge — horizontal axis
637
+ // matches the top edge derivation (tileW_natural = sxW_C × wt / st),
638
+ // vertical matches the left edge derivation (tileH_natural = syH_C × wl / sl).
639
+ // Single stretched <image> for stretch×stretch; otherwise a 2D <pattern>.
356
640
  if (fillCenter) {
641
+ const dwCenter = x2 - x1;
642
+ const dhCenter = y2 - y1;
357
643
  if (rH === "stretch" && rV === "stretch") {
358
- emitStretchedSlice(x1, y1, x2 - x1, y2 - y1, sxC, syC, sxW_C, syH_C);
644
+ emitStretchedSlice(x1, y1, dwCenter, dhCenter, sxC, syC, sxW_C, syH_C);
359
645
  }
360
- else {
361
- // Center repeat is uncommon; fall back to stretch for simplicity.
362
- emitStretchedSlice(x1, y1, x2 - x1, y2 - y1, sxC, syC, sxW_C, syH_C);
646
+ else if (dwCenter > 0 && dhCenter > 0 && sxW_C > 0 && syH_C > 0 && st > 0 && sl > 0) {
647
+ // Per-axis tile size.
648
+ const tileWNatural = sxW_C * (wt / st);
649
+ const tileHNatural = syH_C * (wl / sl);
650
+ let tileW, tileH;
651
+ let patternW, patternH;
652
+ let tileOffX = 0, tileOffY = 0;
653
+ // Horizontal.
654
+ if (rH === "stretch") {
655
+ tileW = dwCenter;
656
+ patternW = dwCenter;
657
+ }
658
+ else if (rH === "round") {
659
+ const count = Math.max(1, Math.round(dwCenter / tileWNatural));
660
+ tileW = dwCenter / count;
661
+ patternW = tileW;
662
+ }
663
+ else if (rH === "space") {
664
+ const count = Math.floor(dwCenter / tileWNatural);
665
+ if (count <= 0) {
666
+ tileW = 0;
667
+ patternW = 0;
668
+ }
669
+ else {
670
+ tileW = tileWNatural;
671
+ patternW = dwCenter / count;
672
+ tileOffX = (patternW - tileW) / 2;
673
+ }
674
+ }
675
+ else { // "repeat"
676
+ tileW = tileWNatural;
677
+ patternW = tileWNatural;
678
+ }
679
+ // Vertical.
680
+ if (rV === "stretch") {
681
+ tileH = dhCenter;
682
+ patternH = dhCenter;
683
+ }
684
+ else if (rV === "round") {
685
+ const count = Math.max(1, Math.round(dhCenter / tileHNatural));
686
+ tileH = dhCenter / count;
687
+ patternH = tileH;
688
+ }
689
+ else if (rV === "space") {
690
+ const count = Math.floor(dhCenter / tileHNatural);
691
+ if (count <= 0) {
692
+ tileH = 0;
693
+ patternH = 0;
694
+ }
695
+ else {
696
+ tileH = tileHNatural;
697
+ patternH = dhCenter / count;
698
+ tileOffY = (patternH - tileH) / 2;
699
+ }
700
+ }
701
+ else {
702
+ tileH = tileHNatural;
703
+ patternH = tileHNatural;
704
+ }
705
+ if (tileW > 0 && tileH > 0 && patternW > 0 && patternH > 0) {
706
+ const imgScaleX = tileW / sxW_C;
707
+ const imgScaleY = tileH / syH_C;
708
+ const inImgX = -sxC * imgScaleX + tileOffX;
709
+ const inImgY = -syC * imgScaleY + tileOffY;
710
+ const inImgW = natW * imgScaleX;
711
+ const inImgH = natH * imgScaleY;
712
+ const patId = `${idPrefix}bipc${clipIdx + usedIds}`;
713
+ usedIds++;
714
+ // For `space` mode, clip the image to the visible tile region so
715
+ // gaps stay transparent (mirrors the edge-tile fix from DM-795).
716
+ const needsClip = rH === "space" || rV === "space";
717
+ let clipDef = "";
718
+ let imgClip = "";
719
+ if (needsClip) {
720
+ const clipBgId = `${idPrefix}bicc${clipIdx + usedIds}`;
721
+ usedIds++;
722
+ clipDef = `<clipPath id="${clipBgId}"><rect x="${r(tileOffX)}" y="${r(tileOffY)}" width="${r(tileW)}" height="${r(tileH)}" /></clipPath>`;
723
+ imgClip = ` clip-path="url(#${clipBgId})"`;
724
+ }
725
+ 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>`);
726
+ parts.push(`${indent}<rect x="${r(x1)}" y="${r(y1)}" width="${r(dwCenter)}" height="${r(dhCenter)}" fill="url(#${patId})" />`);
727
+ }
363
728
  }
364
729
  }
365
730
  return { svg: parts.join("\n"), usedIds };
@@ -93,18 +93,6 @@ export declare function rewriteFragmentMaskDef(outerHTML: string, outputId: stri
93
93
  * DM-493.
94
94
  */
95
95
  export declare function positionFragmentMaskDef(rewrittenOuterHTML: string, elX: number, elY: number, elW: number, elH: number): string;
96
- /**
97
- * Translate a CSS mask-image value + mask-* siblings into an SVG <mask>.
98
- * Handles single-layer gradients and url() sources. Position/size/repeat are
99
- * applied via an internal <pattern> for url sources; gradients use direct
100
- * gradient fills sized to the element box.
101
- *
102
- * SVG <mask> uses luminance by default (bright pixels visible). CSS mask-mode
103
- * 'alpha' makes the alpha channel control visibility. We set mask-type on the
104
- * <mask> element accordingly. Note: Chromium may render mask-mode:'match-source'
105
- * differently depending on the source; we pick alpha for gradients and url()
106
- * (common case) and respect explicit mask-mode when given.
107
- */
108
96
  export declare function buildMaskDef(id: string, maskImage: string, elX: number, elY: number, w: number, h: number, maskMode: string, sizeCss: string, posCss: string, repeatCss: string, compositeCss: string,
109
97
  /** DM-494: lookup table for `mask-image: element(#id)` references. Optional —
110
98
  * callers without element() refs can omit it. The renderer's main caller
@@ -129,4 +117,15 @@ elementRasters?: ReadonlyMap<string, MaskRasterRef>): {
129
117
  * map to xMin/xMid/xMax + yMin/yMid/yMax. Percentages are bucketed to thirds
130
118
  * since SVG has no finer-grained alignment.
131
119
  */
120
+ /**
121
+ * DM-819: rewrite an SVG-source data URI so its top-level `<svg>` declares
122
+ * `width=consumerW height=consumerH preserveAspectRatio="<par>"`. Chrome
123
+ * ignores the outer `<image>`'s `preserveAspectRatio` when the source is
124
+ * SVG (paints at the SVG's own intrinsic size), but it does honor the
125
+ * embedded SVG's own preserveAspectRatio. Baking the alignment into the
126
+ * inner SVG lets `object-fit: cover` on an `<img>` referencing an SVG file
127
+ * actually slice. Returns the input unchanged for raster sources or when
128
+ * the inner SVG can't be parsed.
129
+ */
130
+ export declare function rewriteSvgDataUriPreserveAspectRatio(dataUri: string, w: number, h: number, par: string): string;
132
131
  export declare function preserveAspectRatioFor(fit: string | undefined, pos: string | undefined): string;