inkbridge 0.1.0-beta.2 → 0.1.0-beta.21
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/README.md +108 -25
- package/bin/inkbridge.mjs +354 -83
- package/code.js +40 -11802
- package/manifest.json +1 -0
- package/package.json +74 -23
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/aspect-percent-position-regression.ts +237 -0
- package/scanner/aspect-ratio-regression.ts +90 -0
- package/scanner/blob-placement-regression.ts +2 -2
- package/scanner/block-cache-regression.ts +195 -0
- package/scanner/bundle-size-regression.ts +50 -0
- package/scanner/child-sizing-matrix-regression.ts +303 -0
- package/scanner/cli.ts +342 -13
- package/scanner/component-scanner.ts +2108 -174
- package/scanner/component-sections-regression.ts +198 -0
- package/scanner/compound-classes-lookup-regression.ts +163 -0
- package/scanner/css-token-reader-regression.ts +7 -6
- package/scanner/css-token-reader.ts +152 -31
- package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
- package/scanner/cva-master-icon-regression.ts +315 -0
- package/scanner/data-attr-prop-alias-regression.ts +129 -0
- package/scanner/explicit-size-root-regression.ts +102 -0
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/font-style-resolver-regression.ts +1 -1
- package/scanner/framework-adapter-shadcn-regression.ts +480 -0
- package/scanner/full-width-matrix-regression.ts +338 -0
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/image-src-collector-regression.ts +204 -0
- package/scanner/inline-flex-regression.ts +235 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/instance-rendering-regression.ts +224 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/jsx-text-regression.ts +178 -0
- package/scanner/layout-alignment-regression.ts +108 -0
- package/scanner/layout-flex-regression.ts +90 -0
- package/scanner/layout-mode-regression.ts +71 -0
- package/scanner/layout-sizing-regression.ts +227 -0
- package/scanner/layout-spacing-regression.ts +135 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/percent-position-regression.ts +105 -0
- package/scanner/provider-cascade-regression.ts +224 -0
- package/scanner/provider-flatten-regression.ts +235 -0
- package/scanner/radial-gradient-regression.ts +1 -1
- package/scanner/render-prop-parser-regression.ts +161 -0
- package/scanner/ring-utility-regression.ts +153 -0
- package/scanner/sandbox-spread-regression.ts +125 -0
- package/scanner/selection-pressed-regression.ts +241 -0
- package/scanner/size-full-normalization-regression.ts +127 -0
- package/scanner/state-classification-regression.ts +175 -0
- package/scanner/story-diagnostics-regression.ts +216 -0
- package/scanner/story-dimensioning-regression.ts +298 -0
- package/scanner/story-render-strategy-regression.ts +205 -0
- package/scanner/stretch-to-parent-width-regression.ts +147 -0
- package/scanner/svg-fill-parent-regression.ts +98 -0
- package/scanner/svg-group-inheritance-regression.ts +166 -0
- package/scanner/svg-marker-inline-regression.ts +211 -0
- package/scanner/svg-marker-regression.ts +116 -0
- package/scanner/tailwind-parser.ts +46 -4
- package/scanner/text-resize-matrix-regression.ts +173 -0
- package/scanner/transform-math-regression.ts +1 -1
- package/scanner/types.ts +26 -2
- package/src/cache/frame-cache.ts +150 -0
- package/src/cache/index.ts +2 -0
- package/src/{component-defs.ts → components/component-defs.ts} +25 -10
- package/src/{component-gen.ts → components/component-gen.ts} +43 -116
- package/src/components/component-instance.ts +386 -0
- package/src/components/component-library.ts +44 -0
- package/src/components/component-lookup.ts +161 -0
- package/src/components/index.ts +7 -0
- package/src/components/scanner-types.ts +39 -0
- package/src/components/symbol-instance-policy.ts +312 -0
- package/src/design-system/block-cache.ts +130 -0
- package/src/design-system/component-sections.ts +107 -0
- package/src/design-system/cva-inference.ts +187 -0
- package/src/design-system/cva-master.ts +427 -0
- package/src/design-system/cva-utils.ts +29 -0
- package/src/design-system/design-system.ts +334 -0
- package/src/design-system/frame-stabilizers.ts +191 -0
- package/src/design-system/frame-utils.ts +46 -0
- package/src/design-system/generated-node.ts +84 -0
- package/src/design-system/icon-rendering.ts +229 -0
- package/src/design-system/index.ts +13 -0
- package/src/design-system/instance-rendering.ts +307 -0
- package/src/design-system/master-shared.ts +133 -0
- package/src/design-system/node-helpers.ts +237 -0
- package/src/design-system/node-variants.ts +196 -0
- package/src/design-system/non-cva-master.ts +104 -0
- package/src/design-system/portal-handling.ts +138 -0
- package/src/design-system/preview-builder.ts +738 -0
- package/src/{render-context.ts → design-system/render-context.ts} +32 -6
- package/src/design-system/render-prop-parser.ts +50 -0
- package/src/design-system/responsive-resolver.ts +180 -0
- package/src/design-system/selectable-state.ts +157 -0
- package/src/design-system/state-master.ts +267 -0
- package/src/design-system/state-utils.ts +15 -0
- package/src/design-system/story-builder-context.ts +40 -0
- package/src/design-system/story-builder.ts +1322 -0
- package/src/design-system/story-diagnostics.ts +80 -0
- package/src/design-system/story-dimensioning.ts +272 -0
- package/src/design-system/story-frames.ts +400 -0
- package/src/design-system/story-instance.ts +333 -0
- package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
- package/src/design-system/story-render-strategy.ts +150 -0
- package/src/design-system/story-tree-search.ts +110 -0
- package/src/design-system/symbol-fallback.ts +89 -0
- package/src/design-system/symbol-source.ts +172 -0
- package/src/design-system/table-helpers.ts +56 -0
- package/src/design-system/tag-predicates.ts +99 -0
- package/src/design-system/theme-context.ts +52 -0
- package/src/design-system/typography.ts +100 -0
- package/src/design-system/ui-builder.ts +2676 -0
- package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
- package/src/effects/icon-builder.ts +1074 -0
- package/src/effects/index.ts +5 -0
- package/src/effects/portal-panel.ts +369 -0
- package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
- package/src/framework-adapters/index.ts +47 -0
- package/src/framework-adapters/shadcn.ts +541 -0
- package/src/{github.ts → github/github.ts} +46 -21
- package/src/github/index.ts +1 -0
- package/src/layout/deferred-layout.ts +1556 -0
- package/src/layout/index.ts +24 -0
- package/src/layout/layout-parser.ts +375 -0
- package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
- package/src/layout/parser/alignment.ts +54 -0
- package/src/layout/parser/flex.ts +59 -0
- package/src/layout/parser/index.ts +65 -0
- package/src/layout/parser/ir.ts +80 -0
- package/src/layout/parser/layout-mode.ts +57 -0
- package/src/layout/parser/sizing.ts +241 -0
- package/src/layout/parser/spacing-scale.ts +78 -0
- package/src/layout/parser/spacing.ts +134 -0
- package/src/layout/ring-utils.ts +120 -0
- package/src/layout/size-utils.ts +143 -0
- package/src/layout/text-resize-decision.ts +51 -0
- package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
- package/src/main.ts +444 -162
- package/src/{config.ts → plugin/config.ts} +12 -12
- package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
- package/src/plugin/image-src-collector.ts +52 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin/packs/index.ts +2 -0
- package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
- package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
- package/src/render-engine-version.ts +2 -0
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
- package/src/tailwind/index.ts +8 -0
- package/src/tailwind/jsx-utils.ts +319 -0
- package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
- package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
- package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
- package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
- package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
- package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
- package/src/text/index.ts +4 -0
- package/src/{inline-text.ts → text/inline-text.ts} +13 -13
- package/src/{text-builder.ts → text/text-builder.ts} +24 -7
- package/src/{text-line.ts → text/text-line.ts} +2 -2
- package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
- package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
- package/src/{colors.ts → tokens/colors.ts} +13 -6
- package/src/tokens/index.ts +6 -0
- package/src/{token-source.ts → tokens/token-source.ts} +4 -1
- package/src/{tokens.ts → tokens/tokens.ts} +116 -20
- package/src/{variables.ts → tokens/variables.ts} +447 -102
- package/templates/patch-tokens-route.ts +25 -6
- package/templates/scan-components-route.ts +26 -5
- package/ui.html +485 -37
- package/src/component-lookup.ts +0 -82
- package/src/design-system.ts +0 -59
- package/src/icon-builder.ts +0 -607
- package/src/layout-parser.ts +0 -667
- package/src/story-builder.ts +0 -1706
- package/src/ui-builder.ts +0 -1996
- /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
- /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
- /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon Builder Module
|
|
3
|
+
*
|
|
4
|
+
* Handles icon creation, SVG processing, and icon manipulation for Figma.
|
|
5
|
+
* Extracted from ui-builder.ts for better modularity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { COMPONENT_DEFS } from '../tokens';
|
|
9
|
+
import type { RGB } from '../tokens/colors';
|
|
10
|
+
import type { NodeIR } from '../tailwind/node-ir';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Constants
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const ICON_PATHS: Record<string, { d: string; viewBox: number; stroke: boolean; strokeWidth?: number }> = {
|
|
17
|
+
home: {
|
|
18
|
+
d: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
|
19
|
+
viewBox: 24,
|
|
20
|
+
stroke: true,
|
|
21
|
+
strokeWidth: 2
|
|
22
|
+
},
|
|
23
|
+
portfolio: {
|
|
24
|
+
d: "M3 3C2.44772 3 2 3.44772 2 4V20C2 20.5523 2.44772 21 3 21H21C21.5523 21 22 20.5523 22 20V4C22 3.44772 21.5523 3 21 3H3ZM8 5V8H4V5H8ZM4 14V10H8V14H4ZM4 16H8V19H4V16ZM10 16H20V19H10V16ZM20 14H10V10H20V14ZM20 5V8H10V5H20Z",
|
|
25
|
+
viewBox: 24,
|
|
26
|
+
stroke: false
|
|
27
|
+
},
|
|
28
|
+
strategy: {
|
|
29
|
+
d: "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10Z M16.5 7.5 14 14 7.5 16.5 10 10l6.5-2.5Zm-4.5 5.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z",
|
|
30
|
+
viewBox: 24,
|
|
31
|
+
stroke: true,
|
|
32
|
+
strokeWidth: 1.8
|
|
33
|
+
},
|
|
34
|
+
pda: {
|
|
35
|
+
d: "M22.0049 7.99979H13.0049C12.4526 7.99979 12.0049 8.4475 12.0049 8.99979V14.9998C12.0049 15.5521 12.4526 15.9998 13.0049 15.9998H22.0049V19.9998C22.0049 20.5521 21.5572 20.9998 21.0049 20.9998H3.00488C2.4526 20.9998 2.00488 20.5521 2.00488 19.9998V3.99979C2.00488 3.4475 2.4526 2.99979 3.00488 2.99979H21.0049C21.5572 2.99979 22.0049 3.4475 22.0049 3.99979V7.99979ZM15.0049 10.9998H18.0049V12.9998H15.0049V10.9998Z",
|
|
36
|
+
viewBox: 24,
|
|
37
|
+
stroke: false
|
|
38
|
+
},
|
|
39
|
+
account: {
|
|
40
|
+
d: "M3 4.99509C3 3.89323 3.89262 3 4.99509 3H19.0049C20.1068 3 21 3.89262 21 4.99509V19.0049C21 20.1068 20.1074 21 19.0049 21H4.99509C3.89323 21 3 20.1074 3 19.0049V4.99509ZM5 5V19H19V5H5ZM7.97216 18.1808C7.35347 17.9129 6.76719 17.5843 6.22083 17.2024C7.46773 15.2753 9.63602 14 12.1022 14C14.5015 14 16.6189 15.2071 17.8801 17.0472C17.3438 17.4436 16.7664 17.7877 16.1555 18.0718C15.2472 16.8166 13.77 16 12.1022 16C10.3865 16 8.87271 16.8641 7.97216 18.1808ZM12 13C10.067 13 8.5 11.433 8.5 9.5C8.5 7.567 10.067 6 12 6C13.933 6 15.5 7.567 15.5 9.5C15.5 11.433 13.933 13 12 13ZM12 11C12.8284 11 13.5 10.3284 13.5 9.5C13.5 8.67157 12.8284 8 12 8C11.1716 8 10.5 8.67157 10.5 9.5C10.5 10.3284 11.1716 11 12 11Z",
|
|
41
|
+
viewBox: 24,
|
|
42
|
+
stroke: false
|
|
43
|
+
},
|
|
44
|
+
logout: {
|
|
45
|
+
d: "M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z",
|
|
46
|
+
viewBox: 20,
|
|
47
|
+
stroke: false
|
|
48
|
+
},
|
|
49
|
+
menu: {
|
|
50
|
+
d: "M4 6h16M4 12h16M4 18h16",
|
|
51
|
+
viewBox: 24,
|
|
52
|
+
stroke: true,
|
|
53
|
+
strokeWidth: 2
|
|
54
|
+
},
|
|
55
|
+
close: {
|
|
56
|
+
d: "M6 18L18 6M6 6l12 12",
|
|
57
|
+
viewBox: 24,
|
|
58
|
+
stroke: true,
|
|
59
|
+
strokeWidth: 2
|
|
60
|
+
},
|
|
61
|
+
'chevron-down': {
|
|
62
|
+
d: "M6 9l6 6 6-6",
|
|
63
|
+
viewBox: 24,
|
|
64
|
+
stroke: true,
|
|
65
|
+
strokeWidth: 2
|
|
66
|
+
},
|
|
67
|
+
check: {
|
|
68
|
+
d: "M5 13l4 4L19 7",
|
|
69
|
+
viewBox: 24,
|
|
70
|
+
stroke: true,
|
|
71
|
+
strokeWidth: 2
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const SVG_CHILD_TAGS: Record<string, boolean> = {
|
|
76
|
+
path: true,
|
|
77
|
+
circle: true,
|
|
78
|
+
rect: true,
|
|
79
|
+
line: true,
|
|
80
|
+
polyline: true,
|
|
81
|
+
polygon: true,
|
|
82
|
+
ellipse: true,
|
|
83
|
+
g: true,
|
|
84
|
+
use: true,
|
|
85
|
+
defs: true,
|
|
86
|
+
clippath: true,
|
|
87
|
+
mask: true,
|
|
88
|
+
marker: true,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// SVG attribute names that are camelCase in the spec and must NOT be
|
|
92
|
+
// converted to kebab-case when serializing JSX props back to SVG. The default
|
|
93
|
+
// transform (`viewBox` → `view-box`, `markerWidth` → `marker-width`, etc.)
|
|
94
|
+
// produces invalid SVG attributes for these names. Presentation attributes
|
|
95
|
+
// like `strokeWidth` / `strokeDasharray` ARE kebab-case in SVG, so they're
|
|
96
|
+
// not in this set and continue to use the default conversion.
|
|
97
|
+
const SVG_PRESERVE_CASE_ATTRS: Record<string, boolean> = {
|
|
98
|
+
viewBox: true,
|
|
99
|
+
preserveAspectRatio: true,
|
|
100
|
+
refX: true,
|
|
101
|
+
refY: true,
|
|
102
|
+
markerWidth: true,
|
|
103
|
+
markerHeight: true,
|
|
104
|
+
markerUnits: true,
|
|
105
|
+
gradientUnits: true,
|
|
106
|
+
gradientTransform: true,
|
|
107
|
+
patternUnits: true,
|
|
108
|
+
patternTransform: true,
|
|
109
|
+
patternContentUnits: true,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Helper Functions
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Convert RGB to hex string
|
|
118
|
+
*/
|
|
119
|
+
export function rgbToHex(color: RGB): string {
|
|
120
|
+
const r = Math.round(Math.max(0, Math.min(1, color.r)) * 255);
|
|
121
|
+
const g = Math.round(Math.max(0, Math.min(1, color.g)) * 255);
|
|
122
|
+
const b = Math.round(Math.max(0, Math.min(1, color.b)) * 255);
|
|
123
|
+
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function escapeSvgAttr(value: string): string {
|
|
127
|
+
return String(value || '')
|
|
128
|
+
.replace(/&/g, '&')
|
|
129
|
+
.replace(/"/g, '"')
|
|
130
|
+
.replace(/'/g, ''')
|
|
131
|
+
.replace(/</g, '<')
|
|
132
|
+
.replace(/>/g, '>');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractSvgAttribute(svg: string, attr: string): string | null {
|
|
136
|
+
const re = new RegExp('<svg[^>]*\\s' + attr + '=["\\\']([^"\\\']+)["\\\']', 'i');
|
|
137
|
+
const match = svg.match(re);
|
|
138
|
+
return match ? match[1] : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasMonochromeBlackPaint(svg: string): boolean {
|
|
142
|
+
const strokeMatch = /\sstroke=["']([^"']+)["']/i.exec(svg);
|
|
143
|
+
const fillMatch = /\sfill=["']([^"']+)["']/i.exec(svg);
|
|
144
|
+
const isBlack = (v: string | undefined): boolean => {
|
|
145
|
+
if (!v) return false;
|
|
146
|
+
const n = v.trim().toLowerCase();
|
|
147
|
+
return n === '#000' || n === '#000000' || n === 'black' || n === 'rgb(0,0,0)' || n === 'rgb(0, 0, 0)';
|
|
148
|
+
};
|
|
149
|
+
return isBlack(strokeMatch?.[1]) || isBlack(fillMatch?.[1]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeSvgPaintValue(value: string | null): string | null {
|
|
153
|
+
if (!value) return null;
|
|
154
|
+
const lower = value.trim().toLowerCase();
|
|
155
|
+
if (lower === 'none') return null;
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Flatten `<g>`'s stroke / fill / stroke-* attributes onto child shape
|
|
161
|
+
* elements that don't already set their own. Mirrors CSS inheritance.
|
|
162
|
+
*
|
|
163
|
+
* Why: Figma's `createNodeFromSvg` does NOT honor `<g>` attribute
|
|
164
|
+
* inheritance — a `<path>` inside `<g stroke="#X">` gets neither the
|
|
165
|
+
* stroke nor a fill, then the fallback paint logic in
|
|
166
|
+
* `createIconFromSvg` fills it with the icon color. The arrow-arc
|
|
167
|
+
* pattern (`<g stroke="currentColor"><path d="M..A.." stroke-dasharray="6 6"
|
|
168
|
+
* marker-end="..." /></g>`) was the canonical break: paths rendered as
|
|
169
|
+
* filled green wedges instead of dashed stroked arcs.
|
|
170
|
+
*
|
|
171
|
+
* Implementation: iteratively replace innermost `<g>` blocks (those
|
|
172
|
+
* containing no other `<g>`) with the same `<g>` but with inheritable
|
|
173
|
+
* attrs inlined onto child `<path>` / `<line>` / `<polyline>` / etc.
|
|
174
|
+
* After each pass the next-innermost `<g>` becomes innermost; repeat
|
|
175
|
+
* until stable.
|
|
176
|
+
*/
|
|
177
|
+
const SHAPE_TAGS_FOR_INHERIT = ['path', 'line', 'polyline', 'polygon', 'circle', 'rect', 'ellipse'];
|
|
178
|
+
const SHAPE_TAGS_PATTERN = '(' + SHAPE_TAGS_FOR_INHERIT.join('|') + ')';
|
|
179
|
+
const INHERITABLE_ATTRS = [
|
|
180
|
+
'stroke',
|
|
181
|
+
'stroke-width',
|
|
182
|
+
'stroke-linecap',
|
|
183
|
+
'stroke-linejoin',
|
|
184
|
+
'stroke-dasharray',
|
|
185
|
+
'fill',
|
|
186
|
+
// Opacity / fill-opacity / stroke-opacity are inherited per SVG spec
|
|
187
|
+
// and common in icon libraries that fade `<g>`-grouped paths
|
|
188
|
+
// (e.g. duotone Lucide variants, Heroicons solid/outline pairs).
|
|
189
|
+
'opacity',
|
|
190
|
+
'fill-opacity',
|
|
191
|
+
'stroke-opacity',
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Inline inheritable attrs from the root `<svg>` element onto each shape
|
|
196
|
+
* child that doesn't already declare them. Run AFTER
|
|
197
|
+
* `flattenGroupInheritance` so any `<g>` overrides on the way down win
|
|
198
|
+
* first; only shapes still missing an attr fall back to the root.
|
|
199
|
+
*
|
|
200
|
+
* Why: many icon libraries set stroke / fill / stroke-width at the SVG
|
|
201
|
+
* root level (e.g. `<svg stroke="currentColor" fill="none"
|
|
202
|
+
* stroke-width="2">`). Figma's `createNodeFromSvg` doesn't propagate
|
|
203
|
+
* these to children — without this pass, every icon with that pattern
|
|
204
|
+
* loses its stroke styling.
|
|
205
|
+
*/
|
|
206
|
+
export function flattenSvgRootInheritance(svg: string): string {
|
|
207
|
+
const rootMatch = svg.match(/<svg\b([^>]*)>/i);
|
|
208
|
+
if (!rootMatch) return svg;
|
|
209
|
+
const rootAttrs = rootMatch[1];
|
|
210
|
+
const inherits: string[] = [];
|
|
211
|
+
for (const attr of INHERITABLE_ATTRS) {
|
|
212
|
+
const re = new RegExp('\\s' + attr + '=["\']([^"\']+)["\']', 'i');
|
|
213
|
+
const m = rootAttrs.match(re);
|
|
214
|
+
if (m) inherits.push(attr + '="' + m[1] + '"');
|
|
215
|
+
}
|
|
216
|
+
if (inherits.length === 0) return svg;
|
|
217
|
+
const shapeRe = new RegExp('<' + SHAPE_TAGS_PATTERN + '\\b([^>]*?)(/?)>', 'gi');
|
|
218
|
+
return svg.replace(shapeRe, function(shapeMatch, tagName, shapeAttrs, slash) {
|
|
219
|
+
const additions = inherits.filter(function(p) {
|
|
220
|
+
const name = p.split('=')[0];
|
|
221
|
+
return !new RegExp('\\s' + name + '=', 'i').test(shapeAttrs);
|
|
222
|
+
});
|
|
223
|
+
if (additions.length === 0) return shapeMatch;
|
|
224
|
+
return '<' + tagName + shapeAttrs + ' ' + additions.join(' ') + slash + '>';
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function flattenGroupInheritance(svg: string): string {
|
|
229
|
+
let prev: string;
|
|
230
|
+
let out = svg;
|
|
231
|
+
let safety = 32;
|
|
232
|
+
do {
|
|
233
|
+
prev = out;
|
|
234
|
+
// Match innermost <g> (the one with no nested <g> inside). Inline its
|
|
235
|
+
// inheritable attrs onto child shape elements that don't already declare
|
|
236
|
+
// their own, then STRIP those attrs from the <g> itself so it's no
|
|
237
|
+
// longer "innermost-with-inheritable-attrs" on the next pass. The next
|
|
238
|
+
// pass picks up the now-leaf outer <g>.
|
|
239
|
+
out = out.replace(
|
|
240
|
+
/<g\b([^>]*)>((?:(?!<g\b)[\s\S])*?)<\/g>/gi,
|
|
241
|
+
function(match, gAttrsRaw, gContent) {
|
|
242
|
+
let gAttrs = gAttrsRaw as string;
|
|
243
|
+
const inherits: string[] = [];
|
|
244
|
+
let strippedGAttrs = gAttrs;
|
|
245
|
+
for (const attr of INHERITABLE_ATTRS) {
|
|
246
|
+
const re = new RegExp('\\s' + attr + '=["\']([^"\']+)["\']', 'i');
|
|
247
|
+
const m = gAttrs.match(re);
|
|
248
|
+
if (m) {
|
|
249
|
+
inherits.push(attr + '="' + m[1] + '"');
|
|
250
|
+
strippedGAttrs = strippedGAttrs.replace(re, '');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (inherits.length === 0) return match;
|
|
254
|
+
const shapeRe = new RegExp('<' + SHAPE_TAGS_PATTERN + '\\b([^>]*?)(/?)>', 'gi');
|
|
255
|
+
const inlined = (gContent as string).replace(shapeRe, function(shapeMatch, tagName, shapeAttrs, slash) {
|
|
256
|
+
// Filter out attrs the shape already declares — child's own value wins.
|
|
257
|
+
const additions = inherits.filter(function(p) {
|
|
258
|
+
const name = p.split('=')[0];
|
|
259
|
+
return !new RegExp('\\s' + name + '=', 'i').test(shapeAttrs);
|
|
260
|
+
});
|
|
261
|
+
if (additions.length === 0) return shapeMatch;
|
|
262
|
+
return '<' + tagName + shapeAttrs + ' ' + additions.join(' ') + slash + '>';
|
|
263
|
+
});
|
|
264
|
+
// If the <g> now has no remaining attrs, unwrap it so the outer <g>
|
|
265
|
+
// can be treated as innermost on the next pass.
|
|
266
|
+
if (strippedGAttrs.trim() === '') {
|
|
267
|
+
return inlined;
|
|
268
|
+
}
|
|
269
|
+
return '<g' + strippedGAttrs + '>' + inlined + '</g>';
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
safety--;
|
|
273
|
+
} while (out !== prev && safety > 0);
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Inline SVG `<marker>` references — Figma's `createNodeFromSvg` does NOT
|
|
279
|
+
* render `marker-end` / `marker-start` / `marker-mid`, so arrowheads on
|
|
280
|
+
* paths silently disappear (canonical case: the round-trip arc arrows).
|
|
281
|
+
*
|
|
282
|
+
* For each `<path d="…" marker-end="url(#id)">` we:
|
|
283
|
+
* 1. Parse the `d` attribute to find the LAST command's endpoint and the
|
|
284
|
+
* tangent direction at that endpoint.
|
|
285
|
+
* 2. Look up the `<marker>` definition by id.
|
|
286
|
+
* 3. Inject a `<g transform="translate(x y) rotate(θ) scale(sx sy)
|
|
287
|
+
* translate(-refX -refY)">…</g>` containing the marker's inner SVG,
|
|
288
|
+
* so it renders at the endpoint, rotated along the tangent.
|
|
289
|
+
*
|
|
290
|
+
* Currently handles M / L / A commands in `d` (covers the round-trip arc
|
|
291
|
+
* pattern). Circular arcs only (rx === ry, no x-axis rotation). Other path
|
|
292
|
+
* commands cause the marker to be skipped — better than an incorrect
|
|
293
|
+
* arrowhead orientation.
|
|
294
|
+
*/
|
|
295
|
+
type MarkerDef = {
|
|
296
|
+
viewBoxW: number;
|
|
297
|
+
viewBoxH: number;
|
|
298
|
+
refX: number;
|
|
299
|
+
refY: number;
|
|
300
|
+
markerWidth: number;
|
|
301
|
+
markerHeight: number;
|
|
302
|
+
markerUnits: 'strokeWidth' | 'userSpaceOnUse';
|
|
303
|
+
content: string;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
function parseMarkerAttrs(svg: string): Map<string, MarkerDef> {
|
|
307
|
+
const result = new Map<string, MarkerDef>();
|
|
308
|
+
const re = /<marker\b([^>]*)>([\s\S]*?)<\/marker>/gi;
|
|
309
|
+
let m: RegExpExecArray | null;
|
|
310
|
+
while ((m = re.exec(svg)) !== null) {
|
|
311
|
+
const attrs = m[1];
|
|
312
|
+
const inner = m[2].trim();
|
|
313
|
+
const idMatch = attrs.match(/\sid=["']([^"']+)["']/i);
|
|
314
|
+
if (!idMatch) continue;
|
|
315
|
+
const vbMatch = attrs.match(/\sviewBox=["']([^"']+)["']/i);
|
|
316
|
+
const vb = vbMatch ? vbMatch[1].trim().split(/[\s,]+/).map(parseFloat) : [0, 0, 10, 10];
|
|
317
|
+
const vbW = (vb.length >= 4 && Number.isFinite(vb[2])) ? vb[2] : 10;
|
|
318
|
+
const vbH = (vb.length >= 4 && Number.isFinite(vb[3])) ? vb[3] : 10;
|
|
319
|
+
const refXMatch = attrs.match(/\srefX=["']([^"']+)["']/i);
|
|
320
|
+
const refYMatch = attrs.match(/\srefY=["']([^"']+)["']/i);
|
|
321
|
+
const mwMatch = attrs.match(/\smarkerWidth=["']([^"']+)["']/i);
|
|
322
|
+
const mhMatch = attrs.match(/\smarkerHeight=["']([^"']+)["']/i);
|
|
323
|
+
const muMatch = attrs.match(/\smarkerUnits=["']([^"']+)["']/i);
|
|
324
|
+
// SVG default for `markerUnits` is `strokeWidth` — marker dimensions
|
|
325
|
+
// are scaled by the host path's stroke width. Without honouring this,
|
|
326
|
+
// markers render at literal markerWidth/markerHeight in user units,
|
|
327
|
+
// which is much smaller than the browser shows on a stroked path.
|
|
328
|
+
const markerUnits: 'strokeWidth' | 'userSpaceOnUse' =
|
|
329
|
+
muMatch && muMatch[1].toLowerCase() === 'userspaceonuse'
|
|
330
|
+
? 'userSpaceOnUse'
|
|
331
|
+
: 'strokeWidth';
|
|
332
|
+
result.set(idMatch[1], {
|
|
333
|
+
viewBoxW: vbW,
|
|
334
|
+
viewBoxH: vbH,
|
|
335
|
+
refX: refXMatch ? parseFloat(refXMatch[1]) : 0,
|
|
336
|
+
refY: refYMatch ? parseFloat(refYMatch[1]) : 0,
|
|
337
|
+
markerWidth: mwMatch ? parseFloat(mwMatch[1]) : 3,
|
|
338
|
+
markerHeight: mhMatch ? parseFloat(mhMatch[1]) : 3,
|
|
339
|
+
markerUnits,
|
|
340
|
+
content: inner,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseMarkerRef(attr: string | undefined): string | null {
|
|
347
|
+
if (!attr) return null;
|
|
348
|
+
const m = attr.match(/url\(#([^)]+)\)/);
|
|
349
|
+
return m ? m[1] : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
type PathSegment = {
|
|
353
|
+
cmd: string;
|
|
354
|
+
start: [number, number];
|
|
355
|
+
end: [number, number];
|
|
356
|
+
/** Last control point relative to the endpoint — direction (end - lastCtrl) is the tangent. */
|
|
357
|
+
lastCtrl?: [number, number];
|
|
358
|
+
arc?: number[];
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
function computePathEndpointAndTangent(d: string): { x: number; y: number; angleDeg: number } | null {
|
|
362
|
+
// Tokenise: capture command letters and signed numbers (incl. decimals).
|
|
363
|
+
const tokens = d.match(/[MLAHVCSQTZmlahvcsqtz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g);
|
|
364
|
+
if (!tokens) return null;
|
|
365
|
+
let i = 0;
|
|
366
|
+
let cx = 0, cy = 0;
|
|
367
|
+
let startX = 0, startY = 0;
|
|
368
|
+
// Track previous quadratic control for T-command smoothing (reflection
|
|
369
|
+
// across the current point). Cubic S-command reflects the FIRST control
|
|
370
|
+
// from the previous segment — irrelevant for endpoint tangent (which
|
|
371
|
+
// depends only on the SECOND control), so not tracked.
|
|
372
|
+
let prevQuadraticCtrl: [number, number] | null = null;
|
|
373
|
+
let last: PathSegment | null = null;
|
|
374
|
+
while (i < tokens.length) {
|
|
375
|
+
const t = tokens[i];
|
|
376
|
+
if (!/^[a-zA-Z]$/.test(t)) { i++; continue; }
|
|
377
|
+
const cmd = t;
|
|
378
|
+
const upper = cmd.toUpperCase();
|
|
379
|
+
const isRel = cmd !== upper;
|
|
380
|
+
i++;
|
|
381
|
+
if (upper === 'M' || upper === 'L') {
|
|
382
|
+
const x = parseFloat(tokens[i]);
|
|
383
|
+
const y = parseFloat(tokens[i + 1]);
|
|
384
|
+
i += 2;
|
|
385
|
+
const ex = isRel ? cx + x : x;
|
|
386
|
+
const ey = isRel ? cy + y : y;
|
|
387
|
+
last = { cmd: upper, start: [cx, cy], end: [ex, ey] };
|
|
388
|
+
cx = ex; cy = ey;
|
|
389
|
+
if (upper === 'M') { startX = ex; startY = ey; }
|
|
390
|
+
prevQuadraticCtrl = null;
|
|
391
|
+
} else if (upper === 'H') {
|
|
392
|
+
const x = parseFloat(tokens[i]);
|
|
393
|
+
i += 1;
|
|
394
|
+
const ex = isRel ? cx + x : x;
|
|
395
|
+
last = { cmd: 'L', start: [cx, cy], end: [ex, cy] };
|
|
396
|
+
cx = ex;
|
|
397
|
+
prevQuadraticCtrl = null;
|
|
398
|
+
} else if (upper === 'V') {
|
|
399
|
+
const y = parseFloat(tokens[i]);
|
|
400
|
+
i += 1;
|
|
401
|
+
const ey = isRel ? cy + y : y;
|
|
402
|
+
last = { cmd: 'L', start: [cx, cy], end: [cx, ey] };
|
|
403
|
+
cy = ey;
|
|
404
|
+
prevQuadraticCtrl = null;
|
|
405
|
+
} else if (upper === 'C') {
|
|
406
|
+
const x1 = parseFloat(tokens[i]);
|
|
407
|
+
const y1 = parseFloat(tokens[i + 1]);
|
|
408
|
+
const x2 = parseFloat(tokens[i + 2]);
|
|
409
|
+
const y2 = parseFloat(tokens[i + 3]);
|
|
410
|
+
const x = parseFloat(tokens[i + 4]);
|
|
411
|
+
const y = parseFloat(tokens[i + 5]);
|
|
412
|
+
i += 6;
|
|
413
|
+
const c1: [number, number] = [isRel ? cx + x1 : x1, isRel ? cy + y1 : y1];
|
|
414
|
+
const c2: [number, number] = [isRel ? cx + x2 : x2, isRel ? cy + y2 : y2];
|
|
415
|
+
const ex = isRel ? cx + x : x;
|
|
416
|
+
const ey = isRel ? cy + y : y;
|
|
417
|
+
last = { cmd: 'C', start: [cx, cy], end: [ex, ey], lastCtrl: c2 };
|
|
418
|
+
cx = ex; cy = ey;
|
|
419
|
+
// c1 is unused for tangent computation but tracked for completeness.
|
|
420
|
+
void c1;
|
|
421
|
+
prevQuadraticCtrl = null;
|
|
422
|
+
} else if (upper === 'S') {
|
|
423
|
+
const x2 = parseFloat(tokens[i]);
|
|
424
|
+
const y2 = parseFloat(tokens[i + 1]);
|
|
425
|
+
const x = parseFloat(tokens[i + 2]);
|
|
426
|
+
const y = parseFloat(tokens[i + 3]);
|
|
427
|
+
i += 4;
|
|
428
|
+
const c2: [number, number] = [isRel ? cx + x2 : x2, isRel ? cy + y2 : y2];
|
|
429
|
+
const ex = isRel ? cx + x : x;
|
|
430
|
+
const ey = isRel ? cy + y : y;
|
|
431
|
+
// Tangent at endpoint = (end - c2). Reflection of prev cubic ctrl
|
|
432
|
+
// would be the FIRST control for this segment, irrelevant for end
|
|
433
|
+
// tangent.
|
|
434
|
+
last = { cmd: 'S', start: [cx, cy], end: [ex, ey], lastCtrl: c2 };
|
|
435
|
+
cx = ex; cy = ey;
|
|
436
|
+
prevQuadraticCtrl = null;
|
|
437
|
+
} else if (upper === 'Q') {
|
|
438
|
+
const x1 = parseFloat(tokens[i]);
|
|
439
|
+
const y1 = parseFloat(tokens[i + 1]);
|
|
440
|
+
const x = parseFloat(tokens[i + 2]);
|
|
441
|
+
const y = parseFloat(tokens[i + 3]);
|
|
442
|
+
i += 4;
|
|
443
|
+
const c1: [number, number] = [isRel ? cx + x1 : x1, isRel ? cy + y1 : y1];
|
|
444
|
+
const ex = isRel ? cx + x : x;
|
|
445
|
+
const ey = isRel ? cy + y : y;
|
|
446
|
+
last = { cmd: 'Q', start: [cx, cy], end: [ex, ey], lastCtrl: c1 };
|
|
447
|
+
cx = ex; cy = ey;
|
|
448
|
+
prevQuadraticCtrl = c1;
|
|
449
|
+
} else if (upper === 'T') {
|
|
450
|
+
const x = parseFloat(tokens[i]);
|
|
451
|
+
const y = parseFloat(tokens[i + 1]);
|
|
452
|
+
i += 2;
|
|
453
|
+
// T's control point is the reflection of the previous quadratic
|
|
454
|
+
// control across the current point.
|
|
455
|
+
const c1: [number, number] = prevQuadraticCtrl
|
|
456
|
+
? [2 * cx - prevQuadraticCtrl[0], 2 * cy - prevQuadraticCtrl[1]]
|
|
457
|
+
: [cx, cy];
|
|
458
|
+
const ex = isRel ? cx + x : x;
|
|
459
|
+
const ey = isRel ? cy + y : y;
|
|
460
|
+
last = { cmd: 'T', start: [cx, cy], end: [ex, ey], lastCtrl: c1 };
|
|
461
|
+
cx = ex; cy = ey;
|
|
462
|
+
prevQuadraticCtrl = c1;
|
|
463
|
+
} else if (upper === 'A') {
|
|
464
|
+
const rx = parseFloat(tokens[i]);
|
|
465
|
+
const ry = parseFloat(tokens[i + 1]);
|
|
466
|
+
const rot = parseFloat(tokens[i + 2]);
|
|
467
|
+
const lar = parseFloat(tokens[i + 3]);
|
|
468
|
+
const swp = parseFloat(tokens[i + 4]);
|
|
469
|
+
const x = parseFloat(tokens[i + 5]);
|
|
470
|
+
const y = parseFloat(tokens[i + 6]);
|
|
471
|
+
i += 7;
|
|
472
|
+
const ex = isRel ? cx + x : x;
|
|
473
|
+
const ey = isRel ? cy + y : y;
|
|
474
|
+
last = { cmd: 'A', start: [cx, cy], end: [ex, ey], arc: [rx, ry, rot, lar, swp] };
|
|
475
|
+
cx = ex; cy = ey;
|
|
476
|
+
prevQuadraticCtrl = null;
|
|
477
|
+
} else if (upper === 'Z') {
|
|
478
|
+
// Close path: line back to subpath start.
|
|
479
|
+
last = { cmd: 'L', start: [cx, cy], end: [startX, startY] };
|
|
480
|
+
cx = startX; cy = startY;
|
|
481
|
+
prevQuadraticCtrl = null;
|
|
482
|
+
} else {
|
|
483
|
+
// Unsupported command — abandon marker placement.
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!last) return null;
|
|
488
|
+
let angle: number;
|
|
489
|
+
if (last.cmd === 'M' || last.cmd === 'L') {
|
|
490
|
+
const dx = last.end[0] - last.start[0];
|
|
491
|
+
const dy = last.end[1] - last.start[1];
|
|
492
|
+
if (dx === 0 && dy === 0) return null;
|
|
493
|
+
angle = Math.atan2(dy, dx);
|
|
494
|
+
} else if ((last.cmd === 'C' || last.cmd === 'S' || last.cmd === 'Q' || last.cmd === 'T') && last.lastCtrl) {
|
|
495
|
+
// For bezier curves the tangent at the endpoint is the direction from
|
|
496
|
+
// the last control point TO the endpoint.
|
|
497
|
+
const dx = last.end[0] - last.lastCtrl[0];
|
|
498
|
+
const dy = last.end[1] - last.lastCtrl[1];
|
|
499
|
+
if (dx === 0 && dy === 0) return null;
|
|
500
|
+
angle = Math.atan2(dy, dx);
|
|
501
|
+
} else if (last.cmd === 'A' && last.arc) {
|
|
502
|
+
const [rx, ry, rot, lar, swp] = last.arc;
|
|
503
|
+
if (Math.abs(rx - ry) > 0.001 || Math.abs(rot) > 0.001) return null;
|
|
504
|
+
const r = rx;
|
|
505
|
+
const [x1, y1] = last.start;
|
|
506
|
+
const [x2, y2] = last.end;
|
|
507
|
+
const dx = x2 - x1;
|
|
508
|
+
const dy = y2 - y1;
|
|
509
|
+
const chord = Math.sqrt(dx * dx + dy * dy);
|
|
510
|
+
if (chord === 0 || chord > 2 * r + 0.001) return null;
|
|
511
|
+
const halfChord = chord / 2;
|
|
512
|
+
const h = Math.sqrt(Math.max(0, r * r - halfChord * halfChord));
|
|
513
|
+
// Perpendicular to chord (rotate (dx,dy) by 90° CCW in SVG y-down).
|
|
514
|
+
const px = -dy / chord;
|
|
515
|
+
const py = dx / chord;
|
|
516
|
+
// Center selection: lar XOR swp picks the opposite side.
|
|
517
|
+
const sign = (lar !== swp) ? 1 : -1;
|
|
518
|
+
const mx = (x1 + x2) / 2;
|
|
519
|
+
const my = (y1 + y2) / 2;
|
|
520
|
+
const centerX = mx + sign * h * px;
|
|
521
|
+
const centerY = my + sign * h * py;
|
|
522
|
+
const rdx = x2 - centerX;
|
|
523
|
+
const rdy = y2 - centerY;
|
|
524
|
+
// Tangent at endpoint = velocity along the path. For an arc
|
|
525
|
+
// parametrised as (cx + r*cos θ, cy + r*sin θ), velocity is
|
|
526
|
+
// (-r*sin θ, r*cos θ) = (-rdy, rdx) at the endpoint (since
|
|
527
|
+
// rdx = r*cos θ, rdy = r*sin θ). Sweep=1 means θ INCREASES along the
|
|
528
|
+
// path → that's the forward direction. Sweep=0 reverses it.
|
|
529
|
+
const tdx = (swp === 1) ? -rdy : rdy;
|
|
530
|
+
const tdy = (swp === 1) ? rdx : -rdx;
|
|
531
|
+
angle = Math.atan2(tdy, tdx);
|
|
532
|
+
} else {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
return { x: last.end[0], y: last.end[1], angleDeg: angle * 180 / Math.PI };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function buildInlineMarkerGroup(
|
|
539
|
+
marker: MarkerDef,
|
|
540
|
+
x: number,
|
|
541
|
+
y: number,
|
|
542
|
+
angleDeg: number,
|
|
543
|
+
hostStrokeWidth: number
|
|
544
|
+
): string {
|
|
545
|
+
// With `markerUnits="strokeWidth"` (the SVG default), the marker's box
|
|
546
|
+
// is multiplied by the host path's stroke width. With "userSpaceOnUse"
|
|
547
|
+
// markerWidth/markerHeight are taken as literal user-unit dimensions.
|
|
548
|
+
const unitsMultiplier = marker.markerUnits === 'strokeWidth' ? hostStrokeWidth : 1;
|
|
549
|
+
const sx = (marker.markerWidth * unitsMultiplier) / marker.viewBoxW;
|
|
550
|
+
const sy = (marker.markerHeight * unitsMultiplier) / marker.viewBoxH;
|
|
551
|
+
// Transform applies right-to-left in SVG; this matches the marker
|
|
552
|
+
// positioning spec: translate refX/refY of the (scaled, rotated) marker
|
|
553
|
+
// viewBox to (x, y) on the host path.
|
|
554
|
+
const t = `translate(${x} ${y}) rotate(${angleDeg}) scale(${sx} ${sy}) translate(${-marker.refX} ${-marker.refY})`;
|
|
555
|
+
return `<g transform="${t}">${marker.content}</g>`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function inlineSvgMarkers(svg: string): string {
|
|
559
|
+
const markers = parseMarkerAttrs(svg);
|
|
560
|
+
if (markers.size === 0) return svg;
|
|
561
|
+
const additions: string[] = [];
|
|
562
|
+
const pathRe = /<path\b([^>]*?)\/?>/gi;
|
|
563
|
+
let m: RegExpExecArray | null;
|
|
564
|
+
while ((m = pathRe.exec(svg)) !== null) {
|
|
565
|
+
const attrs = m[1];
|
|
566
|
+
const dMatch = attrs.match(/\sd=["']([^"']+)["']/i);
|
|
567
|
+
if (!dMatch) continue;
|
|
568
|
+
const markerEndMatch = attrs.match(/\smarker-end=["']([^"']+)["']/i);
|
|
569
|
+
const markerStartMatch = attrs.match(/\smarker-start=["']([^"']+)["']/i);
|
|
570
|
+
if (markerEndMatch) {
|
|
571
|
+
const id = parseMarkerRef(markerEndMatch[1]);
|
|
572
|
+
if (id && markers.has(id)) {
|
|
573
|
+
const pt = computePathEndpointAndTangent(dMatch[1]);
|
|
574
|
+
if (pt) {
|
|
575
|
+
// Extract host path's stroke-width — `flattenGroupInheritance`
|
|
576
|
+
// (run earlier in the pipeline) already inlined `<g>`'s
|
|
577
|
+
// stroke-width onto each child path, so reading the path's own
|
|
578
|
+
// attribute covers the inherited case too. Default to 1 per SVG
|
|
579
|
+
// spec when not set.
|
|
580
|
+
const swMatch = attrs.match(/\sstroke-width=["']([^"']+)["']/i);
|
|
581
|
+
const hostStrokeWidth = swMatch ? parseFloat(swMatch[1]) : 1;
|
|
582
|
+
additions.push(buildInlineMarkerGroup(
|
|
583
|
+
markers.get(id)!,
|
|
584
|
+
pt.x,
|
|
585
|
+
pt.y,
|
|
586
|
+
pt.angleDeg,
|
|
587
|
+
Number.isFinite(hostStrokeWidth) ? hostStrokeWidth : 1
|
|
588
|
+
));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// marker-start handling could be added by reversing the path or
|
|
593
|
+
// tracking the first move/line tangent — not needed for round-trip's
|
|
594
|
+
// current arrow design (only marker-end is used).
|
|
595
|
+
void markerStartMatch;
|
|
596
|
+
}
|
|
597
|
+
if (additions.length === 0) return svg;
|
|
598
|
+
// Insert additions just before </svg> so they render on top (Figma
|
|
599
|
+
// honours document order for paint stacking).
|
|
600
|
+
return svg.replace(/<\/svg>/i, additions.join('') + '</svg>');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Preprocess SVG for Figma compatibility
|
|
605
|
+
* - Replaces "currentColor" with the actual hex color (Figma doesn't understand CSS currentColor)
|
|
606
|
+
* - Flattens `<g>` inheritance onto child shape elements (Figma's SVG
|
|
607
|
+
* importer doesn't honor it)
|
|
608
|
+
* - Inlines `<marker>` references as transformed geometry at path
|
|
609
|
+
* endpoints (Figma's SVG importer doesn't render markers)
|
|
610
|
+
*/
|
|
611
|
+
function preprocessSvgForFigma(svg: string, color?: RGB): string {
|
|
612
|
+
const hexColor = color ? rgbToHex(color) : '#000000';
|
|
613
|
+
let output = svg.replace(/currentColor/gi, hexColor);
|
|
614
|
+
output = output.replace(/\scolor=["'][^"']*["']/gi, '');
|
|
615
|
+
output = output.replace(/\sstyle=["']([^"']*)["']/gi, function(_match, rawStyles) {
|
|
616
|
+
const cleaned = String(rawStyles || '')
|
|
617
|
+
.split(';')
|
|
618
|
+
.map(s => s.trim())
|
|
619
|
+
.filter(Boolean)
|
|
620
|
+
.filter(s => s.toLowerCase().indexOf('color:') !== 0)
|
|
621
|
+
.join('; ');
|
|
622
|
+
if (!cleaned) return '';
|
|
623
|
+
return ' style="' + cleaned + '"';
|
|
624
|
+
});
|
|
625
|
+
output = flattenGroupInheritance(output);
|
|
626
|
+
output = flattenSvgRootInheritance(output);
|
|
627
|
+
output = inlineSvgMarkers(output);
|
|
628
|
+
const rootFill = normalizeSvgPaintValue(extractSvgAttribute(output, 'fill'));
|
|
629
|
+
const rootStroke = normalizeSvgPaintValue(extractSvgAttribute(output, 'stroke'));
|
|
630
|
+
|
|
631
|
+
output = output.replace(/<path\b([^>]*)>/gi, function(_match, rawAttrs) {
|
|
632
|
+
let attrs = rawAttrs || '';
|
|
633
|
+
let suffix = '';
|
|
634
|
+
if (attrs.trim().endsWith('/')) {
|
|
635
|
+
attrs = attrs.replace(/\s*\/$/, '');
|
|
636
|
+
suffix = ' /';
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const hasFill = /\sfill=/.test(attrs);
|
|
640
|
+
const hasStroke = /\sstroke=/.test(attrs);
|
|
641
|
+
|
|
642
|
+
if (!hasStroke && rootStroke) {
|
|
643
|
+
attrs += ' stroke="' + rootStroke + '"';
|
|
644
|
+
}
|
|
645
|
+
if (!hasFill) {
|
|
646
|
+
if (!hasStroke && rootFill) {
|
|
647
|
+
attrs += ' fill="' + rootFill + '"';
|
|
648
|
+
} else if (!hasStroke && !rootFill && !rootStroke) {
|
|
649
|
+
attrs += ' fill="' + hexColor + '"';
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return '<path' + attrs + suffix + '>';
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
return output;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// SVG Node Manipulation
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
function collectVectors(node: SceneNode): VectorNode[] {
|
|
664
|
+
if (node.type === 'VECTOR') return [node];
|
|
665
|
+
if ('findAll' in node) {
|
|
666
|
+
return node.findAll((child) => child.type === 'VECTOR') as VectorNode[];
|
|
667
|
+
}
|
|
668
|
+
return [];
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function getVectorPaintUsage(node: SceneNode): { hasFills: boolean; hasStrokes: boolean } {
|
|
672
|
+
let hasFills = false;
|
|
673
|
+
let hasStrokes = false;
|
|
674
|
+
for (const vector of collectVectors(node)) {
|
|
675
|
+
if (Array.isArray(vector.fills) && vector.fills.length > 0) hasFills = true;
|
|
676
|
+
if (Array.isArray(vector.strokes) && vector.strokes.length > 0) hasStrokes = true;
|
|
677
|
+
}
|
|
678
|
+
return { hasFills, hasStrokes };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function recolorIconPaint(node: SceneNode, color: RGB): void {
|
|
682
|
+
const paint: SolidPaint = { type: 'SOLID', color: { r: color.r, g: color.g, b: color.b } };
|
|
683
|
+
for (const vector of collectVectors(node)) {
|
|
684
|
+
if (Array.isArray(vector.fills) && vector.fills.length > 0) {
|
|
685
|
+
vector.fills = [paint];
|
|
686
|
+
}
|
|
687
|
+
if (Array.isArray(vector.strokes) && vector.strokes.length > 0) {
|
|
688
|
+
vector.strokes = [paint];
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function applyIconPaint(
|
|
694
|
+
node: SceneNode,
|
|
695
|
+
color: RGB,
|
|
696
|
+
options: { stroke?: boolean; strokeWidth?: number } = {}
|
|
697
|
+
): void {
|
|
698
|
+
for (const vector of collectVectors(node)) {
|
|
699
|
+
const paint: SolidPaint = { type: 'SOLID', color: { r: color.r, g: color.g, b: color.b } };
|
|
700
|
+
if (options.stroke) {
|
|
701
|
+
vector.strokes = [paint];
|
|
702
|
+
vector.strokeWeight = options.strokeWidth || vector.strokeWeight || 1.5;
|
|
703
|
+
vector.fills = [];
|
|
704
|
+
} else {
|
|
705
|
+
vector.fills = [paint];
|
|
706
|
+
vector.strokes = [];
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export function normalizeIconNode(node: SceneNode): void {
|
|
712
|
+
if (!node) return;
|
|
713
|
+
if ('clipsContent' in node) node.clipsContent = false;
|
|
714
|
+
if ('layoutGrow' in node) node.layoutGrow = 0;
|
|
715
|
+
for (const vector of collectVectors(node)) {
|
|
716
|
+
if ('strokeAlign' in vector) {
|
|
717
|
+
try {
|
|
718
|
+
vector.strokeAlign = 'CENTER';
|
|
719
|
+
} catch (_err) {
|
|
720
|
+
// ignore
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function flattenSvgNode(node: SceneNode): VectorNode | null {
|
|
727
|
+
if (!node || !('children' in node)) return null;
|
|
728
|
+
const children = node.children;
|
|
729
|
+
if (!children.length) return null;
|
|
730
|
+
try {
|
|
731
|
+
const flattened = figma.flatten(Array.from(children));
|
|
732
|
+
flattened.name = node.name;
|
|
733
|
+
normalizeIconNode(flattened);
|
|
734
|
+
try {
|
|
735
|
+
const parent = node.parent;
|
|
736
|
+
if (parent && 'appendChild' in parent) {
|
|
737
|
+
parent.appendChild(flattened);
|
|
738
|
+
}
|
|
739
|
+
node.remove();
|
|
740
|
+
} catch (_err) {
|
|
741
|
+
// ignore
|
|
742
|
+
}
|
|
743
|
+
return flattened;
|
|
744
|
+
} catch (_err) {
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function resizeNode(node: SceneNode, width: number, height: number): void {
|
|
750
|
+
if ('resizeWithoutConstraints' in node) node.resizeWithoutConstraints(width, height);
|
|
751
|
+
else if ('resize' in node) node.resize(width, height);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export function resizeSvgNodeTo(node: SceneNode, width: number, height: number): void {
|
|
755
|
+
if (!node || width <= 0 || height <= 0) return;
|
|
756
|
+
if (node.type === 'VECTOR') {
|
|
757
|
+
resizeNode(node, width, height);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const vectors = collectVectors(node);
|
|
761
|
+
const baseWidth = ('width' in node ? node.width : 0) || 0;
|
|
762
|
+
const baseHeight = ('height' in node ? node.height : 0) || 0;
|
|
763
|
+
if (!vectors.length || baseWidth <= 0 || baseHeight <= 0) {
|
|
764
|
+
resizeNode(node, width, height);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const sx = width / baseWidth;
|
|
768
|
+
const sy = height / baseHeight;
|
|
769
|
+
for (const vector of vectors) {
|
|
770
|
+
resizeNode(vector, vector.width * sx, vector.height * sy);
|
|
771
|
+
if (typeof vector.x === 'number') vector.x = vector.x * sx;
|
|
772
|
+
if (typeof vector.y === 'number') vector.y = vector.y * sy;
|
|
773
|
+
}
|
|
774
|
+
resizeNode(node, width, height);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export function wrapIconNode(
|
|
778
|
+
icon: SceneNode,
|
|
779
|
+
width: number,
|
|
780
|
+
height: number,
|
|
781
|
+
pad: number,
|
|
782
|
+
name: string
|
|
783
|
+
): FrameNode {
|
|
784
|
+
const wrapper = figma.createFrame();
|
|
785
|
+
wrapper.name = name;
|
|
786
|
+
wrapper.layoutMode = 'NONE';
|
|
787
|
+
wrapper.fills = [];
|
|
788
|
+
wrapper.strokes = [];
|
|
789
|
+
wrapper.resize(width, height);
|
|
790
|
+
wrapper.clipsContent = false;
|
|
791
|
+
if ('layoutGrow' in wrapper) wrapper.layoutGrow = 0;
|
|
792
|
+
|
|
793
|
+
const innerWidth = Math.max(1, width - pad * 2);
|
|
794
|
+
const innerHeight = Math.max(1, height - pad * 2);
|
|
795
|
+
if (innerWidth !== width || innerHeight !== height) {
|
|
796
|
+
resizeSvgNodeTo(icon, innerWidth, innerHeight);
|
|
797
|
+
}
|
|
798
|
+
normalizeIconNode(icon);
|
|
799
|
+
if ('x' in icon && 'width' in icon) icon.x = Math.round((width - icon.width) / 2);
|
|
800
|
+
if ('y' in icon && 'height' in icon) icon.y = Math.round((height - icon.height) / 2);
|
|
801
|
+
wrapper.appendChild(icon);
|
|
802
|
+
return wrapper;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
// Icon Creation Functions
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Create an icon from the built-in ICON_PATHS
|
|
811
|
+
*/
|
|
812
|
+
export function createIcon(name: string, color: RGB): FrameNode | null {
|
|
813
|
+
const spec = ICON_PATHS[name];
|
|
814
|
+
if (!spec) return null;
|
|
815
|
+
const hexColor = color && typeof color.r === 'number' ? rgbToHex(color) : '#000000';
|
|
816
|
+
const strokeAttrs = spec.stroke
|
|
817
|
+
? `fill="none" stroke="${hexColor}" stroke-width="${spec.strokeWidth || 1.5}" stroke-linecap="round" stroke-linejoin="round"`
|
|
818
|
+
: `fill="${hexColor}"`;
|
|
819
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${spec.viewBox} ${spec.viewBox}"><path d="${spec.d}" ${strokeAttrs} /></svg>`;
|
|
820
|
+
const node = figma.createNodeFromSvg(svg);
|
|
821
|
+
node.name = `icon/${name}`;
|
|
822
|
+
normalizeIconNode(node);
|
|
823
|
+
// Keep the imported SVG frame so its original viewBox canvas is preserved.
|
|
824
|
+
// Flattening collapses to tight path bounds, which distorts icon proportions
|
|
825
|
+
// for sparse glyphs (for example chevrons) when resized to Tailwind sizes.
|
|
826
|
+
const target = node;
|
|
827
|
+
const usage = getVectorPaintUsage(target);
|
|
828
|
+
if (!usage.hasFills && !usage.hasStrokes) {
|
|
829
|
+
applyIconPaint(target, color, { stroke: spec.stroke, strokeWidth: spec.strokeWidth || 1.5 });
|
|
830
|
+
}
|
|
831
|
+
normalizeIconNode(target);
|
|
832
|
+
return target;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Create an icon from an SVG string
|
|
837
|
+
*/
|
|
838
|
+
export function createIconFromSvg(svg: string, color: RGB): FrameNode | null {
|
|
839
|
+
try {
|
|
840
|
+
// Registry SVGs from the scanner have `stroke="#000"` (or fill) baked in
|
|
841
|
+
// rather than `currentColor` — lucide-react sets currentColor at runtime,
|
|
842
|
+
// but renderToStaticMarkup resolves it to the default black. Treat those
|
|
843
|
+
// monochrome defaults as recolor candidates so icons pick up the
|
|
844
|
+
// caller's color like they would in the browser.
|
|
845
|
+
const usesCurrentColor = /currentcolor/i.test(svg) || hasMonochromeBlackPaint(svg);
|
|
846
|
+
const processedSvg = preprocessSvgForFigma(svg, color);
|
|
847
|
+
const node = figma.createNodeFromSvg(processedSvg);
|
|
848
|
+
node.name = 'icon/svg';
|
|
849
|
+
normalizeIconNode(node);
|
|
850
|
+
|
|
851
|
+
const vectors = collectVectors(node);
|
|
852
|
+
if (vectors.length === 0) {
|
|
853
|
+
node.remove();
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Keep the imported SVG frame so resize operations honor the source
|
|
858
|
+
// viewBox instead of scaling from tight vector bounds.
|
|
859
|
+
const target = node;
|
|
860
|
+
|
|
861
|
+
let finalUsage = getVectorPaintUsage(target);
|
|
862
|
+
if (usesCurrentColor && (finalUsage.hasFills || finalUsage.hasStrokes)) {
|
|
863
|
+
recolorIconPaint(target, color);
|
|
864
|
+
finalUsage = getVectorPaintUsage(target);
|
|
865
|
+
}
|
|
866
|
+
if (!finalUsage.hasFills && !finalUsage.hasStrokes) {
|
|
867
|
+
const svgHasStroke = /stroke=/.test(processedSvg) && !/stroke=["']none["']/i.test(processedSvg);
|
|
868
|
+
const svgHasFill = /fill=/.test(processedSvg) && !/fill=["']none["']/i.test(processedSvg);
|
|
869
|
+
applyIconPaint(target, color, { stroke: svgHasStroke && !svgHasFill });
|
|
870
|
+
finalUsage = getVectorPaintUsage(target);
|
|
871
|
+
if (!finalUsage.hasFills && !finalUsage.hasStrokes) {
|
|
872
|
+
target.remove();
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
normalizeIconNode(target);
|
|
878
|
+
return target;
|
|
879
|
+
} catch (_err) {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
// NodeIR to SVG Conversion
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
function nodeIrToSvgChild(node: NodeIR): string | null {
|
|
889
|
+
if (node.kind !== 'element') return null;
|
|
890
|
+
|
|
891
|
+
const tag = node.tagLower || '';
|
|
892
|
+
const props = node.props || {};
|
|
893
|
+
|
|
894
|
+
if (!SVG_CHILD_TAGS[tag]) {
|
|
895
|
+
const inner = (node.children || [])
|
|
896
|
+
.map(function(child) { return nodeIrToSvgChild(child); })
|
|
897
|
+
.filter(Boolean)
|
|
898
|
+
.join('');
|
|
899
|
+
return inner || null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const attrs: string[] = [];
|
|
903
|
+
for (const key in props) {
|
|
904
|
+
if (props[key] == null) continue;
|
|
905
|
+
if (key === 'className') continue;
|
|
906
|
+
const attrKey = SVG_PRESERVE_CASE_ATTRS[key]
|
|
907
|
+
? key
|
|
908
|
+
: key.replace(/[A-Z]/g, function(m) { return '-' + m.toLowerCase(); });
|
|
909
|
+
attrs.push(`${attrKey}="${escapeSvgAttr(props[key])}"`);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const inner = (node.children || [])
|
|
913
|
+
.map(function(child) { return nodeIrToSvgChild(child); })
|
|
914
|
+
.filter(Boolean)
|
|
915
|
+
.join('');
|
|
916
|
+
if (inner) {
|
|
917
|
+
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`;
|
|
918
|
+
}
|
|
919
|
+
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''} />`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
export function nodeIrToSvg(node: NodeIR): string | null {
|
|
923
|
+
if (node.kind !== 'element' || node.tagLower !== 'svg') return null;
|
|
924
|
+
|
|
925
|
+
const attrs: string[] = [];
|
|
926
|
+
const props = node.props || {};
|
|
927
|
+
|
|
928
|
+
if (props.viewBox) attrs.push(`viewBox="${escapeSvgAttr(props.viewBox)}"`);
|
|
929
|
+
if (props.preserveAspectRatio) attrs.push(`preserveAspectRatio="${escapeSvgAttr(props.preserveAspectRatio)}"`);
|
|
930
|
+
if (props.width) attrs.push(`width="${escapeSvgAttr(props.width)}"`);
|
|
931
|
+
if (props.height) attrs.push(`height="${escapeSvgAttr(props.height)}"`);
|
|
932
|
+
if (props.fill) attrs.push(`fill="${escapeSvgAttr(props.fill)}"`);
|
|
933
|
+
if (props.stroke) attrs.push(`stroke="${escapeSvgAttr(props.stroke)}"`);
|
|
934
|
+
if (props['stroke-width']) attrs.push(`stroke-width="${escapeSvgAttr(props['stroke-width'])}"`);
|
|
935
|
+
if (props.strokeWidth) attrs.push(`stroke-width="${escapeSvgAttr(props.strokeWidth)}"`);
|
|
936
|
+
if (props['stroke-linecap']) attrs.push(`stroke-linecap="${escapeSvgAttr(props['stroke-linecap'])}"`);
|
|
937
|
+
if (props.strokeLinecap) attrs.push(`stroke-linecap="${escapeSvgAttr(props.strokeLinecap)}"`);
|
|
938
|
+
if (props['stroke-linejoin']) attrs.push(`stroke-linejoin="${escapeSvgAttr(props['stroke-linejoin'])}"`);
|
|
939
|
+
if (props.strokeLinejoin) attrs.push(`stroke-linejoin="${escapeSvgAttr(props.strokeLinejoin)}"`);
|
|
940
|
+
|
|
941
|
+
attrs.unshift(`xmlns="http://www.w3.org/2000/svg"`);
|
|
942
|
+
|
|
943
|
+
const children = (node.children || [])
|
|
944
|
+
.map(function(child) { return nodeIrToSvgChild(child); })
|
|
945
|
+
.filter(Boolean)
|
|
946
|
+
.join('');
|
|
947
|
+
|
|
948
|
+
const hasViewBox = !!props.viewBox;
|
|
949
|
+
const safeAttrs = hasViewBox ? attrs : attrs.concat(['viewBox="0 0 24 24"']);
|
|
950
|
+
|
|
951
|
+
return `<svg ${safeAttrs.join(' ')}>${children}</svg>`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
// Size Resolution
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
function resolveSpacingToken(token: string): number | null {
|
|
959
|
+
const spacingScale = COMPONENT_DEFS && COMPONENT_DEFS.spacingScale ? COMPONENT_DEFS.spacingScale : {};
|
|
960
|
+
if (spacingScale && spacingScale[token] != null) return spacingScale[token];
|
|
961
|
+
const numeric = parseFloat(token);
|
|
962
|
+
if (!Number.isNaN(numeric)) return numeric * 4;
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Strips variant prefixes and returns the inner class. Different from
|
|
967
|
+
// `tailwind/class-utils.ts#getBaseClass`, which rejects variant-prefixed
|
|
968
|
+
// classes outright. Icon sizing applies even to hover/responsive variants.
|
|
969
|
+
function stripVariantPrefix(cls: string): string | null {
|
|
970
|
+
if (!cls) return null;
|
|
971
|
+
const parts = cls.split(':');
|
|
972
|
+
return parts[parts.length - 1] || null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function resolveIconSizeFromClasses(classes: string[], props: Record<string, string>): { width: number; height: number } {
|
|
976
|
+
let width: number | null = null;
|
|
977
|
+
let height: number | null = null;
|
|
978
|
+
|
|
979
|
+
const parsePx = (value: string): number | null => {
|
|
980
|
+
const trimmed = value.trim();
|
|
981
|
+
// Percentage values (`width="100%"`) are NOT pixel sizes — they signal
|
|
982
|
+
// fill-parent intent (browser semantics). Returning `parseFloat` on
|
|
983
|
+
// them would yield `100`, mis-sizing e.g. `<svg width="100%" height="100%">`
|
|
984
|
+
// (the Bridge SVG) as a 100×100 icon. Skip; the caller falls back to
|
|
985
|
+
// the icon default + the layout pipeline's fill-parent handling
|
|
986
|
+
// (`applySvgLayoutMarksFromClasses` flags the wrap for resize).
|
|
987
|
+
if (/%\s*$/.test(trimmed)) return null;
|
|
988
|
+
const n = parseFloat(trimmed);
|
|
989
|
+
if (Number.isNaN(n)) return null;
|
|
990
|
+
return n;
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
if (props.size) {
|
|
994
|
+
const sizeValue = parsePx(props.size);
|
|
995
|
+
if (sizeValue != null) {
|
|
996
|
+
width = sizeValue;
|
|
997
|
+
height = sizeValue;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (props.width) {
|
|
1002
|
+
const w = parsePx(props.width);
|
|
1003
|
+
if (w != null) width = w;
|
|
1004
|
+
}
|
|
1005
|
+
if (props.height) {
|
|
1006
|
+
const h = parsePx(props.height);
|
|
1007
|
+
if (h != null) height = h;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Don't `break` on the first match — responsive variants ship MULTIPLE
|
|
1011
|
+
// size-N classes in the resolved list (`size-10 size-14 size-16` after
|
|
1012
|
+
// `resolveClassesForBreakpoint` keeps base + sm + lg at lg breakpoint).
|
|
1013
|
+
// Tailwind / CSS semantics: last declaration wins. Picking the first
|
|
1014
|
+
// (smallest) made every responsive view of an icon render at the base
|
|
1015
|
+
// size.
|
|
1016
|
+
for (const cls of classes) {
|
|
1017
|
+
const base = stripVariantPrefix(cls);
|
|
1018
|
+
if (!base) continue;
|
|
1019
|
+
const sizeBracket = base.match(/^size-\[(\d+(?:\.\d+)?)px\]$/);
|
|
1020
|
+
if (sizeBracket) {
|
|
1021
|
+
const size = parseFloat(sizeBracket[1]);
|
|
1022
|
+
width = size;
|
|
1023
|
+
height = size;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
const sizeToken = base.match(/^size-(\d+(?:\.\d+)?)$/);
|
|
1027
|
+
if (sizeToken) {
|
|
1028
|
+
const size = resolveSpacingToken(sizeToken[1]);
|
|
1029
|
+
if (size != null) {
|
|
1030
|
+
width = size;
|
|
1031
|
+
height = size;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
for (const cls of classes) {
|
|
1037
|
+
const base = stripVariantPrefix(cls);
|
|
1038
|
+
if (!base) continue;
|
|
1039
|
+
const wBracket = base.match(/^w-\[(\d+(?:\.\d+)?)px\]$/);
|
|
1040
|
+
if (wBracket) {
|
|
1041
|
+
width = parseFloat(wBracket[1]);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
const hBracket = base.match(/^h-\[(\d+(?:\.\d+)?)px\]$/);
|
|
1045
|
+
if (hBracket) {
|
|
1046
|
+
height = parseFloat(hBracket[1]);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
const wToken = base.match(/^w-(\d+(?:\.\d+)?)$/);
|
|
1050
|
+
if (wToken) {
|
|
1051
|
+
const w = resolveSpacingToken(wToken[1]);
|
|
1052
|
+
if (w != null) width = w;
|
|
1053
|
+
}
|
|
1054
|
+
const hToken = base.match(/^h-(\d+(?:\.\d+)?)$/);
|
|
1055
|
+
if (hToken) {
|
|
1056
|
+
const h = resolveSpacingToken(hToken[1]);
|
|
1057
|
+
if (h != null) height = h;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (width == null && height == null) {
|
|
1062
|
+
width = 24;
|
|
1063
|
+
height = 24;
|
|
1064
|
+
} else if (width == null && height != null) {
|
|
1065
|
+
width = height;
|
|
1066
|
+
} else if (height == null && width != null) {
|
|
1067
|
+
height = width;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return { width: width as number, height: height as number };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Re-export for convenience
|
|
1074
|
+
export { getVectorPaintUsage };
|