domotion-svg 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FEATURES.md +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- package/src/webfont-unicode-range.test.ts +207 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { captureElementTree, captureElementTreeWithWarnings, elementTreeToSvg, wrapSvg, rootSvgColorSchemeAttr, transparentRootBgRect, getLastCaptureWarnings, logCaptureWarnings, embedRemoteImages, embedResizedDataUri } from "./dom-to-svg.js";
|
|
2
|
+
export { resizeEmbeddedImages } from "./resize-embedded-images.js";
|
|
3
|
+
export type { ResizeEmbeddedImagesOptions } from "./resize-embedded-images.js";
|
|
4
|
+
export type { CapturedElement, CaptureWarning } from "./dom-to-svg.js";
|
|
5
|
+
export { generateAnimatedSvg } from "./animator.js";
|
|
6
|
+
export type { AnimationConfig, AnimationFrame, Overlay, TypingOverlay, TapOverlay, SvgOverlay, IntraFrameAnimation } from "./animator.js";
|
|
7
|
+
export type { CursorOverlay, CursorEvent, CursorMoveEvent, CursorClickEvent, CursorShowEvent, CursorHideEvent, CursorStyle, SelectorResolver } from "./cursor-overlay.js";
|
|
8
|
+
export { DemoRecorder, launchChromium } from "./capture.js";
|
|
9
|
+
export type { CaptureOptions } from "./capture.js";
|
|
10
|
+
export { optimizeSvg } from "./optimize.js";
|
|
11
|
+
export { getGlyphDefs, clearGlyphDefs, registerWebfont, clearWebfonts } from "./text-to-path.js";
|
|
12
|
+
export { discoverAndRegisterWebfonts, attachWebfontTracker } from "./capture.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// kerfjs 0.5.0 ships a typed JSX `IntrinsicElements` table, but the bundled
|
|
2
|
+
// type declarations have a self-referential interface bug at
|
|
3
|
+
// `node_modules/kerfjs/dist/jsx-runtime.d.ts:784`:
|
|
4
|
+
//
|
|
5
|
+
// declare namespace JSX {
|
|
6
|
+
// interface IntrinsicElements extends IntrinsicElements {}
|
|
7
|
+
// }
|
|
8
|
+
//
|
|
9
|
+
// The `extends IntrinsicElements` resolves to the local (just-being-declared)
|
|
10
|
+
// JSX.IntrinsicElements, not the module-scope one with the typed tag table.
|
|
11
|
+
// Result: `JSX.IntrinsicElements` is empty and every `<div>`, `<span>`, etc.
|
|
12
|
+
// fails to type-check with `Property 'div' does not exist on type
|
|
13
|
+
// 'JSX.IntrinsicElements'`.
|
|
14
|
+
//
|
|
15
|
+
// This declaration merging adds a permissive index signature that accepts
|
|
16
|
+
// any tag with any props — restoring the pre-0.5.0 catch-all behaviour. We
|
|
17
|
+
// lose 0.5.0's typed-tag benefit (typos like `<dvi>` no longer fail to
|
|
18
|
+
// compile), but the runtime is unaffected and we unblock the upgrade.
|
|
19
|
+
//
|
|
20
|
+
// Remove this file when kerfjs publishes a fix (rename the outer interface
|
|
21
|
+
// so the JSX namespace's `extends` resolves correctly, or drop the
|
|
22
|
+
// `extends` and explicitly list the tag table inside the namespace).
|
|
23
|
+
|
|
24
|
+
// Importing from the module first registers it for augmentation. Without
|
|
25
|
+
// this, the `declare module` block is treated as a NEW module declaration
|
|
26
|
+
// and clobbers the original `kerfjs/jsx-runtime` exports (TS2305: 'SafeHtml'
|
|
27
|
+
// has no exported member).
|
|
28
|
+
import "kerfjs/jsx-runtime";
|
|
29
|
+
|
|
30
|
+
declare module "kerfjs/jsx-runtime" {
|
|
31
|
+
namespace JSX {
|
|
32
|
+
interface IntrinsicElements {
|
|
33
|
+
[tag: string]: any;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, raw, SafeHtml } from "kerfjs";
|
|
3
|
+
import { SafeHtml as RuntimeSafeHtml } from "kerfjs/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
// Regression test for DM-533: kerfjs <= 0.1.2 shipped two independent
|
|
6
|
+
// `SafeHtml` class definitions — one in the `"kerfjs"` barrel and one in
|
|
7
|
+
// `"kerfjs/jsx-runtime"`. The auto JSX runtime resolves <jsx> calls via the
|
|
8
|
+
// jsx-runtime entry, so a `raw()` value imported from the barrel failed the
|
|
9
|
+
// `instanceof SafeHtml` check inside `renderChildren` and the renderer threw.
|
|
10
|
+
//
|
|
11
|
+
// 0.2.0 fixes this by emitting a shared chunk; both entries re-export the same
|
|
12
|
+
// class. These tests guard that contract — if a future kerfjs upgrade
|
|
13
|
+
// regresses the duplication, JSX rendering breaks across all of Domotion's
|
|
14
|
+
// .tsx files (tests/, site/) and these tests will fail loudly here instead.
|
|
15
|
+
|
|
16
|
+
describe("kerfjs barrel and jsx-runtime share SafeHtml", () => {
|
|
17
|
+
it("exports the same SafeHtml class from both entry points", () => {
|
|
18
|
+
expect(SafeHtml).toBe(RuntimeSafeHtml);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders raw() imported from the barrel inside JSX without throwing", () => {
|
|
22
|
+
const html = (<div>{raw("<b>hi</b>")}</div>).toString();
|
|
23
|
+
expect(html).toBe("<div><b>hi</b></div>");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("auto-escapes string children", () => {
|
|
27
|
+
const html = (<p>{"<script>x</script>"}</p>).toString();
|
|
28
|
+
expect(html).toBe("<p><script>x</script></p>");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Regression test for DM-534: kerfjs 0.2.0's `"kerfjs"` barrel re-exported
|
|
32
|
+
// `SafeHtml`/`isSafeHtml`/`raw` from the shared chunk but omitted `Fragment`,
|
|
33
|
+
// so `<Fragment>…</Fragment>` rendered as `<undefined>…</undefined>`. Fixed
|
|
34
|
+
// upstream in 0.2.1 by adding `Fragment` to the barrel re-export.
|
|
35
|
+
it("exports Fragment from the barrel and renders it as a transparent wrapper", () => {
|
|
36
|
+
expect(Fragment).toBeDefined();
|
|
37
|
+
const html = (
|
|
38
|
+
<Fragment>
|
|
39
|
+
<span>a</span>
|
|
40
|
+
<span>b</span>
|
|
41
|
+
</Fragment>
|
|
42
|
+
).toString();
|
|
43
|
+
expect(html).toBe("<span>a</span><span>b</span>");
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/mask.test.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildMaskDef, positionFragmentMaskDef, rewriteFragmentMaskDef } from "./dom-to-svg.js";
|
|
3
|
+
|
|
4
|
+
// Locks the SVG <mask> emission for the cases exercised by the html-test
|
|
5
|
+
// suite's 23-mask.html fixture (DM-395).
|
|
6
|
+
|
|
7
|
+
describe("buildMaskDef — single-layer gradient masks (DM-395)", () => {
|
|
8
|
+
it("linear-gradient mask renders as mask-type=alpha with absolute-coord gradient", () => {
|
|
9
|
+
const r = buildMaskDef("m", "linear-gradient(to right, black, transparent)",
|
|
10
|
+
0, 0, 180, 120, "match-source", "auto", "0% 0%", "repeat", "add");
|
|
11
|
+
expect(r.def).toContain('mask-type="alpha"');
|
|
12
|
+
// userSpaceOnUse so the gradient angle isn't distorted by the box aspect
|
|
13
|
+
// ratio on non-square elements (DM-395).
|
|
14
|
+
expect(r.def).toContain('gradientUnits="userSpaceOnUse"');
|
|
15
|
+
expect(r.def).toContain("<linearGradient");
|
|
16
|
+
expect(r.def).toContain('width="180"');
|
|
17
|
+
expect(r.def).toContain('height="120"');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("45deg gradient on a non-square box uses real-px endpoints (DM-395)", () => {
|
|
21
|
+
// CSS spec: gradient line length L = |W·sin α| + |H·cos α| with endpoints
|
|
22
|
+
// at center ± L/2 along the angle. For 45° on a 180×120 box: L ≈ 212.13,
|
|
23
|
+
// start at (15, 135), end at (165, -15) relative to the box's top-left.
|
|
24
|
+
// Element offset elX=elY=0 here, so absolute coords are the same.
|
|
25
|
+
const r = buildMaskDef("m", "linear-gradient(45deg, black, transparent)",
|
|
26
|
+
0, 0, 180, 120, "match-source", "auto", "0% 0%", "repeat", "add");
|
|
27
|
+
expect(r.def).toContain('x1="15"');
|
|
28
|
+
expect(r.def).toContain('y1="135"');
|
|
29
|
+
expect(r.def).toContain('x2="165"');
|
|
30
|
+
expect(r.def).toContain('y2="-15"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("radial-gradient mask centers correctly when sized + positioned", () => {
|
|
34
|
+
// mask-image: radial-gradient(circle, black 40%, transparent 40%);
|
|
35
|
+
// mask-size: 80px; mask-position: 25% 25%
|
|
36
|
+
// Element at (680, 240); mask should be at gx=680+25, gy=240+10, 80x80.
|
|
37
|
+
const r = buildMaskDef("m", "radial-gradient(circle, black 40%, transparent 40%)",
|
|
38
|
+
680, 240, 180, 120, "match-source", "80px", "25% 25%", "no-repeat", "add");
|
|
39
|
+
expect(r.def).toContain('x="705"');
|
|
40
|
+
expect(r.def).toContain('y="250"');
|
|
41
|
+
expect(r.def).toContain('width="80"');
|
|
42
|
+
expect(r.def).toContain('height="80"');
|
|
43
|
+
// Center of the 80x80 mask box: cx=745, cy=290.
|
|
44
|
+
expect(r.def).toMatch(/cx="745"/);
|
|
45
|
+
expect(r.def).toMatch(/cy="290"/);
|
|
46
|
+
// farthest-corner radius = sqrt(40^2 + 40^2) ≈ 56.5685.
|
|
47
|
+
expect(r.def).toMatch(/r="56\.5685"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("mask-mode: alpha emits mask-type='alpha'", () => {
|
|
51
|
+
const r = buildMaskDef("m", "linear-gradient(45deg, black, transparent)",
|
|
52
|
+
0, 0, 180, 120, "alpha", "auto", "0% 0%", "repeat", "add");
|
|
53
|
+
expect(r.def).toContain('mask-type="alpha"');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("mask-mode: luminance emits mask-type='luminance'", () => {
|
|
57
|
+
const r = buildMaskDef("m", "linear-gradient(45deg, white, black)",
|
|
58
|
+
0, 0, 180, 120, "luminance", "auto", "0% 0%", "repeat", "add");
|
|
59
|
+
expect(r.def).toContain('mask-type="luminance"');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("match-source resolves to alpha for gradient sources (the common case)", () => {
|
|
63
|
+
// Per the buildMaskDef comment block: 'match-source' is alpha for
|
|
64
|
+
// gradients and bitmap-sourced url() masks. Only explicit 'luminance'
|
|
65
|
+
// opts into luminance interpretation.
|
|
66
|
+
const r = buildMaskDef("m", "radial-gradient(circle, black, transparent)",
|
|
67
|
+
0, 0, 100, 100, "match-source", "auto", "0% 0%", "no-repeat", "add");
|
|
68
|
+
expect(r.def).toContain('mask-type="alpha"');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("buildMaskDef — composite (DM-395)", () => {
|
|
73
|
+
it("composite=add (default) flattens layers into one <mask>", () => {
|
|
74
|
+
const r = buildMaskDef("m",
|
|
75
|
+
"linear-gradient(to right, black, transparent), radial-gradient(circle, black, transparent)",
|
|
76
|
+
0, 0, 180, 120, "match-source", "auto", "0% 0%", "no-repeat", "add");
|
|
77
|
+
// One <mask> with two gradient defs + two filled rects (additive).
|
|
78
|
+
expect((r.def.match(/<mask\s/g) ?? []).length).toBe(1);
|
|
79
|
+
expect((r.def.match(/<linearGradient/g) ?? []).length).toBe(1);
|
|
80
|
+
expect((r.def.match(/<radialGradient/g) ?? []).length).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("composite=intersect chains layers via mask=url(#inner) refs", () => {
|
|
84
|
+
// Mirrors the 'composite: intersect' fixture case:
|
|
85
|
+
// mask-image: linear-gradient(to right, black, transparent), radial-gradient(circle, black 50%, transparent 50%);
|
|
86
|
+
// mask-composite: intersect, intersect;
|
|
87
|
+
// mask-size: auto, 100px;
|
|
88
|
+
// mask-position: 0 0, center;
|
|
89
|
+
const r = buildMaskDef("m",
|
|
90
|
+
"linear-gradient(to right, black, transparent), radial-gradient(circle, black 50%, transparent 50%)",
|
|
91
|
+
0, 0, 180, 120, "match-source", "auto, 100px", "0px 0px, 50% 50%",
|
|
92
|
+
"no-repeat, no-repeat", "intersect, intersect");
|
|
93
|
+
// Two distinct mask elements: outer 'm' + inner 'm i1'.
|
|
94
|
+
expect((r.def.match(/<mask\s/g) ?? []).length).toBe(2);
|
|
95
|
+
expect(r.def).toContain('id="m"');
|
|
96
|
+
expect(r.def).toContain('id="mi1"');
|
|
97
|
+
// The outer mask's painted rect should reference the inner mask.
|
|
98
|
+
expect(r.def).toMatch(/fill="url\(#mg0\)"\s*mask="url\(#mi1\)"/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("rewriteFragmentMaskDef — DM-493 same-document mask fragment refs", () => {
|
|
103
|
+
it("rewrites the outer mask id to the requested output id", () => {
|
|
104
|
+
const out = rewriteFragmentMaskDef(
|
|
105
|
+
`<mask id="m1"><rect width="50" height="50" fill="white"/></mask>`,
|
|
106
|
+
"f0-mkfrag0",
|
|
107
|
+
"f0-",
|
|
108
|
+
);
|
|
109
|
+
expect(out).toContain('id="f0-mkfrag0"');
|
|
110
|
+
expect(out).not.toContain('id="m1"');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("rewrites nested ids with the idPrefix to keep them unique across captures", () => {
|
|
114
|
+
const out = rewriteFragmentMaskDef(
|
|
115
|
+
`<mask id="m1"><linearGradient id="g1"><stop offset="0" stop-color="white"/><stop offset="1" stop-color="black"/></linearGradient><rect fill="url(#g1)" width="50" height="50"/></mask>`,
|
|
116
|
+
"f0-mkfrag0",
|
|
117
|
+
"f0-",
|
|
118
|
+
);
|
|
119
|
+
expect(out).toContain('id="f0-mkfrag0"');
|
|
120
|
+
expect(out).toContain('id="f0-fragid-g1"');
|
|
121
|
+
expect(out).toContain('fill="url(#f0-fragid-g1)"');
|
|
122
|
+
expect(out).not.toContain('id="g1"');
|
|
123
|
+
expect(out).not.toContain("url(#g1)");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rewrites href / xlink:href fragment refs (transitive <use> targets)", () => {
|
|
127
|
+
const out = rewriteFragmentMaskDef(
|
|
128
|
+
`<mask id="m1"><defs><circle id="dot" r="10"/></defs><use href="#dot" x="20" y="20"/></mask>`,
|
|
129
|
+
"f1-mkfrag2",
|
|
130
|
+
"f1-",
|
|
131
|
+
);
|
|
132
|
+
expect(out).toContain('id="f1-mkfrag2"');
|
|
133
|
+
expect(out).toContain('id="f1-fragid-dot"');
|
|
134
|
+
expect(out).toContain('href="#f1-fragid-dot"');
|
|
135
|
+
expect(out).not.toContain("#dot\"");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("does not touch fragment refs that point at ids outside the captured subtree", () => {
|
|
139
|
+
// External-to-mask url(#somethingElse) should pass through unchanged so
|
|
140
|
+
// the renderer doesn't accidentally rewrite refs we didn't define.
|
|
141
|
+
const out = rewriteFragmentMaskDef(
|
|
142
|
+
`<mask id="m1"><rect fill="url(#externalGrad)" width="50" height="50"/></mask>`,
|
|
143
|
+
"f0-mkfrag0",
|
|
144
|
+
"f0-",
|
|
145
|
+
);
|
|
146
|
+
expect(out).toContain("url(#externalGrad)");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("multiple references to the same captured fragment share a single output id (deduped)", () => {
|
|
150
|
+
// Sanity check the rewrite is stable: invoking twice with the same
|
|
151
|
+
// outputId yields identical markup. Stable mapping is what lets the
|
|
152
|
+
// renderer dedupe when many elements reference the same fragment.
|
|
153
|
+
const a = rewriteFragmentMaskDef(`<mask id="m1"><rect width="10" height="10" fill="white"/></mask>`, "f-mkfrag0", "f-");
|
|
154
|
+
const b = rewriteFragmentMaskDef(`<mask id="m1"><rect width="10" height="10" fill="white"/></mask>`, "f-mkfrag0", "f-");
|
|
155
|
+
expect(a).toBe(b);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("positionFragmentMaskDef — DM-493 per-element mask placement", () => {
|
|
160
|
+
it("translates the mask content into the masked element's user-space", () => {
|
|
161
|
+
// CSS mask-image positions the mask source at the masked element's
|
|
162
|
+
// content-box origin. The captured <mask> has its content in its own
|
|
163
|
+
// local coordinates; we wrap the children in a translate(elX, elY) and
|
|
164
|
+
// rewrite the mask's own bounds to match the masked element's box.
|
|
165
|
+
const out = positionFragmentMaskDef(
|
|
166
|
+
`<mask id="mkfrag0" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"><rect x="0" y="0" width="100" height="100" fill="white"/></mask>`,
|
|
167
|
+
236, 20, 200, 120,
|
|
168
|
+
);
|
|
169
|
+
expect(out).toContain('x="236"');
|
|
170
|
+
expect(out).toContain('y="20"');
|
|
171
|
+
expect(out).toContain('width="200"');
|
|
172
|
+
expect(out).toContain('height="120"');
|
|
173
|
+
expect(out).toContain("transform=\"translate(236, 20)\"");
|
|
174
|
+
// Original captured bounds (x=0/y=0/width=100/height=100) should not
|
|
175
|
+
// remain on the outer mask — those were the source-mask bounds, not the
|
|
176
|
+
// target element's.
|
|
177
|
+
expect(out).not.toMatch(/<mask[^>]*\sx="0"/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("forces maskUnits=userSpaceOnUse so the rewritten coords are interpreted absolutely", () => {
|
|
181
|
+
const out = positionFragmentMaskDef(
|
|
182
|
+
`<mask id="m" maskUnits="objectBoundingBox" x="0" y="0" width="1" height="1"><rect x="0.1" y="0.1" width="0.8" height="0.8" fill="white"/></mask>`,
|
|
183
|
+
0, 0, 200, 120,
|
|
184
|
+
);
|
|
185
|
+
expect(out).toContain('maskUnits="userSpaceOnUse"');
|
|
186
|
+
expect(out).not.toContain('maskUnits="objectBoundingBox"');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("buildMaskDef — url() sources (DM-395)", () => {
|
|
191
|
+
it("SVG url() mask emits an empty mask (forceHide), matching Chrome's observed behavior", () => {
|
|
192
|
+
// Chrome's SVG mask source resolves to luminance and most icon SVGs
|
|
193
|
+
// compute near-zero luminance over the tile, so the element is hidden.
|
|
194
|
+
// We deliberately emit an empty mask to reproduce that.
|
|
195
|
+
const r = buildMaskDef("m", 'url("assets/img-orange.svg")',
|
|
196
|
+
0, 0, 180, 120, "match-source", "contain", "50% 50%", "no-repeat", "add");
|
|
197
|
+
expect(r.def).toMatch(/<mask[^>]*><\/mask>/);
|
|
198
|
+
// Mask exists (so the element gets force-hidden).
|
|
199
|
+
expect(r.def).not.toBe("");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("buildMaskDef — element() paint refs (DM-494)", () => {
|
|
204
|
+
function makeRaster(id: string, w = 64, h = 64) {
|
|
205
|
+
return new Map([[id, {
|
|
206
|
+
id, rid: "mr0", width: w, height: h,
|
|
207
|
+
dataUri: "data:image/png;base64,iVBORw0KGgo=",
|
|
208
|
+
rect: { x: 0, y: 0, width: w, height: h },
|
|
209
|
+
}]]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
it("element() ref emits an <image> inside the <mask> with mask-type=luminance under match-source", () => {
|
|
213
|
+
// CSS Masking spec: mask-mode: match-source resolves to luminance for
|
|
214
|
+
// element() paint references — the painted RGB drives mask alpha.
|
|
215
|
+
const rasters = makeRaster("src", 200, 100);
|
|
216
|
+
const r = buildMaskDef("m", "element(#src)",
|
|
217
|
+
0, 0, 200, 100, "match-source", "auto", "0% 0%", "no-repeat", "add", rasters);
|
|
218
|
+
expect(r.def).toContain('mask-type="luminance"');
|
|
219
|
+
expect(r.def).toContain('<image href="data:image/png;base64,iVBORw0KGgo="');
|
|
220
|
+
expect(r.def).toContain('width="200"');
|
|
221
|
+
expect(r.def).toContain('height="100"');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("element() with explicit mask-mode: alpha respects the author override", () => {
|
|
225
|
+
const rasters = makeRaster("src", 200, 100);
|
|
226
|
+
const r = buildMaskDef("m", "element(#src)",
|
|
227
|
+
0, 0, 200, 100, "alpha", "auto", "0% 0%", "no-repeat", "add", rasters);
|
|
228
|
+
expect(r.def).toContain('mask-type="alpha"');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("element() with mask-size: contain fits inside the consumer box (preserveAspectRatio meet)", () => {
|
|
232
|
+
// raster intrinsic 200x100; consumer 100x100. contain → fits = 100x50.
|
|
233
|
+
const rasters = makeRaster("src", 200, 100);
|
|
234
|
+
const r = buildMaskDef("m", "element(#src)",
|
|
235
|
+
0, 0, 100, 100, "match-source", "contain", "0% 0%", "no-repeat", "add", rasters);
|
|
236
|
+
expect(r.def).toContain('width="100"');
|
|
237
|
+
expect(r.def).toContain('height="50"');
|
|
238
|
+
expect(r.def).toContain('preserveAspectRatio="xMidYMid meet"');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("element() with mask-size: cover fills the consumer (preserveAspectRatio slice)", () => {
|
|
242
|
+
// raster 100x200; consumer 100x100. cover → 100x200 (height fills).
|
|
243
|
+
const rasters = makeRaster("src", 100, 200);
|
|
244
|
+
const r = buildMaskDef("m", "element(#src)",
|
|
245
|
+
0, 0, 100, 100, "match-source", "cover", "0% 0%", "no-repeat", "add", rasters);
|
|
246
|
+
expect(r.def).toContain('preserveAspectRatio="xMidYMid slice"');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("element() with no resolved raster (no dataUri) skips emission", () => {
|
|
250
|
+
const empty = new Map<string, import("./dom-to-svg.js").MaskRasterRef>();
|
|
251
|
+
const r = buildMaskDef("m", "element(#src)",
|
|
252
|
+
0, 0, 200, 100, "match-source", "auto", "0% 0%", "no-repeat", "add", empty);
|
|
253
|
+
expect(r.def).toBe("");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("element() ref with elementRasters undefined skips emission (legacy callers)", () => {
|
|
257
|
+
const r = buildMaskDef("m", "element(#src)",
|
|
258
|
+
0, 0, 200, 100, "match-source", "auto", "0% 0%", "no-repeat", "add");
|
|
259
|
+
expect(r.def).toBe("");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("mixed gradient + element() layers — luminance wins under match-source", () => {
|
|
263
|
+
const rasters = makeRaster("src", 64, 64);
|
|
264
|
+
const r = buildMaskDef("m",
|
|
265
|
+
"linear-gradient(black, transparent), element(#src)",
|
|
266
|
+
0, 0, 200, 100, "match-source", "auto, auto", "0% 0%, 0% 0%", "no-repeat, no-repeat", "add",
|
|
267
|
+
rasters);
|
|
268
|
+
// Any element() layer in match-source mode → mask-type=luminance.
|
|
269
|
+
expect(r.def).toContain('mask-type="luminance"');
|
|
270
|
+
// Both layers contribute content.
|
|
271
|
+
expect(r.def).toContain("<linearGradient");
|
|
272
|
+
expect(r.def).toContain('<image href="data:image/png');
|
|
273
|
+
});
|
|
274
|
+
});
|
package/src/optimize.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG Post-Processing Optimizer
|
|
3
|
+
*
|
|
4
|
+
* Uses SVGO to optimize path data and transforms without altering structure.
|
|
5
|
+
* Particularly effective for path-mode text (shortens glyph coordinates, converts
|
|
6
|
+
* to relative commands, reduces decimal precision).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { optimize, type PluginConfig } from "svgo";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Optimize an SVG string. Safe for all rendering modes — only compresses
|
|
13
|
+
* path data, transforms, and whitespace. Does not remove elements or IDs.
|
|
14
|
+
*/
|
|
15
|
+
export function optimizeSvg(svg: string): string {
|
|
16
|
+
// svgo 4.x's PluginConfig discriminated union is too narrow for
|
|
17
|
+
// `makeArcs: false` (the type expects a {threshold, tolerance} object even
|
|
18
|
+
// though svgo accepts `false` at runtime to disable arc conversion).
|
|
19
|
+
// Cast the convertPathData entry so the rest of the plugin list still
|
|
20
|
+
// type-checks.
|
|
21
|
+
const plugins: PluginConfig[] = [
|
|
22
|
+
{ name: "convertPathData", params: {
|
|
23
|
+
floatPrecision: 1,
|
|
24
|
+
transformPrecision: 3,
|
|
25
|
+
makeArcs: false,
|
|
26
|
+
} } as PluginConfig,
|
|
27
|
+
"convertTransform",
|
|
28
|
+
"minifyStyles",
|
|
29
|
+
"removeComments",
|
|
30
|
+
"removeEmptyAttrs",
|
|
31
|
+
];
|
|
32
|
+
const result = optimize(svg, { multipass: true, plugins });
|
|
33
|
+
return result.data;
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { preserveAspectRatioFor } from "./dom-to-svg.js";
|
|
3
|
+
|
|
4
|
+
describe("preserveAspectRatioFor — CSS object-fit/object-position → SVG preserveAspectRatio (DM-472)", () => {
|
|
5
|
+
it("object-fit:fill → none (stretch both axes)", () => {
|
|
6
|
+
expect(preserveAspectRatioFor("fill", "50% 50%")).toBe("none");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("object-fit defaulted to fill when undefined → none", () => {
|
|
10
|
+
expect(preserveAspectRatioFor(undefined, undefined)).toBe("none");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("object-fit:none → none (renderer special-cases via intrinsic-size path)", () => {
|
|
14
|
+
expect(preserveAspectRatioFor("none", "50% 50%")).toBe("none");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("object-fit:contain default position → xMidYMid meet", () => {
|
|
18
|
+
expect(preserveAspectRatioFor("contain", "50% 50%")).toBe("xMidYMid meet");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("object-fit:cover default position → xMidYMid slice", () => {
|
|
22
|
+
expect(preserveAspectRatioFor("cover", "50% 50%")).toBe("xMidYMid slice");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("object-fit:scale-down treated as contain", () => {
|
|
26
|
+
expect(preserveAspectRatioFor("scale-down", "50% 50%")).toBe("xMidYMid meet");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("object-position:left top → xMinYMin alignment", () => {
|
|
30
|
+
expect(preserveAspectRatioFor("contain", "left top")).toBe("xMinYMin meet");
|
|
31
|
+
expect(preserveAspectRatioFor("cover", "left top")).toBe("xMinYMin slice");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("object-position:right bottom → xMaxYMax alignment", () => {
|
|
35
|
+
expect(preserveAspectRatioFor("contain", "right bottom")).toBe("xMaxYMax meet");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("object-position:0% 0% → xMin alignment (same as left top)", () => {
|
|
39
|
+
expect(preserveAspectRatioFor("contain", "0% 0%")).toBe("xMinYMin meet");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("object-position:100% 100% → xMax alignment", () => {
|
|
43
|
+
expect(preserveAspectRatioFor("cover", "100% 100%")).toBe("xMaxYMax slice");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("undefined object-position defaults to center (xMidYMid)", () => {
|
|
47
|
+
expect(preserveAspectRatioFor("contain", undefined)).toBe("xMidYMid meet");
|
|
48
|
+
});
|
|
49
|
+
});
|