@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,443 @@
1
+ /**
2
+ * CSS Specificity Calculation
3
+ *
4
+ * Calculates CSS selector specificity according to W3C spec.
5
+ * https://www.w3.org/TR/selectors-3/#specificity
6
+ *
7
+ * Note: This module deals with integer counts (not precision-critical),
8
+ * but follows project patterns for consistency.
9
+ */
10
+
11
+ // Selector component types
12
+ const SELECTOR_TYPES = {
13
+ ID: 'id', // #foo
14
+ CLASS: 'class', // .foo
15
+ ATTRIBUTE: 'attr', // [foo]
16
+ PSEUDO_CLASS: 'pseudo-class', // :hover
17
+ PSEUDO_ELEMENT: 'pseudo-element', // ::before
18
+ TYPE: 'type', // div
19
+ UNIVERSAL: 'universal' // *
20
+ };
21
+
22
+ /**
23
+ * Parse a CSS selector string into its component parts.
24
+ *
25
+ * Handles:
26
+ * - Tag names (div, span)
27
+ * - Classes (.foo)
28
+ * - IDs (#bar)
29
+ * - Attribute selectors ([type="text"])
30
+ * - Pseudo-classes (:hover, :not())
31
+ * - Pseudo-elements (::before, :after)
32
+ * - Combinators (>, +, ~, space)
33
+ *
34
+ * @param {string} selectorString - CSS selector to parse
35
+ * @returns {Array<Object>} Array of parsed selector components
36
+ * @throws {Error} If selector syntax is invalid
37
+ *
38
+ * @example
39
+ * parseSelector('div.class#id:hover::before')
40
+ * // Returns array of component objects with type and value
41
+ */
42
+ export function parseSelector(selectorString) {
43
+ if (typeof selectorString !== 'string' || !selectorString.trim()) {
44
+ throw new Error('Selector must be a non-empty string');
45
+ }
46
+
47
+ const selector = selectorString.trim();
48
+ const components = [];
49
+ let i = 0;
50
+
51
+ // Split by combinators first (>, +, ~, space) to handle complex selectors
52
+ const parts = splitByCombinators(selector);
53
+
54
+ for (const part of parts) {
55
+ if (!part.trim()) continue;
56
+
57
+ const partComponents = parseSimpleSelector(part.trim());
58
+ components.push(...partComponents);
59
+ }
60
+
61
+ return components;
62
+ }
63
+
64
+ /**
65
+ * Split selector by combinators while preserving them.
66
+ * Handles: descendant (space), child (>), adjacent sibling (+), general sibling (~)
67
+ *
68
+ * @param {string} selector - Selector string
69
+ * @returns {Array<string>} Parts split by combinators
70
+ */
71
+ function splitByCombinators(selector) {
72
+ // Match combinators outside of brackets and parentheses
73
+ const parts = [];
74
+ let current = '';
75
+ let depth = 0; // Track nesting in [] and ()
76
+ let inBracket = false;
77
+ let inParen = false;
78
+
79
+ for (let i = 0; i < selector.length; i++) {
80
+ const char = selector[i];
81
+
82
+ if (char === '[') {
83
+ inBracket = true;
84
+ depth++;
85
+ } else if (char === ']') {
86
+ depth--;
87
+ if (depth === 0) inBracket = false;
88
+ } else if (char === '(') {
89
+ inParen = true;
90
+ depth++;
91
+ } else if (char === ')') {
92
+ depth--;
93
+ if (depth === 0) inParen = false;
94
+ }
95
+
96
+ // Only split on combinators when not inside brackets or parens
97
+ if (depth === 0 && !inBracket && !inParen) {
98
+ if (char === '>' || char === '+' || char === '~') {
99
+ if (current.trim()) parts.push(current.trim());
100
+ parts.push(char); // Keep combinator as separate part for context
101
+ current = '';
102
+ continue;
103
+ } else if (char === ' ' && current.trim()) {
104
+ // Space combinator (descendant)
105
+ parts.push(current.trim());
106
+ current = '';
107
+ continue;
108
+ }
109
+ }
110
+
111
+ current += char;
112
+ }
113
+
114
+ if (current.trim()) {
115
+ parts.push(current.trim());
116
+ }
117
+
118
+ return parts;
119
+ }
120
+
121
+ /**
122
+ * Parse a simple selector (no combinators).
123
+ *
124
+ * @param {string} selector - Simple selector string
125
+ * @returns {Array<Object>} Array of component objects
126
+ */
127
+ function parseSimpleSelector(selector) {
128
+ const components = [];
129
+ let i = 0;
130
+
131
+ while (i < selector.length) {
132
+ const char = selector[i];
133
+
134
+ // ID selector
135
+ if (char === '#') {
136
+ const match = selector.slice(i).match(/^#([\w-]+)/);
137
+ if (!match) throw new Error(`Invalid ID selector at position ${i}`);
138
+ components.push({ type: SELECTOR_TYPES.ID, value: match[1] });
139
+ i += match[0].length;
140
+ }
141
+ // Class selector
142
+ else if (char === '.') {
143
+ const match = selector.slice(i).match(/^\.([\w-]+)/);
144
+ if (!match) throw new Error(`Invalid class selector at position ${i}`);
145
+ components.push({ type: SELECTOR_TYPES.CLASS, value: match[1] });
146
+ i += match[0].length;
147
+ }
148
+ // Attribute selector
149
+ else if (char === '[') {
150
+ const endIdx = findMatchingBracket(selector, i);
151
+ if (endIdx === -1) throw new Error(`Unclosed attribute selector at position ${i}`);
152
+ const attrContent = selector.slice(i + 1, endIdx);
153
+ components.push({ type: SELECTOR_TYPES.ATTRIBUTE, value: attrContent });
154
+ i = endIdx + 1;
155
+ }
156
+ // Pseudo-element (::) or pseudo-class (:)
157
+ else if (char === ':') {
158
+ if (selector[i + 1] === ':') {
159
+ // Pseudo-element
160
+ const match = selector.slice(i).match(/^::([\w-]+)/);
161
+ if (!match) throw new Error(`Invalid pseudo-element at position ${i}`);
162
+ components.push({ type: SELECTOR_TYPES.PSEUDO_ELEMENT, value: match[1] });
163
+ i += match[0].length;
164
+ } else {
165
+ // Pseudo-class (may have arguments like :not())
166
+ const match = selector.slice(i).match(/^:([\w-]+)/);
167
+ if (!match) throw new Error(`Invalid pseudo-class at position ${i}`);
168
+
169
+ let pseudoValue = match[1];
170
+ i += match[0].length;
171
+
172
+ // Check for function notation like :not()
173
+ if (selector[i] === '(') {
174
+ const endIdx = findMatchingParen(selector, i);
175
+ if (endIdx === -1) throw new Error(`Unclosed pseudo-class function at position ${i}`);
176
+ pseudoValue += selector.slice(i, endIdx + 1);
177
+ i = endIdx + 1;
178
+ }
179
+
180
+ components.push({ type: SELECTOR_TYPES.PSEUDO_CLASS, value: pseudoValue });
181
+ }
182
+ }
183
+ // Universal selector
184
+ else if (char === '*') {
185
+ components.push({ type: SELECTOR_TYPES.UNIVERSAL, value: '*' });
186
+ i++;
187
+ }
188
+ // Type selector (element name)
189
+ else if (/[a-zA-Z]/.test(char)) {
190
+ const match = selector.slice(i).match(/^([\w-]+)/);
191
+ if (!match) throw new Error(`Invalid type selector at position ${i}`);
192
+ components.push({ type: SELECTOR_TYPES.TYPE, value: match[1] });
193
+ i += match[0].length;
194
+ }
195
+ // Skip combinators (should not be in simple selector)
196
+ else if (char === '>' || char === '+' || char === '~' || char === ' ') {
197
+ i++;
198
+ }
199
+ else {
200
+ throw new Error(`Unexpected character '${char}' at position ${i}`);
201
+ }
202
+ }
203
+
204
+ return components;
205
+ }
206
+
207
+ /**
208
+ * Find matching closing bracket for attribute selector.
209
+ *
210
+ * @param {string} str - String to search
211
+ * @param {number} startIdx - Index of opening bracket
212
+ * @returns {number} Index of closing bracket or -1 if not found
213
+ */
214
+ function findMatchingBracket(str, startIdx) {
215
+ let depth = 0;
216
+ for (let i = startIdx; i < str.length; i++) {
217
+ if (str[i] === '[') depth++;
218
+ if (str[i] === ']') {
219
+ depth--;
220
+ if (depth === 0) return i;
221
+ }
222
+ }
223
+ return -1;
224
+ }
225
+
226
+ /**
227
+ * Find matching closing parenthesis for pseudo-class function.
228
+ *
229
+ * @param {string} str - String to search
230
+ * @param {number} startIdx - Index of opening parenthesis
231
+ * @returns {number} Index of closing parenthesis or -1 if not found
232
+ */
233
+ function findMatchingParen(str, startIdx) {
234
+ let depth = 0;
235
+ for (let i = startIdx; i < str.length; i++) {
236
+ if (str[i] === '(') depth++;
237
+ if (str[i] === ')') {
238
+ depth--;
239
+ if (depth === 0) return i;
240
+ }
241
+ }
242
+ return -1;
243
+ }
244
+
245
+ /**
246
+ * Calculate CSS specificity for a selector.
247
+ * Returns [a, b, c] where:
248
+ * - a = count of ID selectors
249
+ * - b = count of class selectors, attribute selectors, and pseudo-classes
250
+ * - c = count of type selectors and pseudo-elements
251
+ *
252
+ * Universal selector (*) and combinators do not contribute to specificity.
253
+ *
254
+ * Note: :not() pseudo-class itself doesn't count, but its argument does.
255
+ *
256
+ * @param {string|Array<Object>} selector - CSS selector string or parsed components
257
+ * @returns {Array<number>} [a, b, c] specificity values
258
+ *
259
+ * @example
260
+ * calculateSpecificity('div.class#id:hover::before')
261
+ * // Returns [1, 2, 2] (1 ID, 2 class+pseudo-class, 2 type+pseudo-element)
262
+ */
263
+ export function calculateSpecificity(selector) {
264
+ // Parse if string, otherwise assume it's already parsed
265
+ const components = typeof selector === 'string'
266
+ ? parseSelector(selector)
267
+ : selector;
268
+
269
+ let a = 0; // IDs
270
+ let b = 0; // Classes, attributes, pseudo-classes
271
+ let c = 0; // Types, pseudo-elements
272
+
273
+ for (const component of components) {
274
+ switch (component.type) {
275
+ case SELECTOR_TYPES.ID:
276
+ a++;
277
+ break;
278
+
279
+ case SELECTOR_TYPES.CLASS:
280
+ case SELECTOR_TYPES.ATTRIBUTE:
281
+ b++;
282
+ break;
283
+
284
+ case SELECTOR_TYPES.PSEUDO_CLASS:
285
+ // Handle :not() - it doesn't count itself, but its argument does
286
+ if (component.value.startsWith('not(')) {
287
+ const notContent = component.value.slice(4, -1); // Extract content inside :not()
288
+ const notSpec = calculateSpecificity(notContent);
289
+ a += notSpec[0];
290
+ b += notSpec[1];
291
+ c += notSpec[2];
292
+ } else {
293
+ b++;
294
+ }
295
+ break;
296
+
297
+ case SELECTOR_TYPES.TYPE:
298
+ case SELECTOR_TYPES.PSEUDO_ELEMENT:
299
+ c++;
300
+ break;
301
+
302
+ case SELECTOR_TYPES.UNIVERSAL:
303
+ // Universal selector doesn't contribute to specificity
304
+ break;
305
+
306
+ default:
307
+ // Unknown type, ignore
308
+ break;
309
+ }
310
+ }
311
+
312
+ return [a, b, c];
313
+ }
314
+
315
+ /**
316
+ * Compare two specificity values.
317
+ *
318
+ * @param {Array<number>} spec1 - First specificity [a, b, c]
319
+ * @param {Array<number>} spec2 - Second specificity [a, b, c]
320
+ * @returns {number} -1 if spec1 < spec2, 0 if equal, 1 if spec1 > spec2
321
+ *
322
+ * @example
323
+ * compareSpecificity([1, 0, 0], [0, 2, 1]) // Returns 1 (ID beats classes)
324
+ * compareSpecificity([0, 1, 2], [0, 1, 2]) // Returns 0 (equal)
325
+ */
326
+ export function compareSpecificity(spec1, spec2) {
327
+ if (!Array.isArray(spec1) || spec1.length !== 3) {
328
+ throw new Error('spec1 must be an array of 3 numbers');
329
+ }
330
+ if (!Array.isArray(spec2) || spec2.length !== 3) {
331
+ throw new Error('spec2 must be an array of 3 numbers');
332
+ }
333
+
334
+ // Compare lexicographically: a first, then b, then c
335
+ for (let i = 0; i < 3; i++) {
336
+ if (spec1[i] < spec2[i]) return -1;
337
+ if (spec1[i] > spec2[i]) return 1;
338
+ }
339
+
340
+ return 0; // Equal
341
+ }
342
+
343
+ /**
344
+ * Sort CSS rules by specificity (ascending order).
345
+ * Uses stable sort to preserve source order for rules with equal specificity.
346
+ *
347
+ * @param {Array<Object>} rules - Array of rule objects with 'selector' property
348
+ * @returns {Array<Object>} Sorted array of rules
349
+ *
350
+ * @example
351
+ * const rules = [
352
+ * { selector: '.foo', style: 'color: red' },
353
+ * { selector: '#bar', style: 'color: blue' },
354
+ * { selector: 'div', style: 'color: green' }
355
+ * ];
356
+ * sortBySpecificity(rules)
357
+ * // Returns rules sorted: div, .foo, #bar
358
+ */
359
+ export function sortBySpecificity(rules) {
360
+ if (!Array.isArray(rules)) {
361
+ throw new Error('rules must be an array');
362
+ }
363
+
364
+ // Create array of [rule, specificity, originalIndex] tuples
365
+ const withSpec = rules.map((rule, index) => {
366
+ if (!rule || typeof rule.selector !== 'string') {
367
+ throw new Error(`Rule at index ${index} must have a 'selector' property`);
368
+ }
369
+ return [rule, calculateSpecificity(rule.selector), index];
370
+ });
371
+
372
+ // Stable sort by specificity, using original index to preserve source order
373
+ withSpec.sort((a, b) => {
374
+ const cmp = compareSpecificity(a[1], b[1]);
375
+ if (cmp !== 0) return cmp;
376
+ // If specificity is equal, maintain original order (stable sort)
377
+ return a[2] - b[2];
378
+ });
379
+
380
+ // Extract just the rules
381
+ return withSpec.map(tuple => tuple[0]);
382
+ }
383
+
384
+ /**
385
+ * Stringify parsed selector components back to selector string.
386
+ * Used for verification (round-trip testing).
387
+ *
388
+ * @param {Array<Object>} components - Parsed selector components
389
+ * @returns {string} Selector string
390
+ */
391
+ export function stringifySelector(components) {
392
+ if (!Array.isArray(components)) {
393
+ throw new Error('components must be an array');
394
+ }
395
+
396
+ return components.map(component => {
397
+ switch (component.type) {
398
+ case SELECTOR_TYPES.ID:
399
+ return `#${component.value}`;
400
+ case SELECTOR_TYPES.CLASS:
401
+ return `.${component.value}`;
402
+ case SELECTOR_TYPES.ATTRIBUTE:
403
+ return `[${component.value}]`;
404
+ case SELECTOR_TYPES.PSEUDO_CLASS:
405
+ return `:${component.value}`;
406
+ case SELECTOR_TYPES.PSEUDO_ELEMENT:
407
+ return `::${component.value}`;
408
+ case SELECTOR_TYPES.TYPE:
409
+ return component.value;
410
+ case SELECTOR_TYPES.UNIVERSAL:
411
+ return '*';
412
+ default:
413
+ return '';
414
+ }
415
+ }).join('');
416
+ }
417
+
418
+ /**
419
+ * Verify selector parsing by round-trip test.
420
+ * Parse selector, stringify it, and compare (ignoring whitespace).
421
+ *
422
+ * @param {string} selector - Selector to verify
423
+ * @returns {boolean} True if round-trip matches
424
+ */
425
+ export function verifySelector(selector) {
426
+ const components = parseSelector(selector);
427
+ const reconstructed = stringifySelector(components);
428
+
429
+ // Normalize whitespace for comparison
430
+ const normalize = s => s.replace(/\s+/g, '');
431
+
432
+ return normalize(selector) === normalize(reconstructed);
433
+ }
434
+
435
+ export default {
436
+ SELECTOR_TYPES,
437
+ parseSelector,
438
+ calculateSpecificity,
439
+ compareSpecificity,
440
+ sortBySpecificity,
441
+ stringifySelector,
442
+ verifySelector
443
+ };