@emasoft/svg-matrix 1.0.28 → 1.0.30
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 +325 -0
- package/bin/svg-matrix.js +985 -378
- package/bin/svglinter.cjs +4172 -433
- package/bin/svgm.js +723 -180
- package/package.json +16 -4
- package/src/animation-references.js +71 -52
- package/src/arc-length.js +160 -96
- package/src/bezier-analysis.js +257 -117
- package/src/bezier-intersections.js +411 -148
- package/src/browser-verify.js +240 -100
- package/src/clip-path-resolver.js +350 -142
- package/src/convert-path-data.js +279 -134
- package/src/css-specificity.js +78 -70
- package/src/flatten-pipeline.js +751 -263
- package/src/geometry-to-path.js +511 -182
- package/src/index.js +191 -46
- package/src/inkscape-support.js +18 -7
- package/src/marker-resolver.js +278 -164
- package/src/mask-resolver.js +209 -98
- package/src/matrix.js +147 -67
- package/src/mesh-gradient.js +187 -96
- package/src/off-canvas-detection.js +201 -104
- package/src/path-analysis.js +187 -107
- package/src/path-data-plugins.js +628 -167
- package/src/path-simplification.js +0 -1
- package/src/pattern-resolver.js +125 -88
- package/src/polygon-clip.js +111 -66
- package/src/svg-boolean-ops.js +194 -118
- package/src/svg-collections.js +22 -18
- package/src/svg-flatten.js +282 -164
- package/src/svg-parser.js +427 -200
- package/src/svg-rendering-context.js +147 -104
- package/src/svg-toolbox.js +16381 -3370
- package/src/svg2-polyfills.js +93 -224
- package/src/transform-decomposition.js +46 -41
- package/src/transform-optimization.js +89 -68
- package/src/transforms2d.js +49 -16
- package/src/transforms3d.js +58 -22
- package/src/use-symbol-resolver.js +150 -110
- package/src/vector.js +67 -15
- package/src/vendor/README.md +110 -0
- package/src/vendor/inkscape-hatch-polyfill.js +401 -0
- package/src/vendor/inkscape-hatch-polyfill.min.js +8 -0
- package/src/vendor/inkscape-mesh-polyfill.js +843 -0
- package/src/vendor/inkscape-mesh-polyfill.min.js +8 -0
- package/src/verification.js +288 -124
package/src/flatten-pipeline.js
CHANGED
|
@@ -13,47 +13,57 @@
|
|
|
13
13
|
* @module flatten-pipeline
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import Decimal from
|
|
17
|
-
import { Matrix } from
|
|
18
|
-
import * as Transforms2D from
|
|
19
|
-
import * as SVGFlatten from
|
|
20
|
-
import * as ClipPathResolver from
|
|
21
|
-
import * as MaskResolver from
|
|
22
|
-
import * as UseSymbolResolver from
|
|
23
|
-
import * as PatternResolver from
|
|
24
|
-
import * as MarkerResolver from
|
|
25
|
-
import * as
|
|
26
|
-
import * as GeometryToPath from
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
import Decimal from "decimal.js";
|
|
17
|
+
import { Matrix as _Matrix } from "./matrix.js";
|
|
18
|
+
import * as Transforms2D from "./transforms2d.js";
|
|
19
|
+
import * as SVGFlatten from "./svg-flatten.js";
|
|
20
|
+
import * as ClipPathResolver from "./clip-path-resolver.js";
|
|
21
|
+
import * as MaskResolver from "./mask-resolver.js";
|
|
22
|
+
import * as UseSymbolResolver from "./use-symbol-resolver.js";
|
|
23
|
+
import * as PatternResolver from "./pattern-resolver.js";
|
|
24
|
+
import * as MarkerResolver from "./marker-resolver.js";
|
|
25
|
+
import * as _MeshGradient from "./mesh-gradient.js";
|
|
26
|
+
import * as GeometryToPath from "./geometry-to-path.js";
|
|
27
|
+
import {
|
|
28
|
+
parseSVG,
|
|
29
|
+
SVGElement,
|
|
30
|
+
buildDefsMap,
|
|
31
|
+
parseUrlReference,
|
|
32
|
+
serializeSVG,
|
|
33
|
+
findElementsWithAttribute,
|
|
34
|
+
} from "./svg-parser.js";
|
|
35
|
+
import { Logger as _Logger } from "./logger.js";
|
|
36
|
+
import * as Verification from "./verification.js";
|
|
37
|
+
import { parseCSSIds } from "./animation-references.js";
|
|
38
|
+
import * as PolygonClip from "./polygon-clip.js";
|
|
30
39
|
|
|
31
40
|
Decimal.set({ precision: 80 });
|
|
32
41
|
|
|
33
|
-
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
42
|
+
const D = (x) => (x instanceof Decimal ? x : new Decimal(x));
|
|
34
43
|
|
|
35
44
|
/**
|
|
36
45
|
* Default options for flatten pipeline.
|
|
37
46
|
*/
|
|
38
47
|
const DEFAULT_OPTIONS = {
|
|
39
|
-
precision: 6,
|
|
40
|
-
curveSegments: 20,
|
|
41
|
-
clipSegments: 64,
|
|
42
|
-
bezierArcs: 8,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
resolveUse: true,
|
|
46
|
-
resolveMarkers: true,
|
|
47
|
-
resolvePatterns: true,
|
|
48
|
-
resolveMasks: true,
|
|
49
|
-
resolveClipPaths: true,
|
|
50
|
-
flattenTransforms: true,
|
|
51
|
-
bakeGradients: true,
|
|
52
|
-
removeUnusedDefs: true,
|
|
53
|
-
preserveIds: false,
|
|
48
|
+
precision: 6, // Decimal places in output coordinates
|
|
49
|
+
curveSegments: 20, // Samples per curve for polygon conversion (visual output)
|
|
50
|
+
clipSegments: 64, // Higher samples for clip polygon accuracy (affects E2E precision)
|
|
51
|
+
bezierArcs: 8, // Bezier arcs for circles/ellipses (must be multiple of 4)
|
|
52
|
+
// 8: π/4 optimal base (~0.0004% error)
|
|
53
|
+
// 16: π/8 (~0.000007% error), 32: π/16, 64: π/32 (~0.00000001% error)
|
|
54
|
+
resolveUse: true, // Expand <use> elements
|
|
55
|
+
resolveMarkers: true, // Expand marker instances
|
|
56
|
+
resolvePatterns: true, // Expand pattern fills to geometry
|
|
57
|
+
resolveMasks: true, // Convert masks to clip paths
|
|
58
|
+
resolveClipPaths: true, // Apply clipPath boolean operations
|
|
59
|
+
flattenTransforms: true, // Bake transform attributes into coordinates
|
|
60
|
+
bakeGradients: true, // Bake gradientTransform into gradient coords
|
|
61
|
+
removeUnusedDefs: true, // Remove defs that are no longer referenced
|
|
62
|
+
preserveIds: false, // Keep original IDs on expanded elements
|
|
63
|
+
svg2Polyfills: false, // Apply SVG 2.0 polyfills for backwards compatibility (not yet implemented)
|
|
54
64
|
// NOTE: Verification is ALWAYS enabled - precision is non-negotiable
|
|
55
65
|
// E2E verification tolerance (configurable for different accuracy needs)
|
|
56
|
-
e2eTolerance:
|
|
66
|
+
e2eTolerance: "1e-10", // Default: 1e-10 (very tight with high clipSegments)
|
|
57
67
|
};
|
|
58
68
|
|
|
59
69
|
/**
|
|
@@ -103,13 +113,19 @@ export function flattenSVG(svgString, options = {}) {
|
|
|
103
113
|
try {
|
|
104
114
|
// Parse SVG
|
|
105
115
|
const root = parseSVG(svgString);
|
|
106
|
-
if (root.tagName !==
|
|
107
|
-
throw new Error(
|
|
116
|
+
if (root.tagName !== "svg") {
|
|
117
|
+
throw new Error("Root element must be <svg>");
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
// Build defs map
|
|
111
121
|
let defsMap = buildDefsMap(root);
|
|
112
122
|
|
|
123
|
+
// CRITICAL: Collect all ID references BEFORE any processing
|
|
124
|
+
// This ensures we don't remove defs that were referenced before resolution
|
|
125
|
+
const referencedIds = opts.removeUnusedDefs
|
|
126
|
+
? collectAllReferences(root)
|
|
127
|
+
: null;
|
|
128
|
+
|
|
113
129
|
// Step 1: Resolve <use> elements (must be first - creates new geometry)
|
|
114
130
|
if (opts.resolveUse) {
|
|
115
131
|
const result = resolveAllUseElements(root, defsMap, opts);
|
|
@@ -160,9 +176,9 @@ export function flattenSVG(svgString, options = {}) {
|
|
|
160
176
|
stats.errors.push(...result.errors);
|
|
161
177
|
}
|
|
162
178
|
|
|
163
|
-
// Step 8: Remove unused defs
|
|
164
|
-
if (opts.removeUnusedDefs) {
|
|
165
|
-
const result = removeUnusedDefinitions(root);
|
|
179
|
+
// Step 8: Remove unused defs (using pre-collected references from before processing)
|
|
180
|
+
if (opts.removeUnusedDefs && referencedIds) {
|
|
181
|
+
const result = removeUnusedDefinitions(root, referencedIds);
|
|
166
182
|
stats.defsRemoved = result.count;
|
|
167
183
|
}
|
|
168
184
|
|
|
@@ -170,7 +186,6 @@ export function flattenSVG(svgString, options = {}) {
|
|
|
170
186
|
const svg = serializeSVG(root);
|
|
171
187
|
|
|
172
188
|
return { svg, stats };
|
|
173
|
-
|
|
174
189
|
} catch (error) {
|
|
175
190
|
stats.errors.push(`Pipeline error: ${error.message}`);
|
|
176
191
|
return { svg: svgString, stats }; // Return original on failure
|
|
@@ -183,20 +198,94 @@ export function flattenSVG(svgString, options = {}) {
|
|
|
183
198
|
|
|
184
199
|
/**
|
|
185
200
|
* Resolve all <use> elements by expanding them inline.
|
|
201
|
+
* Converts use references to concrete geometry or cloned elements with transforms.
|
|
202
|
+
*
|
|
203
|
+
* @param {SVGElement} root - Root SVG element
|
|
204
|
+
* @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
|
|
205
|
+
* @param {Object} opts - Processing options
|
|
206
|
+
* @returns {{count: number, errors: Array<string>}} Resolution results
|
|
186
207
|
* @private
|
|
187
208
|
*/
|
|
209
|
+
/**
|
|
210
|
+
* SMIL animation element names that must be preserved during use resolution.
|
|
211
|
+
* Converting these to paths would destroy the animation functionality.
|
|
212
|
+
*/
|
|
213
|
+
// IMPORTANT: Use lowercase for case-insensitive matching with tagName.toLowerCase()
|
|
214
|
+
const SMIL_ANIMATION_ELEMENTS = new Set([
|
|
215
|
+
"animate",
|
|
216
|
+
"animatetransform",
|
|
217
|
+
"animatemotion",
|
|
218
|
+
"animatecolor",
|
|
219
|
+
"set",
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Elements that must be preserved during flattening (not converted to paths).
|
|
224
|
+
* These elements contain content that cannot be represented as path data.
|
|
225
|
+
* IMPORTANT: Use lowercase for case-insensitive matching with tagName.toLowerCase()
|
|
226
|
+
*/
|
|
227
|
+
const PRESERVE_ELEMENTS = new Set([
|
|
228
|
+
"foreignobject",
|
|
229
|
+
"audio",
|
|
230
|
+
"video",
|
|
231
|
+
"iframe",
|
|
232
|
+
"script",
|
|
233
|
+
"style",
|
|
234
|
+
...SMIL_ANIMATION_ELEMENTS,
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if an element or any of its descendants contains elements that must be preserved.
|
|
239
|
+
*
|
|
240
|
+
* @param {SVGElement} el - Element to check
|
|
241
|
+
* @returns {boolean} True if element or descendants contain preserve elements
|
|
242
|
+
* @private
|
|
243
|
+
*/
|
|
244
|
+
function containsPreserveElements(el) {
|
|
245
|
+
if (!el) return false;
|
|
246
|
+
|
|
247
|
+
const tagName = el.tagName?.toLowerCase();
|
|
248
|
+
if (PRESERVE_ELEMENTS.has(tagName)) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Check children recursively (use tagName check instead of instanceof)
|
|
253
|
+
for (const child of el.children || []) {
|
|
254
|
+
if (child && child.tagName && containsPreserveElements(child)) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
188
262
|
function resolveAllUseElements(root, defsMap, opts) {
|
|
189
263
|
const errors = [];
|
|
190
264
|
let count = 0;
|
|
191
265
|
|
|
192
|
-
const useElements = root.getElementsByTagName(
|
|
266
|
+
const useElements = root.getElementsByTagName("use");
|
|
267
|
+
|
|
268
|
+
// Convert defsMap from Map<id, SVGElement> to {id: parsedData} format
|
|
269
|
+
// that UseSymbolResolver.resolveUse() expects (objects with .type property)
|
|
270
|
+
const parsedDefs = {};
|
|
271
|
+
for (const [id, el] of defsMap.entries()) {
|
|
272
|
+
const tagName = el.tagName.toLowerCase();
|
|
273
|
+
if (tagName === "symbol") {
|
|
274
|
+
parsedDefs[id] = UseSymbolResolver.parseSymbolElement(el);
|
|
275
|
+
parsedDefs[id].type = "symbol";
|
|
276
|
+
} else {
|
|
277
|
+
parsedDefs[id] = UseSymbolResolver.parseChildElement(el);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
193
280
|
|
|
194
|
-
for (const useEl of [...useElements]) {
|
|
281
|
+
for (const useEl of [...useElements]) {
|
|
282
|
+
// Clone array since we modify DOM
|
|
195
283
|
try {
|
|
196
|
-
const href =
|
|
284
|
+
const href =
|
|
285
|
+
useEl.getAttribute("href") || useEl.getAttribute("xlink:href");
|
|
197
286
|
if (!href) continue;
|
|
198
287
|
|
|
199
|
-
const refId = href.replace(/^#/,
|
|
288
|
+
const refId = href.replace(/^#/, "");
|
|
200
289
|
const refEl = defsMap.get(refId);
|
|
201
290
|
|
|
202
291
|
if (!refEl) {
|
|
@@ -204,12 +293,91 @@ function resolveAllUseElements(root, defsMap, opts) {
|
|
|
204
293
|
continue;
|
|
205
294
|
}
|
|
206
295
|
|
|
296
|
+
// CRITICAL: Check if referenced element OR the use element itself contains SMIL animations
|
|
297
|
+
// In SVG, animations can be children of <use> elements (animate the use instance)
|
|
298
|
+
// If so, we must clone instead of converting to path to preserve functionality
|
|
299
|
+
const refHasPreserve = containsPreserveElements(refEl);
|
|
300
|
+
const useHasPreserve = containsPreserveElements(useEl);
|
|
301
|
+
|
|
302
|
+
if (refHasPreserve || useHasPreserve) {
|
|
303
|
+
// Clone the referenced element with all children (preserves animations)
|
|
304
|
+
const clonedEl = refEl.cloneNode(true);
|
|
305
|
+
|
|
306
|
+
// Get use element positioning and transform
|
|
307
|
+
const useX = parseFloat(useEl.getAttribute("x") || "0");
|
|
308
|
+
const useY = parseFloat(useEl.getAttribute("y") || "0");
|
|
309
|
+
const useTransform = useEl.getAttribute("transform") || "";
|
|
310
|
+
|
|
311
|
+
// Build combined transform: use transform + translation from x/y
|
|
312
|
+
let combinedTransform = "";
|
|
313
|
+
if (useTransform) {
|
|
314
|
+
combinedTransform = useTransform;
|
|
315
|
+
}
|
|
316
|
+
if (useX !== 0 || useY !== 0) {
|
|
317
|
+
const translatePart = `translate(${useX}, ${useY})`;
|
|
318
|
+
combinedTransform = combinedTransform
|
|
319
|
+
? `${combinedTransform} ${translatePart}`
|
|
320
|
+
: translatePart;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Create a group to wrap the cloned content with the transform
|
|
324
|
+
const wrapperGroup = new SVGElement("g", {});
|
|
325
|
+
if (combinedTransform) {
|
|
326
|
+
wrapperGroup.setAttribute("transform", combinedTransform);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Copy presentation attributes from use element to wrapper
|
|
330
|
+
const presentationAttrs = extractPresentationAttrs(useEl);
|
|
331
|
+
for (const [key, val] of Object.entries(presentationAttrs)) {
|
|
332
|
+
if (val && !wrapperGroup.hasAttribute(key)) {
|
|
333
|
+
wrapperGroup.setAttribute(key, val);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Handle symbol vs regular element
|
|
338
|
+
const refTagName = refEl.tagName.toLowerCase();
|
|
339
|
+
if (refTagName === "symbol") {
|
|
340
|
+
// For symbols, add all children to the wrapper (skip the symbol wrapper)
|
|
341
|
+
// Use tagName check instead of instanceof since cloneNode may not preserve class type
|
|
342
|
+
for (const child of clonedEl.children || []) {
|
|
343
|
+
if (child && child.tagName) {
|
|
344
|
+
wrapperGroup.appendChild(child.cloneNode(true));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
// For regular elements, add the cloned element
|
|
349
|
+
// Remove the ID to avoid duplicates
|
|
350
|
+
clonedEl.removeAttribute("id");
|
|
351
|
+
wrapperGroup.appendChild(clonedEl);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// CRITICAL: Also preserve animation children from the use element itself
|
|
355
|
+
// SMIL animations can be direct children of <use> to animate the instance
|
|
356
|
+
for (const child of useEl.children || []) {
|
|
357
|
+
if (child && child.tagName) {
|
|
358
|
+
const childTagName = child.tagName.toLowerCase();
|
|
359
|
+
if (SMIL_ANIMATION_ELEMENTS.has(childTagName)) {
|
|
360
|
+
// Clone the animation and add to wrapper
|
|
361
|
+
wrapperGroup.appendChild(child.cloneNode(true));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Replace use with the wrapper group
|
|
367
|
+
if (useEl.parentNode) {
|
|
368
|
+
useEl.parentNode.replaceChild(wrapperGroup, useEl);
|
|
369
|
+
count++;
|
|
370
|
+
}
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Standard path conversion for elements without preserve elements
|
|
207
375
|
// Parse use element data
|
|
208
376
|
const useData = UseSymbolResolver.parseUseElement(useEl);
|
|
209
377
|
|
|
210
|
-
// Resolve the use
|
|
211
|
-
const resolved = UseSymbolResolver.resolveUse(useData,
|
|
212
|
-
samples: opts.curveSegments
|
|
378
|
+
// Resolve the use with properly formatted defs (plain objects with .type)
|
|
379
|
+
const resolved = UseSymbolResolver.resolveUse(useData, parsedDefs, {
|
|
380
|
+
samples: opts.curveSegments,
|
|
213
381
|
});
|
|
214
382
|
|
|
215
383
|
if (!resolved) {
|
|
@@ -218,13 +386,16 @@ function resolveAllUseElements(root, defsMap, opts) {
|
|
|
218
386
|
}
|
|
219
387
|
|
|
220
388
|
// Convert resolved to path data
|
|
221
|
-
const pathData = UseSymbolResolver.resolvedUseToPathData(
|
|
389
|
+
const pathData = UseSymbolResolver.resolvedUseToPathData(
|
|
390
|
+
resolved,
|
|
391
|
+
opts.curveSegments,
|
|
392
|
+
);
|
|
222
393
|
|
|
223
394
|
if (pathData) {
|
|
224
395
|
// Create new path element to replace <use>
|
|
225
|
-
const pathEl = new SVGElement(
|
|
396
|
+
const pathEl = new SVGElement("path", {
|
|
226
397
|
d: pathData,
|
|
227
|
-
...extractPresentationAttrs(useEl)
|
|
398
|
+
...extractPresentationAttrs(useEl),
|
|
228
399
|
});
|
|
229
400
|
|
|
230
401
|
// Copy style attributes from resolved
|
|
@@ -256,6 +427,12 @@ function resolveAllUseElements(root, defsMap, opts) {
|
|
|
256
427
|
|
|
257
428
|
/**
|
|
258
429
|
* Resolve all marker references by instantiating marker geometry.
|
|
430
|
+
* Expands markers into concrete path elements placed at path vertices.
|
|
431
|
+
*
|
|
432
|
+
* @param {SVGElement} root - Root SVG element
|
|
433
|
+
* @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
|
|
434
|
+
* @param {Object} opts - Processing options
|
|
435
|
+
* @returns {{count: number, errors: Array<string>}} Resolution results
|
|
259
436
|
* @private
|
|
260
437
|
*/
|
|
261
438
|
function resolveAllMarkers(root, defsMap, opts) {
|
|
@@ -263,30 +440,41 @@ function resolveAllMarkers(root, defsMap, opts) {
|
|
|
263
440
|
let count = 0;
|
|
264
441
|
|
|
265
442
|
// Find all elements with marker attributes
|
|
266
|
-
const markerAttrs = [
|
|
443
|
+
const markerAttrs = ["marker-start", "marker-mid", "marker-end", "marker"];
|
|
267
444
|
|
|
268
445
|
for (const attrName of markerAttrs) {
|
|
269
446
|
const elements = findElementsWithAttribute(root, attrName);
|
|
270
447
|
|
|
271
448
|
for (const el of elements) {
|
|
272
|
-
if (
|
|
449
|
+
if (
|
|
450
|
+
el.tagName !== "path" &&
|
|
451
|
+
el.tagName !== "line" &&
|
|
452
|
+
el.tagName !== "polyline" &&
|
|
453
|
+
el.tagName !== "polygon"
|
|
454
|
+
) {
|
|
273
455
|
continue;
|
|
274
456
|
}
|
|
275
457
|
|
|
276
458
|
try {
|
|
277
459
|
// Resolve markers for this element
|
|
278
|
-
const markerInstances = MarkerResolver.resolveMarkers(
|
|
460
|
+
const markerInstances = MarkerResolver.resolveMarkers(
|
|
461
|
+
el,
|
|
462
|
+
Object.fromEntries(defsMap),
|
|
463
|
+
);
|
|
279
464
|
|
|
280
465
|
if (!markerInstances || markerInstances.length === 0) continue;
|
|
281
466
|
|
|
282
467
|
// Convert markers to path data
|
|
283
|
-
const markerPathData = MarkerResolver.markersToPathData(
|
|
468
|
+
const markerPathData = MarkerResolver.markersToPathData(
|
|
469
|
+
markerInstances,
|
|
470
|
+
opts.precision,
|
|
471
|
+
);
|
|
284
472
|
|
|
285
473
|
if (markerPathData) {
|
|
286
474
|
// Create new path element for marker geometry
|
|
287
|
-
const markerPath = new SVGElement(
|
|
475
|
+
const markerPath = new SVGElement("path", {
|
|
288
476
|
d: markerPathData,
|
|
289
|
-
fill: el.getAttribute(
|
|
477
|
+
fill: el.getAttribute("stroke") || "currentColor", // Markers typically use stroke color
|
|
290
478
|
});
|
|
291
479
|
|
|
292
480
|
// Insert after the original element
|
|
@@ -320,40 +508,54 @@ function resolveAllMarkers(root, defsMap, opts) {
|
|
|
320
508
|
|
|
321
509
|
/**
|
|
322
510
|
* Resolve pattern fills by expanding to tiled geometry.
|
|
511
|
+
* Converts pattern fills into concrete geometry elements.
|
|
512
|
+
*
|
|
513
|
+
* @param {SVGElement} root - Root SVG element
|
|
514
|
+
* @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
|
|
515
|
+
* @param {Object} opts - Processing options
|
|
516
|
+
* @returns {{count: number, errors: Array<string>}} Resolution results
|
|
323
517
|
* @private
|
|
324
518
|
*/
|
|
325
519
|
function resolveAllPatterns(root, defsMap, opts) {
|
|
326
520
|
const errors = [];
|
|
327
521
|
let count = 0;
|
|
328
522
|
|
|
329
|
-
const elementsWithFill = findElementsWithAttribute(root,
|
|
523
|
+
const elementsWithFill = findElementsWithAttribute(root, "fill");
|
|
330
524
|
|
|
331
525
|
for (const el of elementsWithFill) {
|
|
332
|
-
const fill = el.getAttribute(
|
|
333
|
-
if (!fill || !fill.includes(
|
|
526
|
+
const fill = el.getAttribute("fill");
|
|
527
|
+
if (!fill || !fill.includes("url(")) continue;
|
|
334
528
|
|
|
335
529
|
const refId = parseUrlReference(fill);
|
|
336
530
|
if (!refId) continue;
|
|
337
531
|
|
|
338
532
|
const patternEl = defsMap.get(refId);
|
|
339
|
-
if (!patternEl || patternEl.tagName !==
|
|
533
|
+
if (!patternEl || patternEl.tagName !== "pattern") continue;
|
|
340
534
|
|
|
341
535
|
try {
|
|
342
536
|
// Check coordinate system units - skip if non-default
|
|
343
|
-
const patternUnits =
|
|
344
|
-
|
|
345
|
-
const
|
|
537
|
+
const patternUnits =
|
|
538
|
+
patternEl.getAttribute("patternUnits") || "objectBoundingBox";
|
|
539
|
+
const patternContentUnits =
|
|
540
|
+
patternEl.getAttribute("patternContentUnits") || "userSpaceOnUse";
|
|
541
|
+
const patternTransform = patternEl.getAttribute("patternTransform");
|
|
346
542
|
|
|
347
543
|
// PatternResolver handles these cases, but warn about complex patterns
|
|
348
544
|
// that might need special handling or cause issues
|
|
349
|
-
if (patternUnits !==
|
|
350
|
-
errors.push(
|
|
545
|
+
if (patternUnits !== "objectBoundingBox") {
|
|
546
|
+
errors.push(
|
|
547
|
+
`pattern ${refId}: non-default patternUnits="${patternUnits}" may cause rendering issues`,
|
|
548
|
+
);
|
|
351
549
|
}
|
|
352
|
-
if (patternContentUnits !==
|
|
353
|
-
errors.push(
|
|
550
|
+
if (patternContentUnits !== "userSpaceOnUse") {
|
|
551
|
+
errors.push(
|
|
552
|
+
`pattern ${refId}: non-default patternContentUnits="${patternContentUnits}" may cause rendering issues`,
|
|
553
|
+
);
|
|
354
554
|
}
|
|
355
555
|
if (patternTransform) {
|
|
356
|
-
errors.push(
|
|
556
|
+
errors.push(
|
|
557
|
+
`pattern ${refId}: patternTransform present - complex transformation may cause rendering issues`,
|
|
558
|
+
);
|
|
357
559
|
}
|
|
358
560
|
|
|
359
561
|
// Get element bounding box (approximate from path data or attributes)
|
|
@@ -364,24 +566,28 @@ function resolveAllPatterns(root, defsMap, opts) {
|
|
|
364
566
|
const patternData = PatternResolver.parsePatternElement(patternEl);
|
|
365
567
|
|
|
366
568
|
// Resolve pattern to path data
|
|
367
|
-
const patternPathData = PatternResolver.patternToPathData(
|
|
368
|
-
|
|
369
|
-
|
|
569
|
+
const patternPathData = PatternResolver.patternToPathData(
|
|
570
|
+
patternData,
|
|
571
|
+
bbox,
|
|
572
|
+
{
|
|
573
|
+
samples: opts.curveSegments,
|
|
574
|
+
},
|
|
575
|
+
);
|
|
370
576
|
|
|
371
577
|
if (patternPathData) {
|
|
372
578
|
// Create group with clipped pattern geometry
|
|
373
|
-
const patternGroup = new SVGElement(
|
|
579
|
+
const patternGroup = new SVGElement("g", {});
|
|
374
580
|
|
|
375
|
-
const patternPath = new SVGElement(
|
|
581
|
+
const patternPath = new SVGElement("path", {
|
|
376
582
|
d: patternPathData,
|
|
377
|
-
fill:
|
|
583
|
+
fill: "#000", // Pattern content typically has its own fill
|
|
378
584
|
});
|
|
379
585
|
|
|
380
586
|
patternGroup.appendChild(patternPath);
|
|
381
587
|
|
|
382
588
|
// Replace fill with pattern geometry (clip to original shape)
|
|
383
|
-
el.setAttribute(
|
|
384
|
-
el.setAttribute(
|
|
589
|
+
el.setAttribute("fill", "none");
|
|
590
|
+
el.setAttribute("stroke", el.getAttribute("stroke") || "none");
|
|
385
591
|
|
|
386
592
|
if (el.parentNode) {
|
|
387
593
|
el.parentNode.insertBefore(patternGroup, el);
|
|
@@ -402,37 +608,48 @@ function resolveAllPatterns(root, defsMap, opts) {
|
|
|
402
608
|
|
|
403
609
|
/**
|
|
404
610
|
* Resolve mask references by converting to clip geometry.
|
|
611
|
+
* Converts mask elements into clipping paths with boolean intersection.
|
|
612
|
+
*
|
|
613
|
+
* @param {SVGElement} root - Root SVG element
|
|
614
|
+
* @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
|
|
615
|
+
* @param {Object} opts - Processing options
|
|
616
|
+
* @returns {{count: number, errors: Array<string>}} Resolution results
|
|
405
617
|
* @private
|
|
406
618
|
*/
|
|
407
619
|
function resolveAllMasks(root, defsMap, opts) {
|
|
408
620
|
const errors = [];
|
|
409
621
|
let count = 0;
|
|
410
622
|
|
|
411
|
-
const elementsWithMask = findElementsWithAttribute(root,
|
|
623
|
+
const elementsWithMask = findElementsWithAttribute(root, "mask");
|
|
412
624
|
|
|
413
625
|
for (const el of elementsWithMask) {
|
|
414
|
-
const maskRef = el.getAttribute(
|
|
415
|
-
if (!maskRef || !maskRef.includes(
|
|
626
|
+
const maskRef = el.getAttribute("mask");
|
|
627
|
+
if (!maskRef || !maskRef.includes("url(")) continue;
|
|
416
628
|
|
|
417
629
|
const refId = parseUrlReference(maskRef);
|
|
418
630
|
if (!refId) continue;
|
|
419
631
|
|
|
420
632
|
const maskEl = defsMap.get(refId);
|
|
421
|
-
if (!maskEl || maskEl.tagName !==
|
|
633
|
+
if (!maskEl || maskEl.tagName !== "mask") continue;
|
|
422
634
|
|
|
423
635
|
try {
|
|
424
636
|
// Check coordinate system units
|
|
425
|
-
const maskUnits = maskEl.getAttribute(
|
|
426
|
-
const maskContentUnits =
|
|
637
|
+
const maskUnits = maskEl.getAttribute("maskUnits") || "objectBoundingBox";
|
|
638
|
+
const maskContentUnits =
|
|
639
|
+
maskEl.getAttribute("maskContentUnits") || "userSpaceOnUse";
|
|
427
640
|
|
|
428
641
|
// Default for mask is different from clipPath:
|
|
429
642
|
// maskUnits defaults to objectBoundingBox
|
|
430
643
|
// maskContentUnits defaults to userSpaceOnUse
|
|
431
|
-
if (maskUnits !==
|
|
432
|
-
errors.push(
|
|
644
|
+
if (maskUnits !== "objectBoundingBox") {
|
|
645
|
+
errors.push(
|
|
646
|
+
`mask ${refId}: non-default maskUnits="${maskUnits}" may cause rendering issues`,
|
|
647
|
+
);
|
|
433
648
|
}
|
|
434
|
-
if (maskContentUnits !==
|
|
435
|
-
errors.push(
|
|
649
|
+
if (maskContentUnits !== "userSpaceOnUse") {
|
|
650
|
+
errors.push(
|
|
651
|
+
`mask ${refId}: non-default maskContentUnits="${maskContentUnits}" may cause rendering issues`,
|
|
652
|
+
);
|
|
436
653
|
}
|
|
437
654
|
|
|
438
655
|
// Get element bounding box
|
|
@@ -445,7 +662,7 @@ function resolveAllMasks(root, defsMap, opts) {
|
|
|
445
662
|
// Convert mask to clip path data
|
|
446
663
|
const clipPathData = MaskResolver.maskToPathData(maskData, bbox, {
|
|
447
664
|
samples: opts.curveSegments,
|
|
448
|
-
opacityThreshold: 0.5
|
|
665
|
+
opacityThreshold: 0.5,
|
|
449
666
|
});
|
|
450
667
|
|
|
451
668
|
if (clipPathData) {
|
|
@@ -454,16 +671,25 @@ function resolveAllMasks(root, defsMap, opts) {
|
|
|
454
671
|
|
|
455
672
|
if (origPathData) {
|
|
456
673
|
// Perform boolean intersection
|
|
457
|
-
const origPolygon = ClipPathResolver.pathToPolygon(
|
|
458
|
-
|
|
674
|
+
const origPolygon = ClipPathResolver.pathToPolygon(
|
|
675
|
+
origPathData,
|
|
676
|
+
opts.curveSegments,
|
|
677
|
+
);
|
|
678
|
+
const clipPolygon = ClipPathResolver.pathToPolygon(
|
|
679
|
+
clipPathData,
|
|
680
|
+
opts.curveSegments,
|
|
681
|
+
);
|
|
459
682
|
|
|
460
683
|
// Apply clip (intersection)
|
|
461
684
|
const clippedPolygon = intersectPolygons(origPolygon, clipPolygon);
|
|
462
685
|
|
|
463
686
|
if (clippedPolygon && clippedPolygon.length > 2) {
|
|
464
|
-
const clippedPath = ClipPathResolver.polygonToPathData(
|
|
465
|
-
|
|
466
|
-
|
|
687
|
+
const clippedPath = ClipPathResolver.polygonToPathData(
|
|
688
|
+
clippedPolygon,
|
|
689
|
+
opts.precision,
|
|
690
|
+
);
|
|
691
|
+
el.setAttribute("d", clippedPath);
|
|
692
|
+
el.removeAttribute("mask");
|
|
467
693
|
count++;
|
|
468
694
|
}
|
|
469
695
|
}
|
|
@@ -483,6 +709,12 @@ function resolveAllMasks(root, defsMap, opts) {
|
|
|
483
709
|
/**
|
|
484
710
|
* Apply clipPath references by performing boolean intersection.
|
|
485
711
|
* Also computes the difference (outside parts) and verifies area conservation (E2E).
|
|
712
|
+
*
|
|
713
|
+
* @param {SVGElement} root - Root SVG element
|
|
714
|
+
* @param {Map<string, SVGElement>} defsMap - Map of definition IDs to elements
|
|
715
|
+
* @param {Object} opts - Processing options
|
|
716
|
+
* @param {Object} stats - Statistics object to update with results
|
|
717
|
+
* @returns {{count: number, errors: Array<string>}} Clipping results
|
|
486
718
|
* @private
|
|
487
719
|
*/
|
|
488
720
|
function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
@@ -497,28 +729,36 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
|
497
729
|
stats.clipOutsideFragments = []; // Store outside fragments for potential reconstruction
|
|
498
730
|
}
|
|
499
731
|
|
|
500
|
-
const elementsWithClip = findElementsWithAttribute(root,
|
|
732
|
+
const elementsWithClip = findElementsWithAttribute(root, "clip-path");
|
|
501
733
|
|
|
502
734
|
for (const el of elementsWithClip) {
|
|
503
|
-
const clipRef = el.getAttribute(
|
|
504
|
-
if (!clipRef || !clipRef.includes(
|
|
735
|
+
const clipRef = el.getAttribute("clip-path");
|
|
736
|
+
if (!clipRef || !clipRef.includes("url(")) continue;
|
|
505
737
|
|
|
506
738
|
const refId = parseUrlReference(clipRef);
|
|
507
739
|
if (!refId) continue;
|
|
508
740
|
|
|
509
741
|
const clipPathEl = defsMap.get(refId);
|
|
510
|
-
|
|
742
|
+
// SVG tagNames may be case-preserved, check both lowercase and camelCase
|
|
743
|
+
if (
|
|
744
|
+
!clipPathEl ||
|
|
745
|
+
(clipPathEl.tagName !== "clippath" && clipPathEl.tagName !== "clipPath")
|
|
746
|
+
)
|
|
747
|
+
continue;
|
|
511
748
|
|
|
512
749
|
try {
|
|
513
750
|
// Check coordinate system units
|
|
514
|
-
const clipPathUnits =
|
|
751
|
+
const clipPathUnits =
|
|
752
|
+
clipPathEl.getAttribute("clipPathUnits") || "userSpaceOnUse";
|
|
515
753
|
|
|
516
754
|
// userSpaceOnUse is the default and normal case for clipPath
|
|
517
755
|
// objectBoundingBox means coordinates are 0-1 relative to bounding box
|
|
518
|
-
if (clipPathUnits ===
|
|
756
|
+
if (clipPathUnits === "objectBoundingBox") {
|
|
519
757
|
// This requires transforming clip coordinates based on target element's bbox
|
|
520
758
|
// which is complex - warn about it
|
|
521
|
-
errors.push(
|
|
759
|
+
errors.push(
|
|
760
|
+
`clipPath ${refId}: objectBoundingBox units require bbox-relative coordinate transformation`,
|
|
761
|
+
);
|
|
522
762
|
// Note: We continue processing, but results may be incorrect
|
|
523
763
|
}
|
|
524
764
|
|
|
@@ -527,12 +767,13 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
|
527
767
|
if (!origPathData) continue;
|
|
528
768
|
|
|
529
769
|
// Get clip path data from clipPath element's children
|
|
530
|
-
let clipPathData =
|
|
770
|
+
let clipPathData = "";
|
|
531
771
|
for (const child of clipPathEl.children) {
|
|
532
|
-
|
|
772
|
+
// Use tagName check instead of instanceof
|
|
773
|
+
if (child && child.tagName) {
|
|
533
774
|
const childPath = getElementPathData(child, opts.precision);
|
|
534
775
|
if (childPath) {
|
|
535
|
-
clipPathData += (clipPathData ?
|
|
776
|
+
clipPathData += (clipPathData ? " " : "") + childPath;
|
|
536
777
|
}
|
|
537
778
|
}
|
|
538
779
|
}
|
|
@@ -542,34 +783,44 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
|
542
783
|
// Convert to polygons using higher segment count for clip accuracy
|
|
543
784
|
// clipSegments (default 64) provides better curve approximation for E2E verification
|
|
544
785
|
const clipSegs = opts.clipSegments || 64;
|
|
545
|
-
const origPolygon = ClipPathResolver.pathToPolygon(
|
|
546
|
-
|
|
786
|
+
const origPolygon = ClipPathResolver.pathToPolygon(
|
|
787
|
+
origPathData,
|
|
788
|
+
clipSegs,
|
|
789
|
+
);
|
|
790
|
+
const clipPolygon = ClipPathResolver.pathToPolygon(
|
|
791
|
+
clipPathData,
|
|
792
|
+
clipSegs,
|
|
793
|
+
);
|
|
547
794
|
|
|
548
795
|
// Perform intersection (clipped result - what's kept)
|
|
549
796
|
const clippedPolygon = intersectPolygons(origPolygon, clipPolygon);
|
|
550
797
|
|
|
551
798
|
// Convert polygon arrays to proper format for verification
|
|
552
|
-
const origForVerify = origPolygon.map(p => ({
|
|
799
|
+
const origForVerify = origPolygon.map((p) => ({
|
|
553
800
|
x: p.x instanceof Decimal ? p.x : D(p.x),
|
|
554
|
-
y: p.y instanceof Decimal ? p.y : D(p.y)
|
|
801
|
+
y: p.y instanceof Decimal ? p.y : D(p.y),
|
|
555
802
|
}));
|
|
556
|
-
const clipForVerify = clipPolygon.map(p => ({
|
|
803
|
+
const clipForVerify = clipPolygon.map((p) => ({
|
|
557
804
|
x: p.x instanceof Decimal ? p.x : D(p.x),
|
|
558
|
-
y: p.y instanceof Decimal ? p.y : D(p.y)
|
|
805
|
+
y: p.y instanceof Decimal ? p.y : D(p.y),
|
|
559
806
|
}));
|
|
560
807
|
|
|
561
808
|
// VERIFICATION: Verify polygon intersection is valid (ALWAYS runs)
|
|
562
809
|
if (clippedPolygon && clippedPolygon.length > 2) {
|
|
563
|
-
const clippedForVerify = clippedPolygon.map(p => ({
|
|
810
|
+
const clippedForVerify = clippedPolygon.map((p) => ({
|
|
564
811
|
x: p.x instanceof Decimal ? p.x : D(p.x),
|
|
565
|
-
y: p.y instanceof Decimal ? p.y : D(p.y)
|
|
812
|
+
y: p.y instanceof Decimal ? p.y : D(p.y),
|
|
566
813
|
}));
|
|
567
814
|
|
|
568
|
-
const polyResult = Verification.verifyPolygonIntersection(
|
|
815
|
+
const polyResult = Verification.verifyPolygonIntersection(
|
|
816
|
+
origForVerify,
|
|
817
|
+
clipForVerify,
|
|
818
|
+
clippedForVerify,
|
|
819
|
+
);
|
|
569
820
|
stats.verifications.polygons.push({
|
|
570
821
|
element: el.tagName,
|
|
571
822
|
clipPathId: refId,
|
|
572
|
-
...polyResult
|
|
823
|
+
...polyResult,
|
|
573
824
|
});
|
|
574
825
|
if (polyResult.valid) {
|
|
575
826
|
stats.verifications.passed++;
|
|
@@ -580,25 +831,33 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
|
580
831
|
|
|
581
832
|
// E2E VERIFICATION: Compute difference (outside parts) and verify area conservation
|
|
582
833
|
// This ensures: area(original) = area(clipped) + area(outside)
|
|
583
|
-
const outsideFragments = Verification.computePolygonDifference(
|
|
834
|
+
const outsideFragments = Verification.computePolygonDifference(
|
|
835
|
+
origForVerify,
|
|
836
|
+
clipForVerify,
|
|
837
|
+
);
|
|
584
838
|
|
|
585
839
|
// Store outside fragments (marked invisible) for potential reconstruction
|
|
586
840
|
stats.clipOutsideFragments.push({
|
|
587
|
-
elementId: el.getAttribute(
|
|
841
|
+
elementId: el.getAttribute("id") || `clip-${count}`,
|
|
588
842
|
clipPathId: refId,
|
|
589
843
|
fragments: outsideFragments,
|
|
590
|
-
visible: false // These are the "thrown away" parts, stored invisibly
|
|
844
|
+
visible: false, // These are the "thrown away" parts, stored invisibly
|
|
591
845
|
});
|
|
592
846
|
|
|
593
847
|
// E2E Verification: area(original) = area(clipped) + area(outside)
|
|
594
848
|
// Pass configurable tolerance (default 1e-10 with 64 clipSegments)
|
|
595
|
-
const e2eTolerance = opts.e2eTolerance ||
|
|
596
|
-
const e2eResult = Verification.verifyClipPathE2E(
|
|
849
|
+
const e2eTolerance = opts.e2eTolerance || "1e-10";
|
|
850
|
+
const e2eResult = Verification.verifyClipPathE2E(
|
|
851
|
+
origForVerify,
|
|
852
|
+
clippedForVerify,
|
|
853
|
+
outsideFragments,
|
|
854
|
+
e2eTolerance,
|
|
855
|
+
);
|
|
597
856
|
stats.verifications.e2e.push({
|
|
598
857
|
element: el.tagName,
|
|
599
858
|
clipPathId: refId,
|
|
600
|
-
type:
|
|
601
|
-
...e2eResult
|
|
859
|
+
type: "clip-area-conservation",
|
|
860
|
+
...e2eResult,
|
|
602
861
|
});
|
|
603
862
|
if (e2eResult.valid) {
|
|
604
863
|
stats.verifications.passed++;
|
|
@@ -609,16 +868,19 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
|
609
868
|
}
|
|
610
869
|
|
|
611
870
|
if (clippedPolygon && clippedPolygon.length > 2) {
|
|
612
|
-
const clippedPath = ClipPathResolver.polygonToPathData(
|
|
871
|
+
const clippedPath = ClipPathResolver.polygonToPathData(
|
|
872
|
+
clippedPolygon,
|
|
873
|
+
opts.precision,
|
|
874
|
+
);
|
|
613
875
|
|
|
614
876
|
// Update element
|
|
615
|
-
if (el.tagName ===
|
|
616
|
-
el.setAttribute(
|
|
877
|
+
if (el.tagName === "path") {
|
|
878
|
+
el.setAttribute("d", clippedPath);
|
|
617
879
|
} else {
|
|
618
880
|
// Convert shape to path
|
|
619
|
-
const newPath = new SVGElement(
|
|
881
|
+
const newPath = new SVGElement("path", {
|
|
620
882
|
d: clippedPath,
|
|
621
|
-
...extractPresentationAttrs(el)
|
|
883
|
+
...extractPresentationAttrs(el),
|
|
622
884
|
});
|
|
623
885
|
|
|
624
886
|
if (el.parentNode) {
|
|
@@ -626,7 +888,7 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
|
626
888
|
}
|
|
627
889
|
}
|
|
628
890
|
|
|
629
|
-
el.removeAttribute(
|
|
891
|
+
el.removeAttribute("clip-path");
|
|
630
892
|
count++;
|
|
631
893
|
}
|
|
632
894
|
} catch (e) {
|
|
@@ -643,16 +905,22 @@ function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
|
643
905
|
|
|
644
906
|
/**
|
|
645
907
|
* Flatten all transform attributes by baking into coordinates.
|
|
908
|
+
* Applies transformation matrices directly to path coordinates and removes transform attributes.
|
|
909
|
+
*
|
|
910
|
+
* @param {SVGElement} root - Root SVG element
|
|
911
|
+
* @param {Object} opts - Processing options
|
|
912
|
+
* @param {Object} stats - Statistics object to update with results
|
|
913
|
+
* @returns {{count: number, errors: Array<string>}} Flattening results
|
|
646
914
|
* @private
|
|
647
915
|
*/
|
|
648
916
|
function flattenAllTransforms(root, opts, stats) {
|
|
649
917
|
const errors = [];
|
|
650
918
|
let count = 0;
|
|
651
919
|
|
|
652
|
-
const elementsWithTransform = findElementsWithAttribute(root,
|
|
920
|
+
const elementsWithTransform = findElementsWithAttribute(root, "transform");
|
|
653
921
|
|
|
654
922
|
for (const el of elementsWithTransform) {
|
|
655
|
-
const transform = el.getAttribute(
|
|
923
|
+
const transform = el.getAttribute("transform");
|
|
656
924
|
if (!transform) continue;
|
|
657
925
|
|
|
658
926
|
try {
|
|
@@ -664,7 +932,7 @@ function flattenAllTransforms(root, opts, stats) {
|
|
|
664
932
|
stats.verifications.matrices.push({
|
|
665
933
|
element: el.tagName,
|
|
666
934
|
transform,
|
|
667
|
-
...matrixResult
|
|
935
|
+
...matrixResult,
|
|
668
936
|
});
|
|
669
937
|
if (matrixResult.valid) {
|
|
670
938
|
stats.verifications.passed++;
|
|
@@ -677,9 +945,9 @@ function flattenAllTransforms(root, opts, stats) {
|
|
|
677
945
|
const pathData = getElementPathData(el, opts.precision);
|
|
678
946
|
if (!pathData) {
|
|
679
947
|
// For groups, propagate transform to children
|
|
680
|
-
if (el.tagName ===
|
|
948
|
+
if (el.tagName === "g") {
|
|
681
949
|
propagateTransformToChildren(el, ctm, opts, stats);
|
|
682
|
-
el.removeAttribute(
|
|
950
|
+
el.removeAttribute("transform");
|
|
683
951
|
count++;
|
|
684
952
|
}
|
|
685
953
|
continue;
|
|
@@ -690,14 +958,16 @@ function flattenAllTransforms(root, opts, stats) {
|
|
|
690
958
|
// Extract a few key points from the path for verification
|
|
691
959
|
const polygon = ClipPathResolver.pathToPolygon(pathData, 4);
|
|
692
960
|
if (polygon && polygon.length > 0) {
|
|
693
|
-
testPoints = polygon.slice(0, 4).map(p => ({
|
|
961
|
+
testPoints = polygon.slice(0, 4).map((p) => ({
|
|
694
962
|
x: p.x instanceof Decimal ? p.x : D(p.x),
|
|
695
|
-
y: p.y instanceof Decimal ? p.y : D(p.y)
|
|
963
|
+
y: p.y instanceof Decimal ? p.y : D(p.y),
|
|
696
964
|
}));
|
|
697
965
|
}
|
|
698
966
|
|
|
699
967
|
// Transform the path data
|
|
700
|
-
const transformedPath = SVGFlatten.transformPathData(pathData, ctm, {
|
|
968
|
+
const transformedPath = SVGFlatten.transformPathData(pathData, ctm, {
|
|
969
|
+
precision: opts.precision,
|
|
970
|
+
});
|
|
701
971
|
|
|
702
972
|
// VERIFICATION: Verify transform round-trip accuracy for each test point (ALWAYS runs)
|
|
703
973
|
for (let i = 0; i < testPoints.length; i++) {
|
|
@@ -706,7 +976,7 @@ function flattenAllTransforms(root, opts, stats) {
|
|
|
706
976
|
stats.verifications.transforms.push({
|
|
707
977
|
element: el.tagName,
|
|
708
978
|
pointIndex: i,
|
|
709
|
-
...rtResult
|
|
979
|
+
...rtResult,
|
|
710
980
|
});
|
|
711
981
|
if (rtResult.valid) {
|
|
712
982
|
stats.verifications.passed++;
|
|
@@ -717,13 +987,13 @@ function flattenAllTransforms(root, opts, stats) {
|
|
|
717
987
|
}
|
|
718
988
|
|
|
719
989
|
// Update or replace element
|
|
720
|
-
if (el.tagName ===
|
|
721
|
-
el.setAttribute(
|
|
990
|
+
if (el.tagName === "path") {
|
|
991
|
+
el.setAttribute("d", transformedPath);
|
|
722
992
|
} else {
|
|
723
993
|
// Convert shape to path with transformed coordinates
|
|
724
|
-
const newPath = new SVGElement(
|
|
994
|
+
const newPath = new SVGElement("path", {
|
|
725
995
|
d: transformedPath,
|
|
726
|
-
...extractPresentationAttrs(el)
|
|
996
|
+
...extractPresentationAttrs(el),
|
|
727
997
|
});
|
|
728
998
|
|
|
729
999
|
// Remove shape-specific attributes
|
|
@@ -736,7 +1006,7 @@ function flattenAllTransforms(root, opts, stats) {
|
|
|
736
1006
|
}
|
|
737
1007
|
}
|
|
738
1008
|
|
|
739
|
-
el.removeAttribute(
|
|
1009
|
+
el.removeAttribute("transform");
|
|
740
1010
|
count++;
|
|
741
1011
|
} catch (e) {
|
|
742
1012
|
errors.push(`transform: ${e.message}`);
|
|
@@ -748,28 +1018,36 @@ function flattenAllTransforms(root, opts, stats) {
|
|
|
748
1018
|
|
|
749
1019
|
/**
|
|
750
1020
|
* Propagate transform to all children of a group.
|
|
1021
|
+
* Applies parent group transform to all child elements recursively.
|
|
1022
|
+
*
|
|
1023
|
+
* @param {SVGElement} group - Group element with transform
|
|
1024
|
+
* @param {Matrix} ctm - Current transformation matrix
|
|
1025
|
+
* @param {Object} opts - Processing options
|
|
1026
|
+
* @param {Object} stats - Statistics object to update with results
|
|
1027
|
+
* @returns {void}
|
|
751
1028
|
* @private
|
|
752
1029
|
*/
|
|
753
1030
|
function propagateTransformToChildren(group, ctm, opts, stats) {
|
|
754
1031
|
for (const child of [...group.children]) {
|
|
755
|
-
|
|
1032
|
+
// Use tagName check instead of instanceof
|
|
1033
|
+
if (!(child && child.tagName)) continue;
|
|
756
1034
|
|
|
757
|
-
if (child.tagName ===
|
|
1035
|
+
if (child.tagName === "g") {
|
|
758
1036
|
// Nested group - compose transforms
|
|
759
|
-
const childTransform = child.getAttribute(
|
|
1037
|
+
const childTransform = child.getAttribute("transform");
|
|
760
1038
|
if (childTransform) {
|
|
761
1039
|
const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
|
|
762
1040
|
const combined = ctm.mul(childCtm);
|
|
763
|
-
child.setAttribute(
|
|
1041
|
+
child.setAttribute("transform", matrixToTransform(combined));
|
|
764
1042
|
} else {
|
|
765
|
-
child.setAttribute(
|
|
1043
|
+
child.setAttribute("transform", matrixToTransform(ctm));
|
|
766
1044
|
}
|
|
767
1045
|
} else {
|
|
768
1046
|
// Shape or path - apply transform to coordinates
|
|
769
1047
|
const pathData = getElementPathData(child, opts.precision);
|
|
770
1048
|
if (pathData) {
|
|
771
1049
|
// Compose with any existing transform
|
|
772
|
-
const childTransform = child.getAttribute(
|
|
1050
|
+
const childTransform = child.getAttribute("transform");
|
|
773
1051
|
let combinedCtm = ctm;
|
|
774
1052
|
if (childTransform) {
|
|
775
1053
|
const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
|
|
@@ -780,8 +1058,8 @@ function propagateTransformToChildren(group, ctm, opts, stats) {
|
|
|
780
1058
|
const matrixResult = Verification.verifyMatrixInversion(combinedCtm);
|
|
781
1059
|
stats.verifications.matrices.push({
|
|
782
1060
|
element: child.tagName,
|
|
783
|
-
context:
|
|
784
|
-
...matrixResult
|
|
1061
|
+
context: "group-propagation",
|
|
1062
|
+
...matrixResult,
|
|
785
1063
|
});
|
|
786
1064
|
if (matrixResult.valid) {
|
|
787
1065
|
stats.verifications.passed++;
|
|
@@ -790,21 +1068,25 @@ function propagateTransformToChildren(group, ctm, opts, stats) {
|
|
|
790
1068
|
stats.verifications.allPassed = false;
|
|
791
1069
|
}
|
|
792
1070
|
|
|
793
|
-
const transformedPath = SVGFlatten.transformPathData(
|
|
1071
|
+
const transformedPath = SVGFlatten.transformPathData(
|
|
1072
|
+
pathData,
|
|
1073
|
+
combinedCtm,
|
|
1074
|
+
{ precision: opts.precision },
|
|
1075
|
+
);
|
|
794
1076
|
|
|
795
|
-
if (child.tagName ===
|
|
796
|
-
child.setAttribute(
|
|
1077
|
+
if (child.tagName === "path") {
|
|
1078
|
+
child.setAttribute("d", transformedPath);
|
|
797
1079
|
} else {
|
|
798
1080
|
// Replace with path element
|
|
799
|
-
const newPath = new SVGElement(
|
|
1081
|
+
const newPath = new SVGElement("path", {
|
|
800
1082
|
d: transformedPath,
|
|
801
|
-
...extractPresentationAttrs(child)
|
|
1083
|
+
...extractPresentationAttrs(child),
|
|
802
1084
|
});
|
|
803
1085
|
|
|
804
1086
|
group.replaceChild(newPath, child);
|
|
805
1087
|
}
|
|
806
1088
|
|
|
807
|
-
child.removeAttribute(
|
|
1089
|
+
child.removeAttribute("transform");
|
|
808
1090
|
}
|
|
809
1091
|
}
|
|
810
1092
|
}
|
|
@@ -816,6 +1098,12 @@ function propagateTransformToChildren(group, ctm, opts, stats) {
|
|
|
816
1098
|
|
|
817
1099
|
/**
|
|
818
1100
|
* Bake gradientTransform into gradient coordinates.
|
|
1101
|
+
* Applies gradient transforms directly to gradient coordinate attributes.
|
|
1102
|
+
*
|
|
1103
|
+
* @param {SVGElement} root - Root SVG element
|
|
1104
|
+
* @param {Object} opts - Processing options
|
|
1105
|
+
* @param {Object} stats - Statistics object to update with results
|
|
1106
|
+
* @returns {{count: number, errors: Array<string>}} Processing results
|
|
819
1107
|
* @private
|
|
820
1108
|
*/
|
|
821
1109
|
function bakeAllGradientTransforms(root, opts, stats) {
|
|
@@ -823,19 +1111,19 @@ function bakeAllGradientTransforms(root, opts, stats) {
|
|
|
823
1111
|
let count = 0;
|
|
824
1112
|
|
|
825
1113
|
// Process linearGradient elements
|
|
826
|
-
const linearGradients = root.getElementsByTagName(
|
|
1114
|
+
const linearGradients = root.getElementsByTagName("linearGradient");
|
|
827
1115
|
for (const grad of linearGradients) {
|
|
828
|
-
const gradientTransform = grad.getAttribute(
|
|
1116
|
+
const gradientTransform = grad.getAttribute("gradientTransform");
|
|
829
1117
|
if (!gradientTransform) continue;
|
|
830
1118
|
|
|
831
1119
|
try {
|
|
832
1120
|
const ctm = SVGFlatten.parseTransformAttribute(gradientTransform);
|
|
833
1121
|
|
|
834
1122
|
// Transform x1,y1,x2,y2
|
|
835
|
-
const x1 = parseFloat(grad.getAttribute(
|
|
836
|
-
const y1 = parseFloat(grad.getAttribute(
|
|
837
|
-
const x2 = parseFloat(grad.getAttribute(
|
|
838
|
-
const y2 = parseFloat(grad.getAttribute(
|
|
1123
|
+
const x1 = parseFloat(grad.getAttribute("x1") || "0");
|
|
1124
|
+
const y1 = parseFloat(grad.getAttribute("y1") || "0");
|
|
1125
|
+
const x2 = parseFloat(grad.getAttribute("x2") || "1");
|
|
1126
|
+
const y2 = parseFloat(grad.getAttribute("y2") || "0");
|
|
839
1127
|
|
|
840
1128
|
const [tx1, ty1] = Transforms2D.applyTransform(ctm, x1, y1);
|
|
841
1129
|
const [tx2, ty2] = Transforms2D.applyTransform(ctm, x2, y2);
|
|
@@ -843,13 +1131,18 @@ function bakeAllGradientTransforms(root, opts, stats) {
|
|
|
843
1131
|
// VERIFICATION: Verify linear gradient transform (ALWAYS runs)
|
|
844
1132
|
const gradResult = Verification.verifyLinearGradientTransform(
|
|
845
1133
|
{ x1, y1, x2, y2 },
|
|
846
|
-
{
|
|
847
|
-
|
|
1134
|
+
{
|
|
1135
|
+
x1: tx1.toNumber(),
|
|
1136
|
+
y1: ty1.toNumber(),
|
|
1137
|
+
x2: tx2.toNumber(),
|
|
1138
|
+
y2: ty2.toNumber(),
|
|
1139
|
+
},
|
|
1140
|
+
ctm,
|
|
848
1141
|
);
|
|
849
1142
|
stats.verifications.gradients.push({
|
|
850
|
-
gradientId: grad.getAttribute(
|
|
851
|
-
type:
|
|
852
|
-
...gradResult
|
|
1143
|
+
gradientId: grad.getAttribute("id") || "unknown",
|
|
1144
|
+
type: "linear",
|
|
1145
|
+
...gradResult,
|
|
853
1146
|
});
|
|
854
1147
|
if (gradResult.valid) {
|
|
855
1148
|
stats.verifications.passed++;
|
|
@@ -858,11 +1151,11 @@ function bakeAllGradientTransforms(root, opts, stats) {
|
|
|
858
1151
|
stats.verifications.allPassed = false;
|
|
859
1152
|
}
|
|
860
1153
|
|
|
861
|
-
grad.setAttribute(
|
|
862
|
-
grad.setAttribute(
|
|
863
|
-
grad.setAttribute(
|
|
864
|
-
grad.setAttribute(
|
|
865
|
-
grad.removeAttribute(
|
|
1154
|
+
grad.setAttribute("x1", tx1.toFixed(opts.precision));
|
|
1155
|
+
grad.setAttribute("y1", ty1.toFixed(opts.precision));
|
|
1156
|
+
grad.setAttribute("x2", tx2.toFixed(opts.precision));
|
|
1157
|
+
grad.setAttribute("y2", ty2.toFixed(opts.precision));
|
|
1158
|
+
grad.removeAttribute("gradientTransform");
|
|
866
1159
|
count++;
|
|
867
1160
|
} catch (e) {
|
|
868
1161
|
errors.push(`linearGradient: ${e.message}`);
|
|
@@ -870,34 +1163,36 @@ function bakeAllGradientTransforms(root, opts, stats) {
|
|
|
870
1163
|
}
|
|
871
1164
|
|
|
872
1165
|
// Process radialGradient elements
|
|
873
|
-
const radialGradients = root.getElementsByTagName(
|
|
1166
|
+
const radialGradients = root.getElementsByTagName("radialGradient");
|
|
874
1167
|
for (const grad of radialGradients) {
|
|
875
|
-
const gradientTransform = grad.getAttribute(
|
|
1168
|
+
const gradientTransform = grad.getAttribute("gradientTransform");
|
|
876
1169
|
if (!gradientTransform) continue;
|
|
877
1170
|
|
|
878
1171
|
try {
|
|
879
1172
|
const ctm = SVGFlatten.parseTransformAttribute(gradientTransform);
|
|
880
1173
|
|
|
881
1174
|
// Transform cx,cy,fx,fy and scale r
|
|
882
|
-
const cx = parseFloat(grad.getAttribute(
|
|
883
|
-
const cy = parseFloat(grad.getAttribute(
|
|
884
|
-
const fx = parseFloat(grad.getAttribute(
|
|
885
|
-
const fy = parseFloat(grad.getAttribute(
|
|
886
|
-
const r = parseFloat(grad.getAttribute(
|
|
1175
|
+
const cx = parseFloat(grad.getAttribute("cx") || "0.5");
|
|
1176
|
+
const cy = parseFloat(grad.getAttribute("cy") || "0.5");
|
|
1177
|
+
const fx = parseFloat(grad.getAttribute("fx") || cx.toString());
|
|
1178
|
+
const fy = parseFloat(grad.getAttribute("fy") || cy.toString());
|
|
1179
|
+
const r = parseFloat(grad.getAttribute("r") || "0.5");
|
|
887
1180
|
|
|
888
1181
|
const [tcx, tcy] = Transforms2D.applyTransform(ctm, cx, cy);
|
|
889
1182
|
const [tfx, tfy] = Transforms2D.applyTransform(ctm, fx, fy);
|
|
890
1183
|
|
|
891
1184
|
// Scale radius by average scale factor
|
|
892
|
-
const scale = Math.sqrt(
|
|
1185
|
+
const scale = Math.sqrt(
|
|
1186
|
+
Math.abs(ctm.data[0][0].toNumber() * ctm.data[1][1].toNumber()),
|
|
1187
|
+
);
|
|
893
1188
|
const tr = r * scale;
|
|
894
1189
|
|
|
895
|
-
grad.setAttribute(
|
|
896
|
-
grad.setAttribute(
|
|
897
|
-
grad.setAttribute(
|
|
898
|
-
grad.setAttribute(
|
|
899
|
-
grad.setAttribute(
|
|
900
|
-
grad.removeAttribute(
|
|
1190
|
+
grad.setAttribute("cx", tcx.toFixed(opts.precision));
|
|
1191
|
+
grad.setAttribute("cy", tcy.toFixed(opts.precision));
|
|
1192
|
+
grad.setAttribute("fx", tfx.toFixed(opts.precision));
|
|
1193
|
+
grad.setAttribute("fy", tfy.toFixed(opts.precision));
|
|
1194
|
+
grad.setAttribute("r", tr.toFixed(opts.precision));
|
|
1195
|
+
grad.removeAttribute("gradientTransform");
|
|
901
1196
|
count++;
|
|
902
1197
|
} catch (e) {
|
|
903
1198
|
errors.push(`radialGradient: ${e.message}`);
|
|
@@ -912,44 +1207,121 @@ function bakeAllGradientTransforms(root, opts, stats) {
|
|
|
912
1207
|
// ============================================================================
|
|
913
1208
|
|
|
914
1209
|
/**
|
|
915
|
-
*
|
|
1210
|
+
* Collect all ID references in the document.
|
|
1211
|
+
*
|
|
1212
|
+
* This must be called BEFORE any processing that removes references (like resolveMarkers).
|
|
1213
|
+
* Otherwise, we'll incorrectly remove defs that were just used.
|
|
1214
|
+
*
|
|
1215
|
+
* IMPORTANT: This function must find ALL referenced elements, including:
|
|
1216
|
+
* - Elements directly referenced from the document (fill="url(#grad)", xlink:href="#symbol")
|
|
1217
|
+
* - Elements referenced from within <defs> (glyphRef xlink:href="#glyph", gradient xlink:href="#other")
|
|
1218
|
+
* - Elements referenced via any attribute (not just url() and href)
|
|
1219
|
+
*
|
|
916
1220
|
* @private
|
|
917
1221
|
*/
|
|
918
|
-
function
|
|
919
|
-
let count = 0;
|
|
920
|
-
|
|
921
|
-
// Collect all url() references in the document
|
|
1222
|
+
function collectAllReferences(root) {
|
|
922
1223
|
const usedIds = new Set();
|
|
923
1224
|
|
|
1225
|
+
// Collect references from an element and all its children
|
|
924
1226
|
const collectReferences = (el) => {
|
|
1227
|
+
// Check for <style> elements and parse their CSS content for url(#id) references
|
|
1228
|
+
// This is CRITICAL for SVG 2.0 features like shape-inside: url(#textShape)
|
|
1229
|
+
if (el.tagName && el.tagName.toLowerCase() === "style") {
|
|
1230
|
+
const cssContent = el.textContent || "";
|
|
1231
|
+
if (cssContent) {
|
|
1232
|
+
// Parse all url(#id) references from CSS (e.g., shape-inside: url(#textShape), fill: url(#grad))
|
|
1233
|
+
const cssIds = parseCSSIds(cssContent);
|
|
1234
|
+
cssIds.forEach((id) => usedIds.add(id));
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
925
1238
|
for (const attrName of el.getAttributeNames()) {
|
|
926
1239
|
const val = el.getAttribute(attrName);
|
|
927
|
-
if (val
|
|
1240
|
+
if (!val) continue;
|
|
1241
|
+
|
|
1242
|
+
// Check for url() references (fill, stroke, clip-path, mask, filter, marker, etc.)
|
|
1243
|
+
if (val.includes("url(")) {
|
|
928
1244
|
const refId = parseUrlReference(val);
|
|
929
|
-
if (refId)
|
|
1245
|
+
if (refId) {
|
|
1246
|
+
usedIds.add(refId);
|
|
1247
|
+
}
|
|
930
1248
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1249
|
+
|
|
1250
|
+
// Check for href/xlink:href references (use, image, altGlyph, glyphRef, etc.)
|
|
1251
|
+
if (attrName === "href" || attrName === "xlink:href") {
|
|
1252
|
+
const refId = val.replace(/^#/, "");
|
|
1253
|
+
if (refId && refId !== val) {
|
|
1254
|
+
// Only local refs starting with #
|
|
1255
|
+
usedIds.add(refId);
|
|
1256
|
+
}
|
|
934
1257
|
}
|
|
935
1258
|
}
|
|
936
1259
|
|
|
1260
|
+
// Recursively check children (including within <defs>)
|
|
1261
|
+
// Use tagName check instead of instanceof
|
|
937
1262
|
for (const child of el.children) {
|
|
938
|
-
if (child
|
|
1263
|
+
if (child && child.tagName) {
|
|
939
1264
|
collectReferences(child);
|
|
940
1265
|
}
|
|
941
1266
|
}
|
|
942
1267
|
};
|
|
943
1268
|
|
|
1269
|
+
// Collect all references from the entire document
|
|
944
1270
|
collectReferences(root);
|
|
945
1271
|
|
|
946
|
-
|
|
947
|
-
|
|
1272
|
+
return usedIds;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Remove defs that are not in the referencedIds set.
|
|
1277
|
+
*
|
|
1278
|
+
* IMPORTANT: An element should NOT be removed if:
|
|
1279
|
+
* 1. It has an ID that is referenced, OR
|
|
1280
|
+
* 2. It contains (anywhere in its subtree) an element with a referenced ID
|
|
1281
|
+
*
|
|
1282
|
+
* This handles cases like:
|
|
1283
|
+
* <defs>
|
|
1284
|
+
* <g id="container"> <!-- Not referenced, but contains referenced gradients -->
|
|
1285
|
+
* <linearGradient id="grad1"> <!-- Referenced -->
|
|
1286
|
+
*
|
|
1287
|
+
* @param {SVGElement} root - The root SVG element
|
|
1288
|
+
* @param {Set<string>} referencedIds - Set of IDs that were referenced before processing
|
|
1289
|
+
* @private
|
|
1290
|
+
*/
|
|
1291
|
+
function removeUnusedDefinitions(root, referencedIds) {
|
|
1292
|
+
let count = 0;
|
|
1293
|
+
|
|
1294
|
+
// Check if an element or any of its descendants has a referenced ID
|
|
1295
|
+
function hasReferencedDescendant(el) {
|
|
1296
|
+
// Check self
|
|
1297
|
+
const id = el.getAttribute("id");
|
|
1298
|
+
if (id && referencedIds.has(id)) {
|
|
1299
|
+
return true;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Check children recursively (use tagName check instead of instanceof)
|
|
1303
|
+
for (const child of el.children || []) {
|
|
1304
|
+
if (child && child.tagName && hasReferencedDescendant(child)) {
|
|
1305
|
+
return true;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Remove unreferenced elements from <defs>
|
|
1313
|
+
const defsElements = root.getElementsByTagName("defs");
|
|
948
1314
|
for (const defs of defsElements) {
|
|
949
1315
|
for (const child of [...defs.children]) {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
if
|
|
1316
|
+
// Use tagName check instead of instanceof
|
|
1317
|
+
if (child && child.tagName) {
|
|
1318
|
+
// Only remove if neither the element nor any of its descendants are referenced
|
|
1319
|
+
// ALSO never remove foreignObject, audio, video (preserve elements)
|
|
1320
|
+
const childTagName = child.tagName.toLowerCase();
|
|
1321
|
+
if (PRESERVE_ELEMENTS.has(childTagName)) {
|
|
1322
|
+
continue; // Never remove preserve elements from defs
|
|
1323
|
+
}
|
|
1324
|
+
if (!hasReferencedDescendant(child)) {
|
|
953
1325
|
defs.removeChild(child);
|
|
954
1326
|
count++;
|
|
955
1327
|
}
|
|
@@ -966,13 +1338,18 @@ function removeUnusedDefinitions(root) {
|
|
|
966
1338
|
|
|
967
1339
|
/**
|
|
968
1340
|
* Get path data from any shape element.
|
|
1341
|
+
* Converts basic shapes (rect, circle, ellipse, etc.) to path data.
|
|
1342
|
+
*
|
|
1343
|
+
* @param {SVGElement} el - Element to convert
|
|
1344
|
+
* @param {number} precision - Decimal precision for coordinates
|
|
1345
|
+
* @returns {string|null} Path data string or null if not convertible
|
|
969
1346
|
* @private
|
|
970
1347
|
*/
|
|
971
1348
|
function getElementPathData(el, precision) {
|
|
972
1349
|
const tagName = el.tagName.toLowerCase();
|
|
973
1350
|
|
|
974
|
-
if (tagName ===
|
|
975
|
-
return el.getAttribute(
|
|
1351
|
+
if (tagName === "path") {
|
|
1352
|
+
return el.getAttribute("d");
|
|
976
1353
|
}
|
|
977
1354
|
|
|
978
1355
|
// Use GeometryToPath for shape conversion
|
|
@@ -982,34 +1359,49 @@ function getElementPathData(el, precision) {
|
|
|
982
1359
|
};
|
|
983
1360
|
|
|
984
1361
|
switch (tagName) {
|
|
985
|
-
case
|
|
1362
|
+
case "rect":
|
|
986
1363
|
return GeometryToPath.rectToPathData(
|
|
987
|
-
getAttr(
|
|
988
|
-
getAttr(
|
|
989
|
-
getAttr(
|
|
990
|
-
|
|
1364
|
+
getAttr("x"),
|
|
1365
|
+
getAttr("y"),
|
|
1366
|
+
getAttr("width"),
|
|
1367
|
+
getAttr("height"),
|
|
1368
|
+
getAttr("rx"),
|
|
1369
|
+
getAttr("ry") || null,
|
|
1370
|
+
false,
|
|
1371
|
+
precision,
|
|
991
1372
|
);
|
|
992
|
-
case
|
|
1373
|
+
case "circle":
|
|
993
1374
|
return GeometryToPath.circleToPathData(
|
|
994
|
-
getAttr(
|
|
1375
|
+
getAttr("cx"),
|
|
1376
|
+
getAttr("cy"),
|
|
1377
|
+
getAttr("r"),
|
|
1378
|
+
precision,
|
|
995
1379
|
);
|
|
996
|
-
case
|
|
1380
|
+
case "ellipse":
|
|
997
1381
|
return GeometryToPath.ellipseToPathData(
|
|
998
|
-
getAttr(
|
|
999
|
-
getAttr(
|
|
1382
|
+
getAttr("cx"),
|
|
1383
|
+
getAttr("cy"),
|
|
1384
|
+
getAttr("rx"),
|
|
1385
|
+
getAttr("ry"),
|
|
1386
|
+
precision,
|
|
1000
1387
|
);
|
|
1001
|
-
case
|
|
1388
|
+
case "line":
|
|
1002
1389
|
return GeometryToPath.lineToPathData(
|
|
1003
|
-
getAttr(
|
|
1004
|
-
getAttr(
|
|
1390
|
+
getAttr("x1"),
|
|
1391
|
+
getAttr("y1"),
|
|
1392
|
+
getAttr("x2"),
|
|
1393
|
+
getAttr("y2"),
|
|
1394
|
+
precision,
|
|
1005
1395
|
);
|
|
1006
|
-
case
|
|
1396
|
+
case "polyline":
|
|
1007
1397
|
return GeometryToPath.polylineToPathData(
|
|
1008
|
-
el.getAttribute(
|
|
1398
|
+
el.getAttribute("points") || "",
|
|
1399
|
+
precision,
|
|
1009
1400
|
);
|
|
1010
|
-
case
|
|
1401
|
+
case "polygon":
|
|
1011
1402
|
return GeometryToPath.polygonToPathData(
|
|
1012
|
-
el.getAttribute(
|
|
1403
|
+
el.getAttribute("points") || "",
|
|
1404
|
+
precision,
|
|
1013
1405
|
);
|
|
1014
1406
|
default:
|
|
1015
1407
|
return null;
|
|
@@ -1018,6 +1410,10 @@ function getElementPathData(el, precision) {
|
|
|
1018
1410
|
|
|
1019
1411
|
/**
|
|
1020
1412
|
* Get approximate bounding box of an element.
|
|
1413
|
+
* Calculates bounding box by sampling points from the element's geometry.
|
|
1414
|
+
*
|
|
1415
|
+
* @param {SVGElement} el - Element to measure
|
|
1416
|
+
* @returns {{x: number, y: number, width: number, height: number}|null} Bounding box or null if calculation fails
|
|
1021
1417
|
* @private
|
|
1022
1418
|
*/
|
|
1023
1419
|
function getElementBBox(el) {
|
|
@@ -1028,16 +1424,47 @@ function getElementBBox(el) {
|
|
|
1028
1424
|
const polygon = ClipPathResolver.pathToPolygon(pathData, 10);
|
|
1029
1425
|
if (!polygon || polygon.length === 0) return null;
|
|
1030
1426
|
|
|
1031
|
-
let minX = Infinity,
|
|
1427
|
+
let minX = Infinity,
|
|
1428
|
+
minY = Infinity,
|
|
1429
|
+
maxX = -Infinity,
|
|
1430
|
+
maxY = -Infinity;
|
|
1032
1431
|
for (const pt of polygon) {
|
|
1033
|
-
|
|
1034
|
-
|
|
1432
|
+
// Validate point has coordinates
|
|
1433
|
+
if (!pt || (pt.x === undefined && pt.y === undefined)) continue;
|
|
1434
|
+
|
|
1435
|
+
// Convert to numbers, defaulting to 0 for invalid values
|
|
1436
|
+
const x =
|
|
1437
|
+
pt.x instanceof Decimal
|
|
1438
|
+
? pt.x.toNumber()
|
|
1439
|
+
: typeof pt.x === "number"
|
|
1440
|
+
? pt.x
|
|
1441
|
+
: 0;
|
|
1442
|
+
const y =
|
|
1443
|
+
pt.y instanceof Decimal
|
|
1444
|
+
? pt.y.toNumber()
|
|
1445
|
+
: typeof pt.y === "number"
|
|
1446
|
+
? pt.y
|
|
1447
|
+
: 0;
|
|
1448
|
+
|
|
1449
|
+
// Skip NaN values
|
|
1450
|
+
if (isNaN(x) || isNaN(y)) continue;
|
|
1451
|
+
|
|
1035
1452
|
if (x < minX) minX = x;
|
|
1036
1453
|
if (y < minY) minY = y;
|
|
1037
1454
|
if (x > maxX) maxX = x;
|
|
1038
1455
|
if (y > maxY) maxY = y;
|
|
1039
1456
|
}
|
|
1040
1457
|
|
|
1458
|
+
// Validate that we found at least one valid point
|
|
1459
|
+
if (
|
|
1460
|
+
minX === Infinity ||
|
|
1461
|
+
maxX === -Infinity ||
|
|
1462
|
+
minY === Infinity ||
|
|
1463
|
+
maxY === -Infinity
|
|
1464
|
+
) {
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1041
1468
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
1042
1469
|
} catch {
|
|
1043
1470
|
return null;
|
|
@@ -1064,53 +1491,74 @@ function getElementBBox(el) {
|
|
|
1064
1491
|
function extractPresentationAttrs(el) {
|
|
1065
1492
|
const presentationAttrs = [
|
|
1066
1493
|
// Stroke properties
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1494
|
+
"stroke",
|
|
1495
|
+
"stroke-width",
|
|
1496
|
+
"stroke-linecap",
|
|
1497
|
+
"stroke-linejoin",
|
|
1498
|
+
"stroke-dasharray",
|
|
1499
|
+
"stroke-dashoffset",
|
|
1500
|
+
"stroke-miterlimit",
|
|
1501
|
+
"stroke-opacity",
|
|
1502
|
+
"vector-effect", // Affects stroke rendering (non-scaling-stroke)
|
|
1070
1503
|
|
|
1071
1504
|
// Fill properties
|
|
1072
|
-
|
|
1505
|
+
"fill",
|
|
1506
|
+
"fill-opacity",
|
|
1507
|
+
"fill-rule",
|
|
1073
1508
|
|
|
1074
1509
|
// CRITICAL: Non-inheritable but must be preserved on element
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1510
|
+
"clip-path", // Clips geometry - MUST NOT BE LOST
|
|
1511
|
+
"mask", // Masks transparency - MUST NOT BE LOST
|
|
1512
|
+
"filter", // Visual effects - MUST NOT BE LOST
|
|
1513
|
+
"opacity", // Element opacity
|
|
1079
1514
|
|
|
1080
1515
|
// Clip/fill rules
|
|
1081
|
-
|
|
1516
|
+
"clip-rule",
|
|
1082
1517
|
|
|
1083
1518
|
// Marker properties - arrows, dots, etc on paths
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1519
|
+
"marker", // Shorthand for all markers
|
|
1520
|
+
"marker-start", // Start of path
|
|
1521
|
+
"marker-mid", // Vertices
|
|
1522
|
+
"marker-end", // End of path
|
|
1088
1523
|
|
|
1089
1524
|
// Visibility
|
|
1090
|
-
|
|
1525
|
+
"visibility",
|
|
1526
|
+
"display",
|
|
1091
1527
|
|
|
1092
1528
|
// Color
|
|
1093
|
-
|
|
1529
|
+
"color",
|
|
1094
1530
|
|
|
1095
1531
|
// Text properties
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1532
|
+
"font-family",
|
|
1533
|
+
"font-size",
|
|
1534
|
+
"font-weight",
|
|
1535
|
+
"font-style",
|
|
1536
|
+
"text-anchor",
|
|
1537
|
+
"dominant-baseline",
|
|
1538
|
+
"alignment-baseline",
|
|
1539
|
+
"letter-spacing",
|
|
1540
|
+
"word-spacing",
|
|
1541
|
+
"text-decoration",
|
|
1099
1542
|
|
|
1100
1543
|
// Rendering hints
|
|
1101
|
-
|
|
1544
|
+
"shape-rendering",
|
|
1545
|
+
"text-rendering",
|
|
1546
|
+
"image-rendering",
|
|
1547
|
+
"color-rendering",
|
|
1102
1548
|
|
|
1103
1549
|
// Paint order (affects stroke/fill/marker rendering order)
|
|
1104
|
-
|
|
1550
|
+
"paint-order",
|
|
1105
1551
|
|
|
1106
1552
|
// Event handling (visual feedback)
|
|
1107
|
-
|
|
1553
|
+
"pointer-events",
|
|
1554
|
+
"cursor",
|
|
1108
1555
|
|
|
1109
1556
|
// Preserve class and style for CSS targeting
|
|
1110
|
-
|
|
1557
|
+
"class",
|
|
1558
|
+
"style",
|
|
1111
1559
|
|
|
1112
1560
|
// ID must be preserved for references
|
|
1113
|
-
|
|
1561
|
+
"id",
|
|
1114
1562
|
];
|
|
1115
1563
|
|
|
1116
1564
|
const attrs = {};
|
|
@@ -1133,20 +1581,32 @@ function extractPresentationAttrs(el) {
|
|
|
1133
1581
|
*/
|
|
1134
1582
|
function getShapeSpecificAttrs(tagName) {
|
|
1135
1583
|
const attrs = {
|
|
1136
|
-
rect: [
|
|
1137
|
-
circle: [
|
|
1138
|
-
ellipse: [
|
|
1139
|
-
line: [
|
|
1140
|
-
polyline: [
|
|
1141
|
-
polygon: [
|
|
1584
|
+
rect: ["x", "y", "width", "height", "rx", "ry"],
|
|
1585
|
+
circle: ["cx", "cy", "r"],
|
|
1586
|
+
ellipse: ["cx", "cy", "rx", "ry"],
|
|
1587
|
+
line: ["x1", "y1", "x2", "y2"],
|
|
1588
|
+
polyline: ["points"],
|
|
1589
|
+
polygon: ["points"],
|
|
1142
1590
|
// Image element has position/size attributes that don't apply to paths
|
|
1143
|
-
image: [
|
|
1591
|
+
image: [
|
|
1592
|
+
"x",
|
|
1593
|
+
"y",
|
|
1594
|
+
"width",
|
|
1595
|
+
"height",
|
|
1596
|
+
"href",
|
|
1597
|
+
"xlink:href",
|
|
1598
|
+
"preserveAspectRatio",
|
|
1599
|
+
],
|
|
1144
1600
|
};
|
|
1145
1601
|
return attrs[tagName.toLowerCase()] || [];
|
|
1146
1602
|
}
|
|
1147
1603
|
|
|
1148
1604
|
/**
|
|
1149
1605
|
* Convert matrix to transform attribute string.
|
|
1606
|
+
* Converts a Matrix object to SVG matrix() transform format.
|
|
1607
|
+
*
|
|
1608
|
+
* @param {Matrix} matrix - Transformation matrix
|
|
1609
|
+
* @returns {string} SVG transform attribute value
|
|
1150
1610
|
* @private
|
|
1151
1611
|
*/
|
|
1152
1612
|
function matrixToTransform(matrix) {
|
|
@@ -1161,24 +1621,32 @@ function matrixToTransform(matrix) {
|
|
|
1161
1621
|
|
|
1162
1622
|
/**
|
|
1163
1623
|
* Intersect two polygons using Sutherland-Hodgman algorithm.
|
|
1624
|
+
* Computes the boolean intersection of two polygons.
|
|
1625
|
+
*
|
|
1626
|
+
* @param {Array<{x: Decimal, y: Decimal}>} subject - Subject polygon vertices
|
|
1627
|
+
* @param {Array<{x: Decimal, y: Decimal}>} clip - Clip polygon vertices
|
|
1628
|
+
* @returns {Array<{x: Decimal, y: Decimal}>} Intersection polygon vertices
|
|
1164
1629
|
* @private
|
|
1165
1630
|
*/
|
|
1166
1631
|
function intersectPolygons(subject, clip) {
|
|
1167
|
-
//
|
|
1168
|
-
try {
|
|
1169
|
-
const PolygonClip = require('./polygon-clip.js');
|
|
1170
|
-
if (PolygonClip.intersect) {
|
|
1171
|
-
return PolygonClip.intersect(subject, clip);
|
|
1172
|
-
}
|
|
1173
|
-
} catch {
|
|
1174
|
-
// Fall through to simple implementation
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// Simple convex clip implementation
|
|
1632
|
+
// Validate inputs
|
|
1178
1633
|
if (!subject || subject.length < 3 || !clip || clip.length < 3) {
|
|
1179
1634
|
return subject;
|
|
1180
1635
|
}
|
|
1181
1636
|
|
|
1637
|
+
// Use advanced polygon intersection from polygon-clip module if available
|
|
1638
|
+
// Falls back to simple convex clip implementation
|
|
1639
|
+
if (PolygonClip && PolygonClip.polygonIntersection) {
|
|
1640
|
+
try {
|
|
1641
|
+
return PolygonClip.polygonIntersection(subject, clip);
|
|
1642
|
+
} catch (_error) {
|
|
1643
|
+
// Fall through to simple implementation on error
|
|
1644
|
+
// Error is intentionally not logged to avoid noise from expected edge cases
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Simple convex clip implementation (Sutherland-Hodgman)
|
|
1649
|
+
|
|
1182
1650
|
let output = [...subject];
|
|
1183
1651
|
|
|
1184
1652
|
for (let i = 0; i < clip.length; i++) {
|
|
@@ -1213,13 +1681,21 @@ function intersectPolygons(subject, clip) {
|
|
|
1213
1681
|
|
|
1214
1682
|
/**
|
|
1215
1683
|
* Check if point is inside edge (left side).
|
|
1684
|
+
* Uses cross product to determine if point is on the left (inside) of an edge.
|
|
1685
|
+
*
|
|
1686
|
+
* @param {{x: Decimal|number, y: Decimal|number}} point - Point to test
|
|
1687
|
+
* @param {{x: Decimal|number, y: Decimal|number}} edgeStart - Edge start vertex
|
|
1688
|
+
* @param {{x: Decimal|number, y: Decimal|number}} edgeEnd - Edge end vertex
|
|
1689
|
+
* @returns {boolean} True if point is inside (left of) the edge
|
|
1216
1690
|
* @private
|
|
1217
1691
|
*/
|
|
1218
1692
|
function isInsideEdge(point, edgeStart, edgeEnd) {
|
|
1219
1693
|
const px = point.x instanceof Decimal ? point.x.toNumber() : point.x;
|
|
1220
1694
|
const py = point.y instanceof Decimal ? point.y.toNumber() : point.y;
|
|
1221
|
-
const sx =
|
|
1222
|
-
|
|
1695
|
+
const sx =
|
|
1696
|
+
edgeStart.x instanceof Decimal ? edgeStart.x.toNumber() : edgeStart.x;
|
|
1697
|
+
const sy =
|
|
1698
|
+
edgeStart.y instanceof Decimal ? edgeStart.y.toNumber() : edgeStart.y;
|
|
1223
1699
|
const ex = edgeEnd.x instanceof Decimal ? edgeEnd.x.toNumber() : edgeEnd.x;
|
|
1224
1700
|
const ey = edgeEnd.y instanceof Decimal ? edgeEnd.y.toNumber() : edgeEnd.y;
|
|
1225
1701
|
|
|
@@ -1228,7 +1704,16 @@ function isInsideEdge(point, edgeStart, edgeEnd) {
|
|
|
1228
1704
|
|
|
1229
1705
|
/**
|
|
1230
1706
|
* Find intersection point of two lines.
|
|
1707
|
+
*
|
|
1708
|
+
* When lines are nearly parallel (determinant near zero), returns the midpoint
|
|
1709
|
+
* of the first line segment as a reasonable fallback.
|
|
1710
|
+
*
|
|
1231
1711
|
* @private
|
|
1712
|
+
* @param {Object} p1 - First point of first line
|
|
1713
|
+
* @param {Object} p2 - Second point of first line
|
|
1714
|
+
* @param {Object} p3 - First point of second line
|
|
1715
|
+
* @param {Object} p4 - Second point of second line
|
|
1716
|
+
* @returns {Object} Intersection point with x, y as Decimal
|
|
1232
1717
|
*/
|
|
1233
1718
|
function lineIntersect(p1, p2, p3, p4) {
|
|
1234
1719
|
const x1 = p1.x instanceof Decimal ? p1.x.toNumber() : p1.x;
|
|
@@ -1241,6 +1726,9 @@ function lineIntersect(p1, p2, p3, p4) {
|
|
|
1241
1726
|
const y4 = p4.y instanceof Decimal ? p4.y.toNumber() : p4.y;
|
|
1242
1727
|
|
|
1243
1728
|
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
|
1729
|
+
|
|
1730
|
+
// Tolerance of 1e-10 chosen to match Decimal precision expectations
|
|
1731
|
+
// For nearly-parallel lines, return midpoint of first segment as fallback
|
|
1244
1732
|
if (Math.abs(denom) < 1e-10) {
|
|
1245
1733
|
return { x: D((x1 + x2) / 2), y: D((y1 + y2) / 2) };
|
|
1246
1734
|
}
|
|
@@ -1249,7 +1737,7 @@ function lineIntersect(p1, p2, p3, p4) {
|
|
|
1249
1737
|
|
|
1250
1738
|
return {
|
|
1251
1739
|
x: D(x1 + t * (x2 - x1)),
|
|
1252
|
-
y: D(y1 + t * (y2 - y1))
|
|
1740
|
+
y: D(y1 + t * (y2 - y1)),
|
|
1253
1741
|
};
|
|
1254
1742
|
}
|
|
1255
1743
|
|
|
@@ -1259,5 +1747,5 @@ function lineIntersect(p1, p2, p3, p4) {
|
|
|
1259
1747
|
|
|
1260
1748
|
export default {
|
|
1261
1749
|
flattenSVG,
|
|
1262
|
-
DEFAULT_OPTIONS
|
|
1750
|
+
DEFAULT_OPTIONS,
|
|
1263
1751
|
};
|