@emasoft/svg-matrix 1.0.5 → 1.0.7
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 +391 -385
- package/bin/svg-matrix.js +1000 -0
- package/package.json +30 -2
- package/scripts/bootstrap_repo.sh +99 -0
- package/scripts/postinstall.js +252 -0
- package/src/browser-verify.js +463 -0
- package/src/clip-path-resolver.js +760 -0
- package/src/geometry-to-path.js +348 -0
- package/src/index.js +427 -6
- package/src/logger.js +302 -0
- package/src/marker-resolver.js +1006 -0
- package/src/mask-resolver.js +1407 -0
- package/src/mesh-gradient.js +1215 -0
- package/src/pattern-resolver.js +844 -0
- package/src/polygon-clip.js +1491 -0
- package/src/svg-flatten.js +1264 -105
- package/src/text-to-path.js +820 -0
- package/src/transforms2d.js +493 -37
- package/src/transforms3d.js +418 -47
- package/src/use-symbol-resolver.js +1126 -0
- package/samples/preserveAspectRatio_SVG.svg +0 -63
- package/samples/test.svg +0 -39
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use/Symbol Resolver Module - Expand use elements and symbols
|
|
3
|
+
*
|
|
4
|
+
* Resolves SVG <use> elements by inlining their referenced content
|
|
5
|
+
* with proper transforms, viewBox handling, and style inheritance.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - <use> referencing any element by id
|
|
9
|
+
* - <symbol> with viewBox and preserveAspectRatio
|
|
10
|
+
* - x, y, width, height on use elements
|
|
11
|
+
* - Recursive use resolution
|
|
12
|
+
* - Style inheritance
|
|
13
|
+
*
|
|
14
|
+
* @module use-symbol-resolver
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import Decimal from 'decimal.js';
|
|
18
|
+
import { Matrix } from './matrix.js';
|
|
19
|
+
import * as Transforms2D from './transforms2d.js';
|
|
20
|
+
import * as PolygonClip from './polygon-clip.js';
|
|
21
|
+
import * as ClipPathResolver from './clip-path-resolver.js';
|
|
22
|
+
|
|
23
|
+
Decimal.set({ precision: 80 });
|
|
24
|
+
|
|
25
|
+
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse SVG <use> element to structured data.
|
|
29
|
+
*
|
|
30
|
+
* SVG <use> elements reference other elements via href/xlink:href and can apply
|
|
31
|
+
* additional transforms and positioning. The x,y attributes translate the referenced
|
|
32
|
+
* content. When referencing a <symbol>, width/height establish the viewport for
|
|
33
|
+
* viewBox calculations.
|
|
34
|
+
*
|
|
35
|
+
* Handles both modern href and legacy xlink:href attributes, preferring href.
|
|
36
|
+
* The '#' prefix is stripped from internal references to get the target id.
|
|
37
|
+
*
|
|
38
|
+
* @param {Element} useElement - SVG <use> DOM element to parse
|
|
39
|
+
* @returns {Object} Parsed use data with the following properties:
|
|
40
|
+
* - href {string} - Target element id (without '#' prefix)
|
|
41
|
+
* - x {number} - Horizontal translation offset (default: 0)
|
|
42
|
+
* - y {number} - Vertical translation offset (default: 0)
|
|
43
|
+
* - width {number|null} - Viewport width for symbol references (null if not specified)
|
|
44
|
+
* - height {number|null} - Viewport height for symbol references (null if not specified)
|
|
45
|
+
* - transform {string|null} - Additional transform attribute (null if not specified)
|
|
46
|
+
* - style {Object} - Extracted style attributes that inherit to referenced content
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // Parse a use element referencing a symbol with positioning
|
|
50
|
+
* const useEl = document.querySelector('use');
|
|
51
|
+
* // <use href="#icon" x="10" y="20" width="100" height="100" fill="red"/>
|
|
52
|
+
* const parsed = parseUseElement(useEl);
|
|
53
|
+
* // {
|
|
54
|
+
* // href: 'icon',
|
|
55
|
+
* // x: 10, y: 20,
|
|
56
|
+
* // width: 100, height: 100,
|
|
57
|
+
* // transform: null,
|
|
58
|
+
* // style: { fill: 'red', stroke: null, ... }
|
|
59
|
+
* // }
|
|
60
|
+
*/
|
|
61
|
+
export function parseUseElement(useElement) {
|
|
62
|
+
const href = useElement.getAttribute('href') ||
|
|
63
|
+
useElement.getAttribute('xlink:href') || '';
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
href: href.startsWith('#') ? href.slice(1) : href,
|
|
67
|
+
x: parseFloat(useElement.getAttribute('x') || '0'),
|
|
68
|
+
y: parseFloat(useElement.getAttribute('y') || '0'),
|
|
69
|
+
width: useElement.getAttribute('width') ?
|
|
70
|
+
parseFloat(useElement.getAttribute('width')) : null,
|
|
71
|
+
height: useElement.getAttribute('height') ?
|
|
72
|
+
parseFloat(useElement.getAttribute('height')) : null,
|
|
73
|
+
transform: useElement.getAttribute('transform') || null,
|
|
74
|
+
style: extractStyleAttributes(useElement)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse SVG <symbol> element to structured data.
|
|
80
|
+
*
|
|
81
|
+
* SVG <symbol> elements are container elements typically defined in <defs> and
|
|
82
|
+
* instantiated via <use>. They support viewBox for coordinate system control
|
|
83
|
+
* and preserveAspectRatio for scaling behavior.
|
|
84
|
+
*
|
|
85
|
+
* The viewBox defines the symbol's internal coordinate system (minX minY width height).
|
|
86
|
+
* When a <use> references a symbol with width/height, the viewBox is mapped to that
|
|
87
|
+
* viewport according to preserveAspectRatio rules.
|
|
88
|
+
*
|
|
89
|
+
* refX/refY provide an optional reference point for alignment (similar to markers).
|
|
90
|
+
*
|
|
91
|
+
* @param {Element} symbolElement - SVG <symbol> DOM element to parse
|
|
92
|
+
* @returns {Object} Parsed symbol data with the following properties:
|
|
93
|
+
* - id {string} - Symbol's id attribute
|
|
94
|
+
* - viewBox {string|null} - Raw viewBox attribute string
|
|
95
|
+
* - viewBoxParsed {Object|undefined} - Parsed viewBox with x, y, width, height (only if valid)
|
|
96
|
+
* - preserveAspectRatio {string} - How to scale/align viewBox (default: 'xMidYMid meet')
|
|
97
|
+
* - children {Array} - Parsed child elements
|
|
98
|
+
* - refX {number} - Reference point X coordinate (default: 0)
|
|
99
|
+
* - refY {number} - Reference point Y coordinate (default: 0)
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* // Parse a symbol with viewBox
|
|
103
|
+
* const symbolEl = document.querySelector('symbol');
|
|
104
|
+
* // <symbol id="icon" viewBox="0 0 24 24" preserveAspectRatio="xMidYMid meet">
|
|
105
|
+
* // <circle cx="12" cy="12" r="10"/>
|
|
106
|
+
* // </symbol>
|
|
107
|
+
* const parsed = parseSymbolElement(symbolEl);
|
|
108
|
+
* // {
|
|
109
|
+
* // id: 'icon',
|
|
110
|
+
* // viewBox: '0 0 24 24',
|
|
111
|
+
* // viewBoxParsed: { x: 0, y: 0, width: 24, height: 24 },
|
|
112
|
+
* // preserveAspectRatio: 'xMidYMid meet',
|
|
113
|
+
* // children: [{ type: 'circle', cx: 12, cy: 12, r: 10, ... }],
|
|
114
|
+
* // refX: 0, refY: 0
|
|
115
|
+
* // }
|
|
116
|
+
*/
|
|
117
|
+
export function parseSymbolElement(symbolElement) {
|
|
118
|
+
const data = {
|
|
119
|
+
id: symbolElement.getAttribute('id') || '',
|
|
120
|
+
viewBox: symbolElement.getAttribute('viewBox') || null,
|
|
121
|
+
preserveAspectRatio: symbolElement.getAttribute('preserveAspectRatio') || 'xMidYMid meet',
|
|
122
|
+
children: [],
|
|
123
|
+
refX: parseFloat(symbolElement.getAttribute('refX') || '0'),
|
|
124
|
+
refY: parseFloat(symbolElement.getAttribute('refY') || '0')
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Parse viewBox
|
|
128
|
+
if (data.viewBox) {
|
|
129
|
+
const parts = data.viewBox.trim().split(/[\s,]+/).map(Number);
|
|
130
|
+
if (parts.length === 4) {
|
|
131
|
+
data.viewBoxParsed = {
|
|
132
|
+
x: parts[0],
|
|
133
|
+
y: parts[1],
|
|
134
|
+
width: parts[2],
|
|
135
|
+
height: parts[3]
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Parse children
|
|
141
|
+
for (const child of symbolElement.children) {
|
|
142
|
+
data.children.push(parseChildElement(child));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parse any SVG element to structured data for use/symbol resolution.
|
|
150
|
+
*
|
|
151
|
+
* Recursively parses SVG elements extracting geometry-specific attributes
|
|
152
|
+
* based on element type (rect, circle, path, etc.). Used to build a structured
|
|
153
|
+
* representation of symbol contents and referenced elements.
|
|
154
|
+
*
|
|
155
|
+
* Handles:
|
|
156
|
+
* - Shape elements (rect, circle, ellipse, line, path, polygon, polyline)
|
|
157
|
+
* - Container elements (g - group)
|
|
158
|
+
* - Reference elements (use - for nested use resolution)
|
|
159
|
+
* - Common attributes (id, transform, style)
|
|
160
|
+
*
|
|
161
|
+
* @param {Element} element - SVG DOM element to parse
|
|
162
|
+
* @returns {Object} Parsed element data with the following base properties:
|
|
163
|
+
* - type {string} - Element tag name (lowercase)
|
|
164
|
+
* - id {string|null} - Element's id attribute
|
|
165
|
+
* - transform {string|null} - Element's transform attribute
|
|
166
|
+
* - style {Object} - Extracted style attributes (fill, stroke, etc.)
|
|
167
|
+
* Plus element-specific geometry properties:
|
|
168
|
+
* - rect: x, y, width, height, rx, ry
|
|
169
|
+
* - circle: cx, cy, r
|
|
170
|
+
* - ellipse: cx, cy, rx, ry
|
|
171
|
+
* - path: d
|
|
172
|
+
* - polygon/polyline: points
|
|
173
|
+
* - line: x1, y1, x2, y2
|
|
174
|
+
* - g: children (array of parsed child elements)
|
|
175
|
+
* - use: href, x, y, width, height
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* // Parse a circle element
|
|
179
|
+
* const circleEl = document.querySelector('circle');
|
|
180
|
+
* // <circle id="c1" cx="50" cy="50" r="20" fill="blue" transform="rotate(45)"/>
|
|
181
|
+
* const parsed = parseChildElement(circleEl);
|
|
182
|
+
* // {
|
|
183
|
+
* // type: 'circle',
|
|
184
|
+
* // id: 'c1',
|
|
185
|
+
* // cx: 50, cy: 50, r: 20,
|
|
186
|
+
* // transform: 'rotate(45)',
|
|
187
|
+
* // style: { fill: 'blue', ... }
|
|
188
|
+
* // }
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* // Parse a group with nested elements
|
|
192
|
+
* const groupEl = document.querySelector('g');
|
|
193
|
+
* // <g id="group1" transform="translate(10, 20)">
|
|
194
|
+
* // <rect x="0" y="0" width="50" height="50"/>
|
|
195
|
+
* // <circle cx="25" cy="25" r="10"/>
|
|
196
|
+
* // </g>
|
|
197
|
+
* const parsed = parseChildElement(groupEl);
|
|
198
|
+
* // {
|
|
199
|
+
* // type: 'g',
|
|
200
|
+
* // id: 'group1',
|
|
201
|
+
* // transform: 'translate(10, 20)',
|
|
202
|
+
* // children: [
|
|
203
|
+
* // { type: 'rect', x: 0, y: 0, width: 50, height: 50, ... },
|
|
204
|
+
* // { type: 'circle', cx: 25, cy: 25, r: 10, ... }
|
|
205
|
+
* // ],
|
|
206
|
+
* // style: { ... }
|
|
207
|
+
* // }
|
|
208
|
+
*/
|
|
209
|
+
export function parseChildElement(element) {
|
|
210
|
+
const tagName = element.tagName.toLowerCase();
|
|
211
|
+
|
|
212
|
+
const data = {
|
|
213
|
+
type: tagName,
|
|
214
|
+
id: element.getAttribute('id') || null,
|
|
215
|
+
transform: element.getAttribute('transform') || null,
|
|
216
|
+
style: extractStyleAttributes(element)
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
switch (tagName) {
|
|
220
|
+
case 'rect':
|
|
221
|
+
data.x = parseFloat(element.getAttribute('x') || '0');
|
|
222
|
+
data.y = parseFloat(element.getAttribute('y') || '0');
|
|
223
|
+
data.width = parseFloat(element.getAttribute('width') || '0');
|
|
224
|
+
data.height = parseFloat(element.getAttribute('height') || '0');
|
|
225
|
+
data.rx = parseFloat(element.getAttribute('rx') || '0');
|
|
226
|
+
data.ry = parseFloat(element.getAttribute('ry') || '0');
|
|
227
|
+
break;
|
|
228
|
+
case 'circle':
|
|
229
|
+
data.cx = parseFloat(element.getAttribute('cx') || '0');
|
|
230
|
+
data.cy = parseFloat(element.getAttribute('cy') || '0');
|
|
231
|
+
data.r = parseFloat(element.getAttribute('r') || '0');
|
|
232
|
+
break;
|
|
233
|
+
case 'ellipse':
|
|
234
|
+
data.cx = parseFloat(element.getAttribute('cx') || '0');
|
|
235
|
+
data.cy = parseFloat(element.getAttribute('cy') || '0');
|
|
236
|
+
data.rx = parseFloat(element.getAttribute('rx') || '0');
|
|
237
|
+
data.ry = parseFloat(element.getAttribute('ry') || '0');
|
|
238
|
+
break;
|
|
239
|
+
case 'path':
|
|
240
|
+
data.d = element.getAttribute('d') || '';
|
|
241
|
+
break;
|
|
242
|
+
case 'polygon':
|
|
243
|
+
data.points = element.getAttribute('points') || '';
|
|
244
|
+
break;
|
|
245
|
+
case 'polyline':
|
|
246
|
+
data.points = element.getAttribute('points') || '';
|
|
247
|
+
break;
|
|
248
|
+
case 'line':
|
|
249
|
+
data.x1 = parseFloat(element.getAttribute('x1') || '0');
|
|
250
|
+
data.y1 = parseFloat(element.getAttribute('y1') || '0');
|
|
251
|
+
data.x2 = parseFloat(element.getAttribute('x2') || '0');
|
|
252
|
+
data.y2 = parseFloat(element.getAttribute('y2') || '0');
|
|
253
|
+
break;
|
|
254
|
+
case 'g':
|
|
255
|
+
data.children = [];
|
|
256
|
+
for (const child of element.children) {
|
|
257
|
+
data.children.push(parseChildElement(child));
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
case 'use':
|
|
261
|
+
data.href = (element.getAttribute('href') ||
|
|
262
|
+
element.getAttribute('xlink:href') || '').replace('#', '');
|
|
263
|
+
data.x = parseFloat(element.getAttribute('x') || '0');
|
|
264
|
+
data.y = parseFloat(element.getAttribute('y') || '0');
|
|
265
|
+
data.width = element.getAttribute('width') ?
|
|
266
|
+
parseFloat(element.getAttribute('width')) : null;
|
|
267
|
+
data.height = element.getAttribute('height') ?
|
|
268
|
+
parseFloat(element.getAttribute('height')) : null;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return data;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Extract presentation style attributes from an SVG element.
|
|
277
|
+
*
|
|
278
|
+
* Extracts paint and display properties that can be inherited from <use>
|
|
279
|
+
* elements to their referenced content. These attributes participate in
|
|
280
|
+
* the CSS cascade and inheritance model.
|
|
281
|
+
*
|
|
282
|
+
* When a <use> element has fill="red", that fill inherits to the referenced
|
|
283
|
+
* content unless the content has its own fill attribute.
|
|
284
|
+
*
|
|
285
|
+
* @param {Element} element - SVG element to extract styles from
|
|
286
|
+
* @returns {Object} Style attributes object with the following properties:
|
|
287
|
+
* - fill {string|null} - Fill color/paint server
|
|
288
|
+
* - stroke {string|null} - Stroke color/paint server
|
|
289
|
+
* - strokeWidth {string|null} - Stroke width
|
|
290
|
+
* - opacity {string|null} - Overall opacity (0-1)
|
|
291
|
+
* - fillOpacity {string|null} - Fill opacity (0-1)
|
|
292
|
+
* - strokeOpacity {string|null} - Stroke opacity (0-1)
|
|
293
|
+
* - visibility {string|null} - Visibility ('visible', 'hidden', 'collapse')
|
|
294
|
+
* - display {string|null} - Display property ('none', 'inline', etc.)
|
|
295
|
+
* All properties are null if attribute is not present.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* // Extract styles from a use element
|
|
299
|
+
* const useEl = document.querySelector('use');
|
|
300
|
+
* // <use href="#icon" fill="red" stroke="blue" stroke-width="2" opacity="0.8"/>
|
|
301
|
+
* const styles = extractStyleAttributes(useEl);
|
|
302
|
+
* // {
|
|
303
|
+
* // fill: 'red',
|
|
304
|
+
* // stroke: 'blue',
|
|
305
|
+
* // strokeWidth: '2',
|
|
306
|
+
* // opacity: '0.8',
|
|
307
|
+
* // fillOpacity: null,
|
|
308
|
+
* // strokeOpacity: null,
|
|
309
|
+
* // visibility: null,
|
|
310
|
+
* // display: null
|
|
311
|
+
* // }
|
|
312
|
+
*/
|
|
313
|
+
export function extractStyleAttributes(element) {
|
|
314
|
+
return {
|
|
315
|
+
fill: element.getAttribute('fill'),
|
|
316
|
+
stroke: element.getAttribute('stroke'),
|
|
317
|
+
strokeWidth: element.getAttribute('stroke-width'),
|
|
318
|
+
opacity: element.getAttribute('opacity'),
|
|
319
|
+
fillOpacity: element.getAttribute('fill-opacity'),
|
|
320
|
+
strokeOpacity: element.getAttribute('stroke-opacity'),
|
|
321
|
+
visibility: element.getAttribute('visibility'),
|
|
322
|
+
display: element.getAttribute('display')
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Calculate the transform matrix to map a viewBox to a target viewport.
|
|
328
|
+
*
|
|
329
|
+
* This implements the SVG viewBox mapping algorithm that scales and aligns
|
|
330
|
+
* the viewBox coordinate system to fit within the target width/height according
|
|
331
|
+
* to the preserveAspectRatio specification.
|
|
332
|
+
*
|
|
333
|
+
* The viewBox defines a rectangle in user space (minX, minY, width, height) that
|
|
334
|
+
* should be mapped to the viewport rectangle (0, 0, targetWidth, targetHeight).
|
|
335
|
+
*
|
|
336
|
+
* preserveAspectRatio controls:
|
|
337
|
+
* - Alignment: where to position the viewBox within viewport (xMin/xMid/xMax, YMin/YMid/YMax)
|
|
338
|
+
* - Scaling: 'meet' (fit inside, letterbox) or 'slice' (cover, crop)
|
|
339
|
+
* - 'none' allows non-uniform scaling (stretch/squash)
|
|
340
|
+
*
|
|
341
|
+
* @param {Object} viewBox - Parsed viewBox with the following properties:
|
|
342
|
+
* - x {number} - Minimum X of viewBox coordinate system
|
|
343
|
+
* - y {number} - Minimum Y of viewBox coordinate system
|
|
344
|
+
* - width {number} - Width of viewBox rectangle
|
|
345
|
+
* - height {number} - Height of viewBox rectangle
|
|
346
|
+
* @param {number} targetWidth - Target viewport width in user units
|
|
347
|
+
* @param {number} targetHeight - Target viewport height in user units
|
|
348
|
+
* @param {string} [preserveAspectRatio='xMidYMid meet'] - Aspect ratio preservation mode
|
|
349
|
+
* Format: '[none|align] [meet|slice]'
|
|
350
|
+
* Align values: xMinYMin, xMidYMin, xMaxYMin, xMinYMid, xMidYMid, xMaxYMid, xMinYMax, xMidYMax, xMaxYMax
|
|
351
|
+
* @returns {Matrix} 3x3 affine transform matrix mapping viewBox to viewport
|
|
352
|
+
*
|
|
353
|
+
* @example
|
|
354
|
+
* // Symbol with viewBox="0 0 100 100" used with width=200, height=150
|
|
355
|
+
* const viewBox = { x: 0, y: 0, width: 100, height: 100 };
|
|
356
|
+
* const transform = calculateViewBoxTransform(viewBox, 200, 150, 'xMidYMid meet');
|
|
357
|
+
* // Result: uniform scale of 1.5 (min of 200/100, 150/100)
|
|
358
|
+
* // Centered in 200x150 viewport: translate(25, 0) then scale(1.5, 1.5)
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* // Non-uniform scaling with 'none'
|
|
362
|
+
* const viewBox = { x: 0, y: 0, width: 100, height: 50 };
|
|
363
|
+
* const transform = calculateViewBoxTransform(viewBox, 200, 200, 'none');
|
|
364
|
+
* // Result: scale(2, 4) - stretches to fill viewport
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* // 'slice' mode crops to fill viewport
|
|
368
|
+
* const viewBox = { x: 0, y: 0, width: 100, height: 100 };
|
|
369
|
+
* const transform = calculateViewBoxTransform(viewBox, 200, 150, 'xMidYMid slice');
|
|
370
|
+
* // Result: uniform scale of 2.0 (max of 200/100, 150/100)
|
|
371
|
+
* // Centered: translate(0, -25) then scale(2, 2) - height extends beyond viewport
|
|
372
|
+
*/
|
|
373
|
+
export function calculateViewBoxTransform(viewBox, targetWidth, targetHeight, preserveAspectRatio = 'xMidYMid meet') {
|
|
374
|
+
if (!viewBox || !targetWidth || !targetHeight) {
|
|
375
|
+
return Matrix.identity(3);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const vbW = viewBox.width;
|
|
379
|
+
const vbH = viewBox.height;
|
|
380
|
+
const vbX = viewBox.x;
|
|
381
|
+
const vbY = viewBox.y;
|
|
382
|
+
|
|
383
|
+
if (vbW <= 0 || vbH <= 0) {
|
|
384
|
+
return Matrix.identity(3);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Parse preserveAspectRatio
|
|
388
|
+
const parts = preserveAspectRatio.trim().split(/\s+/);
|
|
389
|
+
const align = parts[0] || 'xMidYMid';
|
|
390
|
+
const meetOrSlice = parts[1] || 'meet';
|
|
391
|
+
|
|
392
|
+
if (align === 'none') {
|
|
393
|
+
// Non-uniform scaling
|
|
394
|
+
const scaleX = targetWidth / vbW;
|
|
395
|
+
const scaleY = targetHeight / vbH;
|
|
396
|
+
return Transforms2D.translation(-vbX * scaleX, -vbY * scaleY)
|
|
397
|
+
.mul(Transforms2D.scale(scaleX, scaleY));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Uniform scaling
|
|
401
|
+
const scaleX = targetWidth / vbW;
|
|
402
|
+
const scaleY = targetHeight / vbH;
|
|
403
|
+
let scale;
|
|
404
|
+
|
|
405
|
+
if (meetOrSlice === 'slice') {
|
|
406
|
+
scale = Math.max(scaleX, scaleY);
|
|
407
|
+
} else {
|
|
408
|
+
scale = Math.min(scaleX, scaleY);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Calculate alignment offsets
|
|
412
|
+
let tx = -vbX * scale;
|
|
413
|
+
let ty = -vbY * scale;
|
|
414
|
+
|
|
415
|
+
const scaledWidth = vbW * scale;
|
|
416
|
+
const scaledHeight = vbH * scale;
|
|
417
|
+
|
|
418
|
+
// X alignment
|
|
419
|
+
if (align.includes('xMid')) {
|
|
420
|
+
tx += (targetWidth - scaledWidth) / 2;
|
|
421
|
+
} else if (align.includes('xMax')) {
|
|
422
|
+
tx += targetWidth - scaledWidth;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Y alignment
|
|
426
|
+
if (align.includes('YMid')) {
|
|
427
|
+
ty += (targetHeight - scaledHeight) / 2;
|
|
428
|
+
} else if (align.includes('YMax')) {
|
|
429
|
+
ty += targetHeight - scaledHeight;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return Transforms2D.translation(tx, ty)
|
|
433
|
+
.mul(Transforms2D.scale(scale, scale));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Resolve a <use> element by expanding its referenced content with transforms.
|
|
438
|
+
*
|
|
439
|
+
* This is the core use/symbol resolution algorithm. It:
|
|
440
|
+
* 1. Looks up the target element by id (can be symbol, shape, group, or nested use)
|
|
441
|
+
* 2. Composes transforms: translation from x,y → use's transform → viewBox mapping
|
|
442
|
+
* 3. Recursively resolves nested <use> elements (with depth limit)
|
|
443
|
+
* 4. Propagates style inheritance from <use> to referenced content
|
|
444
|
+
*
|
|
445
|
+
* Transform composition order (right-to-left multiplication):
|
|
446
|
+
* - First: translate by (x, y) to position the reference
|
|
447
|
+
* - Second: apply use element's transform attribute
|
|
448
|
+
* - Third: apply viewBox→viewport mapping (symbols only)
|
|
449
|
+
*
|
|
450
|
+
* For symbols with viewBox, if <use> specifies width/height, those establish the
|
|
451
|
+
* viewport; otherwise the symbol's viewBox width/height is used.
|
|
452
|
+
*
|
|
453
|
+
* @param {Object} useData - Parsed <use> element data from parseUseElement()
|
|
454
|
+
* @param {Object} defs - Map of element id → parsed element data (from buildDefsMap())
|
|
455
|
+
* @param {Object} [options={}] - Resolution options
|
|
456
|
+
* @param {number} [options.maxDepth=10] - Maximum nesting depth for recursive use resolution
|
|
457
|
+
* Prevents infinite recursion from circular references
|
|
458
|
+
* @returns {Object|null} Resolved use data with the following structure:
|
|
459
|
+
* - element {Object} - The referenced target element (symbol, shape, group, etc.)
|
|
460
|
+
* - transform {Matrix} - Composed 3x3 transform matrix to apply to all children
|
|
461
|
+
* - children {Array} - Array of resolved child objects, each with:
|
|
462
|
+
* - element {Object} - Child element data
|
|
463
|
+
* - transform {Matrix} - Child's local transform
|
|
464
|
+
* - children {Array} - (if child is nested use) Recursively resolved children
|
|
465
|
+
* - inheritedStyle {Object} - Style attributes from <use> that cascade to children
|
|
466
|
+
* Returns null if target not found or max depth exceeded.
|
|
467
|
+
*
|
|
468
|
+
* @example
|
|
469
|
+
* // Simple shape reference
|
|
470
|
+
* const defs = {
|
|
471
|
+
* 'myCircle': { type: 'circle', cx: 0, cy: 0, r: 10 }
|
|
472
|
+
* };
|
|
473
|
+
* const useData = { href: 'myCircle', x: 100, y: 50, style: { fill: 'red' } };
|
|
474
|
+
* const resolved = resolveUse(useData, defs);
|
|
475
|
+
* // {
|
|
476
|
+
* // element: { type: 'circle', cx: 0, cy: 0, r: 10 },
|
|
477
|
+
* // transform: Matrix(translate(100, 50)),
|
|
478
|
+
* // children: [{ element: {...}, transform: identity }],
|
|
479
|
+
* // inheritedStyle: { fill: 'red' }
|
|
480
|
+
* // }
|
|
481
|
+
*
|
|
482
|
+
* @example
|
|
483
|
+
* // Symbol with viewBox
|
|
484
|
+
* const defs = {
|
|
485
|
+
* 'icon': {
|
|
486
|
+
* type: 'symbol',
|
|
487
|
+
* viewBoxParsed: { x: 0, y: 0, width: 24, height: 24 },
|
|
488
|
+
* preserveAspectRatio: 'xMidYMid meet',
|
|
489
|
+
* children: [{ type: 'path', d: 'M...' }]
|
|
490
|
+
* }
|
|
491
|
+
* };
|
|
492
|
+
* const useData = { href: 'icon', x: 10, y: 20, width: 48, height: 48 };
|
|
493
|
+
* const resolved = resolveUse(useData, defs);
|
|
494
|
+
* // transform composes: translate(10,20) → viewBox mapping (scale 2x)
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* // Nested use (use referencing another use)
|
|
498
|
+
* const defs = {
|
|
499
|
+
* 'shape': { type: 'rect', x: 0, y: 0, width: 10, height: 10 },
|
|
500
|
+
* 'ref1': { type: 'use', href: 'shape', x: 5, y: 5 }
|
|
501
|
+
* };
|
|
502
|
+
* const useData = { href: 'ref1', x: 100, y: 100 };
|
|
503
|
+
* const resolved = resolveUse(useData, defs);
|
|
504
|
+
* // Recursively resolves ref1 → shape, composing transforms
|
|
505
|
+
*/
|
|
506
|
+
export function resolveUse(useData, defs, options = {}) {
|
|
507
|
+
const { maxDepth = 10 } = options;
|
|
508
|
+
|
|
509
|
+
if (maxDepth <= 0) {
|
|
510
|
+
return null; // Prevent infinite recursion
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const target = defs[useData.href];
|
|
514
|
+
if (!target) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Calculate base transform from x, y
|
|
519
|
+
let transform = Transforms2D.translation(useData.x, useData.y);
|
|
520
|
+
|
|
521
|
+
// Apply use element's transform if present
|
|
522
|
+
if (useData.transform) {
|
|
523
|
+
const useTransform = ClipPathResolver.parseTransform(useData.transform);
|
|
524
|
+
transform = transform.mul(useTransform);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Handle symbol with viewBox
|
|
528
|
+
if (target.type === 'symbol' && target.viewBoxParsed) {
|
|
529
|
+
const width = useData.width || target.viewBoxParsed.width;
|
|
530
|
+
const height = useData.height || target.viewBoxParsed.height;
|
|
531
|
+
|
|
532
|
+
const viewBoxTransform = calculateViewBoxTransform(
|
|
533
|
+
target.viewBoxParsed,
|
|
534
|
+
width,
|
|
535
|
+
height,
|
|
536
|
+
target.preserveAspectRatio
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
transform = transform.mul(viewBoxTransform);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Resolve children
|
|
543
|
+
const resolvedChildren = [];
|
|
544
|
+
const children = target.children || [target];
|
|
545
|
+
|
|
546
|
+
for (const child of children) {
|
|
547
|
+
if (child.type === 'use') {
|
|
548
|
+
// Recursive resolution
|
|
549
|
+
const resolved = resolveUse(child, defs, { maxDepth: maxDepth - 1 });
|
|
550
|
+
if (resolved) {
|
|
551
|
+
resolvedChildren.push(resolved);
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
resolvedChildren.push({
|
|
555
|
+
element: child,
|
|
556
|
+
transform: Matrix.identity(3)
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
element: target,
|
|
563
|
+
transform,
|
|
564
|
+
children: resolvedChildren,
|
|
565
|
+
inheritedStyle: useData.style
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Flatten a resolved <use> element tree to an array of transformed polygons.
|
|
571
|
+
*
|
|
572
|
+
* Recursively traverses the resolved use element hierarchy, converting all
|
|
573
|
+
* shapes to polygons and applying accumulated transforms. This produces a
|
|
574
|
+
* flat list of polygons ready for rendering, clipping, or geometric operations.
|
|
575
|
+
*
|
|
576
|
+
* Each shape element (rect, circle, path, etc.) is converted to a polygon
|
|
577
|
+
* approximation with the specified number of curve samples. Transforms are
|
|
578
|
+
* composed from parent to child.
|
|
579
|
+
*
|
|
580
|
+
* Style attributes are merged during flattening, combining inherited styles
|
|
581
|
+
* from <use> elements with element-specific styles (element styles take precedence).
|
|
582
|
+
*
|
|
583
|
+
* @param {Object} resolved - Resolved use data from resolveUse(), with structure:
|
|
584
|
+
* - transform {Matrix} - Transform to apply to all children
|
|
585
|
+
* - children {Array} - Child elements or nested resolved uses
|
|
586
|
+
* - inheritedStyle {Object} - Style attributes to cascade to children
|
|
587
|
+
* @param {number} [samples=20] - Number of samples for curve approximation
|
|
588
|
+
* Higher values produce smoother polygons for curved shapes (circles, arcs, etc.)
|
|
589
|
+
* @returns {Array<Object>} Array of polygon objects, each with:
|
|
590
|
+
* - polygon {Array<{x, y}>} - Array of transformed vertex points
|
|
591
|
+
* - style {Object} - Merged style attributes (inherited + element-specific)
|
|
592
|
+
*
|
|
593
|
+
* @example
|
|
594
|
+
* // Flatten a simple resolved use
|
|
595
|
+
* const defs = { 'c': { type: 'circle', cx: 10, cy: 10, r: 5, style: { fill: 'blue' } } };
|
|
596
|
+
* const useData = { href: 'c', x: 100, y: 50, style: { stroke: 'red' } };
|
|
597
|
+
* const resolved = resolveUse(useData, defs);
|
|
598
|
+
* const polygons = flattenResolvedUse(resolved, 20);
|
|
599
|
+
* // [
|
|
600
|
+
* // {
|
|
601
|
+
* // polygon: [{x: 115, y: 50}, {x: 114.9, y: 51.5}, ...], // 20 points approximating circle
|
|
602
|
+
* // style: { fill: 'blue', stroke: 'red', ... } // merged styles
|
|
603
|
+
* // }
|
|
604
|
+
* // ]
|
|
605
|
+
*
|
|
606
|
+
* @example
|
|
607
|
+
* // Flatten nested uses with composed transforms
|
|
608
|
+
* const resolved = { // Complex nested structure
|
|
609
|
+
* transform: Matrix(translate(10, 20)),
|
|
610
|
+
* children: [
|
|
611
|
+
* {
|
|
612
|
+
* element: { type: 'rect', x: 0, y: 0, width: 50, height: 30 },
|
|
613
|
+
* transform: Matrix(rotate(45))
|
|
614
|
+
* }
|
|
615
|
+
* ],
|
|
616
|
+
* inheritedStyle: { fill: 'green' }
|
|
617
|
+
* };
|
|
618
|
+
* const polygons = flattenResolvedUse(resolved);
|
|
619
|
+
* // Rectangle converted to 4-point polygon, transformed by translate→rotate
|
|
620
|
+
*/
|
|
621
|
+
export function flattenResolvedUse(resolved, samples = 20) {
|
|
622
|
+
const results = [];
|
|
623
|
+
|
|
624
|
+
if (!resolved) return results;
|
|
625
|
+
|
|
626
|
+
for (const child of resolved.children) {
|
|
627
|
+
const childTransform = resolved.transform.mul(child.transform);
|
|
628
|
+
const element = child.element;
|
|
629
|
+
|
|
630
|
+
if (child.children) {
|
|
631
|
+
// Recursive flattening
|
|
632
|
+
const nested = flattenResolvedUse(child, samples);
|
|
633
|
+
for (const n of nested) {
|
|
634
|
+
n.polygon = n.polygon.map(p => {
|
|
635
|
+
const [x, y] = Transforms2D.applyTransform(resolved.transform, p.x, p.y);
|
|
636
|
+
return { x, y };
|
|
637
|
+
});
|
|
638
|
+
results.push(n);
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
// Convert element to polygon
|
|
642
|
+
const polygon = elementToPolygon(element, childTransform, samples);
|
|
643
|
+
if (polygon.length >= 3) {
|
|
644
|
+
results.push({
|
|
645
|
+
polygon,
|
|
646
|
+
style: mergeStyles(resolved.inheritedStyle, element.style)
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return results;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Convert an SVG element to a transformed polygon approximation.
|
|
657
|
+
*
|
|
658
|
+
* Delegates to ClipPathResolver.shapeToPolygon() for the element→polygon
|
|
659
|
+
* conversion, then applies the transform matrix to all vertices.
|
|
660
|
+
*
|
|
661
|
+
* Supports all standard SVG shapes:
|
|
662
|
+
* - rect, circle, ellipse: converted to polygons with curved edges sampled
|
|
663
|
+
* - path: parsed and sampled (curves approximated)
|
|
664
|
+
* - polygon, polyline: parsed directly
|
|
665
|
+
* - line: converted to 2-point polygon
|
|
666
|
+
*
|
|
667
|
+
* @param {Object} element - Parsed element data from parseChildElement()
|
|
668
|
+
* Must have 'type' property and geometry attributes (x, y, cx, cy, d, points, etc.)
|
|
669
|
+
* @param {Matrix} transform - 3x3 affine transform matrix to apply to vertices
|
|
670
|
+
* @param {number} [samples=20] - Number of samples for curve approximation
|
|
671
|
+
* Used for circles, ellipses, path curves, rounded rect corners, etc.
|
|
672
|
+
* @returns {Array<{x, y}>} Array of transformed polygon vertices
|
|
673
|
+
* Empty array if element cannot be converted to polygon.
|
|
674
|
+
*
|
|
675
|
+
* @example
|
|
676
|
+
* // Convert circle to transformed polygon
|
|
677
|
+
* const element = { type: 'circle', cx: 10, cy: 10, r: 5 };
|
|
678
|
+
* const transform = Transforms2D.translation(100, 50).mul(Transforms2D.scale(2, 2));
|
|
679
|
+
* const polygon = elementToPolygon(element, transform, 16);
|
|
680
|
+
* // Returns 16 points approximating circle at (10,10) r=5, then scaled 2x and translated to (100,50)
|
|
681
|
+
*
|
|
682
|
+
* @example
|
|
683
|
+
* // Convert rectangle (becomes 4-point polygon)
|
|
684
|
+
* const element = { type: 'rect', x: 0, y: 0, width: 50, height: 30, rx: 0, ry: 0 };
|
|
685
|
+
* const transform = Matrix.identity(3);
|
|
686
|
+
* const polygon = elementToPolygon(element, transform);
|
|
687
|
+
* // [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 30 }, { x: 0, y: 30 }]
|
|
688
|
+
*/
|
|
689
|
+
export function elementToPolygon(element, transform, samples = 20) {
|
|
690
|
+
// Use ClipPathResolver's shapeToPolygon
|
|
691
|
+
let polygon = ClipPathResolver.shapeToPolygon(element, null, samples);
|
|
692
|
+
|
|
693
|
+
// Apply transform
|
|
694
|
+
if (transform && polygon.length > 0) {
|
|
695
|
+
polygon = polygon.map(p => {
|
|
696
|
+
const [x, y] = Transforms2D.applyTransform(transform, p.x, p.y);
|
|
697
|
+
return { x, y };
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return polygon;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Merge inherited styles from <use> with element-specific styles.
|
|
706
|
+
*
|
|
707
|
+
* Implements the SVG style inheritance cascade where <use> element styles
|
|
708
|
+
* propagate to referenced content, but element-specific styles take precedence.
|
|
709
|
+
*
|
|
710
|
+
* This follows the CSS cascade model:
|
|
711
|
+
* - Element's own attributes have highest priority
|
|
712
|
+
* - Inherited attributes from <use> fill in gaps
|
|
713
|
+
* - null/undefined element values allow inheritance
|
|
714
|
+
* - Explicit element values (even if same as inherited) prevent inheritance
|
|
715
|
+
*
|
|
716
|
+
* Example: <use fill="red" href="#shape"/> references <circle fill="blue"/>
|
|
717
|
+
* Result: circle gets fill="blue" (element's own style wins)
|
|
718
|
+
*
|
|
719
|
+
* Example: <use fill="red" href="#shape"/> references <circle/>
|
|
720
|
+
* Result: circle gets fill="red" (inherits from use)
|
|
721
|
+
*
|
|
722
|
+
* @param {Object} inherited - Style attributes from <use> element (from extractStyleAttributes)
|
|
723
|
+
* May be null or undefined. Properties with null values are not inherited.
|
|
724
|
+
* @param {Object} element - Element's own style attributes (from extractStyleAttributes)
|
|
725
|
+
* Properties with non-null values take precedence over inherited values.
|
|
726
|
+
* @returns {Object} Merged style object where:
|
|
727
|
+
* - Element properties are preserved if not null/undefined
|
|
728
|
+
* - Inherited properties fill in where element has null/undefined
|
|
729
|
+
* - Result contains all properties from both objects
|
|
730
|
+
*
|
|
731
|
+
* @example
|
|
732
|
+
* // Element style overrides inherited
|
|
733
|
+
* const inherited = { fill: 'red', stroke: 'blue', opacity: '0.5' };
|
|
734
|
+
* const element = { fill: 'green', stroke: null, strokeWidth: '2' };
|
|
735
|
+
* const merged = mergeStyles(inherited, element);
|
|
736
|
+
* // {
|
|
737
|
+
* // fill: 'green', // element overrides
|
|
738
|
+
* // stroke: 'blue', // inherited (element was null)
|
|
739
|
+
* // opacity: '0.5', // inherited (element didn't have it)
|
|
740
|
+
* // strokeWidth: '2' // from element
|
|
741
|
+
* // }
|
|
742
|
+
*
|
|
743
|
+
* @example
|
|
744
|
+
* // No inherited styles
|
|
745
|
+
* const merged = mergeStyles(null, { fill: 'blue' });
|
|
746
|
+
* // { fill: 'blue' }
|
|
747
|
+
*/
|
|
748
|
+
export function mergeStyles(inherited, element) {
|
|
749
|
+
const result = { ...element };
|
|
750
|
+
|
|
751
|
+
for (const [key, value] of Object.entries(inherited || {})) {
|
|
752
|
+
// Inherit if value is not null and element doesn't have a value (null or undefined)
|
|
753
|
+
if (value !== null && (result[key] === null || result[key] === undefined)) {
|
|
754
|
+
result[key] = value;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return result;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Calculate the axis-aligned bounding box of a resolved <use> element.
|
|
763
|
+
*
|
|
764
|
+
* Flattens the entire use/symbol hierarchy to polygons, then computes
|
|
765
|
+
* the minimum rectangle that contains all transformed vertices.
|
|
766
|
+
*
|
|
767
|
+
* This is useful for:
|
|
768
|
+
* - Determining the rendered extent of a use element
|
|
769
|
+
* - Layout calculations
|
|
770
|
+
* - Viewport fitting
|
|
771
|
+
* - Collision detection
|
|
772
|
+
*
|
|
773
|
+
* The bounding box is axis-aligned (edges parallel to X/Y axes) in the
|
|
774
|
+
* final coordinate space after all transforms have been applied.
|
|
775
|
+
*
|
|
776
|
+
* @param {Object} resolved - Resolved use data from resolveUse()
|
|
777
|
+
* Contains the element tree with composed transforms
|
|
778
|
+
* @param {number} [samples=20] - Number of samples for curve approximation
|
|
779
|
+
* Higher values give tighter bounds for curved shapes
|
|
780
|
+
* @returns {Object} Bounding box with properties:
|
|
781
|
+
* - x {number} - Minimum X coordinate (left edge)
|
|
782
|
+
* - y {number} - Minimum Y coordinate (top edge)
|
|
783
|
+
* - width {number} - Width of bounding box
|
|
784
|
+
* - height {number} - Height of bounding box
|
|
785
|
+
* Returns {x:0, y:0, width:0, height:0} if no polygons or resolved is null.
|
|
786
|
+
*
|
|
787
|
+
* @example
|
|
788
|
+
* // Get bbox of a circle use element
|
|
789
|
+
* const defs = { 'c': { type: 'circle', cx: 0, cy: 0, r: 10 } };
|
|
790
|
+
* const useData = { href: 'c', x: 100, y: 50, style: {} };
|
|
791
|
+
* const resolved = resolveUse(useData, defs);
|
|
792
|
+
* const bbox = getResolvedBBox(resolved, 20);
|
|
793
|
+
* // { x: 90, y: 40, width: 20, height: 20 }
|
|
794
|
+
* // Circle at (0,0) r=10, translated to (100,50), bounds from 90 to 110, 40 to 60
|
|
795
|
+
*
|
|
796
|
+
* @example
|
|
797
|
+
* // Get bbox of rotated rectangle
|
|
798
|
+
* const defs = { 'r': { type: 'rect', x: 0, y: 0, width: 100, height: 50, transform: 'rotate(45)' } };
|
|
799
|
+
* const useData = { href: 'r', x: 0, y: 0 };
|
|
800
|
+
* const resolved = resolveUse(useData, defs);
|
|
801
|
+
* const bbox = getResolvedBBox(resolved);
|
|
802
|
+
* // Axis-aligned bbox enclosing the rotated rectangle (wider than original)
|
|
803
|
+
*/
|
|
804
|
+
export function getResolvedBBox(resolved, samples = 20) {
|
|
805
|
+
const polygons = flattenResolvedUse(resolved, samples);
|
|
806
|
+
|
|
807
|
+
let minX = Infinity;
|
|
808
|
+
let minY = Infinity;
|
|
809
|
+
let maxX = -Infinity;
|
|
810
|
+
let maxY = -Infinity;
|
|
811
|
+
|
|
812
|
+
for (const { polygon } of polygons) {
|
|
813
|
+
for (const p of polygon) {
|
|
814
|
+
const x = Number(p.x);
|
|
815
|
+
const y = Number(p.y);
|
|
816
|
+
minX = Math.min(minX, x);
|
|
817
|
+
minY = Math.min(minY, y);
|
|
818
|
+
maxX = Math.max(maxX, x);
|
|
819
|
+
maxY = Math.max(maxY, y);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (minX === Infinity) {
|
|
824
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
x: minX,
|
|
829
|
+
y: minY,
|
|
830
|
+
width: maxX - minX,
|
|
831
|
+
height: maxY - minY
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Apply a clipping polygon to a resolved <use> element.
|
|
837
|
+
*
|
|
838
|
+
* Flattens the use element to polygons, then computes the intersection
|
|
839
|
+
* of each polygon with the clip polygon using the Sutherland-Hodgman
|
|
840
|
+
* algorithm. This implements SVG clipPath functionality.
|
|
841
|
+
*
|
|
842
|
+
* The clip polygon should be in the same coordinate space as the
|
|
843
|
+
* resolved use element (i.e., after all transforms have been applied).
|
|
844
|
+
*
|
|
845
|
+
* Clipping can produce multiple output polygons per input polygon if
|
|
846
|
+
* the clip path splits a shape into disjoint regions.
|
|
847
|
+
*
|
|
848
|
+
* Degenerate results (< 3 vertices) are filtered out automatically.
|
|
849
|
+
*
|
|
850
|
+
* @param {Object} resolved - Resolved use data from resolveUse()
|
|
851
|
+
* Contains the element tree with composed transforms
|
|
852
|
+
* @param {Array<{x, y}>} clipPolygon - Clipping polygon vertices
|
|
853
|
+
* Must be a closed polygon (clockwise or counter-clockwise)
|
|
854
|
+
* @param {number} [samples=20] - Number of samples for curve approximation
|
|
855
|
+
* Affects the input polygons (curves in the use element)
|
|
856
|
+
* @returns {Array<Object>} Array of clipped polygon objects, each with:
|
|
857
|
+
* - polygon {Array<{x, y}>} - Clipped polygon vertices (intersection result)
|
|
858
|
+
* - style {Object} - Preserved style attributes from original polygon
|
|
859
|
+
* Only polygons with ≥3 vertices are included.
|
|
860
|
+
*
|
|
861
|
+
* @example
|
|
862
|
+
* // Clip a circle to a rectangular region
|
|
863
|
+
* const defs = { 'c': { type: 'circle', cx: 50, cy: 50, r: 30 } };
|
|
864
|
+
* const useData = { href: 'c', x: 0, y: 0, style: { fill: 'blue' } };
|
|
865
|
+
* const resolved = resolveUse(useData, defs);
|
|
866
|
+
* const clipRect = [
|
|
867
|
+
* { x: 40, y: 40 },
|
|
868
|
+
* { x: 80, y: 40 },
|
|
869
|
+
* { x: 80, y: 80 },
|
|
870
|
+
* { x: 40, y: 80 }
|
|
871
|
+
* ];
|
|
872
|
+
* const clipped = clipResolvedUse(resolved, clipRect, 20);
|
|
873
|
+
* // Returns polygons representing the quarter-circle intersection
|
|
874
|
+
* // [{ polygon: [...], style: { fill: 'blue', ... } }]
|
|
875
|
+
*
|
|
876
|
+
* @example
|
|
877
|
+
* // Complex clip that may split shapes
|
|
878
|
+
* const clipPath = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 50, y: 100 }]; // Triangle
|
|
879
|
+
* const clipped = clipResolvedUse(resolved, clipPath);
|
|
880
|
+
* // May produce multiple disjoint polygons if use element spans outside triangle
|
|
881
|
+
*/
|
|
882
|
+
export function clipResolvedUse(resolved, clipPolygon, samples = 20) {
|
|
883
|
+
const polygons = flattenResolvedUse(resolved, samples);
|
|
884
|
+
const result = [];
|
|
885
|
+
|
|
886
|
+
for (const { polygon, style } of polygons) {
|
|
887
|
+
const clipped = PolygonClip.polygonIntersection(polygon, clipPolygon);
|
|
888
|
+
|
|
889
|
+
for (const clippedPoly of clipped) {
|
|
890
|
+
if (clippedPoly.length >= 3) {
|
|
891
|
+
result.push({
|
|
892
|
+
polygon: clippedPoly,
|
|
893
|
+
style
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return result;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Convert a resolved <use> element to SVG path data string.
|
|
904
|
+
*
|
|
905
|
+
* Flattens the use element hierarchy to polygons, then generates
|
|
906
|
+
* combined SVG path data using M (moveto), L (lineto), and Z (closepath)
|
|
907
|
+
* commands. All curves are approximated as polylines.
|
|
908
|
+
*
|
|
909
|
+
* The resulting path data can be used to:
|
|
910
|
+
* - Create a <path> element representing the expanded use
|
|
911
|
+
* - Export to other formats
|
|
912
|
+
* - Perform path-based operations
|
|
913
|
+
*
|
|
914
|
+
* Multiple shapes produce a compound path with multiple M commands.
|
|
915
|
+
* Coordinates are formatted to 6 decimal places for precision.
|
|
916
|
+
*
|
|
917
|
+
* @param {Object} resolved - Resolved use data from resolveUse()
|
|
918
|
+
* Contains the element tree with composed transforms
|
|
919
|
+
* @param {number} [samples=20] - Number of samples for curve approximation
|
|
920
|
+
* Higher values produce smoother paths but longer data strings
|
|
921
|
+
* @returns {string} SVG path data string with M, L, Z commands
|
|
922
|
+
* Multiple polygons are concatenated with spaces.
|
|
923
|
+
* Returns empty string if no valid polygons.
|
|
924
|
+
*
|
|
925
|
+
* @example
|
|
926
|
+
* // Convert circle use to path data
|
|
927
|
+
* const defs = { 'c': { type: 'circle', cx: 10, cy: 10, r: 5 } };
|
|
928
|
+
* const useData = { href: 'c', x: 100, y: 50 };
|
|
929
|
+
* const resolved = resolveUse(useData, defs);
|
|
930
|
+
* const pathData = resolvedUseToPathData(resolved, 8);
|
|
931
|
+
* // "M 115.000000 50.000000 L 114.619... L 113.535... ... Z"
|
|
932
|
+
* // 8-point approximation of circle, moved to (110, 60)
|
|
933
|
+
*
|
|
934
|
+
* @example
|
|
935
|
+
* // Multiple shapes produce compound path
|
|
936
|
+
* const defs = {
|
|
937
|
+
* 'icon': {
|
|
938
|
+
* type: 'symbol',
|
|
939
|
+
* children: [
|
|
940
|
+
* { type: 'rect', x: 0, y: 0, width: 10, height: 10 },
|
|
941
|
+
* { type: 'circle', cx: 15, cy: 5, r: 3 }
|
|
942
|
+
* ]
|
|
943
|
+
* }
|
|
944
|
+
* };
|
|
945
|
+
* const useData = { href: 'icon', x: 0, y: 0 };
|
|
946
|
+
* const resolved = resolveUse(useData, defs);
|
|
947
|
+
* const pathData = resolvedUseToPathData(resolved);
|
|
948
|
+
* // "M 0.000000 0.000000 L 10.000000 0.000000 ... Z M 18.000000 5.000000 L ... Z"
|
|
949
|
+
* // Two closed subpaths (rectangle + circle)
|
|
950
|
+
*/
|
|
951
|
+
export function resolvedUseToPathData(resolved, samples = 20) {
|
|
952
|
+
const polygons = flattenResolvedUse(resolved, samples);
|
|
953
|
+
const paths = [];
|
|
954
|
+
|
|
955
|
+
for (const { polygon } of polygons) {
|
|
956
|
+
if (polygon.length >= 3) {
|
|
957
|
+
let d = '';
|
|
958
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
959
|
+
const p = polygon[i];
|
|
960
|
+
const x = Number(p.x).toFixed(6);
|
|
961
|
+
const y = Number(p.y).toFixed(6);
|
|
962
|
+
d += i === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`;
|
|
963
|
+
}
|
|
964
|
+
d += ' Z';
|
|
965
|
+
paths.push(d);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return paths.join(' ');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Build a definitions map from an SVG document for use/symbol resolution.
|
|
974
|
+
*
|
|
975
|
+
* Scans the entire SVG document for elements with id attributes and parses
|
|
976
|
+
* them into a lookup table. This map is used by resolveUse() to find
|
|
977
|
+
* referenced elements.
|
|
978
|
+
*
|
|
979
|
+
* Elements can be in <defs>, <symbol>, or anywhere in the document.
|
|
980
|
+
* The SVG spec allows <use> to reference any element with an id, not just
|
|
981
|
+
* those in <defs>.
|
|
982
|
+
*
|
|
983
|
+
* Special handling for <symbol> elements: they are parsed with viewBox
|
|
984
|
+
* and preserveAspectRatio support via parseSymbolElement(). All other
|
|
985
|
+
* elements use parseChildElement().
|
|
986
|
+
*
|
|
987
|
+
* @param {Element} svgRoot - SVG root element or any container element
|
|
988
|
+
* Typically the <svg> element, but can be any parent to search within
|
|
989
|
+
* @returns {Object} Map object where:
|
|
990
|
+
* - Keys are element id strings
|
|
991
|
+
* - Values are parsed element data objects with:
|
|
992
|
+
* - type {string} - Element tag name
|
|
993
|
+
* - Geometry properties specific to element type
|
|
994
|
+
* - children {Array} - For symbols and groups
|
|
995
|
+
* - viewBoxParsed {Object} - For symbols with viewBox
|
|
996
|
+
* - All other properties from parseSymbolElement() or parseChildElement()
|
|
997
|
+
*
|
|
998
|
+
* @example
|
|
999
|
+
* // Build defs map from SVG document
|
|
1000
|
+
* const svg = document.querySelector('svg');
|
|
1001
|
+
* // <svg>
|
|
1002
|
+
* // <defs>
|
|
1003
|
+
* // <symbol id="icon" viewBox="0 0 24 24">
|
|
1004
|
+
* // <circle cx="12" cy="12" r="10"/>
|
|
1005
|
+
* // </symbol>
|
|
1006
|
+
* // <circle id="dot" cx="0" cy="0" r="5"/>
|
|
1007
|
+
* // </defs>
|
|
1008
|
+
* // <rect id="bg" x="0" y="0" width="100" height="100"/>
|
|
1009
|
+
* // </svg>
|
|
1010
|
+
* const defs = buildDefsMap(svg);
|
|
1011
|
+
* // {
|
|
1012
|
+
* // 'icon': { type: 'symbol', viewBoxParsed: {...}, children: [...], ... },
|
|
1013
|
+
* // 'dot': { type: 'circle', cx: 0, cy: 0, r: 5, ... },
|
|
1014
|
+
* // 'bg': { type: 'rect', x: 0, y: 0, width: 100, height: 100, ... }
|
|
1015
|
+
* // }
|
|
1016
|
+
*
|
|
1017
|
+
* @example
|
|
1018
|
+
* // Use the defs map for resolution
|
|
1019
|
+
* const defs = buildDefsMap(svg);
|
|
1020
|
+
* const useData = parseUseElement(useElement);
|
|
1021
|
+
* const resolved = resolveUse(useData, defs);
|
|
1022
|
+
*/
|
|
1023
|
+
export function buildDefsMap(svgRoot) {
|
|
1024
|
+
const defs = {};
|
|
1025
|
+
|
|
1026
|
+
// Find all elements with id
|
|
1027
|
+
const elementsWithId = svgRoot.querySelectorAll('[id]');
|
|
1028
|
+
|
|
1029
|
+
for (const element of elementsWithId) {
|
|
1030
|
+
const id = element.getAttribute('id');
|
|
1031
|
+
const tagName = element.tagName.toLowerCase();
|
|
1032
|
+
|
|
1033
|
+
if (tagName === 'symbol') {
|
|
1034
|
+
defs[id] = parseSymbolElement(element);
|
|
1035
|
+
defs[id].type = 'symbol';
|
|
1036
|
+
} else {
|
|
1037
|
+
defs[id] = parseChildElement(element);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return defs;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Resolve all <use> elements in an SVG document.
|
|
1046
|
+
*
|
|
1047
|
+
* This is a convenience function that:
|
|
1048
|
+
* 1. Builds the definitions map from the document
|
|
1049
|
+
* 2. Finds all <use> elements
|
|
1050
|
+
* 3. Resolves each one individually
|
|
1051
|
+
* 4. Returns an array of results with original DOM element, parsed data, and resolution
|
|
1052
|
+
*
|
|
1053
|
+
* Useful for batch processing an entire SVG document, such as:
|
|
1054
|
+
* - Expanding all uses for rendering
|
|
1055
|
+
* - Converting uses to inline elements
|
|
1056
|
+
* - Analyzing use element usage
|
|
1057
|
+
* - Generating expanded SVG output
|
|
1058
|
+
*
|
|
1059
|
+
* Failed resolutions (target not found, max depth exceeded) are filtered out.
|
|
1060
|
+
*
|
|
1061
|
+
* @param {Element} svgRoot - SVG root element to search within
|
|
1062
|
+
* Typically the <svg> element. All <use> elements within will be resolved.
|
|
1063
|
+
* @param {Object} [options={}] - Resolution options passed to resolveUse()
|
|
1064
|
+
* @param {number} [options.maxDepth=10] - Maximum nesting depth for recursive use resolution
|
|
1065
|
+
* @returns {Array<Object>} Array of successfully resolved use elements, each with:
|
|
1066
|
+
* - original {Element} - The original <use> DOM element
|
|
1067
|
+
* - useData {Object} - Parsed use element data from parseUseElement()
|
|
1068
|
+
* - resolved {Object} - Resolved structure from resolveUse() with:
|
|
1069
|
+
* - element {Object} - Referenced target element
|
|
1070
|
+
* - transform {Matrix} - Composed transform matrix
|
|
1071
|
+
* - children {Array} - Resolved children
|
|
1072
|
+
* - inheritedStyle {Object} - Inherited style attributes
|
|
1073
|
+
*
|
|
1074
|
+
* @example
|
|
1075
|
+
* // Resolve all uses in an SVG document
|
|
1076
|
+
* const svg = document.querySelector('svg');
|
|
1077
|
+
* // <svg>
|
|
1078
|
+
* // <defs>
|
|
1079
|
+
* // <circle id="dot" r="5"/>
|
|
1080
|
+
* // </defs>
|
|
1081
|
+
* // <use href="#dot" x="10" y="20" fill="red"/>
|
|
1082
|
+
* // <use href="#dot" x="30" y="40" fill="blue"/>
|
|
1083
|
+
* // </svg>
|
|
1084
|
+
* const allResolved = resolveAllUses(svg);
|
|
1085
|
+
* // [
|
|
1086
|
+
* // {
|
|
1087
|
+
* // original: <use> element,
|
|
1088
|
+
* // useData: { href: 'dot', x: 10, y: 20, style: { fill: 'red' }, ... },
|
|
1089
|
+
* // resolved: { element: {...}, transform: Matrix(...), ... }
|
|
1090
|
+
* // },
|
|
1091
|
+
* // {
|
|
1092
|
+
* // original: <use> element,
|
|
1093
|
+
* // useData: { href: 'dot', x: 30, y: 40, style: { fill: 'blue' }, ... },
|
|
1094
|
+
* // resolved: { element: {...}, transform: Matrix(...), ... }
|
|
1095
|
+
* // }
|
|
1096
|
+
* // ]
|
|
1097
|
+
*
|
|
1098
|
+
* @example
|
|
1099
|
+
* // Convert all uses to inline paths
|
|
1100
|
+
* const resolved = resolveAllUses(svg);
|
|
1101
|
+
* for (const { original, resolved: result } of resolved) {
|
|
1102
|
+
* const pathData = resolvedUseToPathData(result);
|
|
1103
|
+
* const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1104
|
+
* path.setAttribute('d', pathData);
|
|
1105
|
+
* original.parentNode.replaceChild(path, original);
|
|
1106
|
+
* }
|
|
1107
|
+
*/
|
|
1108
|
+
export function resolveAllUses(svgRoot, options = {}) {
|
|
1109
|
+
const defs = buildDefsMap(svgRoot);
|
|
1110
|
+
const useElements = svgRoot.querySelectorAll('use');
|
|
1111
|
+
const resolved = [];
|
|
1112
|
+
|
|
1113
|
+
for (const useEl of useElements) {
|
|
1114
|
+
const useData = parseUseElement(useEl);
|
|
1115
|
+
const result = resolveUse(useData, defs, options);
|
|
1116
|
+
if (result) {
|
|
1117
|
+
resolved.push({
|
|
1118
|
+
original: useEl,
|
|
1119
|
+
useData,
|
|
1120
|
+
resolved: result
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return resolved;
|
|
1126
|
+
}
|