@protoboardai/views 0.0.0-stage → 0.2.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 (56) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +21 -2
  4. package/package.json +57 -4
  5. package/src/applies.ts +54 -0
  6. package/src/builtins.tsx +89 -0
  7. package/src/drawing/BoardDrawingView.tsx +515 -0
  8. package/src/drawing/CircuitPcbView.tsx +494 -0
  9. package/src/drawing/DrawingControls.tsx +39 -0
  10. package/src/drawing/boards.ts +82 -0
  11. package/src/drawing/circuit.ts +413 -0
  12. package/src/drawing/kicadSchematic.ts +277 -0
  13. package/src/drawing/palettes.ts +150 -0
  14. package/src/drawing/svg.ts +445 -0
  15. package/src/drawing/usePanZoom.ts +400 -0
  16. package/src/icons.tsx +69 -0
  17. package/src/index.ts +33 -0
  18. package/src/inspector/DrcPanel.tsx +302 -0
  19. package/src/inspector/SelectionInspector.tsx +392 -0
  20. package/src/inspector/index.ts +9 -0
  21. package/src/inspector/model.ts +224 -0
  22. package/src/pair/PairView.tsx +314 -0
  23. package/src/pair/model.ts +206 -0
  24. package/src/parts/BomEditor.tsx +564 -0
  25. package/src/parts/CardPages.tsx +317 -0
  26. package/src/parts/CategoryList.tsx +72 -0
  27. package/src/parts/CloudPanel.tsx +160 -0
  28. package/src/parts/GlbPreview.tsx +116 -0
  29. package/src/parts/LibrariesManager.tsx +251 -0
  30. package/src/parts/LibraryBrowser.tsx +191 -0
  31. package/src/parts/LibraryPage.tsx +168 -0
  32. package/src/parts/PartCard.tsx +274 -0
  33. package/src/parts/PartDetail.tsx +613 -0
  34. package/src/parts/PartStatus.tsx +54 -0
  35. package/src/parts/Prices.tsx +241 -0
  36. package/src/parts/ProjectParts.tsx +174 -0
  37. package/src/parts/ProjectPartsPage.tsx +264 -0
  38. package/src/parts/SettingsPage.tsx +268 -0
  39. package/src/parts/Surface.tsx +51 -0
  40. package/src/parts/Windowed.tsx +70 -0
  41. package/src/parts/carousel.tsx +344 -0
  42. package/src/parts/index.ts +32 -0
  43. package/src/parts/library-data.ts +726 -0
  44. package/src/parts/matte.ts +69 -0
  45. package/src/parts/model.ts +590 -0
  46. package/src/parts/part-pages.ts +379 -0
  47. package/src/parts/part-status.ts +88 -0
  48. package/src/parts/revision.ts +11 -0
  49. package/src/parts/three.d.ts +5 -0
  50. package/src/parts/ui.tsx +335 -0
  51. package/src/parts/views.tsx +433 -0
  52. package/src/registry.ts +107 -0
  53. package/src/tabs.ts +118 -0
  54. package/src/theme.ts +225 -0
  55. package/src/types.ts +93 -0
  56. package/src/ui/Select.tsx +99 -0
