@1agh/maude 0.25.0 → 0.27.0
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/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/sync/agent.ts +23 -8
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/sync-agent.test.ts +28 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file draw/serialize.ts — Phase 25 geometry-engine serializer
|
|
3
|
+
* @scope plugins/design/dev-server/draw/serialize.ts
|
|
4
|
+
* @purpose The dual serializer: ONE `DrawPrimitive[]` produces BOTH an
|
|
5
|
+
* optimized SVG string (`toSvg`) for on-disk assets AND a JSX string
|
|
6
|
+
* (`toJsx`) for inline canvas embedding. Both render the SAME
|
|
7
|
+
* intermediate node tree (`primitivesToNodes`), differing only in
|
|
8
|
+
* attribute dialect (`stroke-width`/`class` vs `strokeWidth`/
|
|
9
|
+
* `className`) and the `xmlns` document marker — so the on-disk SVG
|
|
10
|
+
* and the on-canvas JSX can never structurally drift. This is the
|
|
11
|
+
* exact single-source invariant `canvas-arrowheads.ts` enforces for
|
|
12
|
+
* arrows, generalized to the whole vocabulary.
|
|
13
|
+
*
|
|
14
|
+
* a11y: `toSvg` injects `role="img"` + `<title>` + `<desc>`, keeps
|
|
15
|
+
* the `viewBox`, and defaults paint to `currentColor` so the mark
|
|
16
|
+
* inherits theme color (dark-mode / single-color flatten for free).
|
|
17
|
+
*
|
|
18
|
+
* React-free (DDR-067) — `toJsx` returns a STRING, never a React
|
|
19
|
+
* element; this module imports nothing from react.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { CURRENT_COLOR } from './palette.ts';
|
|
23
|
+
import type { DrawPrimitive, DrawStyle, Point } from './primitives.ts';
|
|
24
|
+
|
|
25
|
+
export interface A11y {
|
|
26
|
+
/** Accessible name; rendered as `<title>` + announced under `role="img"`. */
|
|
27
|
+
title?: string;
|
|
28
|
+
/** Long description; rendered as `<desc>`. */
|
|
29
|
+
desc?: string;
|
|
30
|
+
role?: string;
|
|
31
|
+
/** Purely decorative: `aria-hidden`, no title/desc/role. */
|
|
32
|
+
decorative?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SerializeOpts {
|
|
36
|
+
viewBox: string;
|
|
37
|
+
width?: number | string;
|
|
38
|
+
height?: number | string;
|
|
39
|
+
a11y?: A11y;
|
|
40
|
+
/** Paint applied where a fillable shape declares neither fill nor stroke. */
|
|
41
|
+
defaultColor?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Dialect-neutral node — the single source both renderers consume. */
|
|
45
|
+
export interface SvgNode {
|
|
46
|
+
tag: string;
|
|
47
|
+
/** Ordered [logicalKey, value]. `logicalKey` maps to a dialect attr name. */
|
|
48
|
+
attrs: Array<[string, string]>;
|
|
49
|
+
children: SvgNode[];
|
|
50
|
+
/** Raw text content (already a string; escaped at render time). */
|
|
51
|
+
text?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Logical attr key → { svg, jsx } names. Keys absent here are identical in both
|
|
55
|
+
// dialects (x, y, width, height, cx, cy, r, rx, ry, x1, d, points, fill, stroke,
|
|
56
|
+
// opacity, transform, viewBox, id, href, role).
|
|
57
|
+
const ATTR_DIALECT: Record<string, { svg: string; jsx: string }> = {
|
|
58
|
+
strokeWidth: { svg: 'stroke-width', jsx: 'strokeWidth' },
|
|
59
|
+
strokeLinecap: { svg: 'stroke-linecap', jsx: 'strokeLinecap' },
|
|
60
|
+
strokeLinejoin: { svg: 'stroke-linejoin', jsx: 'strokeLinejoin' },
|
|
61
|
+
strokeDasharray: { svg: 'stroke-dasharray', jsx: 'strokeDasharray' },
|
|
62
|
+
fillOpacity: { svg: 'fill-opacity', jsx: 'fillOpacity' },
|
|
63
|
+
strokeOpacity: { svg: 'stroke-opacity', jsx: 'strokeOpacity' },
|
|
64
|
+
fontSize: { svg: 'font-size', jsx: 'fontSize' },
|
|
65
|
+
fontFamily: { svg: 'font-family', jsx: 'fontFamily' },
|
|
66
|
+
fontWeight: { svg: 'font-weight', jsx: 'fontWeight' },
|
|
67
|
+
textAnchor: { svg: 'text-anchor', jsx: 'textAnchor' },
|
|
68
|
+
dominantBaseline: { svg: 'dominant-baseline', jsx: 'dominantBaseline' },
|
|
69
|
+
letterSpacing: { svg: 'letter-spacing', jsx: 'letterSpacing' },
|
|
70
|
+
stopColor: { svg: 'stop-color', jsx: 'stopColor' },
|
|
71
|
+
stopOpacity: { svg: 'stop-opacity', jsx: 'stopOpacity' },
|
|
72
|
+
clipPath: { svg: 'clip-path', jsx: 'clipPath' },
|
|
73
|
+
floodColor: { svg: 'flood-color', jsx: 'floodColor' },
|
|
74
|
+
floodOpacity: { svg: 'flood-opacity', jsx: 'floodOpacity' },
|
|
75
|
+
colorInterpolationFilters: {
|
|
76
|
+
svg: 'color-interpolation-filters',
|
|
77
|
+
jsx: 'colorInterpolationFilters',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const CONTAINER_TAGS = new Set([
|
|
82
|
+
'svg',
|
|
83
|
+
'g',
|
|
84
|
+
'defs',
|
|
85
|
+
'symbol',
|
|
86
|
+
'text',
|
|
87
|
+
'title',
|
|
88
|
+
'desc',
|
|
89
|
+
'linearGradient',
|
|
90
|
+
'radialGradient',
|
|
91
|
+
'filter',
|
|
92
|
+
'pattern',
|
|
93
|
+
'mask',
|
|
94
|
+
'clipPath',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function num(n: number): string {
|
|
98
|
+
if (Number.isInteger(n)) return String(n);
|
|
99
|
+
return String(Math.round(n * 100) / 100); // floatPrecision: 2
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function pointsStr(pts: Point[]): string {
|
|
103
|
+
return pts.map((p) => `${num(p.x)},${num(p.y)}`).join(' ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function dashValue(dash: boolean | number[], strokeWidth?: number): string | null {
|
|
107
|
+
if (dash === false) return null;
|
|
108
|
+
if (dash === true) {
|
|
109
|
+
const w = strokeWidth ?? 1;
|
|
110
|
+
return `${num(w * 3)} ${num(w * 2)}`;
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(dash) && dash.length) return dash.map(num).join(' ');
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Append `DrawStyle` attributes, resolving the `currentColor` / `fill="none"`
|
|
118
|
+
* defaults so a stroked outline never gets an accidental black fill and a
|
|
119
|
+
* paint-less shape inherits theme color.
|
|
120
|
+
*/
|
|
121
|
+
function pushStyle(
|
|
122
|
+
attrs: Array<[string, string]>,
|
|
123
|
+
s: DrawStyle,
|
|
124
|
+
fillable: boolean,
|
|
125
|
+
defaultColor: string
|
|
126
|
+
): void {
|
|
127
|
+
const hasFill = s.fill !== undefined;
|
|
128
|
+
const hasStroke = s.stroke !== undefined;
|
|
129
|
+
|
|
130
|
+
if (fillable) {
|
|
131
|
+
if (!hasFill && !hasStroke) {
|
|
132
|
+
attrs.push(['fill', defaultColor]);
|
|
133
|
+
} else if (!hasFill && hasStroke) {
|
|
134
|
+
attrs.push(['fill', 'none']);
|
|
135
|
+
} else if (hasFill) {
|
|
136
|
+
attrs.push(['fill', s.fill as string]);
|
|
137
|
+
}
|
|
138
|
+
} else if (hasFill) {
|
|
139
|
+
attrs.push(['fill', s.fill as string]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (hasStroke) attrs.push(['stroke', s.stroke as string]);
|
|
143
|
+
else if (!fillable && !hasFill) attrs.push(['stroke', defaultColor]); // bare line → inherit
|
|
144
|
+
|
|
145
|
+
if (s.strokeWidth !== undefined) attrs.push(['strokeWidth', num(s.strokeWidth)]);
|
|
146
|
+
if (s.strokeLinecap) attrs.push(['strokeLinecap', s.strokeLinecap]);
|
|
147
|
+
if (s.strokeLinejoin) attrs.push(['strokeLinejoin', s.strokeLinejoin]);
|
|
148
|
+
if (s.opacity !== undefined) attrs.push(['opacity', num(s.opacity)]);
|
|
149
|
+
if (s.fillOpacity !== undefined) attrs.push(['fillOpacity', num(s.fillOpacity)]);
|
|
150
|
+
if (s.strokeOpacity !== undefined) attrs.push(['strokeOpacity', num(s.strokeOpacity)]);
|
|
151
|
+
if (s.dash !== undefined) {
|
|
152
|
+
const d = dashValue(s.dash, s.strokeWidth);
|
|
153
|
+
if (d) attrs.push(['strokeDasharray', d]);
|
|
154
|
+
}
|
|
155
|
+
if (s.id !== undefined) attrs.push(['id', s.id]);
|
|
156
|
+
if (s.filter !== undefined) attrs.push(['filter', s.filter]);
|
|
157
|
+
if (s.mask !== undefined) attrs.push(['mask', s.mask]);
|
|
158
|
+
if (s.clipPath !== undefined) attrs.push(['clipPath', s.clipPath]);
|
|
159
|
+
if (s.mixBlendMode !== undefined) attrs.push(['mixBlendMode', s.mixBlendMode]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function feToNode(fp: {
|
|
163
|
+
fe: string;
|
|
164
|
+
attrs?: Record<string, string | number>;
|
|
165
|
+
children?: unknown[];
|
|
166
|
+
}): SvgNode {
|
|
167
|
+
const attrs: Array<[string, string]> = Object.entries(fp.attrs ?? {}).map(([k, v]) => [
|
|
168
|
+
k,
|
|
169
|
+
String(v),
|
|
170
|
+
]);
|
|
171
|
+
const kids = (fp.children ?? []) as Array<Parameters<typeof feToNode>[0]>;
|
|
172
|
+
return { tag: fp.fe, attrs, children: kids.map(feToNode) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function primitiveToNode(p: DrawPrimitive, defaultColor: string): SvgNode {
|
|
176
|
+
switch (p.el) {
|
|
177
|
+
case 'rect': {
|
|
178
|
+
const attrs: Array<[string, string]> = [
|
|
179
|
+
['x', num(p.x)],
|
|
180
|
+
['y', num(p.y)],
|
|
181
|
+
['width', num(p.width)],
|
|
182
|
+
['height', num(p.height)],
|
|
183
|
+
];
|
|
184
|
+
if (p.rx !== undefined) attrs.push(['rx', num(p.rx)]);
|
|
185
|
+
if (p.ry !== undefined) attrs.push(['ry', num(p.ry)]);
|
|
186
|
+
pushStyle(attrs, p, true, defaultColor);
|
|
187
|
+
return { tag: 'rect', attrs, children: [] };
|
|
188
|
+
}
|
|
189
|
+
case 'circle': {
|
|
190
|
+
const attrs: Array<[string, string]> = [
|
|
191
|
+
['cx', num(p.cx)],
|
|
192
|
+
['cy', num(p.cy)],
|
|
193
|
+
['r', num(p.r)],
|
|
194
|
+
];
|
|
195
|
+
pushStyle(attrs, p, true, defaultColor);
|
|
196
|
+
return { tag: 'circle', attrs, children: [] };
|
|
197
|
+
}
|
|
198
|
+
case 'ellipse': {
|
|
199
|
+
const attrs: Array<[string, string]> = [
|
|
200
|
+
['cx', num(p.cx)],
|
|
201
|
+
['cy', num(p.cy)],
|
|
202
|
+
['rx', num(p.rx)],
|
|
203
|
+
['ry', num(p.ry)],
|
|
204
|
+
];
|
|
205
|
+
pushStyle(attrs, p, true, defaultColor);
|
|
206
|
+
return { tag: 'ellipse', attrs, children: [] };
|
|
207
|
+
}
|
|
208
|
+
case 'line': {
|
|
209
|
+
const attrs: Array<[string, string]> = [
|
|
210
|
+
['x1', num(p.x1)],
|
|
211
|
+
['y1', num(p.y1)],
|
|
212
|
+
['x2', num(p.x2)],
|
|
213
|
+
['y2', num(p.y2)],
|
|
214
|
+
];
|
|
215
|
+
pushStyle(attrs, p, false, defaultColor);
|
|
216
|
+
return { tag: 'line', attrs, children: [] };
|
|
217
|
+
}
|
|
218
|
+
case 'polyline': {
|
|
219
|
+
const attrs: Array<[string, string]> = [['points', pointsStr(p.points)]];
|
|
220
|
+
pushStyle(attrs, p, false, defaultColor);
|
|
221
|
+
return { tag: 'polyline', attrs, children: [] };
|
|
222
|
+
}
|
|
223
|
+
case 'polygon': {
|
|
224
|
+
const attrs: Array<[string, string]> = [['points', pointsStr(p.points)]];
|
|
225
|
+
pushStyle(attrs, p, true, defaultColor);
|
|
226
|
+
return { tag: 'polygon', attrs, children: [] };
|
|
227
|
+
}
|
|
228
|
+
case 'path': {
|
|
229
|
+
const attrs: Array<[string, string]> = [['d', p.d]];
|
|
230
|
+
pushStyle(attrs, p, true, defaultColor);
|
|
231
|
+
return { tag: 'path', attrs, children: [] };
|
|
232
|
+
}
|
|
233
|
+
case 'text': {
|
|
234
|
+
const attrs: Array<[string, string]> = [
|
|
235
|
+
['x', num(p.x)],
|
|
236
|
+
['y', num(p.y)],
|
|
237
|
+
];
|
|
238
|
+
if (p.fontSize !== undefined) attrs.push(['fontSize', num(p.fontSize)]);
|
|
239
|
+
if (p.fontFamily !== undefined) attrs.push(['fontFamily', p.fontFamily]);
|
|
240
|
+
if (p.fontWeight !== undefined) attrs.push(['fontWeight', String(p.fontWeight)]);
|
|
241
|
+
if (p.textAnchor !== undefined) attrs.push(['textAnchor', p.textAnchor]);
|
|
242
|
+
if (p.dominantBaseline !== undefined) attrs.push(['dominantBaseline', p.dominantBaseline]);
|
|
243
|
+
if (p.letterSpacing !== undefined) attrs.push(['letterSpacing', num(p.letterSpacing)]);
|
|
244
|
+
pushStyle(attrs, p, true, defaultColor);
|
|
245
|
+
return { tag: 'text', attrs, children: [], text: p.content };
|
|
246
|
+
}
|
|
247
|
+
case 'group': {
|
|
248
|
+
const attrs: Array<[string, string]> = [];
|
|
249
|
+
if (p.transform) attrs.push(['transform', p.transform]);
|
|
250
|
+
if (p.opacity !== undefined) attrs.push(['opacity', num(p.opacity)]);
|
|
251
|
+
if (p.id !== undefined) attrs.push(['id', p.id]);
|
|
252
|
+
// Groups carry the compositing surface — filter / mask / clip / blend — so
|
|
253
|
+
// a whole sub-drawing can be warped, masked, or blended at once.
|
|
254
|
+
if (p.filter !== undefined) attrs.push(['filter', p.filter]);
|
|
255
|
+
if (p.mask !== undefined) attrs.push(['mask', p.mask]);
|
|
256
|
+
if (p.clipPath !== undefined) attrs.push(['clipPath', p.clipPath]);
|
|
257
|
+
if (p.mixBlendMode !== undefined) attrs.push(['mixBlendMode', p.mixBlendMode]);
|
|
258
|
+
return { tag: 'g', attrs, children: p.children.map((c) => primitiveToNode(c, defaultColor)) };
|
|
259
|
+
}
|
|
260
|
+
case 'defs':
|
|
261
|
+
return {
|
|
262
|
+
tag: 'defs',
|
|
263
|
+
attrs: [],
|
|
264
|
+
children: p.children.map((c) => primitiveToNode(c, defaultColor)),
|
|
265
|
+
};
|
|
266
|
+
case 'symbol': {
|
|
267
|
+
const attrs: Array<[string, string]> = [['id', p.id]];
|
|
268
|
+
if (p.viewBox) attrs.push(['viewBox', p.viewBox]);
|
|
269
|
+
return {
|
|
270
|
+
tag: 'symbol',
|
|
271
|
+
attrs,
|
|
272
|
+
children: p.children.map((c) => primitiveToNode(c, defaultColor)),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
case 'use': {
|
|
276
|
+
const attrs: Array<[string, string]> = [['href', p.href]];
|
|
277
|
+
if (p.x !== undefined) attrs.push(['x', num(p.x)]);
|
|
278
|
+
if (p.y !== undefined) attrs.push(['y', num(p.y)]);
|
|
279
|
+
if (p.width !== undefined) attrs.push(['width', num(p.width)]);
|
|
280
|
+
if (p.height !== undefined) attrs.push(['height', num(p.height)]);
|
|
281
|
+
if (p.transform !== undefined) attrs.push(['transform', p.transform]);
|
|
282
|
+
pushStyle(attrs, p, true, defaultColor);
|
|
283
|
+
return { tag: 'use', attrs, children: [] };
|
|
284
|
+
}
|
|
285
|
+
case 'linearGradient': {
|
|
286
|
+
const attrs: Array<[string, string]> = [['id', p.id]];
|
|
287
|
+
if (p.x1 !== undefined) attrs.push(['x1', num(p.x1)]);
|
|
288
|
+
if (p.y1 !== undefined) attrs.push(['y1', num(p.y1)]);
|
|
289
|
+
if (p.x2 !== undefined) attrs.push(['x2', num(p.x2)]);
|
|
290
|
+
if (p.y2 !== undefined) attrs.push(['y2', num(p.y2)]);
|
|
291
|
+
if (p.gradientUnits !== undefined) attrs.push(['gradientUnits', p.gradientUnits]);
|
|
292
|
+
return { tag: 'linearGradient', attrs, children: p.stops.map(stopNode) };
|
|
293
|
+
}
|
|
294
|
+
case 'radialGradient': {
|
|
295
|
+
const attrs: Array<[string, string]> = [['id', p.id]];
|
|
296
|
+
if (p.cx !== undefined) attrs.push(['cx', num(p.cx)]);
|
|
297
|
+
if (p.cy !== undefined) attrs.push(['cy', num(p.cy)]);
|
|
298
|
+
if (p.r !== undefined) attrs.push(['r', num(p.r)]);
|
|
299
|
+
if (p.fx !== undefined) attrs.push(['fx', num(p.fx)]);
|
|
300
|
+
if (p.fy !== undefined) attrs.push(['fy', num(p.fy)]);
|
|
301
|
+
if (p.gradientUnits !== undefined) attrs.push(['gradientUnits', p.gradientUnits]);
|
|
302
|
+
return { tag: 'radialGradient', attrs, children: p.stops.map(stopNode) };
|
|
303
|
+
}
|
|
304
|
+
case 'filter': {
|
|
305
|
+
const attrs: Array<[string, string]> = [['id', p.id]];
|
|
306
|
+
if (p.x !== undefined) attrs.push(['x', String(p.x)]);
|
|
307
|
+
if (p.y !== undefined) attrs.push(['y', String(p.y)]);
|
|
308
|
+
if (p.width !== undefined) attrs.push(['width', String(p.width)]);
|
|
309
|
+
if (p.height !== undefined) attrs.push(['height', String(p.height)]);
|
|
310
|
+
if (p.colorInterpolationFilters !== undefined)
|
|
311
|
+
attrs.push(['colorInterpolationFilters', p.colorInterpolationFilters]);
|
|
312
|
+
return { tag: 'filter', attrs, children: p.prims.map(feToNode) };
|
|
313
|
+
}
|
|
314
|
+
case 'pattern': {
|
|
315
|
+
const attrs: Array<[string, string]> = [
|
|
316
|
+
['id', p.id],
|
|
317
|
+
['width', num(p.width)],
|
|
318
|
+
['height', num(p.height)],
|
|
319
|
+
['patternUnits', p.patternUnits ?? 'userSpaceOnUse'],
|
|
320
|
+
];
|
|
321
|
+
if (p.patternTransform !== undefined) attrs.push(['patternTransform', p.patternTransform]);
|
|
322
|
+
return {
|
|
323
|
+
tag: 'pattern',
|
|
324
|
+
attrs,
|
|
325
|
+
children: p.children.map((c) => primitiveToNode(c, defaultColor)),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
case 'mask':
|
|
329
|
+
return {
|
|
330
|
+
tag: 'mask',
|
|
331
|
+
attrs: [['id', p.id]],
|
|
332
|
+
children: p.children.map((c) => primitiveToNode(c, defaultColor)),
|
|
333
|
+
};
|
|
334
|
+
case 'clipPath':
|
|
335
|
+
return {
|
|
336
|
+
tag: 'clipPath',
|
|
337
|
+
attrs: [['id', p.id]],
|
|
338
|
+
children: p.children.map((c) => primitiveToNode(c, defaultColor)),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function stopNode(s: { offset: number; color: string; opacity?: number }): SvgNode {
|
|
344
|
+
const attrs: Array<[string, string]> = [
|
|
345
|
+
['offset', num(s.offset)],
|
|
346
|
+
['stopColor', s.color],
|
|
347
|
+
];
|
|
348
|
+
if (s.opacity !== undefined) attrs.push(['stopOpacity', num(s.opacity)]);
|
|
349
|
+
return { tag: 'stop', attrs, children: [] };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Build the dialect-neutral `<svg>` node tree both serializers render. Injects
|
|
354
|
+
* the a11y nodes (`<title>`/`<desc>` or `aria-hidden`) and the root attributes.
|
|
355
|
+
*/
|
|
356
|
+
export function primitivesToNodes(primitives: DrawPrimitive[], opts: SerializeOpts): SvgNode {
|
|
357
|
+
const defaultColor = opts.defaultColor ?? CURRENT_COLOR;
|
|
358
|
+
const rootAttrs: Array<[string, string]> = [['viewBox', opts.viewBox]];
|
|
359
|
+
if (opts.width !== undefined) rootAttrs.push(['width', String(opts.width)]);
|
|
360
|
+
if (opts.height !== undefined) rootAttrs.push(['height', String(opts.height)]);
|
|
361
|
+
|
|
362
|
+
const a11y = opts.a11y;
|
|
363
|
+
const children: SvgNode[] = [];
|
|
364
|
+
if (a11y?.decorative) {
|
|
365
|
+
rootAttrs.push(['aria-hidden', 'true']);
|
|
366
|
+
} else if (a11y && (a11y.title || a11y.desc)) {
|
|
367
|
+
rootAttrs.push(['role', a11y.role ?? 'img']);
|
|
368
|
+
if (a11y.title) children.push({ tag: 'title', attrs: [], children: [], text: a11y.title });
|
|
369
|
+
if (a11y.desc) children.push({ tag: 'desc', attrs: [], children: [], text: a11y.desc });
|
|
370
|
+
} else if (a11y?.role) {
|
|
371
|
+
rootAttrs.push(['role', a11y.role]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const p of primitives) children.push(primitiveToNode(p, defaultColor));
|
|
375
|
+
return { tag: 'svg', attrs: rootAttrs, children };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
379
|
+
// Rendering
|
|
380
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
type Dialect = 'svg' | 'jsx';
|
|
383
|
+
|
|
384
|
+
function attrName(key: string, dialect: Dialect): string {
|
|
385
|
+
const mapped = ATTR_DIALECT[key];
|
|
386
|
+
return mapped ? mapped[dialect] : key;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function escapeText(s: string, dialect: Dialect): string {
|
|
390
|
+
let out = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
391
|
+
// In JSX text, `{` / `}` open expression containers — neutralize them.
|
|
392
|
+
if (dialect === 'jsx') out = out.replace(/\{/g, '{').replace(/\}/g, '}');
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function escapeAttr(s: string): string {
|
|
397
|
+
return s.replace(/&/g, '&').replace(/"/g, '"');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function renderNode(node: SvgNode, dialect: Dialect, indent: string): string {
|
|
401
|
+
const pad = indent;
|
|
402
|
+
const attrParts: string[] = [];
|
|
403
|
+
|
|
404
|
+
if (node.tag === 'svg' && dialect === 'svg') {
|
|
405
|
+
attrParts.push('xmlns="http://www.w3.org/2000/svg"');
|
|
406
|
+
}
|
|
407
|
+
for (const [key, value] of node.attrs) {
|
|
408
|
+
// `mix-blend-mode` is CSS-only — emit a dialect-correct `style` (SVG: a
|
|
409
|
+
// CSS string; JSX: a React style object) rather than a presentation attr.
|
|
410
|
+
if (key === 'mixBlendMode') {
|
|
411
|
+
attrParts.push(
|
|
412
|
+
dialect === 'svg'
|
|
413
|
+
? `style="mix-blend-mode:${escapeAttr(value)}"`
|
|
414
|
+
: `style={{ mixBlendMode: "${escapeAttr(value)}" }}`
|
|
415
|
+
);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
attrParts.push(`${attrName(key, dialect)}="${escapeAttr(value)}"`);
|
|
419
|
+
}
|
|
420
|
+
const attrStr = attrParts.length ? ` ${attrParts.join(' ')}` : '';
|
|
421
|
+
|
|
422
|
+
const isContainer = CONTAINER_TAGS.has(node.tag);
|
|
423
|
+
const hasText = node.text !== undefined && node.text !== '';
|
|
424
|
+
const hasChildren = node.children.length > 0;
|
|
425
|
+
|
|
426
|
+
if (!isContainer && !hasText && !hasChildren) {
|
|
427
|
+
return `${pad}<${node.tag}${attrStr} />`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Text-only leaf (text/title/desc) on a single line.
|
|
431
|
+
if (hasText && !hasChildren) {
|
|
432
|
+
return `${pad}<${node.tag}${attrStr}>${escapeText(node.text as string, dialect)}</${node.tag}>`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const inner = node.children.map((c) => renderNode(c, dialect, `${pad} `)).join('\n');
|
|
436
|
+
const textLine = hasText ? `${pad} ${escapeText(node.text as string, dialect)}\n` : '';
|
|
437
|
+
return `${pad}<${node.tag}${attrStr}>\n${textLine}${inner}\n${pad}</${node.tag}>`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Serialize primitives to a complete, accessible SVG document string
|
|
442
|
+
* (`xmlns` + `viewBox` + `role`/`<title>`/`<desc>`). Pair with
|
|
443
|
+
* `optimize()` for the final on-disk asset.
|
|
444
|
+
*/
|
|
445
|
+
export function toSvg(primitives: DrawPrimitive[], opts: SerializeOpts): string {
|
|
446
|
+
const root = primitivesToNodes(primitives, opts);
|
|
447
|
+
return renderNode(root, 'svg', '');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Serialize primitives to a JSX string for inline embedding into a canvas TSX
|
|
452
|
+
* file (`strokeWidth`/`className` dialect, no `xmlns`). Structurally identical
|
|
453
|
+
* to {@link toSvg} — same node tree, same shapes, same geometry.
|
|
454
|
+
*/
|
|
455
|
+
export function toJsx(primitives: DrawPrimitive[], opts: SerializeOpts): string {
|
|
456
|
+
const root = primitivesToNodes(primitives, opts);
|
|
457
|
+
return renderNode(root, 'jsx', '');
|
|
458
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
brushStroke,
|
|
4
|
+
contourLines,
|
|
5
|
+
crossHatch,
|
|
6
|
+
engraveLines,
|
|
7
|
+
hatch,
|
|
8
|
+
roughenFilter,
|
|
9
|
+
scatterAlong,
|
|
10
|
+
stippleFill,
|
|
11
|
+
} from '../brush.ts';
|
|
12
|
+
import type { Point } from '../primitives.ts';
|
|
13
|
+
import { circle } from '../primitives.ts';
|
|
14
|
+
import { toSvg } from '../serialize.ts';
|
|
15
|
+
|
|
16
|
+
const LINE: Point[] = [
|
|
17
|
+
{ x: 10, y: 50 },
|
|
18
|
+
{ x: 40, y: 20 },
|
|
19
|
+
{ x: 70, y: 60 },
|
|
20
|
+
{ x: 100, y: 40 },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
describe('L1 roughenFilter', () => {
|
|
24
|
+
test('emits a feTurbulence → feDisplacementMap chain', () => {
|
|
25
|
+
const svg = toSvg([roughenFilter('rough', { scale: 6 })], { viewBox: '0 0 10 10' });
|
|
26
|
+
expect(svg).toContain('<filter id="rough"');
|
|
27
|
+
expect(svg).toContain('<feTurbulence');
|
|
28
|
+
expect(svg).toContain('<feDisplacementMap');
|
|
29
|
+
expect(svg).toContain('scale="6"');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('L2 brushStroke (variable width → filled outline)', () => {
|
|
34
|
+
test('is a closed filled path, not a stroke', () => {
|
|
35
|
+
const p = brushStroke(LINE, { width: 14, taper: 'both' }) as {
|
|
36
|
+
el: string;
|
|
37
|
+
d: string;
|
|
38
|
+
fill?: string;
|
|
39
|
+
stroke?: string;
|
|
40
|
+
};
|
|
41
|
+
expect(p.el).toBe('path');
|
|
42
|
+
expect(p.d.startsWith('M')).toBe(true);
|
|
43
|
+
expect(p.d).toContain('C'); // smoothed
|
|
44
|
+
expect(p.d.trimEnd().endsWith('Z')).toBe(true); // closed outline
|
|
45
|
+
expect(p.fill).toBe('currentColor'); // filled, default theme color
|
|
46
|
+
expect(p.stroke).toBeUndefined(); // NOT an svg stroke
|
|
47
|
+
});
|
|
48
|
+
test('taper "both" → pointed ends (start ≈ end ≈ centerline endpoints)', () => {
|
|
49
|
+
const p = brushStroke(LINE, { width: 20, taper: 'both' }) as { d: string };
|
|
50
|
+
// first move-to should be ~ the first centerline point (half-width ≈ 0 at t=0)
|
|
51
|
+
const m = /^M([-\d.]+) ([-\d.]+)/.exec(p.d);
|
|
52
|
+
expect(m).not.toBeNull();
|
|
53
|
+
const x0 = Number((m as RegExpExecArray)[1]);
|
|
54
|
+
const y0 = Number((m as RegExpExecArray)[2]);
|
|
55
|
+
expect(Math.hypot(x0 - 10, y0 - 50)).toBeLessThan(2);
|
|
56
|
+
});
|
|
57
|
+
test('a custom pressure profile is honored', () => {
|
|
58
|
+
const d1 = (brushStroke(LINE, { width: 16, profile: () => 1 }) as { d: string }).d;
|
|
59
|
+
const d2 = (brushStroke(LINE, { width: 16, profile: () => 0.2 }) as { d: string }).d;
|
|
60
|
+
expect(d1).not.toBe(d2); // thinner profile → different outline
|
|
61
|
+
});
|
|
62
|
+
test('deterministic', () => {
|
|
63
|
+
expect((brushStroke(LINE, { width: 12 }) as { d: string }).d).toBe(
|
|
64
|
+
(brushStroke(LINE, { width: 12 }) as { d: string }).d
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('L3 scatterAlong (stamp along a path)', () => {
|
|
70
|
+
test('produces `count` stamped groups, deterministic for a seed', () => {
|
|
71
|
+
const make = () => circle({ cx: 0, cy: 0, r: 3, fill: '#333' });
|
|
72
|
+
const g1 = scatterAlong(LINE, make, { count: 8, jitter: 4, scaleVar: 0.3, seed: 5 }) as {
|
|
73
|
+
el: string;
|
|
74
|
+
children: unknown[];
|
|
75
|
+
};
|
|
76
|
+
expect(g1.el).toBe('group');
|
|
77
|
+
expect(g1.children).toHaveLength(8);
|
|
78
|
+
const g2 = scatterAlong(LINE, make, { count: 8, jitter: 4, scaleVar: 0.3, seed: 5 });
|
|
79
|
+
expect(JSON.stringify(g1)).toBe(JSON.stringify(g2)); // deterministic
|
|
80
|
+
const g3 = scatterAlong(LINE, make, { count: 8, jitter: 4, scaleVar: 0.3, seed: 6 });
|
|
81
|
+
expect(JSON.stringify(g1)).not.toBe(JSON.stringify(g3)); // seed changes layout
|
|
82
|
+
});
|
|
83
|
+
test('stamps carry a transform (placed along the path)', () => {
|
|
84
|
+
const g = scatterAlong(LINE, () => circle({ cx: 0, cy: 0, r: 2 }), { count: 3 }) as {
|
|
85
|
+
children: Array<{ transform?: string }>;
|
|
86
|
+
};
|
|
87
|
+
for (const s of g.children) expect(s.transform).toContain('translate(');
|
|
88
|
+
});
|
|
89
|
+
test('align rotates stamps to the tangent', () => {
|
|
90
|
+
const g = scatterAlong(LINE, () => circle({ cx: 0, cy: 0, r: 2 }), {
|
|
91
|
+
count: 4,
|
|
92
|
+
align: true,
|
|
93
|
+
}) as {
|
|
94
|
+
children: Array<{ transform?: string }>;
|
|
95
|
+
};
|
|
96
|
+
expect(g.children.some((s) => (s.transform ?? '').includes('rotate('))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('L4 hatch / crossHatch (engraving line shading)', () => {
|
|
101
|
+
const region = { x: 0, y: 0, width: 100, height: 60 };
|
|
102
|
+
test('hatch fills a region with parallel lines; tighter spacing = more lines (darker)', () => {
|
|
103
|
+
const sparse = hatch(region, { angle: 45, spacing: 12 }) as {
|
|
104
|
+
el: string;
|
|
105
|
+
children: Array<{ el: string }>;
|
|
106
|
+
};
|
|
107
|
+
const dense = hatch(region, { angle: 45, spacing: 4 }) as { children: unknown[] };
|
|
108
|
+
expect(sparse.el).toBe('group');
|
|
109
|
+
expect(sparse.children.every((c) => c.el === 'line')).toBe(true);
|
|
110
|
+
expect(dense.children.length).toBeGreaterThan(sparse.children.length);
|
|
111
|
+
});
|
|
112
|
+
test('weightVar makes alternating lines differ in width (burin swell)', () => {
|
|
113
|
+
const g = hatch(region, { spacing: 8, weight: 1, weightVar: 0.5 }) as {
|
|
114
|
+
children: Array<{ strokeWidth?: number }>;
|
|
115
|
+
};
|
|
116
|
+
const widths = new Set(g.children.map((c) => c.strokeWidth));
|
|
117
|
+
expect(widths.size).toBeGreaterThan(1);
|
|
118
|
+
});
|
|
119
|
+
test('crossHatch is two hatch groups at different angles', () => {
|
|
120
|
+
const g = crossHatch(region, { angle: 30, spacing: 8 }) as { children: Array<{ el: string }> };
|
|
121
|
+
expect(g.children).toHaveLength(2);
|
|
122
|
+
expect(g.children.every((c) => c.el === 'group')).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('L5 contourLines + stippleFill (form-following engraving + graded tone)', () => {
|
|
127
|
+
test('contourLines emits one offset path per offset, following the stroke', () => {
|
|
128
|
+
const g = contourLines(
|
|
129
|
+
[
|
|
130
|
+
{ x: 0, y: 0 },
|
|
131
|
+
{ x: 50, y: 10 },
|
|
132
|
+
{ x: 100, y: 0 },
|
|
133
|
+
],
|
|
134
|
+
{ offsets: [-8, -4, 0, 4, 8], color: '#caa' }
|
|
135
|
+
) as { el: string; children: Array<{ el: string; d: string; stroke?: string; fill?: string }> };
|
|
136
|
+
expect(g.children).toHaveLength(5);
|
|
137
|
+
expect(
|
|
138
|
+
g.children.every((c) => c.el === 'path' && c.fill === 'none' && c.stroke === '#caa')
|
|
139
|
+
).toBe(true);
|
|
140
|
+
expect(g.children[0].d.startsWith('M')).toBe(true);
|
|
141
|
+
expect(g.children[0].d).toContain('C'); // smooth
|
|
142
|
+
});
|
|
143
|
+
test('contourLines count+spacing centers the family on the stroke; deterministic', () => {
|
|
144
|
+
const a = contourLines(
|
|
145
|
+
[
|
|
146
|
+
{ x: 0, y: 0 },
|
|
147
|
+
{ x: 100, y: 0 },
|
|
148
|
+
],
|
|
149
|
+
{ count: 6, spacing: 5 }
|
|
150
|
+
) as { children: unknown[] };
|
|
151
|
+
expect(a.children).toHaveLength(6);
|
|
152
|
+
expect(
|
|
153
|
+
JSON.stringify(
|
|
154
|
+
contourLines(
|
|
155
|
+
[
|
|
156
|
+
{ x: 0, y: 0 },
|
|
157
|
+
{ x: 100, y: 0 },
|
|
158
|
+
],
|
|
159
|
+
{ count: 6, spacing: 5 }
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
).toBe(JSON.stringify(a));
|
|
163
|
+
});
|
|
164
|
+
test('stippleFill density grades the dot count (light vs dark) + deterministic', () => {
|
|
165
|
+
const region = { x: 0, y: 0, width: 100, height: 100 };
|
|
166
|
+
const dark = stippleFill(region, { dots: 400, density: () => 1, seed: 3 }) as {
|
|
167
|
+
children: unknown[];
|
|
168
|
+
};
|
|
169
|
+
const light = stippleFill(region, { dots: 400, density: () => 0.1, seed: 3 }) as {
|
|
170
|
+
children: unknown[];
|
|
171
|
+
};
|
|
172
|
+
expect(dark.children.length).toBeGreaterThan(light.children.length * 3);
|
|
173
|
+
expect(JSON.stringify(stippleFill(region, { dots: 400, density: () => 1, seed: 3 }))).toBe(
|
|
174
|
+
JSON.stringify(dark)
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
test('stippleFill dots land inside the region', () => {
|
|
178
|
+
const g = stippleFill({ x: 10, y: 20, width: 80, height: 60 }, { dots: 100, seed: 2 }) as {
|
|
179
|
+
children: Array<{ cx: number; cy: number }>;
|
|
180
|
+
};
|
|
181
|
+
for (const d of g.children) {
|
|
182
|
+
expect(d.cx).toBeGreaterThanOrEqual(10);
|
|
183
|
+
expect(d.cx).toBeLessThanOrEqual(90);
|
|
184
|
+
expect(d.cy).toBeGreaterThanOrEqual(20);
|
|
185
|
+
expect(d.cy).toBeLessThanOrEqual(80);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('L6 engraveLines (organic per-line burin marks)', () => {
|
|
191
|
+
const stroke = [
|
|
192
|
+
{ x: 0, y: 0 },
|
|
193
|
+
{ x: 60, y: 8 },
|
|
194
|
+
{ x: 120, y: 0 },
|
|
195
|
+
];
|
|
196
|
+
test('each line is a tapered filled brushStroke (closed path), deterministic', () => {
|
|
197
|
+
const g = engraveLines(stroke, { count: 8, spacing: 5, seed: 4 }) as {
|
|
198
|
+
el: string;
|
|
199
|
+
children: Array<{ el: string; d: string; fill?: string }>;
|
|
200
|
+
};
|
|
201
|
+
expect(g.el).toBe('group');
|
|
202
|
+
expect(g.children.length).toBeGreaterThan(4);
|
|
203
|
+
expect(g.children.every((c) => c.el === 'path' && c.d.trimEnd().endsWith('Z'))).toBe(true); // filled outline, not stroke
|
|
204
|
+
expect(JSON.stringify(engraveLines(stroke, { count: 8, spacing: 5, seed: 4 }))).toBe(
|
|
205
|
+
JSON.stringify(g)
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
test('different seeds → different (jittered) output', () => {
|
|
209
|
+
expect(JSON.stringify(engraveLines(stroke, { count: 8, seed: 1 }))).not.toBe(
|
|
210
|
+
JSON.stringify(engraveLines(stroke, { count: 8, seed: 2 }))
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|