@emasoft/svg-matrix 1.0.27 → 1.0.28
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/bin/svg-matrix.js +11 -2
- package/bin/svgm.js +25 -8
- package/package.json +1 -1
- package/src/index.js +2 -2
- package/src/inkscape-support.js +393 -0
- package/src/svg-collections.js +26 -1
- package/src/svg-toolbox.js +140 -38
- package/src/svg2-polyfills.js +21 -21
package/bin/svg-matrix.js
CHANGED
|
@@ -1353,7 +1353,16 @@ function parseArgs(args) {
|
|
|
1353
1353
|
let i = 0;
|
|
1354
1354
|
|
|
1355
1355
|
while (i < args.length) {
|
|
1356
|
-
|
|
1356
|
+
let arg = args[i];
|
|
1357
|
+
let argValue = null;
|
|
1358
|
+
|
|
1359
|
+
// Handle --arg=value format
|
|
1360
|
+
if (arg.includes('=') && arg.startsWith('--')) {
|
|
1361
|
+
const eqIdx = arg.indexOf('=');
|
|
1362
|
+
argValue = arg.substring(eqIdx + 1);
|
|
1363
|
+
arg = arg.substring(0, eqIdx);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1357
1366
|
switch (arg) {
|
|
1358
1367
|
case '-o': case '--output': cfg.output = args[++i]; break;
|
|
1359
1368
|
case '-l': case '--list': cfg.listFile = args[++i]; break;
|
|
@@ -1393,7 +1402,7 @@ function parseArgs(args) {
|
|
|
1393
1402
|
case '--preserve-vendor': cfg.preserveVendor = true; break;
|
|
1394
1403
|
// Namespace preservation option (comma-separated list)
|
|
1395
1404
|
case '--preserve-ns': {
|
|
1396
|
-
const namespaces = args[++i];
|
|
1405
|
+
const namespaces = argValue || args[++i];
|
|
1397
1406
|
if (!namespaces) {
|
|
1398
1407
|
logError('--preserve-ns requires a comma-separated list of namespaces');
|
|
1399
1408
|
process.exit(CONSTANTS.EXIT_ERROR);
|
package/bin/svgm.js
CHANGED
|
@@ -220,6 +220,13 @@ async function optimizeSvg(content, options = {}) {
|
|
|
220
220
|
const doc = parseSVG(content);
|
|
221
221
|
const pipeline = DEFAULT_PIPELINE;
|
|
222
222
|
|
|
223
|
+
// Detect SVG 2 features BEFORE optimization pipeline removes them
|
|
224
|
+
// (removeUnknownsAndDefaults strips elements not in SVG 1.1 spec)
|
|
225
|
+
let svg2Features = null;
|
|
226
|
+
if (options.svg2Polyfills) {
|
|
227
|
+
svg2Features = detectSVG2Features(doc);
|
|
228
|
+
}
|
|
229
|
+
|
|
223
230
|
// Run optimization pipeline
|
|
224
231
|
for (const pluginName of pipeline) {
|
|
225
232
|
const fn = SVGToolbox[pluginName];
|
|
@@ -246,11 +253,11 @@ async function optimizeSvg(content, options = {}) {
|
|
|
246
253
|
}
|
|
247
254
|
}
|
|
248
255
|
|
|
249
|
-
// Inject SVG 2 polyfills if requested
|
|
250
|
-
if (options.svg2Polyfills) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
injectPolyfills(doc);
|
|
256
|
+
// Inject SVG 2 polyfills if requested (using pre-detected features)
|
|
257
|
+
if (options.svg2Polyfills && svg2Features) {
|
|
258
|
+
if (svg2Features.meshGradients.length > 0 || svg2Features.hatches.length > 0) {
|
|
259
|
+
// Pass pre-detected features since pipeline may have removed SVG2 elements
|
|
260
|
+
injectPolyfills(doc, { features: svg2Features });
|
|
254
261
|
}
|
|
255
262
|
}
|
|
256
263
|
|
|
@@ -432,7 +439,15 @@ function parseArgs(args) {
|
|
|
432
439
|
let i = 0;
|
|
433
440
|
|
|
434
441
|
while (i < args.length) {
|
|
435
|
-
|
|
442
|
+
let arg = args[i];
|
|
443
|
+
let argValue = null;
|
|
444
|
+
|
|
445
|
+
// Handle --arg=value format
|
|
446
|
+
if (arg.includes('=') && arg.startsWith('--')) {
|
|
447
|
+
const eqIdx = arg.indexOf('=');
|
|
448
|
+
argValue = arg.substring(eqIdx + 1);
|
|
449
|
+
arg = arg.substring(0, eqIdx);
|
|
450
|
+
}
|
|
436
451
|
|
|
437
452
|
switch (arg) {
|
|
438
453
|
case '-v':
|
|
@@ -533,8 +548,10 @@ function parseArgs(args) {
|
|
|
533
548
|
break;
|
|
534
549
|
|
|
535
550
|
case '--preserve-ns':
|
|
536
|
-
|
|
537
|
-
|
|
551
|
+
{
|
|
552
|
+
const val = argValue || args[++i];
|
|
553
|
+
cfg.preserveNamespaces = val.split(',').map(s => s.trim().toLowerCase());
|
|
554
|
+
}
|
|
538
555
|
break;
|
|
539
556
|
|
|
540
557
|
case '--svg2-polyfills':
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* SVG path conversion, and 2D/3D affine transformations using Decimal.js.
|
|
6
6
|
*
|
|
7
7
|
* @module @emasoft/svg-matrix
|
|
8
|
-
* @version 1.0.
|
|
8
|
+
* @version 1.0.28
|
|
9
9
|
* @license MIT
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
@@ -87,7 +87,7 @@ Decimal.set({ precision: 80 });
|
|
|
87
87
|
* Library version
|
|
88
88
|
* @constant {string}
|
|
89
89
|
*/
|
|
90
|
-
export const VERSION = '1.0.
|
|
90
|
+
export const VERSION = '1.0.28';
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Default precision for path output (decimal places)
|
package/src/inkscape-support.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* @module inkscape-support
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { SVGElement } from './svg-parser.js';
|
|
11
|
+
|
|
10
12
|
// Inkscape namespace URIs
|
|
11
13
|
export const INKSCAPE_NS = 'http://www.inkscape.org/namespaces/inkscape';
|
|
12
14
|
export const SODIPODI_NS = 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd';
|
|
@@ -246,3 +248,394 @@ export function ensureInkscapeNamespaces(doc) {
|
|
|
246
248
|
|
|
247
249
|
return doc;
|
|
248
250
|
}
|
|
251
|
+
|
|
252
|
+
// ============================================================================
|
|
253
|
+
// LAYER EXTRACTION
|
|
254
|
+
// ============================================================================
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Find all IDs referenced by an element and its descendants.
|
|
258
|
+
* Looks for url(#id) references in fill, stroke, clip-path, mask, marker-*, filter, etc.
|
|
259
|
+
* Also checks xlink:href and href attributes for #id references.
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} element - SVG element to scan
|
|
262
|
+
* @returns {Set<string>} Set of referenced IDs
|
|
263
|
+
*/
|
|
264
|
+
export function findReferencedIds(element) {
|
|
265
|
+
const ids = new Set();
|
|
266
|
+
|
|
267
|
+
// Attributes that can contain url(#id) references
|
|
268
|
+
const urlRefAttrs = [
|
|
269
|
+
'fill', 'stroke', 'clip-path', 'mask', 'filter',
|
|
270
|
+
'marker-start', 'marker-mid', 'marker-end',
|
|
271
|
+
'fill-opacity', 'stroke-opacity' // Sometimes reference paint servers
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
// Attributes that can contain #id or url(#id) references
|
|
275
|
+
const hrefAttrs = ['href', 'xlink:href'];
|
|
276
|
+
|
|
277
|
+
const extractUrlId = (value) => {
|
|
278
|
+
if (!value) return null;
|
|
279
|
+
// Match url(#id) or url("#id")
|
|
280
|
+
const match = value.match(/url\(["']?#([^"')]+)["']?\)/);
|
|
281
|
+
return match ? match[1] : null;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const extractHrefId = (value) => {
|
|
285
|
+
if (!value) return null;
|
|
286
|
+
// Match #id references
|
|
287
|
+
if (value.startsWith('#')) {
|
|
288
|
+
return value.slice(1);
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const walk = (el) => {
|
|
294
|
+
if (!el) return;
|
|
295
|
+
|
|
296
|
+
// Check url() references
|
|
297
|
+
for (const attr of urlRefAttrs) {
|
|
298
|
+
const id = extractUrlId(el.getAttribute?.(attr));
|
|
299
|
+
if (id) ids.add(id);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Check href references
|
|
303
|
+
for (const attr of hrefAttrs) {
|
|
304
|
+
const id = extractHrefId(el.getAttribute?.(attr));
|
|
305
|
+
if (id) ids.add(id);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check style attribute for url() references
|
|
309
|
+
const style = el.getAttribute?.('style');
|
|
310
|
+
if (style) {
|
|
311
|
+
const urlMatches = style.matchAll(/url\(["']?#([^"')]+)["']?\)/g);
|
|
312
|
+
for (const match of urlMatches) {
|
|
313
|
+
ids.add(match[1]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Recurse into children
|
|
318
|
+
if (el.children) {
|
|
319
|
+
for (const child of el.children) {
|
|
320
|
+
walk(child);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
walk(element);
|
|
326
|
+
return ids;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build a map of all defs elements by their ID.
|
|
331
|
+
*
|
|
332
|
+
* @param {Object} doc - Parsed SVG document
|
|
333
|
+
* @returns {Map<string, Object>} Map of ID to element
|
|
334
|
+
*/
|
|
335
|
+
export function buildDefsMap(doc) {
|
|
336
|
+
const defsMap = new Map();
|
|
337
|
+
|
|
338
|
+
const walk = (el) => {
|
|
339
|
+
if (!el) return;
|
|
340
|
+
|
|
341
|
+
// If element has an ID, add to map
|
|
342
|
+
const id = el.getAttribute?.('id');
|
|
343
|
+
if (id) {
|
|
344
|
+
defsMap.set(id, el);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Recurse
|
|
348
|
+
if (el.children) {
|
|
349
|
+
for (const child of el.children) {
|
|
350
|
+
walk(child);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// Only scan defs elements for efficiency
|
|
356
|
+
const findDefs = (el) => {
|
|
357
|
+
if (!el) return;
|
|
358
|
+
if (el.tagName === 'defs') {
|
|
359
|
+
walk(el);
|
|
360
|
+
}
|
|
361
|
+
if (el.children) {
|
|
362
|
+
for (const child of el.children) {
|
|
363
|
+
findDefs(child);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
findDefs(doc);
|
|
369
|
+
return defsMap;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Recursively resolve all dependencies for a set of IDs.
|
|
374
|
+
* Defs elements can reference other defs (e.g., gradient with xlink:href to another gradient).
|
|
375
|
+
*
|
|
376
|
+
* @param {Set<string>} initialIds - Initial set of IDs to resolve
|
|
377
|
+
* @param {Map<string, Object>} defsMap - Map of all defs elements
|
|
378
|
+
* @returns {Set<string>} Complete set of IDs including all nested dependencies
|
|
379
|
+
*/
|
|
380
|
+
export function resolveDefsDependencies(initialIds, defsMap) {
|
|
381
|
+
const resolved = new Set();
|
|
382
|
+
const toProcess = [...initialIds];
|
|
383
|
+
|
|
384
|
+
while (toProcess.length > 0) {
|
|
385
|
+
const id = toProcess.pop();
|
|
386
|
+
if (resolved.has(id)) continue;
|
|
387
|
+
|
|
388
|
+
const element = defsMap.get(id);
|
|
389
|
+
if (!element) continue;
|
|
390
|
+
|
|
391
|
+
resolved.add(id);
|
|
392
|
+
|
|
393
|
+
// Find references within this def element
|
|
394
|
+
const nestedRefs = findReferencedIds(element);
|
|
395
|
+
for (const nestedId of nestedRefs) {
|
|
396
|
+
if (!resolved.has(nestedId) && defsMap.has(nestedId)) {
|
|
397
|
+
toProcess.push(nestedId);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return resolved;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Deep clone an SVG element and all its children using proper SVGElement class.
|
|
407
|
+
*
|
|
408
|
+
* @param {Object} element - Element to clone
|
|
409
|
+
* @returns {SVGElement} Cloned element with serialize() method
|
|
410
|
+
*/
|
|
411
|
+
export function cloneElement(element) {
|
|
412
|
+
if (!element) return null;
|
|
413
|
+
|
|
414
|
+
// Get attributes as plain object
|
|
415
|
+
const attrs = {};
|
|
416
|
+
if (element._attributes) {
|
|
417
|
+
Object.assign(attrs, element._attributes);
|
|
418
|
+
} else if (element.getAttributeNames) {
|
|
419
|
+
for (const name of element.getAttributeNames()) {
|
|
420
|
+
attrs[name] = element.getAttribute(name);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Clone children recursively
|
|
425
|
+
const clonedChildren = [];
|
|
426
|
+
if (element.children) {
|
|
427
|
+
for (const child of element.children) {
|
|
428
|
+
const clonedChild = cloneElement(child);
|
|
429
|
+
if (clonedChild) {
|
|
430
|
+
clonedChildren.push(clonedChild);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Create proper SVGElement with serialize() method
|
|
436
|
+
const clone = new SVGElement(
|
|
437
|
+
element.tagName,
|
|
438
|
+
attrs,
|
|
439
|
+
clonedChildren,
|
|
440
|
+
element.textContent || null
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
return clone;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Extract a single layer as a standalone SVG document.
|
|
448
|
+
* Includes only the defs elements that are referenced by the layer.
|
|
449
|
+
*
|
|
450
|
+
* @param {Object} doc - Source parsed SVG document
|
|
451
|
+
* @param {Object|string} layerOrId - Layer element or layer ID to extract
|
|
452
|
+
* @param {Object} [options] - Options
|
|
453
|
+
* @param {boolean} [options.includeHiddenLayers=false] - Include hidden layers in output
|
|
454
|
+
* @param {boolean} [options.preserveTransform=true] - Preserve layer transform attribute
|
|
455
|
+
* @returns {{svg: SVGElement, layerInfo: {id: string, label: string}}} Extracted SVG and layer info
|
|
456
|
+
*/
|
|
457
|
+
export function extractLayer(doc, layerOrId, options = {}) {
|
|
458
|
+
const { preserveTransform = true } = options;
|
|
459
|
+
|
|
460
|
+
// Find the layer element
|
|
461
|
+
let layer;
|
|
462
|
+
if (typeof layerOrId === 'string') {
|
|
463
|
+
const layers = findLayers(doc);
|
|
464
|
+
const found = layers.find(l => l.id === layerOrId || l.label === layerOrId);
|
|
465
|
+
if (!found) {
|
|
466
|
+
throw new Error(`Layer not found: ${layerOrId}`);
|
|
467
|
+
}
|
|
468
|
+
layer = found.element;
|
|
469
|
+
} else {
|
|
470
|
+
layer = layerOrId;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!isInkscapeLayer(layer)) {
|
|
474
|
+
throw new Error('Element is not an Inkscape layer');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Get SVG root element
|
|
478
|
+
const svgRoot = doc.documentElement || doc;
|
|
479
|
+
|
|
480
|
+
// Build defs map from source document
|
|
481
|
+
const defsMap = buildDefsMap(doc);
|
|
482
|
+
|
|
483
|
+
// Find all IDs referenced by this layer
|
|
484
|
+
const referencedIds = findReferencedIds(layer);
|
|
485
|
+
|
|
486
|
+
// Resolve all nested dependencies
|
|
487
|
+
const requiredDefIds = resolveDefsDependencies(referencedIds, defsMap);
|
|
488
|
+
|
|
489
|
+
// Get SVG root attributes
|
|
490
|
+
const svgAttrs = {};
|
|
491
|
+
if (svgRoot._attributes) {
|
|
492
|
+
Object.assign(svgAttrs, svgRoot._attributes);
|
|
493
|
+
} else if (svgRoot.getAttributeNames) {
|
|
494
|
+
for (const name of svgRoot.getAttributeNames()) {
|
|
495
|
+
svgAttrs[name] = svgRoot.getAttribute(name);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Build children array for new SVG
|
|
500
|
+
const svgChildren = [];
|
|
501
|
+
|
|
502
|
+
// Create defs element with required definitions
|
|
503
|
+
if (requiredDefIds.size > 0) {
|
|
504
|
+
const defsChildren = [];
|
|
505
|
+
for (const id of requiredDefIds) {
|
|
506
|
+
const defElement = defsMap.get(id);
|
|
507
|
+
if (defElement) {
|
|
508
|
+
defsChildren.push(cloneElement(defElement));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const newDefs = new SVGElement('defs', {}, defsChildren, null);
|
|
512
|
+
svgChildren.push(newDefs);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Clone the layer
|
|
516
|
+
const clonedLayer = cloneElement(layer);
|
|
517
|
+
|
|
518
|
+
// Optionally remove the transform
|
|
519
|
+
if (!preserveTransform && clonedLayer._attributes) {
|
|
520
|
+
delete clonedLayer._attributes.transform;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
svgChildren.push(clonedLayer);
|
|
524
|
+
|
|
525
|
+
// Create new SVG document using SVGElement
|
|
526
|
+
const newSvg = new SVGElement('svg', svgAttrs, svgChildren, null);
|
|
527
|
+
|
|
528
|
+
// Get layer info
|
|
529
|
+
const layerInfo = {
|
|
530
|
+
id: layer.getAttribute('id'),
|
|
531
|
+
label: getLayerLabel(layer)
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
return { svg: newSvg, layerInfo };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Extract all layers from an Inkscape SVG as separate documents.
|
|
539
|
+
*
|
|
540
|
+
* @param {Object} doc - Source parsed SVG document
|
|
541
|
+
* @param {Object} [options] - Options
|
|
542
|
+
* @param {boolean} [options.includeHidden=false] - Include hidden layers (display:none or visibility:hidden)
|
|
543
|
+
* @param {boolean} [options.preserveTransform=true] - Preserve layer transform attributes
|
|
544
|
+
* @returns {Array<{svg: Object, layerInfo: {id: string, label: string}}>} Array of extracted SVGs
|
|
545
|
+
*/
|
|
546
|
+
export function extractAllLayers(doc, options = {}) {
|
|
547
|
+
const { includeHidden = false } = options;
|
|
548
|
+
const layers = findLayers(doc);
|
|
549
|
+
const results = [];
|
|
550
|
+
|
|
551
|
+
for (const layerData of layers) {
|
|
552
|
+
const layer = layerData.element;
|
|
553
|
+
|
|
554
|
+
// Skip hidden layers unless requested
|
|
555
|
+
if (!includeHidden) {
|
|
556
|
+
const style = layer.getAttribute('style') || '';
|
|
557
|
+
const display = layer.getAttribute('display');
|
|
558
|
+
const visibility = layer.getAttribute('visibility');
|
|
559
|
+
|
|
560
|
+
if (display === 'none' ||
|
|
561
|
+
visibility === 'hidden' ||
|
|
562
|
+
style.includes('display:none') ||
|
|
563
|
+
style.includes('visibility:hidden')) {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
const extracted = extractLayer(doc, layer, options);
|
|
570
|
+
results.push(extracted);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
// Skip layers that fail to extract
|
|
573
|
+
console.warn(`Failed to extract layer ${layerData.id || layerData.label}: ${e.message}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return results;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Get a summary of shared resources between layers.
|
|
582
|
+
* Useful for understanding what defs are shared across layers.
|
|
583
|
+
*
|
|
584
|
+
* @param {Object} doc - Parsed SVG document
|
|
585
|
+
* @returns {Object} Summary of shared resources
|
|
586
|
+
*/
|
|
587
|
+
export function analyzeLayerDependencies(doc) {
|
|
588
|
+
const layers = findLayers(doc);
|
|
589
|
+
const defsMap = buildDefsMap(doc);
|
|
590
|
+
const layerRefs = new Map(); // layer ID -> Set of referenced def IDs
|
|
591
|
+
const defUsage = new Map(); // def ID -> Set of layer IDs that use it
|
|
592
|
+
|
|
593
|
+
for (const layerData of layers) {
|
|
594
|
+
const layer = layerData.element;
|
|
595
|
+
const layerId = layerData.id || layerData.label || 'unnamed';
|
|
596
|
+
|
|
597
|
+
// Find refs for this layer
|
|
598
|
+
const refs = findReferencedIds(layer);
|
|
599
|
+
const resolved = resolveDefsDependencies(refs, defsMap);
|
|
600
|
+
|
|
601
|
+
layerRefs.set(layerId, resolved);
|
|
602
|
+
|
|
603
|
+
// Track which defs are used by which layers
|
|
604
|
+
for (const defId of resolved) {
|
|
605
|
+
if (!defUsage.has(defId)) {
|
|
606
|
+
defUsage.set(defId, new Set());
|
|
607
|
+
}
|
|
608
|
+
defUsage.get(defId).add(layerId);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Find shared defs (used by more than one layer)
|
|
613
|
+
const sharedDefs = [];
|
|
614
|
+
const exclusiveDefs = new Map(); // layer ID -> defs only used by that layer
|
|
615
|
+
|
|
616
|
+
for (const [defId, layerSet] of defUsage) {
|
|
617
|
+
if (layerSet.size > 1) {
|
|
618
|
+
sharedDefs.push({
|
|
619
|
+
id: defId,
|
|
620
|
+
usedBy: [...layerSet]
|
|
621
|
+
});
|
|
622
|
+
} else {
|
|
623
|
+
const layerId = [...layerSet][0];
|
|
624
|
+
if (!exclusiveDefs.has(layerId)) {
|
|
625
|
+
exclusiveDefs.set(layerId, []);
|
|
626
|
+
}
|
|
627
|
+
exclusiveDefs.get(layerId).push(defId);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
layers: layers.map(l => ({
|
|
633
|
+
id: l.id,
|
|
634
|
+
label: l.label,
|
|
635
|
+
referencedDefs: [...(layerRefs.get(l.id || l.label || 'unnamed') || [])]
|
|
636
|
+
})),
|
|
637
|
+
sharedDefs,
|
|
638
|
+
exclusiveDefs: Object.fromEntries(exclusiveDefs),
|
|
639
|
+
totalDefs: defsMap.size
|
|
640
|
+
};
|
|
641
|
+
}
|
package/src/svg-collections.js
CHANGED
|
@@ -718,7 +718,9 @@ export const allowedChildrenPerElement = {
|
|
|
718
718
|
'circle', 'clipPath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'font-face',
|
|
719
719
|
'foreignObject', 'g', 'image', 'line', 'linearGradient', 'marker', 'mask', 'metadata',
|
|
720
720
|
'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialGradient', 'rect', 'script',
|
|
721
|
-
'set', 'style', 'svg', 'switch', 'symbol', 'text', 'title', 'use', 'view'
|
|
721
|
+
'set', 'style', 'svg', 'switch', 'symbol', 'text', 'title', 'use', 'view',
|
|
722
|
+
// SVG 2.0 elements
|
|
723
|
+
'meshgradient', 'meshGradient', 'hatch', 'solidcolor', 'solidColor'
|
|
722
724
|
]),
|
|
723
725
|
'symbol': new Set([
|
|
724
726
|
'a', 'animate', 'animateColor', 'animateMotion', 'animateTransform', 'circle',
|
|
@@ -781,6 +783,29 @@ export const allowedChildrenPerElement = {
|
|
|
781
783
|
'radialGradient': new Set([
|
|
782
784
|
'animate', 'animateTransform', 'desc', 'metadata', 'set', 'stop', 'title'
|
|
783
785
|
]),
|
|
786
|
+
// SVG 2.0 mesh gradient element hierarchy
|
|
787
|
+
'meshgradient': new Set([
|
|
788
|
+
'animate', 'animateTransform', 'desc', 'metadata', 'meshrow', 'set', 'title'
|
|
789
|
+
]),
|
|
790
|
+
'meshGradient': new Set([
|
|
791
|
+
'animate', 'animateTransform', 'desc', 'metadata', 'meshrow', 'set', 'title'
|
|
792
|
+
]),
|
|
793
|
+
'meshrow': new Set([
|
|
794
|
+
'meshpatch'
|
|
795
|
+
]),
|
|
796
|
+
'meshpatch': new Set([
|
|
797
|
+
'stop'
|
|
798
|
+
]),
|
|
799
|
+
// SVG 2.0 hatch element
|
|
800
|
+
'hatch': new Set([
|
|
801
|
+
'animate', 'animateTransform', 'desc', 'hatchpath', 'hatchPath', 'metadata', 'script', 'set', 'style', 'title'
|
|
802
|
+
]),
|
|
803
|
+
'hatchpath': new Set([
|
|
804
|
+
'animate', 'animateTransform', 'desc', 'metadata', 'script', 'set', 'title'
|
|
805
|
+
]),
|
|
806
|
+
'hatchPath': new Set([
|
|
807
|
+
'animate', 'animateTransform', 'desc', 'metadata', 'script', 'set', 'title'
|
|
808
|
+
]),
|
|
784
809
|
'filter': new Set([
|
|
785
810
|
'animate', 'animateTransform', 'desc', 'feBlend', 'feColorMatrix', 'feComponentTransfer',
|
|
786
811
|
'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap',
|
package/src/svg-toolbox.js
CHANGED
|
@@ -874,6 +874,49 @@ export const removeUnknownsAndDefaults = createOperation(
|
|
|
874
874
|
css.includes(':not(');
|
|
875
875
|
});
|
|
876
876
|
|
|
877
|
+
// Map lowercase SVG tag names to their canonical mixed-case forms
|
|
878
|
+
// SVG is case-sensitive but XML parsers may lowercase tag names
|
|
879
|
+
const tagNameMap = {
|
|
880
|
+
'clippath': 'clipPath',
|
|
881
|
+
'textpath': 'textPath',
|
|
882
|
+
'lineargradient': 'linearGradient',
|
|
883
|
+
'radialgradient': 'radialGradient',
|
|
884
|
+
'meshgradient': 'meshGradient',
|
|
885
|
+
'hatchpath': 'hatchPath',
|
|
886
|
+
'solidcolor': 'solidColor',
|
|
887
|
+
'foreignobject': 'foreignObject',
|
|
888
|
+
'feblend': 'feBlend',
|
|
889
|
+
'fecolormatrix': 'feColorMatrix',
|
|
890
|
+
'fecomponenttransfer': 'feComponentTransfer',
|
|
891
|
+
'fecomposite': 'feComposite',
|
|
892
|
+
'feconvolvematrix': 'feConvolveMatrix',
|
|
893
|
+
'fediffuselighting': 'feDiffuseLighting',
|
|
894
|
+
'fedisplacementmap': 'feDisplacementMap',
|
|
895
|
+
'fedistantlight': 'feDistantLight',
|
|
896
|
+
'fedropshadow': 'feDropShadow',
|
|
897
|
+
'feflood': 'feFlood',
|
|
898
|
+
'fefunca': 'feFuncA',
|
|
899
|
+
'fefuncb': 'feFuncB',
|
|
900
|
+
'fefuncg': 'feFuncG',
|
|
901
|
+
'fefuncr': 'feFuncR',
|
|
902
|
+
'fegaussianblur': 'feGaussianBlur',
|
|
903
|
+
'feimage': 'feImage',
|
|
904
|
+
'femerge': 'feMerge',
|
|
905
|
+
'femergenode': 'feMergeNode',
|
|
906
|
+
'femorphology': 'feMorphology',
|
|
907
|
+
'feoffset': 'feOffset',
|
|
908
|
+
'fepointlight': 'fePointLight',
|
|
909
|
+
'fespecularlighting': 'feSpecularLighting',
|
|
910
|
+
'fespotlight': 'feSpotLight',
|
|
911
|
+
'fetile': 'feTile',
|
|
912
|
+
'feturbulence': 'feTurbulence',
|
|
913
|
+
'animatemotion': 'animateMotion',
|
|
914
|
+
'animatetransform': 'animateTransform',
|
|
915
|
+
};
|
|
916
|
+
const canonicalTagName = (tag) => tagNameMap[tag] || tag;
|
|
917
|
+
|
|
918
|
+
// Known SVG elements - include both mixed-case and lowercase variants
|
|
919
|
+
// because XML parsers may lowercase tag names
|
|
877
920
|
const knownElements = [
|
|
878
921
|
"svg",
|
|
879
922
|
"g",
|
|
@@ -887,13 +930,22 @@ export const removeUnknownsAndDefaults = createOperation(
|
|
|
887
930
|
"text",
|
|
888
931
|
"tspan",
|
|
889
932
|
"tref",
|
|
890
|
-
"textPath",
|
|
933
|
+
"textPath", "textpath",
|
|
891
934
|
"defs",
|
|
892
|
-
"clipPath",
|
|
935
|
+
"clipPath", "clippath",
|
|
893
936
|
"mask",
|
|
894
937
|
"pattern",
|
|
895
|
-
"linearGradient",
|
|
896
|
-
"radialGradient",
|
|
938
|
+
"linearGradient", "lineargradient",
|
|
939
|
+
"radialGradient", "radialgradient",
|
|
940
|
+
// SVG 2.0 gradient elements (mesh gradients)
|
|
941
|
+
"meshGradient", "meshgradient",
|
|
942
|
+
"meshrow",
|
|
943
|
+
"meshpatch",
|
|
944
|
+
// SVG 2.0 hatch elements
|
|
945
|
+
"hatch",
|
|
946
|
+
"hatchpath", "hatchPath",
|
|
947
|
+
// SVG 2.0 solid color
|
|
948
|
+
"solidcolor", "solidColor",
|
|
897
949
|
"stop",
|
|
898
950
|
"image",
|
|
899
951
|
"use",
|
|
@@ -902,38 +954,38 @@ export const removeUnknownsAndDefaults = createOperation(
|
|
|
902
954
|
"title",
|
|
903
955
|
"desc",
|
|
904
956
|
"metadata",
|
|
905
|
-
"foreignObject",
|
|
957
|
+
"foreignObject", "foreignobject",
|
|
906
958
|
"switch",
|
|
907
959
|
"a",
|
|
908
960
|
"filter",
|
|
909
|
-
"feBlend",
|
|
910
|
-
"feColorMatrix",
|
|
911
|
-
"feComponentTransfer",
|
|
912
|
-
"feComposite",
|
|
913
|
-
"feConvolveMatrix",
|
|
914
|
-
"feDiffuseLighting",
|
|
915
|
-
"feDisplacementMap",
|
|
916
|
-
"feDistantLight",
|
|
917
|
-
"feDropShadow",
|
|
918
|
-
"feFlood",
|
|
919
|
-
"feFuncA",
|
|
920
|
-
"feFuncB",
|
|
921
|
-
"feFuncG",
|
|
922
|
-
"feFuncR",
|
|
923
|
-
"feGaussianBlur",
|
|
924
|
-
"feImage",
|
|
925
|
-
"feMerge",
|
|
926
|
-
"feMergeNode",
|
|
927
|
-
"feMorphology",
|
|
928
|
-
"feOffset",
|
|
929
|
-
"fePointLight",
|
|
930
|
-
"feSpecularLighting",
|
|
931
|
-
"feSpotLight",
|
|
932
|
-
"feTile",
|
|
933
|
-
"feTurbulence",
|
|
961
|
+
"feBlend", "feblend",
|
|
962
|
+
"feColorMatrix", "fecolormatrix",
|
|
963
|
+
"feComponentTransfer", "fecomponenttransfer",
|
|
964
|
+
"feComposite", "fecomposite",
|
|
965
|
+
"feConvolveMatrix", "feconvolvematrix",
|
|
966
|
+
"feDiffuseLighting", "fediffuselighting",
|
|
967
|
+
"feDisplacementMap", "fedisplacementmap",
|
|
968
|
+
"feDistantLight", "fedistantlight",
|
|
969
|
+
"feDropShadow", "fedropshadow",
|
|
970
|
+
"feFlood", "feflood",
|
|
971
|
+
"feFuncA", "fefunca",
|
|
972
|
+
"feFuncB", "fefuncb",
|
|
973
|
+
"feFuncG", "fefuncg",
|
|
974
|
+
"feFuncR", "fefuncr",
|
|
975
|
+
"feGaussianBlur", "fegaussianblur",
|
|
976
|
+
"feImage", "feimage",
|
|
977
|
+
"feMerge", "femerge",
|
|
978
|
+
"feMergeNode", "femergenode",
|
|
979
|
+
"feMorphology", "femorphology",
|
|
980
|
+
"feOffset", "feoffset",
|
|
981
|
+
"fePointLight", "fepointlight",
|
|
982
|
+
"feSpecularLighting", "fespecularlighting",
|
|
983
|
+
"feSpotLight", "fespotlight",
|
|
984
|
+
"feTile", "fetile",
|
|
985
|
+
"feTurbulence", "feturbulence",
|
|
934
986
|
"animate",
|
|
935
|
-
"animateMotion",
|
|
936
|
-
"animateTransform",
|
|
987
|
+
"animateMotion", "animatemotion",
|
|
988
|
+
"animateTransform", "animatetransform",
|
|
937
989
|
"set",
|
|
938
990
|
"mpath",
|
|
939
991
|
"view",
|
|
@@ -1123,7 +1175,9 @@ export const removeUnknownsAndDefaults = createOperation(
|
|
|
1123
1175
|
// Remove unknown children (but protect known ones)
|
|
1124
1176
|
// P4-1: Also validate parent-child relationships per SVG spec
|
|
1125
1177
|
const parentTagLower = el.tagName.toLowerCase();
|
|
1126
|
-
|
|
1178
|
+
// Try lowercase key first, then canonical mixed-case (case-insensitive lookup)
|
|
1179
|
+
const allowedChildren = allowedChildrenPerElement[parentTagLower] ||
|
|
1180
|
+
allowedChildrenPerElement[canonicalTagName(parentTagLower)];
|
|
1127
1181
|
|
|
1128
1182
|
for (const child of [...el.children]) {
|
|
1129
1183
|
if (isElement(child)) {
|
|
@@ -1142,7 +1196,10 @@ export const removeUnknownsAndDefaults = createOperation(
|
|
|
1142
1196
|
|
|
1143
1197
|
// P4-1: Check if child is allowed for this parent
|
|
1144
1198
|
// Only validate if we have rules for this parent
|
|
1145
|
-
|
|
1199
|
+
// Use case-insensitive check: try both lowercase and canonical form
|
|
1200
|
+
if (allowedChildren &&
|
|
1201
|
+
!allowedChildren.has(childTagLower) &&
|
|
1202
|
+
!allowedChildren.has(canonicalTagName(childTagLower))) {
|
|
1146
1203
|
// Invalid child for this parent - remove it
|
|
1147
1204
|
el.removeChild(child);
|
|
1148
1205
|
continue;
|
|
@@ -1323,12 +1380,42 @@ export const removeHiddenElements = createOperation((doc, options = {}) => {
|
|
|
1323
1380
|
|
|
1324
1381
|
/**
|
|
1325
1382
|
* Remove empty text elements
|
|
1383
|
+
* Recursively checks for text content in child elements (tspan, etc.)
|
|
1384
|
+
* Preserves text elements with namespace attributes when preserveNamespaces is set
|
|
1326
1385
|
*/
|
|
1327
1386
|
export const removeEmptyText = createOperation((doc, options = {}) => {
|
|
1328
1387
|
const textElements = doc.getElementsByTagName("text");
|
|
1329
1388
|
|
|
1389
|
+
// Helper to get all text content recursively from element and children
|
|
1390
|
+
const getTextContentRecursive = (el) => {
|
|
1391
|
+
let text = el.textContent || '';
|
|
1392
|
+
if (el.children) {
|
|
1393
|
+
for (const child of el.children) {
|
|
1394
|
+
text += getTextContentRecursive(child);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return text;
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
// Helper to check if element has preserved namespace attributes
|
|
1401
|
+
const hasPreservedNsAttrs = (el, preserveNs) => {
|
|
1402
|
+
if (!preserveNs || preserveNs.length === 0) return false;
|
|
1403
|
+
const attrNames = el.getAttributeNames ? el.getAttributeNames() : [];
|
|
1404
|
+
return attrNames.some(attr =>
|
|
1405
|
+
preserveNs.some(ns => attr.startsWith(ns + ':'))
|
|
1406
|
+
);
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1409
|
+
const preserveNamespaces = options.preserveNamespaces || [];
|
|
1410
|
+
|
|
1330
1411
|
for (const text of [...textElements]) {
|
|
1331
|
-
|
|
1412
|
+
const recursiveContent = getTextContentRecursive(text);
|
|
1413
|
+
const isEmpty = !recursiveContent || recursiveContent.trim() === "";
|
|
1414
|
+
|
|
1415
|
+
// Don't remove if it has preserved namespace attributes (e.g., Inkscape tile metadata)
|
|
1416
|
+
const hasNsAttrs = hasPreservedNsAttrs(text, preserveNamespaces);
|
|
1417
|
+
|
|
1418
|
+
if (isEmpty && !hasNsAttrs) {
|
|
1332
1419
|
if (text.parentNode) {
|
|
1333
1420
|
text.parentNode.removeChild(text);
|
|
1334
1421
|
}
|
|
@@ -1340,14 +1427,16 @@ export const removeEmptyText = createOperation((doc, options = {}) => {
|
|
|
1340
1427
|
|
|
1341
1428
|
/**
|
|
1342
1429
|
* Remove empty container elements
|
|
1430
|
+
* Note: Patterns with xlink:href/href inherit content and are NOT empty
|
|
1343
1431
|
*/
|
|
1344
1432
|
export const removeEmptyContainers = createOperation((doc, options = {}) => {
|
|
1433
|
+
// Include both mixed-case and lowercase variants for case-insensitive matching
|
|
1345
1434
|
const containers = [
|
|
1346
1435
|
"g",
|
|
1347
1436
|
"defs",
|
|
1348
1437
|
"symbol",
|
|
1349
1438
|
"marker",
|
|
1350
|
-
"clipPath",
|
|
1439
|
+
"clipPath", "clippath",
|
|
1351
1440
|
"mask",
|
|
1352
1441
|
"pattern",
|
|
1353
1442
|
];
|
|
@@ -1358,8 +1447,21 @@ export const removeEmptyContainers = createOperation((doc, options = {}) => {
|
|
|
1358
1447
|
if (isElement(child)) processElement(child);
|
|
1359
1448
|
}
|
|
1360
1449
|
|
|
1361
|
-
|
|
1362
|
-
|
|
1450
|
+
const tagLower = el.tagName?.toLowerCase();
|
|
1451
|
+
|
|
1452
|
+
// Skip if not a container type
|
|
1453
|
+
if (!containers.includes(el.tagName) && !containers.includes(tagLower)) {
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Don't remove if element has href/xlink:href (inherits content from reference)
|
|
1458
|
+
// This is common for patterns that reference other patterns
|
|
1459
|
+
if (el.getAttribute('href') || el.getAttribute('xlink:href')) {
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Remove if container is truly empty (no children)
|
|
1464
|
+
if (el.children.length === 0) {
|
|
1363
1465
|
if (el.parentNode) {
|
|
1364
1466
|
el.parentNode.removeChild(el);
|
|
1365
1467
|
}
|
package/src/svg2-polyfills.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* @module svg2-polyfills
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { SVGElement } from './svg-parser.js';
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* SVG 2.0 features that can be polyfilled
|
|
15
17
|
*/
|
|
@@ -364,10 +366,12 @@ export function generatePolyfillScript(features) {
|
|
|
364
366
|
* @param {Object} doc - Parsed SVG document
|
|
365
367
|
* @param {Object} [options] - Options
|
|
366
368
|
* @param {boolean} [options.force=false] - Force injection even if no features detected
|
|
369
|
+
* @param {Object} [options.features] - Pre-detected features (use instead of re-detecting)
|
|
367
370
|
* @returns {Object} The document (modified in place)
|
|
368
371
|
*/
|
|
369
372
|
export function injectPolyfills(doc, options = {}) {
|
|
370
|
-
|
|
373
|
+
// Use pre-detected features if provided (for when pipeline has removed SVG2 elements)
|
|
374
|
+
const features = options.features || detectSVG2Features(doc);
|
|
371
375
|
|
|
372
376
|
// Check if polyfills are needed
|
|
373
377
|
if (!options.force &&
|
|
@@ -382,28 +386,24 @@ export function injectPolyfills(doc, options = {}) {
|
|
|
382
386
|
// Find or create the SVG root
|
|
383
387
|
const svg = doc.documentElement || doc;
|
|
384
388
|
|
|
385
|
-
// Create script
|
|
386
|
-
//
|
|
387
|
-
const scriptEl = {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
textContent: script,
|
|
392
|
-
getAttribute(name) { return this.attributes.get(name); },
|
|
393
|
-
setAttribute(name, value) { this.attributes.set(name, value); },
|
|
394
|
-
hasAttribute(name) { return this.attributes.has(name); },
|
|
395
|
-
removeAttribute(name) { this.attributes.delete(name); },
|
|
396
|
-
getAttributeNames() { return [...this.attributes.keys()]; },
|
|
397
|
-
appendChild(child) { this.children.push(child); },
|
|
398
|
-
removeChild(child) {
|
|
399
|
-
const idx = this.children.indexOf(child);
|
|
400
|
-
if (idx >= 0) this.children.splice(idx, 1);
|
|
401
|
-
}
|
|
402
|
-
};
|
|
389
|
+
// Create a proper SVGElement for the script
|
|
390
|
+
// The script content uses CDATA to avoid XML escaping issues
|
|
391
|
+
const scriptEl = new SVGElement('script', {
|
|
392
|
+
type: 'text/javascript',
|
|
393
|
+
id: 'svg-matrix-polyfill'
|
|
394
|
+
}, [], script);
|
|
403
395
|
|
|
404
|
-
// Insert script at beginning of SVG (after
|
|
396
|
+
// Insert script at beginning of SVG (after defs if present, else at start)
|
|
405
397
|
if (svg.children && svg.children.length > 0) {
|
|
406
|
-
|
|
398
|
+
// Find first non-defs element to insert before
|
|
399
|
+
let insertIdx = 0;
|
|
400
|
+
for (let i = 0; i < svg.children.length; i++) {
|
|
401
|
+
if (svg.children[i].tagName === 'defs') {
|
|
402
|
+
insertIdx = i + 1;
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
svg.children.splice(insertIdx, 0, scriptEl);
|
|
407
407
|
} else if (svg.children) {
|
|
408
408
|
svg.children.push(scriptEl);
|
|
409
409
|
}
|