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.
Files changed (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { insetCornerRadii, parseCornerRadii, roundedRectPath, roundedRectSvg } from "./dom-to-svg.js";
3
+ describe("parseCornerRadii: shorthand and longhand", () => {
4
+ it("treats four equal circular corners as uniform", () => {
5
+ const c = parseCornerRadii({
6
+ borderTopLeftRadius: "10px 10px",
7
+ borderTopRightRadius: "10px 10px",
8
+ borderBottomRightRadius: "10px 10px",
9
+ borderBottomLeftRadius: "10px 10px",
10
+ }, 200, 100);
11
+ expect(c.uniform).toBe(true);
12
+ expect(c.tl).toEqual({ h: 10, v: 10 });
13
+ });
14
+ it("flags asymmetric per-corner radii as non-uniform (DM-300)", () => {
15
+ const c = parseCornerRadii({
16
+ borderTopLeftRadius: "10px 10px",
17
+ borderTopRightRadius: "30px 30px",
18
+ borderBottomRightRadius: "50px 50px",
19
+ borderBottomLeftRadius: "70px 70px",
20
+ }, 200, 100);
21
+ expect(c.uniform).toBe(false);
22
+ expect(c.tl.h).toBe(10);
23
+ expect(c.tr.h).toBe(30);
24
+ expect(c.br.h).toBe(50);
25
+ expect(c.bl.h).toBe(70);
26
+ });
27
+ it("flags elliptical corners as non-uniform even when all four are equal", () => {
28
+ const c = parseCornerRadii({
29
+ borderTopLeftRadius: "50px 20px",
30
+ borderTopRightRadius: "50px 20px",
31
+ borderBottomRightRadius: "50px 20px",
32
+ borderBottomLeftRadius: "50px 20px",
33
+ }, 400, 80);
34
+ expect(c.uniform).toBe(false);
35
+ expect(c.tl).toEqual({ h: 50, v: 20 });
36
+ });
37
+ it("falls back to the borderRadius shorthand when longhands aren't captured", () => {
38
+ const c = parseCornerRadii({ borderRadius: "12px" }, 100, 100);
39
+ expect(c.uniform).toBe(true);
40
+ expect(c.tl).toEqual({ h: 12, v: 12 });
41
+ });
42
+ it("scales corners down to fit the edge length (CSS corner-overlap §5.5)", () => {
43
+ // 999px corners on a 200x100 box: top edge has rTL.h + rTR.h = 1998 > 200,
44
+ // so all corners scale by 200/1998 ≈ 0.1. The vertical sums (rTR.v +
45
+ // rBR.v = 1998 > 100, etc.) drive a tighter scale of 100/1998 ≈ 0.05,
46
+ // which wins. Final per-corner radius: ~50 (half the box height).
47
+ const c = parseCornerRadii({
48
+ borderTopLeftRadius: "999px 999px",
49
+ borderTopRightRadius: "999px 999px",
50
+ borderBottomRightRadius: "999px 999px",
51
+ borderBottomLeftRadius: "999px 999px",
52
+ }, 200, 100);
53
+ expect(c.tl.h).toBeCloseTo(50, 0);
54
+ expect(c.tl.v).toBeCloseTo(50, 0);
55
+ expect(c.uniform).toBe(true);
56
+ });
57
+ });
58
+ describe("insetCornerRadii: inner-corner derivation", () => {
59
+ it("shrinks each corner by the matching adjacent border-side widths", () => {
60
+ const c = parseCornerRadii({
61
+ borderTopLeftRadius: "20px 20px",
62
+ borderTopRightRadius: "20px 20px",
63
+ borderBottomRightRadius: "20px 20px",
64
+ borderBottomLeftRadius: "20px 20px",
65
+ }, 100, 100);
66
+ const inner = insetCornerRadii(c, 5, 3, 5, 3);
67
+ // TL: shrink by left=3 (h) and top=5 (v).
68
+ expect(inner.tl).toEqual({ h: 17, v: 15 });
69
+ // TR: shrink by right=3 (h) and top=5 (v).
70
+ expect(inner.tr).toEqual({ h: 17, v: 15 });
71
+ });
72
+ it("clamps shrunk corners to zero rather than going negative", () => {
73
+ const c = parseCornerRadii({
74
+ borderTopLeftRadius: "4px 4px",
75
+ borderTopRightRadius: "4px 4px",
76
+ borderBottomRightRadius: "4px 4px",
77
+ borderBottomLeftRadius: "4px 4px",
78
+ }, 100, 100);
79
+ const inner = insetCornerRadii(c, 10, 10, 10, 10);
80
+ expect(inner.tl).toEqual({ h: 0, v: 0 });
81
+ });
82
+ });
83
+ describe("roundedRectPath: SVG d-attribute geometry", () => {
84
+ it("emits a clockwise path with one elliptical arc per corner", () => {
85
+ const c = parseCornerRadii({
86
+ borderTopLeftRadius: "10px 10px",
87
+ borderTopRightRadius: "20px 20px",
88
+ borderBottomRightRadius: "30px 30px",
89
+ borderBottomLeftRadius: "40px 40px",
90
+ }, 200, 100);
91
+ const d = roundedRectPath(0, 0, 200, 100, c);
92
+ // Starts at (TL.h, 0).
93
+ expect(d.startsWith("M10,0 ")).toBe(true);
94
+ // Has 4 elliptical arc commands, one per non-zero corner.
95
+ const arcs = d.match(/A/g) || [];
96
+ expect(arcs.length).toBe(4);
97
+ // Closes the path.
98
+ expect(d.endsWith(" Z")).toBe(true);
99
+ });
100
+ it("omits the arc for a zero-radius corner so adjacent lines meet sharply", () => {
101
+ const c = parseCornerRadii({
102
+ borderTopLeftRadius: "0px 0px",
103
+ borderTopRightRadius: "20px 20px",
104
+ borderBottomRightRadius: "0px 0px",
105
+ borderBottomLeftRadius: "20px 20px",
106
+ }, 100, 100);
107
+ const d = roundedRectPath(0, 0, 100, 100, c);
108
+ const arcs = d.match(/A/g) || [];
109
+ expect(arcs.length).toBe(2);
110
+ });
111
+ });
112
+ describe("roundedRectSvg: rect-or-path branching", () => {
113
+ it("emits <rect rx> for uniform circular corners (fast path)", () => {
114
+ const c = parseCornerRadii({
115
+ borderTopLeftRadius: "8px 8px",
116
+ borderTopRightRadius: "8px 8px",
117
+ borderBottomRightRadius: "8px 8px",
118
+ borderBottomLeftRadius: "8px 8px",
119
+ }, 100, 50);
120
+ const svg = roundedRectSvg(0, 0, 100, 50, c, 'fill="red"');
121
+ expect(svg.startsWith("<rect ")).toBe(true);
122
+ expect(svg).toContain('rx="8"');
123
+ expect(svg).toContain('fill="red"');
124
+ });
125
+ it("emits <path> for asymmetric corners (DM-300)", () => {
126
+ const c = parseCornerRadii({
127
+ borderTopLeftRadius: "10px 10px",
128
+ borderTopRightRadius: "30px 30px",
129
+ borderBottomRightRadius: "50px 50px",
130
+ borderBottomLeftRadius: "70px 70px",
131
+ }, 200, 100);
132
+ const svg = roundedRectSvg(0, 0, 200, 100, c, 'fill="blue"');
133
+ expect(svg.startsWith("<path ")).toBe(true);
134
+ expect(svg).toContain('fill="blue"');
135
+ // Four arcs.
136
+ expect((svg.match(/A/g) || []).length).toBe(4);
137
+ });
138
+ it("emits <path> for elliptical corners (50px / 20px)", () => {
139
+ const c = parseCornerRadii({
140
+ borderTopLeftRadius: "50px 20px",
141
+ borderTopRightRadius: "50px 20px",
142
+ borderBottomRightRadius: "50px 20px",
143
+ borderBottomLeftRadius: "50px 20px",
144
+ }, 400, 80);
145
+ const svg = roundedRectSvg(0, 0, 400, 80, c, "");
146
+ expect(svg.startsWith("<path ")).toBe(true);
147
+ });
148
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Page Capture
3
+ *
4
+ * Uses Playwright to navigate to a URL, wait for it to settle,
5
+ * and capture the DOM as SVG via the dom-to-svg converter.
6
+ */
7
+ import { type Browser, type LaunchOptions, type Page } from "@playwright/test";
8
+ export interface CaptureOptions {
9
+ width: number;
10
+ height: number;
11
+ mobile?: boolean;
12
+ /**
13
+ * Sets the browser context's `prefers-color-scheme` media feature, which
14
+ * controls how dark-mode-aware sites resolve their CSS. Default behavior
15
+ * (undefined) follows Playwright's own default of "light".
16
+ */
17
+ colorScheme?: "light" | "dark" | "no-preference";
18
+ /** Authenticate via dev-login API before capturing */
19
+ devUser?: string;
20
+ /**
21
+ * DM-512: when true, fetch every http(s) image URL referenced by the
22
+ * captured tree and inline it as a `data:` URI in the output SVG. The
23
+ * resulting SVG loads correctly in image viewers (Preview, QuickLook,
24
+ * Finder thumbnail, etc.) that don't fetch remote resources from local
25
+ * files. Adds capture-time network I/O proportional to the number of
26
+ * unique referenced URLs; per-URL fetch failures are logged via the
27
+ * capture-warnings pipeline but don't fail the overall capture.
28
+ * Default: false (URLs pass through verbatim — works in browsers that
29
+ * fetch from file:// pages but not in offline viewers).
30
+ */
31
+ selfContained?: boolean;
32
+ /**
33
+ * DM-528: per-URL fetch timeout (ms) for the `selfContained` pre-pass.
34
+ * Caps the time a stalled CDN host can hold up the capture; total
35
+ * pre-pass time is bounded by `embedRemoteImagesTimeoutMs` (fetches run
36
+ * in parallel) rather than the sum across all URLs. Default 10000.
37
+ */
38
+ embedRemoteImagesTimeoutMs?: number;
39
+ /**
40
+ * DM-529: number of retry attempts for transient failures (5xx /
41
+ * network-error / timeout) in the `selfContained` pre-pass. 4xx
42
+ * responses are not retried. Default 1.
43
+ */
44
+ embedRemoteImagesRetries?: number;
45
+ /**
46
+ * DM-529: backoff delay (ms) between retry attempts in the
47
+ * `selfContained` pre-pass. Default 500.
48
+ */
49
+ embedRemoteImagesRetryBackoffMs?: number;
50
+ /**
51
+ * DM-526 / DM-539: when true, run the `resizeEmbeddedImages` pre-pass
52
+ * after `embedRemoteImages` to downscale each inlined image to its
53
+ * consumer's render rect × `embedRemoteImagesHiDPIFactor`, re-encoded as
54
+ * PNG. Yields 50–80 % SVG size reduction on news-site captures with no
55
+ * visible diff at the captured viewport. No-op unless `selfContained` is
56
+ * also true (resize only acts on what the embed pass already inlined).
57
+ * Default false. See `docs/27-image-resize-on-embed.md`.
58
+ */
59
+ embedRemoteImagesResize?: boolean;
60
+ /**
61
+ * DM-526 / DM-539: hiDPI multiplier applied to each consumer's render rect
62
+ * before resizing. `2.0` (default) leaves headroom for retina viewing /
63
+ * zoom; `1.0` produces the smallest output (matches Chromium's painted
64
+ * resolution at devicePixelRatio: 1); `3.0` covers iPhone-Pro density.
65
+ * Values < 1 are clamped to 1.
66
+ */
67
+ embedRemoteImagesHiDPIFactor?: number;
68
+ }
69
+ /**
70
+ * Launch Chromium via Playwright, auto-installing the browser binary on first
71
+ * use if it's missing. Use this instead of importing `chromium` from
72
+ * `@playwright/test` directly when you want a frictionless first-run
73
+ * experience for users of your tool.
74
+ *
75
+ * The install step is `npx playwright install chromium` and runs synchronously
76
+ * (stdout / stderr inherited) so the user sees its progress. Subsequent calls
77
+ * are a normal `chromium.launch()` with no overhead.
78
+ */
79
+ export declare function launchChromium(opts?: LaunchOptions): Promise<Browser>;
80
+ export declare class DemoRecorder {
81
+ private browser;
82
+ private context;
83
+ private page;
84
+ private width;
85
+ private height;
86
+ private baseUrl;
87
+ private selfContained;
88
+ private embedRemoteImagesTimeoutMs;
89
+ private embedRemoteImagesRetries;
90
+ private embedRemoteImagesRetryBackoffMs;
91
+ private embedRemoteImagesResize;
92
+ private embedRemoteImagesHiDPIFactor;
93
+ constructor(baseUrl: string, opts: CaptureOptions);
94
+ init(opts: CaptureOptions): Promise<void>;
95
+ /** Navigate to a URL and capture the visible DOM as SVG. */
96
+ captureUrl(path: string, waitMs?: number, idPrefix?: string): Promise<string>;
97
+ /** Capture the current page state as SVG content. */
98
+ captureCurrent(idPrefix?: string): Promise<string>;
99
+ /**
100
+ * Capture a full-page (scrollable) DOM as SVG.
101
+ * Returns SVG content that may be taller than the viewport.
102
+ */
103
+ captureFullPage(idPrefix?: string): Promise<{
104
+ svgContent: string;
105
+ pageHeight: number;
106
+ }>;
107
+ /** Get the underlying Playwright page for custom interactions. */
108
+ getPage(): Page;
109
+ /** Get the bounding box of an element (for positioning overlays). */
110
+ getBoundingBox(selector: string): Promise<{
111
+ x: number;
112
+ y: number;
113
+ width: number;
114
+ height: number;
115
+ } | null>;
116
+ close(): Promise<void>;
117
+ }
118
+ /**
119
+ * Install a `requestfinished` listener that records every font-file URL the
120
+ * browser fetches into a Set. Returns the set + a detach handle. Pair with
121
+ * `discoverAndRegisterWebfonts(page, tracker.urls)` after the page loads.
122
+ *
123
+ * Needed because `performance.getEntriesByType("resource")` omits
124
+ * cross-origin fonts that don't send `Timing-Allow-Origin: *` (most CDNs
125
+ * don't), and most webfonts in the wild are served cross-origin.
126
+ *
127
+ * Attach BEFORE navigation so the listener catches the initial fetches.
128
+ */
129
+ export declare function attachWebfontTracker(page: Page): {
130
+ urls: Set<string>;
131
+ detach: () => void;
132
+ };
133
+ /**
134
+ * Discover all `@font-face` rules in the page's stylesheets, fetch each
135
+ * font file via the browser context's request API (so cookies / CORS / auth
136
+ * follow whatever the browser is using), and register the bytes with
137
+ * `text-to-path.ts` so the renderer can draw with the actual webfont glyphs
138
+ * instead of falling through to the system-font substitutes.
139
+ *
140
+ * Should be called AFTER `await page.evaluate(() => document.fonts.ready)`
141
+ * — otherwise late-loading fonts may not be in `document.styleSheets` yet.
142
+ *
143
+ * Cross-origin stylesheets whose `cssRules` throw a SecurityError are silently
144
+ * skipped (we can't enumerate their rules from JS). Same-origin sheets and
145
+ * inline `<style>` blocks always work.
146
+ *
147
+ * Caller is responsible for `clearWebfonts()` between captures if needed.
148
+ * No-op when the page declares no `@font-face` rules.
149
+ */
150
+ export declare function discoverAndRegisterWebfonts(page: Page, observedFontUrls?: Iterable<string>): Promise<{
151
+ family: string;
152
+ weight: number;
153
+ style: string;
154
+ url: string;
155
+ source: "font-face" | "resource";
156
+ ok: boolean;
157
+ error?: string;
158
+ }[]>;
159
+ /**
160
+ * DM-545: parse `@font-face` rules out of a raw CSS text fetched server-side.
161
+ * Used for cross-origin stylesheets that the page-side `cssRules` walker can't
162
+ * read. Tolerant scanner — handles top-level `@font-face` and rules nested
163
+ * inside any number of `@media` / `@supports` / `@layer` / `@container`
164
+ * blocks (recurses through balanced braces). Returns the same FaceRule shape
165
+ * the page-side walker emits so the downstream registration loop is uniform.
166
+ *
167
+ * Not a full CSS parser — comment-stripping handles `/* … *​/`, but exotic
168
+ * inputs (custom properties holding @font-face strings, CSSOM-injected rules
169
+ * that never serialised back to text) aren't covered. Adequate for the
170
+ * mainstream marketing-site case that motivated the change.
171
+ */
172
+ export declare function parseFontFaceRulesFromCssText(cssText: string, baseUrl: string): Array<{
173
+ kind: "font-face";
174
+ family: string;
175
+ weight: string;
176
+ style: string;
177
+ url: string;
178
+ urls?: string[];
179
+ unicodeRange?: Array<[number, number]>;
180
+ }>;
181
+ /**
182
+ * Parse a CSS `unicode-range` descriptor value (CSS Fonts 4 §4.5) into a list
183
+ * of inclusive `[from, to]` codepoint intervals. Accepts the three forms:
184
+ * single (`U+26`), interval (`U+0-7F`), and wildcard (`U+4??`). Multiple
185
+ * ranges are comma-separated. Returns `undefined` for empty / unparseable
186
+ * input — callers treat that as the CSS default coverage (U+0..U+10FFFF).
187
+ *
188
+ * This is the Node-side twin of the in-page parser inlined inside
189
+ * `discoverAndRegisterWebfonts`'s `page.evaluate` body. The page-side copy
190
+ * can't import from here (it runs in the browser context), but the logic must
191
+ * stay aligned — covered by tests on this exported version.
192
+ */
193
+ export declare function parseUnicodeRangeDescriptor(value: string): Array<[number, number]> | undefined;