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.
- package/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
package/src/render/borders.ts
CHANGED
|
@@ -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)
|
|
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]
|
|
279
|
-
const rV =
|
|
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
|
-
//
|
|
360
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
396
|
-
emitTiledSliceEdge(x1, y2, x2 - x1, wb, sxC, syB, sxW_C, sb, "x", rH
|
|
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
|
|
404
|
-
emitTiledSliceEdge(x2, y1, wr, y2 - y1, sxR, syC, sr, syH_C, "y", rV
|
|
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
|
|
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,
|
|
410
|
-
} else {
|
|
411
|
-
//
|
|
412
|
-
|
|
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
|
|