@@ -0,0 +1,445 @@
1
+ /**
2
+ * A board drawing's SVG (a circuit tool's schematic or PCB layer, by its
3
+ * standard-output file name), made safe to put in the page and themed.
4
+ *
5
+ * The file is parsed with a small XML tokenizer and written out again from
6
+ * an allow-list: drawing elements and presentation attributes only, no
7
+ * scripts, no event handlers, no external references, no `<style>` (the view
8
+ * brings its own rules). Ids are prefixed so two drawings on a page never
9
+ * share one. Colours the tool wrote are replaced by the roles a palette
10
+ * gives them (`var(--pb-d-<role>)`), so the drawing follows the shell's
11
+ * theme; a colour the palette does not know stays as it is.
12
+ *
13
+ * No DOM here: it runs in Node for tests and in any shell.
14
+ */
15
+
16
+ /** A known colour → the role it plays in the drawing (`ink`, `wire`, `copper`, …). */
17
+ export type Palette = ReadonlyMap<string, string>;
18
+
19
+ export interface ThemedSvg {
20
+ /** The `<svg>` element's markup, ready for `innerHTML`. */
21
+ markup: string;
22
+ /**
23
+ * The tool's own class rules (its `<style>`), themed and safe, as
24
+ * declarations per class selector: the view scopes them to its drawing.
25
+ */
26
+ rules: { selector: string; declarations: string }[];
27
+ /** The drawing's viewBox (or 0 0 width height). */
28
+ viewBox?: [number, number, number, number];
29
+ /** Every colour seen and the role it got (undefined: kept). */
30
+ colours: Map<string, string | undefined>;
31
+ /**
32
+ * The tool that wrote the file, when it says (`kicad`: KiCad's Eeschema or
33
+ * Pcbnew export; `tscircuit`: circuit-to-svg). KiCad writes a board's
34
+ * bottom already mirrored, and its schematic's text as strokes with an
35
+ * invisible `<text>` beside each.
36
+ */
37
+ generator?: "kicad" | "tscircuit";
38
+ }
39
+
40
+ const ELEMENTS = new Set([
41
+ "svg", "g", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "text", "tspan", "textPath",
42
+ "defs", "pattern", "clipPath", "mask", "use", "symbol", "marker", "linearGradient", "radialGradient", "stop", "title", "desc",
43
+ ]);
44
+
45
+ const ATTRIBUTES = new Set([
46
+ "d", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry", "width", "height", "points", "transform", "viewBox",
47
+ "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "stroke-miterlimit",
48
+ "fill-opacity", "fill-rule", "clip-rule", "opacity", "font-family", "font-size", "font-weight", "font-style", "text-anchor", "dominant-baseline",
49
+ "alignment-baseline", "letter-spacing", "id", "class", "style", "patternUnits", "patternContentUnits", "patternTransform", "href", "xlink:href",
50
+ "clip-path", "mask", "marker-start", "marker-mid", "marker-end", "offset", "stop-color", "stop-opacity", "gradientUnits", "gradientTransform",
51
+ "vector-effect", "shape-rendering", "preserveAspectRatio", "visibility", "display", "markerWidth", "markerHeight",
52
+ "refX", "refY", "orient", "markerUnits", "dx", "dy", "rotate", "textLength", "lengthAdjust", "startOffset", "fx", "fy", "spreadMethod",
53
+ ]);
54
+
55
+ /** Style properties kept from a `style` attribute. */
56
+ const STYLE_PROPS = new Set([
57
+ "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-dasharray", "stroke-opacity", "fill-opacity", "opacity",
58
+ "font-family", "font-size", "font-weight", "font-style", "text-anchor", "dominant-baseline", "letter-spacing", "visibility", "display",
59
+ "background-color", "stop-color", "stop-opacity", "vector-effect", "shape-rendering",
60
+ ]);
61
+
62
+ /** Attributes and style properties that carry a colour. */
63
+ const COLOUR_PROPS = new Set(["fill", "stroke", "stop-color", "background-color"]);
64
+
65
+ const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
66
+ const escapeAttr = (s: string) => s.replace(/[&<>"]/g, (c) => ESC[c]);
67
+
68
+ /** Decode the XML entities an attribute value may hold (numeric and the five named ones). */
69
+ function decode(s: string): string {
70
+ return s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (_, e: string) => {
71
+ const l = e.toLowerCase();
72
+ if (l === "amp") return "&";
73
+ if (l === "lt") return "<";
74
+ if (l === "gt") return ">";
75
+ if (l === "quot") return '"';
76
+ if (l === "apos") return "'";
77
+ const n = l.startsWith("#x") ? parseInt(l.slice(2), 16) : parseInt(l.slice(1), 10);
78
+ return Number.isFinite(n) && n > 0 && n < 0x110000 ? String.fromCodePoint(n) : "";
79
+ });
80
+ }
81
+
82
+ /** A colour written the canonical way (`rgb(1, 2, 3)` and `#010203` both as `#010203`), for palette lookup. */
83
+ export function canonicalColour(value: string): string {
84
+ const v = value.trim().toLowerCase();
85
+ const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(v);
86
+ if (rgb) {
87
+ const hex = [rgb[1], rgb[2], rgb[3]].map((n) => Math.max(0, Math.min(255, Number(n))).toString(16).padStart(2, "0")).join("");
88
+ return rgb[4] !== undefined && Number(rgb[4]) < 1 ? `#${hex}/${Number(rgb[4])}` : `#${hex}`;
89
+ }
90
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
91
+ if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
92
+ const named: Record<string, string> = { white: "#ffffff", black: "#000000", red: "#ff0000" };
93
+ return named[v] ?? v;
94
+ }
95
+
96
+ /** A value safe in a style declaration or attribute: no markup, no external url, no script. */
97
+ function safeValue(v: string): boolean {
98
+ if (/[<>]/.test(v)) return false;
99
+ if (/expression\s*\(|javascript:|@import|behavior\s*:/i.test(v)) return false;
100
+ const urls = v.match(/url\(([^)]*)\)/gi) ?? [];
101
+ return urls.every((u) => /^url\(\s*['"]?#[\w:.-]+['"]?\s*\)$/i.test(u));
102
+ }
103
+
104
+ interface Token {
105
+ kind: "open" | "close" | "text";
106
+ name?: string;
107
+ attrs?: [string, string][];
108
+ selfClosing?: boolean;
109
+ text?: string;
110
+ }
111
+
112
+ /** The tokens of an XML document (comments, processing instructions, doctypes and CDATA dropped). */
113
+ export function* tokens(xml: string): Generator<Token> {
114
+ let i = 0;
115
+ const n = xml.length;
116
+ while (i < n) {
117
+ const lt = xml.indexOf("<", i);
118
+ if (lt < 0) {
119
+ yield { kind: "text", text: xml.slice(i) };
120
+ return;
121
+ }
122
+ if (lt > i) yield { kind: "text", text: xml.slice(i, lt) };
123
+ if (xml.startsWith("<!--", lt)) {
124
+ const end = xml.indexOf("-->", lt + 4);
125
+ i = end < 0 ? n : end + 3;
126
+ continue;
127
+ }
128
+ if (xml.startsWith("<![CDATA[", lt)) {
129
+ const end = xml.indexOf("]]>", lt + 9);
130
+ i = end < 0 ? n : end + 3;
131
+ continue;
132
+ }
133
+ if (xml.startsWith("<?", lt) || xml.startsWith("<!", lt)) {
134
+ const end = xml.indexOf(">", lt + 2);
135
+ i = end < 0 ? n : end + 1;
136
+ continue;
137
+ }
138
+ if (xml[lt + 1] === "/") {
139
+ const end = xml.indexOf(">", lt + 2);
140
+ if (end < 0) return;
141
+ yield { kind: "close", name: xml.slice(lt + 2, end).trim() };
142
+ i = end + 1;
143
+ continue;
144
+ }
145
+ // an opening tag: name, then attributes (quoted values may hold ">")
146
+ let j = lt + 1;
147
+ while (j < n && /[^\s/>]/.test(xml[j])) j++;
148
+ const name = xml.slice(lt + 1, j);
149
+ const attrs: [string, string][] = [];
150
+ let selfClosing = false;
151
+ for (;;) {
152
+ while (j < n && /\s/.test(xml[j])) j++;
153
+ if (j >= n) return;
154
+ if (xml[j] === ">") {
155
+ j++;
156
+ break;
157
+ }
158
+ if (xml[j] === "/" && xml[j + 1] === ">") {
159
+ selfClosing = true;
160
+ j += 2;
161
+ break;
162
+ }
163
+ let k = j;
164
+ while (k < n && /[^\s=/>]/.test(xml[k])) k++;
165
+ const attr = xml.slice(j, k);
166
+ j = k;
167
+ while (j < n && /\s/.test(xml[j])) j++;
168
+ let value = "";
169
+ if (xml[j] === "=") {
170
+ j++;
171
+ while (j < n && /\s/.test(xml[j])) j++;
172
+ const q = xml[j];
173
+ if (q === '"' || q === "'") {
174
+ const end = xml.indexOf(q, j + 1);
175
+ if (end < 0) return;
176
+ value = xml.slice(j + 1, end);
177
+ j = end + 1;
178
+ } else {
179
+ let e = j;
180
+ while (e < n && /[^\s>]/.test(xml[e])) e++;
181
+ value = xml.slice(j, e);
182
+ j = e;
183
+ }
184
+ }
185
+ if (attr) attrs.push([attr, decode(value)]);
186
+ if (k === j && !attr) j++;
187
+ }
188
+ yield { kind: "open", name, attrs, selfClosing };
189
+ i = j;
190
+ }
191
+ }
192
+
193
+ export interface ThemeSvgOptions {
194
+ palette: Palette;
195
+ /** Prefix for every id (unique per drawing on a page). */
196
+ idPrefix: string;
197
+ /** The role of a colour by where it is (an element's class, the property), before the palette. */
198
+ roleOf?: (colour: string, at: { element: string; className: string; property: string }) => string | undefined;
199
+ /**
200
+ * Mirror the drawing left to right inside its viewBox: a board's bottom
201
+ * side as it is seen from below. The content goes in one
202
+ * `<g data-pb-mirror="…">` (its value is the axis sum, 2·x + width).
203
+ */
204
+ mirror?: boolean;
205
+ }
206
+
207
+ /** An SVG affine map, `[a, b, c, d, e, f]` as in `matrix(a b c d e f)`. */
208
+ export type Affine = [number, number, number, number, number, number];
209
+ const IDENTITY: Affine = [1, 0, 0, 1, 0, 0];
210
+ const mul = (m: Affine, n: Affine): Affine => [
211
+ m[0] * n[0] + m[2] * n[1],
212
+ m[1] * n[0] + m[3] * n[1],
213
+ m[0] * n[2] + m[2] * n[3],
214
+ m[1] * n[2] + m[3] * n[3],
215
+ m[0] * n[4] + m[2] * n[5] + m[4],
216
+ m[1] * n[4] + m[3] * n[5] + m[5],
217
+ ];
218
+ /** `about(x, y, m)`: `m` done about the point (x, y). */
219
+ const about = (x: number, y: number, m: Affine): Affine => mul(mul([1, 0, 0, 1, x, y], m), [1, 0, 0, 1, -x, -y]);
220
+
221
+ /** An SVG `transform` list as one affine map (identity when there is none). */
222
+ export function affineOf(transform: string | undefined): Affine {
223
+ let m = IDENTITY;
224
+ for (const f of (transform ?? "").matchAll(/(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/g)) {
225
+ const a = f[2].trim().split(/[\s,]+/).filter(Boolean).map(Number);
226
+ const rad = ((a[0] ?? 0) * Math.PI) / 180;
227
+ let n: Affine = IDENTITY;
228
+ if (f[1] === "matrix" && a.length >= 6) n = [a[0], a[1], a[2], a[3], a[4], a[5]];
229
+ else if (f[1] === "translate") n = [1, 0, 0, 1, a[0] ?? 0, a[1] ?? 0];
230
+ else if (f[1] === "scale") n = [a[0], 0, 0, a[1] ?? a[0], 0, 0];
231
+ else if (f[1] === "rotate") {
232
+ n = [Math.cos(rad), Math.sin(rad), -Math.sin(rad), Math.cos(rad), 0, 0];
233
+ if (a.length >= 3) n = about(a[1], a[2], n);
234
+ } else if (f[1] === "skewX") n = [1, 0, Math.tan(rad), 1, 0, 0];
235
+ else if (f[1] === "skewY") n = [1, Math.tan(rad), 0, 1, 0, 0];
236
+ if (n.every(Number.isFinite)) m = mul(m, n);
237
+ }
238
+ return m;
239
+ }
240
+
241
+ /**
242
+ * How text drawn with `m` (its own and every ancestor's linear map) reads
243
+ * on screen: `mirrored` when it is a mirror image, and the direction its
244
+ * baseline runs in degrees (0 left to right, -90 bottom to top, screen y down).
245
+ */
246
+ export function textReading(m: Affine): { mirrored: boolean; angle: number } {
247
+ return { mirrored: m[0] * m[3] - m[1] * m[2] < 0, angle: (Math.atan2(m[1], m[0]) * 180) / Math.PI };
248
+ }
249
+
250
+ /** A baseline the owner's rule allows: left to right, or bottom to top (top facing left). */
251
+ export const readableAngle = (angle: number): boolean => angle > -135 + 1e-6 && angle <= 45 + 1e-6;
252
+
253
+ const SWAP_ANCHOR: Record<string, string> = { start: "end", end: "start" };
254
+
255
+ /** Parse, sanitise and theme an SVG document. */
256
+ export function themeSvg(source: string, options: ThemeSvgOptions): ThemedSvg {
257
+ const out: string[] = [];
258
+ const colours = new Map<string, string | undefined>();
259
+ const stack: { name: string; kept: boolean; linear: Affine }[] = [];
260
+ let mirrorSum: number | undefined;
261
+ let dropDepth = 0;
262
+ let viewBox: [number, number, number, number] | undefined;
263
+ let root = true;
264
+ let inStyle = false;
265
+ let generator: ThemedSvg["generator"];
266
+ const css: string[] = [];
267
+ const prefixId = (id: string) => `${options.idPrefix}${id.replace(/[^\w:.-]/g, "_")}`;
268
+ const recolour = (value: string, element: string, className: string, property: string): string => {
269
+ const v = value.trim();
270
+ if (!v || v === "none" || v === "transparent" || v === "currentColor" || v.startsWith("url(")) {
271
+ return v.startsWith("url(") ? v.replace(/url\(\s*['"]?#([\w:.-]+)['"]?\s*\)/g, (_, id: string) => `url(#${prefixId(id)})`) : v;
272
+ }
273
+ const c = canonicalColour(v);
274
+ const role = options.roleOf?.(c, { element, className, property }) ?? options.palette.get(c);
275
+ colours.set(c, role);
276
+ return role ? `var(--pb-d-${role})` : v;
277
+ };
278
+
279
+ for (const t of tokens(source)) {
280
+ if (t.kind === "text") {
281
+ if (inStyle) css.push(t.text!);
282
+ if (!generator && stack.length && (stack[stack.length - 1].name === "desc" || stack[stack.length - 1].name === "title")) {
283
+ if (/Eeschema|Pcbnew|PCBNEW|KiCad/.test(t.text!)) generator = "kicad";
284
+ }
285
+ if (dropDepth || !stack.length) continue;
286
+ const parent = stack[stack.length - 1].name;
287
+ // text only where SVG draws it; anything that looks like markup was already split off by the tokenizer
288
+ if (["text", "tspan", "textPath", "title", "desc"].includes(parent)) out.push(t.text!.replace(/[<>]/g, ""));
289
+ continue;
290
+ }
291
+ if (t.kind === "close") {
292
+ const top = stack.pop();
293
+ if (!top) continue;
294
+ if (top.name === "style") inStyle = false;
295
+ if (!top.kept) dropDepth--;
296
+ else if (!dropDepth) out.push(`${!stack.length && mirrorSum !== undefined ? "</g>" : ""}</${top.name}>`);
297
+ continue;
298
+ }
299
+ const name = t.name!;
300
+ const kept = !dropDepth && ELEMENTS.has(name) && (!root || name === "svg");
301
+ if (name === "style" && !dropDepth && !t.selfClosing) inStyle = true;
302
+ if (!kept) {
303
+ if (!t.selfClosing) {
304
+ stack.push({ name, kept: false, linear: IDENTITY });
305
+ dropDepth++;
306
+ }
307
+ continue;
308
+ }
309
+ const attrs = new Map(t.attrs);
310
+ const className = attrs.get("class") ?? "";
311
+ if (!generator && attrs.has("data-software-used-string") && /circuit-to-svg|tscircuit/i.test(attrs.get("data-software-used-string") ?? "")) generator = "tscircuit";
312
+ const written: string[] = [];
313
+ const styles: string[] = [];
314
+ /** The roles this element's own stroke and fill got (`data-pb-stroke`, `data-pb-fill`: what the view finds wires and bodies by). */
315
+ const roles: { stroke?: string; fill?: string } = {};
316
+ const noteRole = (prop: string, v: string) => {
317
+ const m = /^var\(--pb-d-([\w-]+)\)$/.exec(v);
318
+ if (m && (prop === "stroke" || prop === "fill")) roles[prop] ??= m[1];
319
+ return v;
320
+ };
321
+ for (const [k, raw] of t.attrs!) {
322
+ if (k.startsWith("data-")) {
323
+ if (/^data-[\w-]+$/.test(k)) written.push(`${k}="${escapeAttr(raw)}"`);
324
+ continue;
325
+ }
326
+ if (!ATTRIBUTES.has(k)) continue;
327
+ let v = raw;
328
+ if (k === "id") v = prefixId(v);
329
+ else if (k === "href" || k === "xlink:href") {
330
+ if (!/^#[\w:.-]+$/.test(v)) continue;
331
+ written.push(`href="#${escapeAttr(prefixId(v.slice(1)))}"`);
332
+ continue;
333
+ } else if (k === "style") {
334
+ for (const decl of v.split(";")) {
335
+ const at = decl.indexOf(":");
336
+ if (at < 0) continue;
337
+ const prop = decl.slice(0, at).trim().toLowerCase();
338
+ const val = decl.slice(at + 1).trim();
339
+ if (!STYLE_PROPS.has(prop) || !safeValue(val)) continue;
340
+ styles.push(`${prop}: ${COLOUR_PROPS.has(prop) ? noteRole(prop, recolour(val, name, className, prop)) : val}`);
341
+ }
342
+ continue;
343
+ } else if (!safeValue(v)) continue;
344
+ if (COLOUR_PROPS.has(k)) {
345
+ // a themed colour must be a style (presentation attributes take no var())
346
+ const c = noteRole(k, recolour(v, name, className, k));
347
+ if (c.startsWith("var(")) styles.push(`${k}: ${c}`);
348
+ else written.push(`${k}="${escapeAttr(c)}"`);
349
+ continue;
350
+ }
351
+ if (["clip-path", "mask", "marker-start", "marker-mid", "marker-end"].includes(k)) v = recolour(v, name, className, k);
352
+ written.push(`${k}="${escapeAttr(v)}"`);
353
+ }
354
+ // text reads upright (or bottom to top) whatever the tool or the mirror did to it
355
+ const parentLinear = stack.length ? stack[stack.length - 1].linear : IDENTITY;
356
+ let linear = mul(parentLinear, affineOf(attrs.get("transform")));
357
+ if (name === "text") {
358
+ const px = parseFloat(attrs.get("x") ?? "0") || 0;
359
+ const py = parseFloat(attrs.get("y") ?? "0") || 0;
360
+ let own = affineOf(attrs.get("transform"));
361
+ let swap = false;
362
+ let fixed = false;
363
+ if (textReading(linear).mirrored) {
364
+ own = mul(own, about(px, py, [-1, 0, 0, 1, 0, 0]));
365
+ swap = !swap;
366
+ fixed = true;
367
+ }
368
+ if (!readableAngle(textReading(mul(parentLinear, own)).angle)) {
369
+ own = mul(own, about(px, py, [-1, 0, 0, -1, 0, 0]));
370
+ swap = !swap;
371
+ fixed = true;
372
+ }
373
+ if (fixed) {
374
+ linear = mul(parentLinear, own);
375
+ const round = (v: number) => String(Math.round(v * 1e6) / 1e6 || 0);
376
+ const next = `transform="matrix(${own.map(round).join(",")})"`;
377
+ const at = written.findIndex((w) => w.startsWith("transform="));
378
+ if (at >= 0) written[at] = next;
379
+ else written.push(next);
380
+ // the text keeps its place: its anchor flips with it
381
+ const anchor = attrs.get("text-anchor");
382
+ if (swap && anchor && SWAP_ANCHOR[anchor]) {
383
+ const i = written.findIndex((w) => w.startsWith("text-anchor="));
384
+ if (i >= 0) written[i] = `text-anchor="${SWAP_ANCHOR[anchor]}"`;
385
+ }
386
+ }
387
+ }
388
+ let opening = "";
389
+ if (root) {
390
+ root = false;
391
+ const vb = attrs.get("viewBox")?.trim().split(/[\s,]+/).map(Number);
392
+ const w = parseFloat(attrs.get("width") ?? "");
393
+ const h = parseFloat(attrs.get("height") ?? "");
394
+ if (vb?.length === 4 && vb.every(Number.isFinite) && vb[2] > 0 && vb[3] > 0) viewBox = vb as [number, number, number, number];
395
+ else if (w > 0 && h > 0) {
396
+ viewBox = [0, 0, w, h];
397
+ written.push(`viewBox="0 0 ${w} ${h}"`);
398
+ }
399
+ if (options.mirror && viewBox && !t.selfClosing) {
400
+ mirrorSum = 2 * viewBox[0] + viewBox[2];
401
+ opening = `<g data-pb-mirror="${mirrorSum}" transform="matrix(-1 0 0 1 ${mirrorSum} 0)">`;
402
+ linear = mul(linear, [-1, 0, 0, 1, mirrorSum, 0]);
403
+ }
404
+ }
405
+ // the page the tool draws behind the drawing (a rect as large as the viewBox): the view's background, never fitted to
406
+ if (name === "rect" && viewBox && !dropDepth) {
407
+ const n = (k: string) => parseFloat(attrs.get(k) ?? "0") || 0;
408
+ const [x, y, w, h] = [n("x"), n("y"), n("width"), n("height")];
409
+ if (w >= viewBox[2] * 0.98 && h >= viewBox[3] * 0.98 && x <= viewBox[0] + viewBox[2] * 0.01 && y <= viewBox[1] + viewBox[3] * 0.01) written.push('data-pb-page=""');
410
+ }
411
+ if (roles.stroke) written.push(`data-pb-stroke="${roles.stroke}"`);
412
+ if (roles.fill) written.push(`data-pb-fill="${roles.fill}"`);
413
+ if (styles.length) written.push(`style="${escapeAttr(styles.join("; "))}"`);
414
+ out.push(`<${name}${written.length ? ` ${written.join(" ")}` : ""}${t.selfClosing ? "/>" : ">"}${opening}`);
415
+ if (!t.selfClosing) stack.push({ name, kept: true, linear });
416
+ }
417
+ // close what the file left open
418
+ while (stack.length) {
419
+ const top = stack.pop()!;
420
+ if (top.kept && !dropDepth) out.push(`${!stack.length && mirrorSum !== undefined ? "</g>" : ""}</${top.name}>`);
421
+ if (!top.kept) dropDepth--;
422
+ }
423
+ // the tool's class rules: class selectors only, safe properties, colours by role
424
+ const rules: ThemedSvg["rules"] = [];
425
+ for (const m of css.join("").replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
426
+ const selector = m[1].trim();
427
+ if (!/^\.[\w-]+(\s*,\s*\.[\w-]+)*$/.test(selector)) continue;
428
+ const decls: string[] = [];
429
+ for (const decl of m[2].split(";")) {
430
+ const at = decl.indexOf(":");
431
+ if (at < 0) continue;
432
+ const prop = decl.slice(0, at).trim().toLowerCase();
433
+ const val = decl.slice(at + 1).trim();
434
+ // faces are the view's (its theme's), not the tool's
435
+ if (!STYLE_PROPS.has(prop) || prop === "font-family" || !safeValue(val)) continue;
436
+ decls.push(`${prop}: ${COLOUR_PROPS.has(prop) ? recolour(val, "style", selector, prop) : val}`);
437
+ }
438
+ if (decls.length) rules.push({ selector, declarations: decls.join("; ") });
439
+ }
440
+ return { markup: out.join(""), rules, viewBox, colours, ...(generator ? { generator } : {}) };
441
+ }
442
+
443
+ /** The tool's rules scoped to one drawing (`scope` is a selector for its container). */
444
+ export const scopedRules = (rules: ThemedSvg["rules"], scope: string): string =>
445
+ rules.map((r) => `${r.selector.split(",").map((x) => `${scope} ${x.trim()}`).join(", ")} { ${r.declarations} }`).join("\n");