@emasoft/svg-matrix 1.0.18 → 1.0.20

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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Animation-Aware Reference Tracking for SVG
3
+ *
4
+ * Unlike SVGO (which destroys animations by removing "unused" defs),
5
+ * this module tracks ALL references including:
6
+ *
7
+ * 1. STATIC REFERENCES:
8
+ * - href="#id", xlink:href="#id"
9
+ * - url(#id) in fill, stroke, clip-path, mask, filter, marker-*
10
+ * - CSS url() references in style attributes and <style> blocks
11
+ *
12
+ * 2. SMIL ANIMATION REFERENCES:
13
+ * - <animate values="#id1;#id2;#id3"> (frame-by-frame animation)
14
+ * - <animate from="#id1" to="#id2">
15
+ * - <set to="#id">
16
+ * - begin="id.click", begin="id.end", begin="id.begin+1s"
17
+ * - end="id.click", end="id.end"
18
+ * - <animateMotion><mpath xlink:href="#path"/></animateMotion>
19
+ *
20
+ * 3. CSS ANIMATION REFERENCES:
21
+ * - @keyframes names referenced by animation-name
22
+ * - url(#id) in @keyframes rules
23
+ *
24
+ * 4. JAVASCRIPT PATTERN DETECTION (best-effort):
25
+ * - getElementById('id') patterns
26
+ * - querySelector('#id') patterns
27
+ *
28
+ * @module animation-references
29
+ */
30
+
31
+ import { SVGElement } from './svg-parser.js';
32
+
33
+ // Animation elements that can reference other elements
34
+ const ANIMATION_ELEMENTS = ['animate', 'animateTransform', 'animateMotion', 'animateColor', 'set'];
35
+
36
+ // Attributes that can contain ID references
37
+ const HREF_ATTRIBUTES = ['href', 'xlink:href'];
38
+
39
+ // Attributes that use url(#id) syntax
40
+ const URL_ATTRIBUTES = [
41
+ 'fill', 'stroke', 'clip-path', 'mask', 'filter',
42
+ 'marker-start', 'marker-mid', 'marker-end',
43
+ 'cursor', 'color-profile'
44
+ ];
45
+
46
+ // Animation timing attributes that can reference other elements by ID
47
+ const TIMING_ATTRIBUTES = ['begin', 'end'];
48
+
49
+ // Animation value attributes that can contain ID references
50
+ const VALUE_ATTRIBUTES = ['values', 'from', 'to', 'by'];
51
+
52
+ /**
53
+ * Parse ID from url(#id) or url("#id") syntax
54
+ * @param {string} value - Attribute value
55
+ * @returns {string|null} Parsed ID or null
56
+ */
57
+ export function parseUrlId(value) {
58
+ if (!value || typeof value !== 'string') return null;
59
+
60
+ // Match url(#id) or url("#id") or url('#id') with optional whitespace
61
+ const match = value.match(/url\(\s*["']?#([^"')\s]+)\s*["']?\s*\)/);
62
+ return match ? match[1] : null;
63
+ }
64
+
65
+ /**
66
+ * Parse ID from href="#id" or xlink:href="#id" syntax
67
+ * @param {string} value - Attribute value
68
+ * @returns {string|null} Parsed ID or null
69
+ */
70
+ export function parseHrefId(value) {
71
+ if (!value || typeof value !== 'string') return null;
72
+ if (value.startsWith('#')) {
73
+ return value.substring(1);
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Parse IDs from animation values attribute (semicolon-separated)
80
+ * Example: values="#FRAME001;#FRAME002;#FRAME003"
81
+ * @param {string} value - Values attribute
82
+ * @returns {string[]} Array of parsed IDs
83
+ */
84
+ export function parseAnimationValueIds(value) {
85
+ if (!value || typeof value !== 'string') return [];
86
+
87
+ const ids = [];
88
+ // Split by semicolon and find #id references
89
+ const parts = value.split(';');
90
+ for (const part of parts) {
91
+ const trimmed = part.trim();
92
+ if (trimmed.startsWith('#')) {
93
+ ids.push(trimmed.substring(1));
94
+ }
95
+ // Also check for url(#id) within values
96
+ const urlId = parseUrlId(trimmed);
97
+ if (urlId) ids.push(urlId);
98
+ }
99
+ return ids;
100
+ }
101
+
102
+ /**
103
+ * Parse IDs from timing attributes (begin, end)
104
+ * Examples:
105
+ * - "button.click" -> "button"
106
+ * - "anim1.end" -> "anim1"
107
+ * - "anim1.begin+1s" -> "anim1"
108
+ * - "click" -> null (no ID reference)
109
+ * - "3s;button.click" -> "button"
110
+ * @param {string} value - Timing attribute value
111
+ * @returns {string[]} Array of parsed IDs
112
+ */
113
+ export function parseTimingIds(value) {
114
+ if (!value || typeof value !== 'string') return [];
115
+
116
+ const ids = [];
117
+ // Split by semicolon for multiple timing values
118
+ const parts = value.split(';');
119
+
120
+ for (const part of parts) {
121
+ const trimmed = part.trim();
122
+ // Match patterns like "id.event" or "id.begin" or "id.end"
123
+ // Events: click, mousedown, mouseup, mouseover, mouseout, focusin, focusout, etc.
124
+ const match = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\.(begin|end|click|mousedown|mouseup|mouseover|mouseout|mousemove|mouseenter|mouseleave|focusin|focusout|activate|repeat)/);
125
+ if (match) {
126
+ ids.push(match[1]);
127
+ }
128
+ }
129
+ return ids;
130
+ }
131
+
132
+ /**
133
+ * Parse IDs from CSS content (style attribute or <style> element)
134
+ * @param {string} css - CSS content
135
+ * @returns {string[]} Array of parsed IDs
136
+ */
137
+ export function parseCSSIds(css) {
138
+ if (!css || typeof css !== 'string') return [];
139
+
140
+ const ids = [];
141
+
142
+ // Find url(#id) references
143
+ const urlRegex = /url\(\s*["']?#([^"')]+)["']?\s*\)/g;
144
+ let match;
145
+ while ((match = urlRegex.exec(css)) !== null) {
146
+ ids.push(match[1]);
147
+ }
148
+
149
+ return ids;
150
+ }
151
+
152
+ /**
153
+ * Parse IDs from JavaScript content (best-effort)
154
+ * @param {string} js - JavaScript content
155
+ * @returns {string[]} Array of parsed IDs
156
+ */
157
+ export function parseJavaScriptIds(js) {
158
+ if (!js || typeof js !== 'string') return [];
159
+
160
+ const ids = [];
161
+
162
+ // getElementById('id') or getElementById("id")
163
+ const getByIdRegex = /getElementById\(\s*["']([^"']+)["']\s*\)/g;
164
+ let match;
165
+ while ((match = getByIdRegex.exec(js)) !== null) {
166
+ ids.push(match[1]);
167
+ }
168
+
169
+ // querySelector('#id') or querySelector("#id")
170
+ const querySelectorRegex = /querySelector(?:All)?\(\s*["']#([^"'#\s]+)["']\s*\)/g;
171
+ while ((match = querySelectorRegex.exec(js)) !== null) {
172
+ ids.push(match[1]);
173
+ }
174
+
175
+ return ids;
176
+ }
177
+
178
+ /**
179
+ * Collect all references from a single element
180
+ * @param {SVGElement} el - SVG element to scan
181
+ * @returns {{static: Set<string>, animation: Set<string>, css: Set<string>, js: Set<string>}}
182
+ */
183
+ export function collectElementReferences(el) {
184
+ const refs = {
185
+ static: new Set(),
186
+ animation: new Set(),
187
+ css: new Set(),
188
+ js: new Set()
189
+ };
190
+
191
+ const tagName = el.tagName?.toLowerCase() || '';
192
+ const isAnimationElement = ANIMATION_ELEMENTS.includes(tagName);
193
+
194
+ for (const attrName of el.getAttributeNames()) {
195
+ const value = el.getAttribute(attrName);
196
+ if (!value) continue;
197
+
198
+ // Check href attributes
199
+ if (HREF_ATTRIBUTES.includes(attrName)) {
200
+ const id = parseHrefId(value);
201
+ if (id) {
202
+ if (isAnimationElement) {
203
+ refs.animation.add(id);
204
+ } else {
205
+ refs.static.add(id);
206
+ }
207
+ }
208
+ }
209
+
210
+ // Check url() attributes
211
+ if (URL_ATTRIBUTES.includes(attrName) || attrName === 'style') {
212
+ const id = parseUrlId(value);
213
+ if (id) refs.static.add(id);
214
+
215
+ // For style attribute, also parse CSS IDs
216
+ if (attrName === 'style') {
217
+ parseCSSIds(value).forEach(id => refs.css.add(id));
218
+ }
219
+ }
220
+
221
+ // Check animation timing attributes (begin, end)
222
+ if (TIMING_ATTRIBUTES.includes(attrName)) {
223
+ parseTimingIds(value).forEach(id => refs.animation.add(id));
224
+ }
225
+
226
+ // Check animation value attributes (values, from, to, by)
227
+ if (VALUE_ATTRIBUTES.includes(attrName)) {
228
+ parseAnimationValueIds(value).forEach(id => refs.animation.add(id));
229
+ }
230
+
231
+ // Also check for url() in any attribute (some custom attributes may use it)
232
+ if (!URL_ATTRIBUTES.includes(attrName) && value.includes('url(')) {
233
+ const id = parseUrlId(value);
234
+ if (id) refs.static.add(id);
235
+ }
236
+ }
237
+
238
+ // Check <mpath> element inside <animateMotion>
239
+ if (tagName === 'animatemotion') {
240
+ const mpaths = el.getElementsByTagName('mpath');
241
+ for (const mpath of mpaths) {
242
+ for (const attr of HREF_ATTRIBUTES) {
243
+ const value = mpath.getAttribute(attr);
244
+ const id = parseHrefId(value);
245
+ if (id) refs.animation.add(id);
246
+ }
247
+ }
248
+ }
249
+
250
+ return refs;
251
+ }
252
+
253
+ /**
254
+ * Collect all ID references from an SVG document tree
255
+ * This is the main function that ensures we never remove animated elements
256
+ *
257
+ * @param {SVGElement} root - Root SVG element
258
+ * @returns {{
259
+ * all: Set<string>,
260
+ * static: Set<string>,
261
+ * animation: Set<string>,
262
+ * css: Set<string>,
263
+ * js: Set<string>,
264
+ * details: Map<string, {sources: string[], type: string}>
265
+ * }}
266
+ */
267
+ export function collectAllReferences(root) {
268
+ const result = {
269
+ all: new Set(),
270
+ static: new Set(),
271
+ animation: new Set(),
272
+ css: new Set(),
273
+ js: new Set(),
274
+ details: new Map() // ID -> { sources: [], type: 'static'|'animation'|'css'|'js' }
275
+ };
276
+
277
+ // Type priority: animation > js > css > static
278
+ // Animation is highest priority because SVGO destroys it
279
+ const TYPE_PRIORITY = { animation: 4, js: 3, css: 2, static: 1 };
280
+
281
+ const addRef = (id, type, source) => {
282
+ result.all.add(id);
283
+ result[type].add(id);
284
+
285
+ if (!result.details.has(id)) {
286
+ result.details.set(id, { sources: [], type });
287
+ } else {
288
+ // Update type if new type has higher priority
289
+ const existing = result.details.get(id);
290
+ if (TYPE_PRIORITY[type] > TYPE_PRIORITY[existing.type]) {
291
+ existing.type = type;
292
+ }
293
+ }
294
+ result.details.get(id).sources.push(source);
295
+ };
296
+
297
+ const processElement = (el, path = '') => {
298
+ const tagName = el.tagName?.toLowerCase() || '';
299
+ const currentPath = path ? `${path}>${tagName}` : tagName;
300
+ const elId = el.getAttribute('id');
301
+ const elPath = elId ? `${tagName}#${elId}` : currentPath;
302
+
303
+ // Collect element references
304
+ const refs = collectElementReferences(el);
305
+
306
+ refs.static.forEach(id => addRef(id, 'static', elPath));
307
+ refs.animation.forEach(id => addRef(id, 'animation', elPath));
308
+ refs.css.forEach(id => addRef(id, 'css', elPath));
309
+ refs.js.forEach(id => addRef(id, 'js', elPath));
310
+
311
+ // Process <style> elements
312
+ if (tagName === 'style') {
313
+ const cssContent = el.textContent || '';
314
+ parseCSSIds(cssContent).forEach(id => addRef(id, 'css', `<style>`));
315
+ }
316
+
317
+ // Process <script> elements
318
+ if (tagName === 'script') {
319
+ const jsContent = el.textContent || '';
320
+ parseJavaScriptIds(jsContent).forEach(id => addRef(id, 'js', `<script>`));
321
+ }
322
+
323
+ // Recurse to children
324
+ for (const child of el.children) {
325
+ if (child instanceof SVGElement) {
326
+ processElement(child, currentPath);
327
+ }
328
+ }
329
+ };
330
+
331
+ processElement(root);
332
+
333
+ return result;
334
+ }
335
+
336
+ /**
337
+ * Check if an ID is referenced anywhere (static, animation, CSS, or JS)
338
+ * @param {SVGElement} root - Root SVG element
339
+ * @param {string} id - ID to check
340
+ * @returns {boolean} True if the ID is referenced
341
+ */
342
+ export function isIdReferenced(root, id) {
343
+ const refs = collectAllReferences(root);
344
+ return refs.all.has(id);
345
+ }
346
+
347
+ /**
348
+ * Get detailed reference information for an ID
349
+ * @param {SVGElement} root - Root SVG element
350
+ * @param {string} id - ID to check
351
+ * @returns {{referenced: boolean, type: string|null, sources: string[]}}
352
+ */
353
+ export function getIdReferenceInfo(root, id) {
354
+ const refs = collectAllReferences(root);
355
+ const details = refs.details.get(id);
356
+
357
+ return {
358
+ referenced: refs.all.has(id),
359
+ type: details?.type || null,
360
+ sources: details?.sources || []
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Find unreferenced IDs in defs that are SAFE to remove
366
+ * Unlike SVGO, this properly skips animation-referenced elements
367
+ *
368
+ * @param {SVGElement} root - Root SVG element
369
+ * @returns {{safeToRemove: string[], referenced: string[], animationReferenced: string[]}}
370
+ */
371
+ export function findUnreferencedDefs(root) {
372
+ const refs = collectAllReferences(root);
373
+
374
+ const safeToRemove = [];
375
+ const referenced = [];
376
+ const animationReferenced = [];
377
+
378
+ // Scan all defs
379
+ const defsElements = root.getElementsByTagName('defs');
380
+ for (const defs of defsElements) {
381
+ for (const child of defs.children) {
382
+ if (child instanceof SVGElement) {
383
+ const id = child.getAttribute('id');
384
+ if (!id) continue;
385
+
386
+ if (refs.animation.has(id)) {
387
+ animationReferenced.push(id);
388
+ } else if (refs.all.has(id)) {
389
+ referenced.push(id);
390
+ } else {
391
+ safeToRemove.push(id);
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ return { safeToRemove, referenced, animationReferenced };
398
+ }
399
+
400
+ /**
401
+ * Remove only truly unreferenced defs (animation-safe version)
402
+ * This is the SAFE alternative to SVGO's aggressive removal
403
+ *
404
+ * @param {SVGElement} root - Root SVG element
405
+ * @returns {{removed: string[], kept: string[], keptForAnimation: string[]}}
406
+ */
407
+ export function removeUnreferencedDefsSafe(root) {
408
+ const { safeToRemove, referenced, animationReferenced } = findUnreferencedDefs(root);
409
+
410
+ const removed = [];
411
+
412
+ // Only remove elements that are truly unreferenced
413
+ const defsElements = root.getElementsByTagName('defs');
414
+ for (const defs of defsElements) {
415
+ for (const child of [...defs.children]) {
416
+ if (child instanceof SVGElement) {
417
+ const id = child.getAttribute('id');
418
+ if (id && safeToRemove.includes(id)) {
419
+ defs.removeChild(child);
420
+ removed.push(id);
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ return {
427
+ removed,
428
+ kept: referenced,
429
+ keptForAnimation: animationReferenced
430
+ };
431
+ }
432
+
433
+ // Named exports for all functions
434
+ export {
435
+ ANIMATION_ELEMENTS,
436
+ HREF_ATTRIBUTES,
437
+ URL_ATTRIBUTES,
438
+ TIMING_ATTRIBUTES,
439
+ VALUE_ATTRIBUTES
440
+ };