html-minifier-next 4.15.2 → 4.16.1

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 CHANGED
@@ -358,41 +358,39 @@ How does HTML Minifier Next compare to other minifiers? (All minification with t
358
358
  | Site | Original Size (KB) | [HTML Minifier Next](https://github.com/j9t/html-minifier-next) ([config](https://github.com/j9t/html-minifier-next/blob/main/benchmarks/html-minifier.json))<br>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-next)](https://socket.dev/npm/package/html-minifier-next) | [htmlnano](https://github.com/posthtml/htmlnano)<br>[![npm last update](https://img.shields.io/npm/last-update/htmlnano)](https://socket.dev/npm/package/htmlnano) | [@swc/html](https://github.com/swc-project/swc)<br>[![npm last update](https://img.shields.io/npm/last-update/@swc/html)](https://socket.dev/npm/package/@swc/html) | [minify-html](https://github.com/wilsonzlin/minify-html)<br>[![npm last update](https://img.shields.io/npm/last-update/@minify-html/node)](https://socket.dev/npm/package/@minify-html/node) | [minimize](https://github.com/Swaagie/minimize)<br>[![npm last update](https://img.shields.io/npm/last-update/minimize)](https://socket.dev/npm/package/minimize) | [html­com­pressor.­com](https://htmlcompressor.com/) |
359
359
  | --- | --- | --- | --- | --- | --- | --- | --- |
360
360
  | [A List Apart](https://alistapart.com/) | 59 | **50** | 51 | 52 | 51 | 54 | 52 |
361
- | [Apple](https://www.apple.com/) | 266 | **206** | 236 | 239 | 240 | 242 | 243 |
362
- | [BBC](https://www.bbc.co.uk/) | 643 | **584** | 603 | 604 | 605 | 638 | n/a |
361
+ | [Apple](https://www.apple.com/) | 211 | **177** | 188 | 190 | 191 | 192 | 193 |
362
+ | [BBC](https://www.bbc.co.uk/) | 675 | **614** | 633 | 634 | 635 | 669 | n/a |
363
363
  | [CERN](https://home.cern/) | 152 | **83** | 91 | 91 | 91 | 93 | 96 |
364
364
  | [CSS-Tricks](https://css-tricks.com/) | 162 | **119** | 127 | 143 | 143 | 148 | 144 |
365
365
  | [ECMAScript](https://tc39.es/ecma262/) | 7250 | **6401** | 6573 | 6455 | 6578 | 6626 | n/a |
366
366
  | [EDRi](https://edri.org/) | 80 | **59** | 70 | 70 | 71 | 75 | 73 |
367
- | [EFF](https://www.eff.org/) | 55 | **46** | 49 | 48 | 49 | 50 | 50 |
367
+ | [EFF](https://www.eff.org/) | 54 | **45** | 49 | 47 | 48 | 49 | 49 |
368
368
  | [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | 32 | 32 | 32 | 32 | 32 |
369
- | [FAZ](https://www.faz.net/aktuell/) | 1564 | 1457 | **1404** | 1489 | 1500 | 1511 | n/a |
369
+ | [FAZ](https://www.faz.net/aktuell/) | 1579 | 1468 | **1414** | 1503 | 1514 | 1525 | n/a |
370
370
  | [French Tech](https://lafrenchtech.gouv.fr/) | 153 | **122** | 126 | 126 | 126 | 132 | 127 |
371
- | [Frontend Dogma](https://frontenddogma.com/) | 225 | **217** | 238 | 223 | 225 | 243 | 224 |
372
- | [Google](https://www.google.com/) | 76 | **71** | n/a | 72 | 73 | 75 | 75 |
373
- | [Ground News](https://ground.news/) | 2373 | **2089** | 2185 | 2211 | 2213 | 2360 | n/a |
371
+ | [Frontend Dogma](https://frontenddogma.com/) | 225 | **217** | 238 | 223 | 225 | 244 | 225 |
372
+ | [Google](https://www.google.com/) | 18 | **16** | 17 | 17 | 17 | 18 | 18 |
374
373
  | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | 148 | 153 | **147** | 149 | 155 | 149 |
375
374
  | [Igalia](https://www.igalia.com/) | 50 | **33** | 36 | 36 | 36 | 37 | 36 |
376
- | [Leanpub](https://leanpub.com/) | 229 | **199** | 214 | 213 | 214 | 224 | 226 |
375
+ | [Leanpub](https://leanpub.com/) | 231 | **202** | 216 | 216 | 217 | 227 | 229 |
377
376
  | [Mastodon](https://mastodon.social/explore) | 37 | **28** | 32 | 35 | 35 | 36 | 36 |
378
- | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | 64 | 65 | 65 | 68 | 68 |
379
- | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **196** | 203 | 201 | 200 | 202 | 203 |
380
- | [Mistral AI](https://mistral.ai/) | 360 | **319** | 323 | 326 | 327 | 357 | n/a |
377
+ | [MDN](https://developer.mozilla.org/en-US/) | 106 | **60** | 62 | 63 | 63 | 66 | 66 |
378
+ | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **197** | 203 | 201 | 201 | 203 | 204 |
379
+ | [Mistral AI](https://mistral.ai/) | 361 | **319** | 324 | 326 | 327 | 357 | n/a |
381
380
  | [Mozilla](https://www.mozilla.org/) | 45 | **31** | 34 | 34 | 34 | 35 | 35 |
382
381
  | [Nielsen Norman Group](https://www.nngroup.com/) | 86 | 68 | **55** | 74 | 75 | 77 | 76 |
383
- | [SitePoint](https://www.sitepoint.com/) | 491 | **360** | 431 | 465 | 470 | 488 | n/a |
384
382
  | [Startup-Verband](https://startupverband.de/) | 42 | **29** | 30 | 30 | 30 | 31 | 30 |
385
- | [TetraLogical](https://tetralogical.com/) | 44 | 39 | **35** | 38 | 39 | 39 | 39 |
383
+ | [TetraLogical](https://tetralogical.com/) | 44 | 38 | **35** | 38 | 39 | 39 | 39 |
386
384
  | [TPGi](https://www.tpgi.com/) | 175 | **159** | 160 | 164 | 166 | 172 | 172 |
387
- | [United Nations](https://www.un.org/en/) | 151 | **112** | 121 | 125 | 125 | 130 | 123 |
385
+ | [United Nations](https://www.un.org/en/) | 152 | **112** | 121 | 125 | 125 | 130 | 123 |
388
386
  | [Vivaldi](https://vivaldi.com/) | 92 | **74** | n/a | 79 | 81 | 83 | 81 |
389
387
  | [W3C](https://www.w3.org/) | 50 | **36** | 39 | 38 | 38 | 41 | 39 |
390
- | **Average processing time** | | 121 ms (30/30) | 648 ms (28/30) | 50 ms (30/30) | **14 ms (30/30)** | 275 ms (30/30) | 1409 ms (24/30) |
388
+ | **Average processing time** | | 120 ms (28/28) | 138 ms (27/28) | 41 ms (28/28) | **14 ms (28/28)** | 283 ms (28/28) | 1349 ms (24/28) |
391
389
 
392
- (Last updated: Dec 23, 2025)
390
+ (Last updated: Dec 26, 2025)
393
391
  <!-- End auto-generated -->
394
392
 
395
- Notes: htmlnano runs in an isolated process for crash protection, adding ~50–100ms overhead per test. Minimize does not minify CSS and JS. [HTML Minifier Terser](https://github.com/terser/html-minifier-terser) is currently not included due to issues around whitespace collapsing and removal of code using modern CSS features, issues which appeared to distort the data.
393
+ Notes: Minimize does not minify CSS and JS. [HTML Minifier Terser](https://github.com/terser/html-minifier-terser) is currently not included due to issues around whitespace collapsing and removal of code using modern CSS features, issues which appeared to distort the data.
396
394
 
397
395
  ## Examples
398
396
 
@@ -624,37 +624,29 @@ class Sorter {
624
624
  for (let i = 0, len = this.keys.length; i < len; i++) {
625
625
  const token = this.keys[i];
626
626
 
627
- // Build position map for this token to avoid repeated `indexOf`
628
- const positions = [];
627
+ // Single pass: Count matches and collect non-matches
628
+ let matchCount = 0;
629
+ const others = [];
630
+
629
631
  for (let j = fromIndex; j < tokens.length; j++) {
630
632
  if (tokens[j] === token) {
631
- positions.push(j);
633
+ matchCount++;
634
+ } else {
635
+ others.push(tokens[j]);
632
636
  }
633
637
  }
634
638
 
635
- if (positions.length > 0) {
636
- // Build new array with tokens in sorted order instead of splicing
637
- const result = [];
638
-
639
- // Add all instances of the current token first
640
- for (let j = 0; j < positions.length; j++) {
641
- result.push(token);
639
+ if (matchCount > 0) {
640
+ // Rebuild: `matchCount` instances of token first, then others
641
+ let writeIdx = fromIndex;
642
+ for (let j = 0; j < matchCount; j++) {
643
+ tokens[writeIdx++] = token;
642
644
  }
643
-
644
- // Add other tokens, skipping positions where current token was
645
- const posSet = new Set(positions);
646
- for (let j = fromIndex; j < tokens.length; j++) {
647
- if (!posSet.has(j)) {
648
- result.push(tokens[j]);
649
- }
645
+ for (let j = 0; j < others.length; j++) {
646
+ tokens[writeIdx++] = others[j];
650
647
  }
651
648
 
652
- // Copy sorted portion back to tokens array
653
- for (let j = 0; j < result.length; j++) {
654
- tokens[fromIndex + j] = result[j];
655
- }
656
-
657
- const newFromIndex = fromIndex + positions.length;
649
+ const newFromIndex = fromIndex + matchCount;
658
650
  return this.sorterMap.get(token).sort(tokens, newFromIndex);
659
651
  }
660
652
  }
@@ -1260,8 +1252,31 @@ async function processScript(text, options, currentAttrs, minifyHTML) {
1260
1252
  * - Numeric precision reduction for coordinates and path data
1261
1253
  * - Whitespace removal in attribute values (numeric sequences)
1262
1254
  * - Default attribute removal (safe, well-documented defaults)
1263
- * - Color minification (hex shortening, rgb() to hex)
1255
+ * - Color minification (hex shortening, rgb() to hex, named colors)
1256
+ * - Identity transform removal
1257
+ * - Path data space optimization
1258
+ */
1259
+
1260
+
1261
+ // Cache for minified numbers
1262
+ const numberCache = new LRU(100);
1263
+
1264
+ /**
1265
+ * Named colors that are shorter than their hex equivalents
1266
+ * Only includes cases where using the name saves bytes
1264
1267
  */
1268
+ const NAMED_COLORS = {
1269
+ '#f00': 'red', // #f00 (4) → red (3), saves 1
1270
+ '#c0c0c0': 'silver', // #c0c0c0 (7) → silver (6), saves 1
1271
+ '#808080': 'gray', // #808080 (7) → gray (4), saves 3
1272
+ '#800000': 'maroon', // #800000 (7) → maroon (6), saves 1
1273
+ '#808000': 'olive', // #808000 (7) → olive (5), saves 2
1274
+ '#008000': 'green', // #008000 (7) → green (5), saves 2
1275
+ '#800080': 'purple', // #800080 (7) → purple (6), saves 1
1276
+ '#008080': 'teal', // #008080 (7) → teal (4), saves 3
1277
+ '#000080': 'navy', // #000080 (7) → navy (4), saves 3
1278
+ '#ffa500': 'orange' // #ffa500 (7) → orange (6), saves 1
1279
+ };
1265
1280
 
1266
1281
  /**
1267
1282
  * Default SVG attribute values that can be safely removed
@@ -1295,7 +1310,22 @@ const SVG_DEFAULT_ATTRS = {
1295
1310
  opacity: value => value === '1',
1296
1311
  visibility: value => value === 'visible',
1297
1312
  display: value => value === 'inline',
1298
- overflow: value => value === 'visible'
1313
+ // Note: Overflow handled especially in `isDefaultAttribute` (not safe for root `<svg>`)
1314
+
1315
+ // Clipping and masking defaults
1316
+ 'clip-rule': value => value === 'nonzero',
1317
+ 'clip-path': value => value === 'none',
1318
+ mask: value => value === 'none',
1319
+
1320
+ // Marker defaults
1321
+ 'marker-start': value => value === 'none',
1322
+ 'marker-mid': value => value === 'none',
1323
+ 'marker-end': value => value === 'none',
1324
+
1325
+ // Filter and color defaults
1326
+ filter: value => value === 'none',
1327
+ 'color-interpolation': value => value === 'sRGB',
1328
+ 'color-interpolation-filters': value => value === 'linearRGB'
1299
1329
  };
1300
1330
 
1301
1331
  /**
@@ -1305,6 +1335,20 @@ const SVG_DEFAULT_ATTRS = {
1305
1335
  * @returns {string} Minified numeric string
1306
1336
  */
1307
1337
  function minifyNumber(num, precision = 3) {
1338
+ // Fast path for common values (avoids parsing and caching)
1339
+ if (num === '0' || num === '1') return num;
1340
+ // Common decimal variants that tools export
1341
+ if (num === '0.0' || num === '0.00' || num === '0.000') return '0';
1342
+ if (num === '1.0' || num === '1.00' || num === '1.000') return '1';
1343
+
1344
+ // Check cache
1345
+ // (Note: uses input string as key, so “0.0000” and “0.00000” create separate entries.
1346
+ // This is intentional to avoid parsing overhead.
1347
+ // Real-world SVG files from export tools typically use consistent formats.)
1348
+ const cacheKey = `${num}:${precision}`;
1349
+ const cached = numberCache.get(cacheKey);
1350
+ if (cached !== undefined) return cached;
1351
+
1308
1352
  const parsed = parseFloat(num);
1309
1353
 
1310
1354
  // Handle special cases
@@ -1316,11 +1360,13 @@ function minifyNumber(num, precision = 3) {
1316
1360
  const fixed = parsed.toFixed(precision);
1317
1361
  const trimmed = fixed.replace(/\.?0+$/, '');
1318
1362
 
1319
- return trimmed || '0';
1363
+ const result = trimmed || '0';
1364
+ numberCache.set(cacheKey, result);
1365
+ return result;
1320
1366
  }
1321
1367
 
1322
1368
  /**
1323
- * Minify SVG path data by reducing numeric precision
1369
+ * Minify SVG path data by reducing numeric precision and removing unnecessary spaces
1324
1370
  * @param {string} pathData - SVG path data string
1325
1371
  * @param {number} precision - Decimal precision for coordinates
1326
1372
  * @returns {string} Minified path data
@@ -1328,11 +1374,25 @@ function minifyNumber(num, precision = 3) {
1328
1374
  function minifyPathData(pathData, precision = 3) {
1329
1375
  if (!pathData || typeof pathData !== 'string') return pathData;
1330
1376
 
1331
- // Match numbers (including scientific notation and negative values)
1332
- // Regex: optional minus, digits, optional decimal point and more digits, optional exponent
1333
- return pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
1377
+ // First, minify all numbers
1378
+ let result = pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
1334
1379
  return minifyNumber(match, precision);
1335
1380
  });
1381
+
1382
+ // Remove unnecessary spaces around path commands
1383
+ // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
1384
+ // M 10 20 → M10 20, L -5 -3 → L-5-3
1385
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
1386
+
1387
+ // Safe to remove space before command letter when preceded by a number
1388
+ // 0 L → 0L, 20 M → 20M
1389
+ result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
1390
+
1391
+ // Safe to remove space before negative number when preceded by a number
1392
+ // 10 -20 → 10-20 (numbers are separated by the minus sign)
1393
+ result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
1394
+
1395
+ return result;
1336
1396
  }
1337
1397
 
1338
1398
  /**
@@ -1361,27 +1421,49 @@ function minifyAttributeWhitespace(value) {
1361
1421
  }
1362
1422
 
1363
1423
  /**
1364
- * Minify color values (hex shortening, rgb to hex conversion)
1424
+ * Minify color values (hex shortening, rgb to hex conversion, named colors)
1425
+ * Only processes simple color values; preserves case-sensitive references like `url(#id)`
1365
1426
  * @param {string} color - Color value to minify
1366
1427
  * @returns {string} Minified color value
1367
1428
  */
1368
1429
  function minifyColor(color) {
1369
1430
  if (!color || typeof color !== 'string') return color;
1370
1431
 
1371
- const trimmed = color.trim().toLowerCase();
1432
+ const trimmed = color.trim();
1433
+
1434
+ // Don’t process values that aren’t simple colors (preserve case-sensitive references)
1435
+ // `url(#id)`, `var(--name)`, `inherit`, `currentColor`, etc.
1436
+ if (trimmed.includes('url(') || trimmed.includes('var(') ||
1437
+ trimmed === 'inherit' || trimmed === 'currentColor') {
1438
+ return trimmed;
1439
+ }
1440
+
1441
+ // Now safe to lowercase for color matching
1442
+ const lower = trimmed.toLowerCase();
1372
1443
 
1373
1444
  // Shorten 6-digit hex to 3-digit when possible
1374
1445
  // #aabbcc → #abc, #000000 → #000
1375
- const hexMatch = trimmed.match(/^#([0-9a-f]{6})$/);
1446
+ const hexMatch = lower.match(/^#([0-9a-f]{6})$/);
1376
1447
  if (hexMatch) {
1377
1448
  const hex = hexMatch[1];
1378
1449
  if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) {
1379
- return '#' + hex[0] + hex[2] + hex[4];
1450
+ const shortened = '#' + hex[0] + hex[2] + hex[4];
1451
+ // Try to use named color if shorter
1452
+ return NAMED_COLORS[shortened] || shortened;
1380
1453
  }
1454
+ // Can’t shorten, but check for named color
1455
+ return NAMED_COLORS[lower] || lower;
1456
+ }
1457
+
1458
+ // Match 3-digit hex colors
1459
+ const hex3Match = lower.match(/^#[0-9a-f]{3}$/);
1460
+ if (hex3Match) {
1461
+ // Check if there’s a shorter named color
1462
+ return NAMED_COLORS[lower] || lower;
1381
1463
  }
1382
1464
 
1383
1465
  // Convert rgb(255,255,255) to hex
1384
- const rgbMatch = trimmed.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
1466
+ const rgbMatch = lower.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
1385
1467
  if (rgbMatch) {
1386
1468
  const r = parseInt(rgbMatch[1], 10);
1387
1469
  const g = parseInt(rgbMatch[2], 10);
@@ -1396,13 +1478,15 @@ function minifyColor(color) {
1396
1478
 
1397
1479
  // Try to shorten if possible
1398
1480
  if (hexColor[1] === hexColor[2] && hexColor[3] === hexColor[4] && hexColor[5] === hexColor[6]) {
1399
- return '#' + hexColor[1] + hexColor[3] + hexColor[5];
1481
+ const shortened = '#' + hexColor[1] + hexColor[3] + hexColor[5];
1482
+ return NAMED_COLORS[shortened] || shortened;
1400
1483
  }
1401
- return hexColor;
1484
+ return NAMED_COLORS[hexColor] || hexColor;
1402
1485
  }
1403
1486
  }
1404
1487
 
1405
- return color;
1488
+ // Not a recognized color format, return as-is (preserves case)
1489
+ return trimmed;
1406
1490
  }
1407
1491
 
1408
1492
  // Attributes that contain numeric sequences or path data
@@ -1432,13 +1516,58 @@ const COLOR_ATTRS = new Set([
1432
1516
  'lighting-color'
1433
1517
  ]);
1434
1518
 
1519
+ // Pre-compiled regexes for identity transform detection (compiled once at module load)
1520
+ // Separator pattern: Accepts comma with optional spaces or one or more spaces
1521
+ const SEP = '(?:\\s*,\\s*|\\s+)';
1522
+
1523
+ // `translate(0)`, `translate(0,0)`, `translate(0 0)` (matches 0, 0.0, 0.00, etc.)
1524
+ const IDENTITY_TRANSLATE_RE = new RegExp(`^translate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}0(?:\\.0+)?\\s*)?\\)$`, 'i');
1525
+
1526
+ // `scale(1)`, `scale(1,1)`, `scale(1 1)` (matches 1, 1.0, 1.00, etc.)
1527
+ const IDENTITY_SCALE_RE = new RegExp(`^scale\\s*\\(\\s*1(?:\\.0+)?\\s*(?:${SEP}1(?:\\.0+)?\\s*)?\\)$`, 'i');
1528
+
1529
+ // `rotate(0)`, `rotate(0 cx cy)`, `rotate(0, cx, cy)` (matches 0, 0.0, 0.00, etc.)
1530
+ // Note: `cx` and `cy` must be valid numbers if present
1531
+ const IDENTITY_ROTATE_RE = new RegExp(`^rotate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}-?\\d+(?:\\.\\d+)?${SEP}-?\\d+(?:\\.\\d+)?)?\\s*\\)$`, 'i');
1532
+
1533
+ // `skewX(0)`, `skewY(0)` (matches 0, 0.0, 0.00, etc.)
1534
+ const IDENTITY_SKEW_RE = /^skew[XY]\s*\(\s*0(?:\.0+)?\s*\)$/i;
1535
+
1536
+ // `matrix(1,0,0,1,0,0)`, `matrix(1 0 0 1 0 0)`—identity matrix (matches 1.0/0.0 variants)
1537
+ const IDENTITY_MATRIX_RE = new RegExp(`^matrix\\s*\\(\\s*1(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}1(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*\\)$`, 'i');
1538
+
1539
+ /**
1540
+ * Check if a transform attribute has no effect (identity transform)
1541
+ * @param {string} transform - Transform attribute value
1542
+ * @returns {boolean} True if transform is an identity (has no effect)
1543
+ */
1544
+ function isIdentityTransform(transform) {
1545
+ if (!transform || typeof transform !== 'string') return false;
1546
+
1547
+ const trimmed = transform.trim();
1548
+
1549
+ // Check for common identity transforms using pre-compiled regexes
1550
+ return IDENTITY_TRANSLATE_RE.test(trimmed) ||
1551
+ IDENTITY_SCALE_RE.test(trimmed) ||
1552
+ IDENTITY_ROTATE_RE.test(trimmed) ||
1553
+ IDENTITY_SKEW_RE.test(trimmed) ||
1554
+ IDENTITY_MATRIX_RE.test(trimmed);
1555
+ }
1556
+
1435
1557
  /**
1436
1558
  * Check if an attribute should be removed based on default value
1559
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
1437
1560
  * @param {string} name - Attribute name
1438
1561
  * @param {string} value - Attribute value
1439
1562
  * @returns {boolean} True if attribute can be removed
1440
1563
  */
1441
- function isDefaultAttribute(name, value) {
1564
+ function isDefaultAttribute(tag, name, value) {
1565
+ // Special case: `overflow="visible"` is unsafe for root `<svg>` element
1566
+ // Root SVG may need explicit `overflow="visible"` to show clipped content
1567
+ if (name === 'overflow' && value === 'visible') {
1568
+ return tag !== 'svg'; // Only remove for non-root SVG elements
1569
+ }
1570
+
1442
1571
  const checker = SVG_DEFAULT_ATTRS[name];
1443
1572
  if (!checker) return false;
1444
1573
 
@@ -1489,17 +1618,23 @@ function minifySVGAttributeValue(name, value, options = {}) {
1489
1618
 
1490
1619
  /**
1491
1620
  * Check if an SVG attribute can be removed
1621
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
1492
1622
  * @param {string} name - Attribute name
1493
1623
  * @param {string} value - Attribute value
1494
1624
  * @param {Object} options - Minification options
1495
1625
  * @returns {boolean} True if attribute should be removed
1496
1626
  */
1497
- function shouldRemoveSVGAttribute(name, value, options = {}) {
1627
+ function shouldRemoveSVGAttribute(tag, name, value, options = {}) {
1498
1628
  const { removeDefaults = true } = options;
1499
1629
 
1500
1630
  if (!removeDefaults) return false;
1501
1631
 
1502
- return isDefaultAttribute(name, value);
1632
+ // Check for identity transforms
1633
+ if (name === 'transform' && isIdentityTransform(value)) {
1634
+ return true;
1635
+ }
1636
+
1637
+ return isDefaultAttribute(tag, name, value);
1503
1638
  }
1504
1639
 
1505
1640
  /**
@@ -2067,7 +2202,15 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2067
2202
  // Apply early whitespace normalization if enabled
2068
2203
  // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
2069
2204
  if (options.collapseAttributeWhitespace) {
2070
- attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
2205
+ // Single-pass: Trim leading/trailing whitespace and collapse internal whitespace to single space
2206
+ attrValue = attrValue.replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$|[ \n\r\t\f]+/g, function(match, offset, str) {
2207
+ // Leading whitespace (`offset === 0`)
2208
+ if (offset === 0) return '';
2209
+ // Trailing whitespace (match ends at string end)
2210
+ if (offset + match.length === str.length) return '';
2211
+ // Internal whitespace
2212
+ return ' ';
2213
+ });
2071
2214
  }
2072
2215
 
2073
2216
  if (isEventAttribute(attrName, options)) {
@@ -2241,7 +2384,7 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2241
2384
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
2242
2385
  attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) ||
2243
2386
  (options.insideSVG && options.minifySVG &&
2244
- shouldRemoveSVGAttribute(attrName, attrValue, options.minifySVG))) {
2387
+ shouldRemoveSVGAttribute(tag, attrName, attrValue, options.minifySVG))) {
2245
2388
  return;
2246
2389
  }
2247
2390
 
@@ -3236,37 +3236,29 @@ class Sorter {
3236
3236
  for (let i = 0, len = this.keys.length; i < len; i++) {
3237
3237
  const token = this.keys[i];
3238
3238
 
3239
- // Build position map for this token to avoid repeated `indexOf`
3240
- const positions = [];
3239
+ // Single pass: Count matches and collect non-matches
3240
+ let matchCount = 0;
3241
+ const others = [];
3242
+
3241
3243
  for (let j = fromIndex; j < tokens.length; j++) {
3242
3244
  if (tokens[j] === token) {
3243
- positions.push(j);
3245
+ matchCount++;
3246
+ } else {
3247
+ others.push(tokens[j]);
3244
3248
  }
3245
3249
  }
3246
3250
 
3247
- if (positions.length > 0) {
3248
- // Build new array with tokens in sorted order instead of splicing
3249
- const result = [];
3250
-
3251
- // Add all instances of the current token first
3252
- for (let j = 0; j < positions.length; j++) {
3253
- result.push(token);
3251
+ if (matchCount > 0) {
3252
+ // Rebuild: `matchCount` instances of token first, then others
3253
+ let writeIdx = fromIndex;
3254
+ for (let j = 0; j < matchCount; j++) {
3255
+ tokens[writeIdx++] = token;
3254
3256
  }
3255
-
3256
- // Add other tokens, skipping positions where current token was
3257
- const posSet = new Set(positions);
3258
- for (let j = fromIndex; j < tokens.length; j++) {
3259
- if (!posSet.has(j)) {
3260
- result.push(tokens[j]);
3261
- }
3257
+ for (let j = 0; j < others.length; j++) {
3258
+ tokens[writeIdx++] = others[j];
3262
3259
  }
3263
3260
 
3264
- // Copy sorted portion back to tokens array
3265
- for (let j = 0; j < result.length; j++) {
3266
- tokens[fromIndex + j] = result[j];
3267
- }
3268
-
3269
- const newFromIndex = fromIndex + positions.length;
3261
+ const newFromIndex = fromIndex + matchCount;
3270
3262
  return this.sorterMap.get(token).sort(tokens, newFromIndex);
3271
3263
  }
3272
3264
  }
@@ -6402,8 +6394,31 @@ async function processScript(text, options, currentAttrs, minifyHTML) {
6402
6394
  * - Numeric precision reduction for coordinates and path data
6403
6395
  * - Whitespace removal in attribute values (numeric sequences)
6404
6396
  * - Default attribute removal (safe, well-documented defaults)
6405
- * - Color minification (hex shortening, rgb() to hex)
6397
+ * - Color minification (hex shortening, rgb() to hex, named colors)
6398
+ * - Identity transform removal
6399
+ * - Path data space optimization
6400
+ */
6401
+
6402
+
6403
+ // Cache for minified numbers
6404
+ const numberCache = new LRU(100);
6405
+
6406
+ /**
6407
+ * Named colors that are shorter than their hex equivalents
6408
+ * Only includes cases where using the name saves bytes
6406
6409
  */
6410
+ const NAMED_COLORS = {
6411
+ '#f00': 'red', // #f00 (4) → red (3), saves 1
6412
+ '#c0c0c0': 'silver', // #c0c0c0 (7) → silver (6), saves 1
6413
+ '#808080': 'gray', // #808080 (7) → gray (4), saves 3
6414
+ '#800000': 'maroon', // #800000 (7) → maroon (6), saves 1
6415
+ '#808000': 'olive', // #808000 (7) → olive (5), saves 2
6416
+ '#008000': 'green', // #008000 (7) → green (5), saves 2
6417
+ '#800080': 'purple', // #800080 (7) → purple (6), saves 1
6418
+ '#008080': 'teal', // #008080 (7) → teal (4), saves 3
6419
+ '#000080': 'navy', // #000080 (7) → navy (4), saves 3
6420
+ '#ffa500': 'orange' // #ffa500 (7) → orange (6), saves 1
6421
+ };
6407
6422
 
6408
6423
  /**
6409
6424
  * Default SVG attribute values that can be safely removed
@@ -6437,7 +6452,22 @@ const SVG_DEFAULT_ATTRS = {
6437
6452
  opacity: value => value === '1',
6438
6453
  visibility: value => value === 'visible',
6439
6454
  display: value => value === 'inline',
6440
- overflow: value => value === 'visible'
6455
+ // Note: Overflow handled especially in `isDefaultAttribute` (not safe for root `<svg>`)
6456
+
6457
+ // Clipping and masking defaults
6458
+ 'clip-rule': value => value === 'nonzero',
6459
+ 'clip-path': value => value === 'none',
6460
+ mask: value => value === 'none',
6461
+
6462
+ // Marker defaults
6463
+ 'marker-start': value => value === 'none',
6464
+ 'marker-mid': value => value === 'none',
6465
+ 'marker-end': value => value === 'none',
6466
+
6467
+ // Filter and color defaults
6468
+ filter: value => value === 'none',
6469
+ 'color-interpolation': value => value === 'sRGB',
6470
+ 'color-interpolation-filters': value => value === 'linearRGB'
6441
6471
  };
6442
6472
 
6443
6473
  /**
@@ -6447,6 +6477,20 @@ const SVG_DEFAULT_ATTRS = {
6447
6477
  * @returns {string} Minified numeric string
6448
6478
  */
6449
6479
  function minifyNumber(num, precision = 3) {
6480
+ // Fast path for common values (avoids parsing and caching)
6481
+ if (num === '0' || num === '1') return num;
6482
+ // Common decimal variants that tools export
6483
+ if (num === '0.0' || num === '0.00' || num === '0.000') return '0';
6484
+ if (num === '1.0' || num === '1.00' || num === '1.000') return '1';
6485
+
6486
+ // Check cache
6487
+ // (Note: uses input string as key, so “0.0000” and “0.00000” create separate entries.
6488
+ // This is intentional to avoid parsing overhead.
6489
+ // Real-world SVG files from export tools typically use consistent formats.)
6490
+ const cacheKey = `${num}:${precision}`;
6491
+ const cached = numberCache.get(cacheKey);
6492
+ if (cached !== undefined) return cached;
6493
+
6450
6494
  const parsed = parseFloat(num);
6451
6495
 
6452
6496
  // Handle special cases
@@ -6458,11 +6502,13 @@ function minifyNumber(num, precision = 3) {
6458
6502
  const fixed = parsed.toFixed(precision);
6459
6503
  const trimmed = fixed.replace(/\.?0+$/, '');
6460
6504
 
6461
- return trimmed || '0';
6505
+ const result = trimmed || '0';
6506
+ numberCache.set(cacheKey, result);
6507
+ return result;
6462
6508
  }
6463
6509
 
6464
6510
  /**
6465
- * Minify SVG path data by reducing numeric precision
6511
+ * Minify SVG path data by reducing numeric precision and removing unnecessary spaces
6466
6512
  * @param {string} pathData - SVG path data string
6467
6513
  * @param {number} precision - Decimal precision for coordinates
6468
6514
  * @returns {string} Minified path data
@@ -6470,11 +6516,25 @@ function minifyNumber(num, precision = 3) {
6470
6516
  function minifyPathData(pathData, precision = 3) {
6471
6517
  if (!pathData || typeof pathData !== 'string') return pathData;
6472
6518
 
6473
- // Match numbers (including scientific notation and negative values)
6474
- // Regex: optional minus, digits, optional decimal point and more digits, optional exponent
6475
- return pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
6519
+ // First, minify all numbers
6520
+ let result = pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
6476
6521
  return minifyNumber(match, precision);
6477
6522
  });
6523
+
6524
+ // Remove unnecessary spaces around path commands
6525
+ // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
6526
+ // M 10 20 → M10 20, L -5 -3 → L-5-3
6527
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
6528
+
6529
+ // Safe to remove space before command letter when preceded by a number
6530
+ // 0 L → 0L, 20 M → 20M
6531
+ result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
6532
+
6533
+ // Safe to remove space before negative number when preceded by a number
6534
+ // 10 -20 → 10-20 (numbers are separated by the minus sign)
6535
+ result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
6536
+
6537
+ return result;
6478
6538
  }
6479
6539
 
6480
6540
  /**
@@ -6503,27 +6563,49 @@ function minifyAttributeWhitespace(value) {
6503
6563
  }
6504
6564
 
6505
6565
  /**
6506
- * Minify color values (hex shortening, rgb to hex conversion)
6566
+ * Minify color values (hex shortening, rgb to hex conversion, named colors)
6567
+ * Only processes simple color values; preserves case-sensitive references like `url(#id)`
6507
6568
  * @param {string} color - Color value to minify
6508
6569
  * @returns {string} Minified color value
6509
6570
  */
6510
6571
  function minifyColor(color) {
6511
6572
  if (!color || typeof color !== 'string') return color;
6512
6573
 
6513
- const trimmed = color.trim().toLowerCase();
6574
+ const trimmed = color.trim();
6575
+
6576
+ // Don’t process values that aren’t simple colors (preserve case-sensitive references)
6577
+ // `url(#id)`, `var(--name)`, `inherit`, `currentColor`, etc.
6578
+ if (trimmed.includes('url(') || trimmed.includes('var(') ||
6579
+ trimmed === 'inherit' || trimmed === 'currentColor') {
6580
+ return trimmed;
6581
+ }
6582
+
6583
+ // Now safe to lowercase for color matching
6584
+ const lower = trimmed.toLowerCase();
6514
6585
 
6515
6586
  // Shorten 6-digit hex to 3-digit when possible
6516
6587
  // #aabbcc → #abc, #000000 → #000
6517
- const hexMatch = trimmed.match(/^#([0-9a-f]{6})$/);
6588
+ const hexMatch = lower.match(/^#([0-9a-f]{6})$/);
6518
6589
  if (hexMatch) {
6519
6590
  const hex = hexMatch[1];
6520
6591
  if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) {
6521
- return '#' + hex[0] + hex[2] + hex[4];
6592
+ const shortened = '#' + hex[0] + hex[2] + hex[4];
6593
+ // Try to use named color if shorter
6594
+ return NAMED_COLORS[shortened] || shortened;
6522
6595
  }
6596
+ // Can’t shorten, but check for named color
6597
+ return NAMED_COLORS[lower] || lower;
6598
+ }
6599
+
6600
+ // Match 3-digit hex colors
6601
+ const hex3Match = lower.match(/^#[0-9a-f]{3}$/);
6602
+ if (hex3Match) {
6603
+ // Check if there’s a shorter named color
6604
+ return NAMED_COLORS[lower] || lower;
6523
6605
  }
6524
6606
 
6525
6607
  // Convert rgb(255,255,255) to hex
6526
- const rgbMatch = trimmed.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
6608
+ const rgbMatch = lower.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
6527
6609
  if (rgbMatch) {
6528
6610
  const r = parseInt(rgbMatch[1], 10);
6529
6611
  const g = parseInt(rgbMatch[2], 10);
@@ -6538,13 +6620,15 @@ function minifyColor(color) {
6538
6620
 
6539
6621
  // Try to shorten if possible
6540
6622
  if (hexColor[1] === hexColor[2] && hexColor[3] === hexColor[4] && hexColor[5] === hexColor[6]) {
6541
- return '#' + hexColor[1] + hexColor[3] + hexColor[5];
6623
+ const shortened = '#' + hexColor[1] + hexColor[3] + hexColor[5];
6624
+ return NAMED_COLORS[shortened] || shortened;
6542
6625
  }
6543
- return hexColor;
6626
+ return NAMED_COLORS[hexColor] || hexColor;
6544
6627
  }
6545
6628
  }
6546
6629
 
6547
- return color;
6630
+ // Not a recognized color format, return as-is (preserves case)
6631
+ return trimmed;
6548
6632
  }
6549
6633
 
6550
6634
  // Attributes that contain numeric sequences or path data
@@ -6574,13 +6658,58 @@ const COLOR_ATTRS = new Set([
6574
6658
  'lighting-color'
6575
6659
  ]);
6576
6660
 
6661
+ // Pre-compiled regexes for identity transform detection (compiled once at module load)
6662
+ // Separator pattern: Accepts comma with optional spaces or one or more spaces
6663
+ const SEP = '(?:\\s*,\\s*|\\s+)';
6664
+
6665
+ // `translate(0)`, `translate(0,0)`, `translate(0 0)` (matches 0, 0.0, 0.00, etc.)
6666
+ const IDENTITY_TRANSLATE_RE = new RegExp(`^translate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}0(?:\\.0+)?\\s*)?\\)$`, 'i');
6667
+
6668
+ // `scale(1)`, `scale(1,1)`, `scale(1 1)` (matches 1, 1.0, 1.00, etc.)
6669
+ const IDENTITY_SCALE_RE = new RegExp(`^scale\\s*\\(\\s*1(?:\\.0+)?\\s*(?:${SEP}1(?:\\.0+)?\\s*)?\\)$`, 'i');
6670
+
6671
+ // `rotate(0)`, `rotate(0 cx cy)`, `rotate(0, cx, cy)` (matches 0, 0.0, 0.00, etc.)
6672
+ // Note: `cx` and `cy` must be valid numbers if present
6673
+ const IDENTITY_ROTATE_RE = new RegExp(`^rotate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}-?\\d+(?:\\.\\d+)?${SEP}-?\\d+(?:\\.\\d+)?)?\\s*\\)$`, 'i');
6674
+
6675
+ // `skewX(0)`, `skewY(0)` (matches 0, 0.0, 0.00, etc.)
6676
+ const IDENTITY_SKEW_RE = /^skew[XY]\s*\(\s*0(?:\.0+)?\s*\)$/i;
6677
+
6678
+ // `matrix(1,0,0,1,0,0)`, `matrix(1 0 0 1 0 0)`—identity matrix (matches 1.0/0.0 variants)
6679
+ const IDENTITY_MATRIX_RE = new RegExp(`^matrix\\s*\\(\\s*1(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}1(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*\\)$`, 'i');
6680
+
6681
+ /**
6682
+ * Check if a transform attribute has no effect (identity transform)
6683
+ * @param {string} transform - Transform attribute value
6684
+ * @returns {boolean} True if transform is an identity (has no effect)
6685
+ */
6686
+ function isIdentityTransform(transform) {
6687
+ if (!transform || typeof transform !== 'string') return false;
6688
+
6689
+ const trimmed = transform.trim();
6690
+
6691
+ // Check for common identity transforms using pre-compiled regexes
6692
+ return IDENTITY_TRANSLATE_RE.test(trimmed) ||
6693
+ IDENTITY_SCALE_RE.test(trimmed) ||
6694
+ IDENTITY_ROTATE_RE.test(trimmed) ||
6695
+ IDENTITY_SKEW_RE.test(trimmed) ||
6696
+ IDENTITY_MATRIX_RE.test(trimmed);
6697
+ }
6698
+
6577
6699
  /**
6578
6700
  * Check if an attribute should be removed based on default value
6701
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
6579
6702
  * @param {string} name - Attribute name
6580
6703
  * @param {string} value - Attribute value
6581
6704
  * @returns {boolean} True if attribute can be removed
6582
6705
  */
6583
- function isDefaultAttribute(name, value) {
6706
+ function isDefaultAttribute(tag, name, value) {
6707
+ // Special case: `overflow="visible"` is unsafe for root `<svg>` element
6708
+ // Root SVG may need explicit `overflow="visible"` to show clipped content
6709
+ if (name === 'overflow' && value === 'visible') {
6710
+ return tag !== 'svg'; // Only remove for non-root SVG elements
6711
+ }
6712
+
6584
6713
  const checker = SVG_DEFAULT_ATTRS[name];
6585
6714
  if (!checker) return false;
6586
6715
 
@@ -6631,17 +6760,23 @@ function minifySVGAttributeValue(name, value, options = {}) {
6631
6760
 
6632
6761
  /**
6633
6762
  * Check if an SVG attribute can be removed
6763
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
6634
6764
  * @param {string} name - Attribute name
6635
6765
  * @param {string} value - Attribute value
6636
6766
  * @param {Object} options - Minification options
6637
6767
  * @returns {boolean} True if attribute should be removed
6638
6768
  */
6639
- function shouldRemoveSVGAttribute(name, value, options = {}) {
6769
+ function shouldRemoveSVGAttribute(tag, name, value, options = {}) {
6640
6770
  const { removeDefaults = true } = options;
6641
6771
 
6642
6772
  if (!removeDefaults) return false;
6643
6773
 
6644
- return isDefaultAttribute(name, value);
6774
+ // Check for identity transforms
6775
+ if (name === 'transform' && isIdentityTransform(value)) {
6776
+ return true;
6777
+ }
6778
+
6779
+ return isDefaultAttribute(tag, name, value);
6645
6780
  }
6646
6781
 
6647
6782
  /**
@@ -7209,7 +7344,15 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
7209
7344
  // Apply early whitespace normalization if enabled
7210
7345
  // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
7211
7346
  if (options.collapseAttributeWhitespace) {
7212
- attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
7347
+ // Single-pass: Trim leading/trailing whitespace and collapse internal whitespace to single space
7348
+ attrValue = attrValue.replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$|[ \n\r\t\f]+/g, function(match, offset, str) {
7349
+ // Leading whitespace (`offset === 0`)
7350
+ if (offset === 0) return '';
7351
+ // Trailing whitespace (match ends at string end)
7352
+ if (offset + match.length === str.length) return '';
7353
+ // Internal whitespace
7354
+ return ' ';
7355
+ });
7213
7356
  }
7214
7357
 
7215
7358
  if (isEventAttribute(attrName, options)) {
@@ -7383,7 +7526,7 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
7383
7526
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
7384
7527
  attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) ||
7385
7528
  (options.insideSVG && options.minifySVG &&
7386
- shouldRemoveSVGAttribute(attrName, attrValue, options.minifySVG))) {
7529
+ shouldRemoveSVGAttribute(tag, attrName, attrValue, options.minifySVG))) {
7387
7530
  return;
7388
7531
  }
7389
7532
 
@@ -1 +1 @@
1
- {"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../../../src/lib/attributes.js"],"names":[],"mappings":"AAuBA,yDAEC;AAED,mEAOC;AAED,uEAWC;AAED,8DAGC;AAED,4EAOC;AAED,mGAqBC;AAED,mEAGC;AAED,qEAGC;AAED,kEAWC;AAED,sEAGC;AAED,4DAWC;AAED,2EAEC;AAED,qEAaC;AAED,wEAUC;AAED,sEAUC;AAED,2EAEC;AAED,2DAEC;AAED,8DAUC;AAED,uEAUC;AAED,oGASC;AAED,4DAOC;AAID,0IA0IC;AAsBD;;;;GAwCC;AAED,6GA4EC"}
1
+ {"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../../../src/lib/attributes.js"],"names":[],"mappings":"AAuBA,yDAEC;AAED,mEAOC;AAED,uEAWC;AAED,8DAGC;AAED,4EAOC;AAED,mGAqBC;AAED,mEAGC;AAED,qEAGC;AAED,kEAWC;AAED,sEAGC;AAED,4DAWC;AAED,2EAEC;AAED,qEAaC;AAED,wEAUC;AAED,sEAUC;AAED,2EAEC;AAED,2DAEC;AAED,8DAUC;AAED,uEAUC;AAED,oGASC;AAED,4DAOC;AAID,0IAkJC;AAsBD;;;;GAwCC;AAED,6GA4EC"}
@@ -8,12 +8,13 @@
8
8
  export function minifySVGAttributeValue(name: string, value: string, options?: any): string;
9
9
  /**
10
10
  * Check if an SVG attribute can be removed
11
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
11
12
  * @param {string} name - Attribute name
12
13
  * @param {string} value - Attribute value
13
14
  * @param {Object} options - Minification options
14
15
  * @returns {boolean} True if attribute should be removed
15
16
  */
16
- export function shouldRemoveSVGAttribute(name: string, value: string, options?: any): boolean;
17
+ export function shouldRemoveSVGAttribute(tag: string, name: string, value: string, options?: any): boolean;
17
18
  /**
18
19
  * Get default SVG minification options
19
20
  * @param {Object} userOptions - User-provided options
@@ -1 +1 @@
1
- {"version":3,"file":"svg.d.ts","sourceRoot":"","sources":["../../../src/lib/svg.js"],"names":[],"mappings":"AAwMA;;;;;;GAMG;AACH,8CALW,MAAM,SACN,MAAM,kBAEJ,MAAM,CA0BlB;AAED;;;;;;GAMG;AACH,+CALW,MAAM,SACN,MAAM,kBAEJ,OAAO,CAQnB;AAED;;;;GAIG;AACH,6DAkBC"}
1
+ {"version":3,"file":"svg.d.ts","sourceRoot":"","sources":["../../../src/lib/svg.js"],"names":[],"mappings":"AAkVA;;;;;;GAMG;AACH,8CALW,MAAM,SACN,MAAM,kBAEJ,MAAM,CA0BlB;AAED;;;;;;;GAOG;AACH,8CANW,MAAM,QACN,MAAM,SACN,MAAM,kBAEJ,OAAO,CAanB;AAED;;;;GAIG;AACH,6DAkBC"}
@@ -1 +1 @@
1
- {"version":3,"file":"tokenchain.d.ts","sourceRoot":"","sources":["../../src/tokenchain.js"],"names":[],"mappings":";AA2CA;IAGI,mBAAoB;IAGtB,uBAOC;IAED,uBAiDC;CACF;AA5GD;IACE,2CAuCC;CACF"}
1
+ {"version":3,"file":"tokenchain.d.ts","sourceRoot":"","sources":["../../src/tokenchain.js"],"names":[],"mappings":";AAmCA;IAGI,mBAAoB;IAGtB,uBAOC;IAED,uBAiDC;CACF;AApGD;IACE,2CA+BC;CACF"}
package/package.json CHANGED
@@ -93,5 +93,5 @@
93
93
  "test:watch": "node --test --watch tests/*.spec.js"
94
94
  },
95
95
  "type": "module",
96
- "version": "4.15.2"
96
+ "version": "4.16.1"
97
97
  }
@@ -226,7 +226,15 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
226
226
  // Apply early whitespace normalization if enabled
227
227
  // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
228
228
  if (options.collapseAttributeWhitespace) {
229
- attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
229
+ // Single-pass: Trim leading/trailing whitespace and collapse internal whitespace to single space
230
+ attrValue = attrValue.replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$|[ \n\r\t\f]+/g, function(match, offset, str) {
231
+ // Leading whitespace (`offset === 0`)
232
+ if (offset === 0) return '';
233
+ // Trailing whitespace (match ends at string end)
234
+ if (offset + match.length === str.length) return '';
235
+ // Internal whitespace
236
+ return ' ';
237
+ });
230
238
  }
231
239
 
232
240
  if (isEventAttribute(attrName, options)) {
@@ -400,7 +408,7 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
400
408
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
401
409
  attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) ||
402
410
  (options.insideSVG && options.minifySVG &&
403
- shouldRemoveSVGAttribute(attrName, attrValue, options.minifySVG))) {
411
+ shouldRemoveSVGAttribute(tag, attrName, attrValue, options.minifySVG))) {
404
412
  return;
405
413
  }
406
414
 
package/src/lib/svg.js CHANGED
@@ -4,9 +4,33 @@
4
4
  * - Numeric precision reduction for coordinates and path data
5
5
  * - Whitespace removal in attribute values (numeric sequences)
6
6
  * - Default attribute removal (safe, well-documented defaults)
7
- * - Color minification (hex shortening, rgb() to hex)
7
+ * - Color minification (hex shortening, rgb() to hex, named colors)
8
+ * - Identity transform removal
9
+ * - Path data space optimization
8
10
  */
9
11
 
12
+ import { LRU } from './utils.js';
13
+
14
+ // Cache for minified numbers
15
+ const numberCache = new LRU(100);
16
+
17
+ /**
18
+ * Named colors that are shorter than their hex equivalents
19
+ * Only includes cases where using the name saves bytes
20
+ */
21
+ const NAMED_COLORS = {
22
+ '#f00': 'red', // #f00 (4) → red (3), saves 1
23
+ '#c0c0c0': 'silver', // #c0c0c0 (7) → silver (6), saves 1
24
+ '#808080': 'gray', // #808080 (7) → gray (4), saves 3
25
+ '#800000': 'maroon', // #800000 (7) → maroon (6), saves 1
26
+ '#808000': 'olive', // #808000 (7) → olive (5), saves 2
27
+ '#008000': 'green', // #008000 (7) → green (5), saves 2
28
+ '#800080': 'purple', // #800080 (7) → purple (6), saves 1
29
+ '#008080': 'teal', // #008080 (7) → teal (4), saves 3
30
+ '#000080': 'navy', // #000080 (7) → navy (4), saves 3
31
+ '#ffa500': 'orange' // #ffa500 (7) → orange (6), saves 1
32
+ };
33
+
10
34
  /**
11
35
  * Default SVG attribute values that can be safely removed
12
36
  * Only includes well-documented, widely-supported defaults
@@ -39,7 +63,22 @@ const SVG_DEFAULT_ATTRS = {
39
63
  opacity: value => value === '1',
40
64
  visibility: value => value === 'visible',
41
65
  display: value => value === 'inline',
42
- overflow: value => value === 'visible'
66
+ // Note: Overflow handled especially in `isDefaultAttribute` (not safe for root `<svg>`)
67
+
68
+ // Clipping and masking defaults
69
+ 'clip-rule': value => value === 'nonzero',
70
+ 'clip-path': value => value === 'none',
71
+ mask: value => value === 'none',
72
+
73
+ // Marker defaults
74
+ 'marker-start': value => value === 'none',
75
+ 'marker-mid': value => value === 'none',
76
+ 'marker-end': value => value === 'none',
77
+
78
+ // Filter and color defaults
79
+ filter: value => value === 'none',
80
+ 'color-interpolation': value => value === 'sRGB',
81
+ 'color-interpolation-filters': value => value === 'linearRGB'
43
82
  };
44
83
 
45
84
  /**
@@ -49,6 +88,20 @@ const SVG_DEFAULT_ATTRS = {
49
88
  * @returns {string} Minified numeric string
50
89
  */
51
90
  function minifyNumber(num, precision = 3) {
91
+ // Fast path for common values (avoids parsing and caching)
92
+ if (num === '0' || num === '1') return num;
93
+ // Common decimal variants that tools export
94
+ if (num === '0.0' || num === '0.00' || num === '0.000') return '0';
95
+ if (num === '1.0' || num === '1.00' || num === '1.000') return '1';
96
+
97
+ // Check cache
98
+ // (Note: uses input string as key, so “0.0000” and “0.00000” create separate entries.
99
+ // This is intentional to avoid parsing overhead.
100
+ // Real-world SVG files from export tools typically use consistent formats.)
101
+ const cacheKey = `${num}:${precision}`;
102
+ const cached = numberCache.get(cacheKey);
103
+ if (cached !== undefined) return cached;
104
+
52
105
  const parsed = parseFloat(num);
53
106
 
54
107
  // Handle special cases
@@ -60,11 +113,13 @@ function minifyNumber(num, precision = 3) {
60
113
  const fixed = parsed.toFixed(precision);
61
114
  const trimmed = fixed.replace(/\.?0+$/, '');
62
115
 
63
- return trimmed || '0';
116
+ const result = trimmed || '0';
117
+ numberCache.set(cacheKey, result);
118
+ return result;
64
119
  }
65
120
 
66
121
  /**
67
- * Minify SVG path data by reducing numeric precision
122
+ * Minify SVG path data by reducing numeric precision and removing unnecessary spaces
68
123
  * @param {string} pathData - SVG path data string
69
124
  * @param {number} precision - Decimal precision for coordinates
70
125
  * @returns {string} Minified path data
@@ -72,11 +127,25 @@ function minifyNumber(num, precision = 3) {
72
127
  function minifyPathData(pathData, precision = 3) {
73
128
  if (!pathData || typeof pathData !== 'string') return pathData;
74
129
 
75
- // Match numbers (including scientific notation and negative values)
76
- // Regex: optional minus, digits, optional decimal point and more digits, optional exponent
77
- return pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
130
+ // First, minify all numbers
131
+ let result = pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
78
132
  return minifyNumber(match, precision);
79
133
  });
134
+
135
+ // Remove unnecessary spaces around path commands
136
+ // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
137
+ // M 10 20 → M10 20, L -5 -3 → L-5-3
138
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
139
+
140
+ // Safe to remove space before command letter when preceded by a number
141
+ // 0 L → 0L, 20 M → 20M
142
+ result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
143
+
144
+ // Safe to remove space before negative number when preceded by a number
145
+ // 10 -20 → 10-20 (numbers are separated by the minus sign)
146
+ result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
147
+
148
+ return result;
80
149
  }
81
150
 
82
151
  /**
@@ -105,27 +174,49 @@ function minifyAttributeWhitespace(value) {
105
174
  }
106
175
 
107
176
  /**
108
- * Minify color values (hex shortening, rgb to hex conversion)
177
+ * Minify color values (hex shortening, rgb to hex conversion, named colors)
178
+ * Only processes simple color values; preserves case-sensitive references like `url(#id)`
109
179
  * @param {string} color - Color value to minify
110
180
  * @returns {string} Minified color value
111
181
  */
112
182
  function minifyColor(color) {
113
183
  if (!color || typeof color !== 'string') return color;
114
184
 
115
- const trimmed = color.trim().toLowerCase();
185
+ const trimmed = color.trim();
186
+
187
+ // Don’t process values that aren’t simple colors (preserve case-sensitive references)
188
+ // `url(#id)`, `var(--name)`, `inherit`, `currentColor`, etc.
189
+ if (trimmed.includes('url(') || trimmed.includes('var(') ||
190
+ trimmed === 'inherit' || trimmed === 'currentColor') {
191
+ return trimmed;
192
+ }
193
+
194
+ // Now safe to lowercase for color matching
195
+ const lower = trimmed.toLowerCase();
116
196
 
117
197
  // Shorten 6-digit hex to 3-digit when possible
118
198
  // #aabbcc → #abc, #000000 → #000
119
- const hexMatch = trimmed.match(/^#([0-9a-f]{6})$/);
199
+ const hexMatch = lower.match(/^#([0-9a-f]{6})$/);
120
200
  if (hexMatch) {
121
201
  const hex = hexMatch[1];
122
202
  if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) {
123
- return '#' + hex[0] + hex[2] + hex[4];
203
+ const shortened = '#' + hex[0] + hex[2] + hex[4];
204
+ // Try to use named color if shorter
205
+ return NAMED_COLORS[shortened] || shortened;
124
206
  }
207
+ // Can’t shorten, but check for named color
208
+ return NAMED_COLORS[lower] || lower;
209
+ }
210
+
211
+ // Match 3-digit hex colors
212
+ const hex3Match = lower.match(/^#[0-9a-f]{3}$/);
213
+ if (hex3Match) {
214
+ // Check if there’s a shorter named color
215
+ return NAMED_COLORS[lower] || lower;
125
216
  }
126
217
 
127
218
  // Convert rgb(255,255,255) to hex
128
- const rgbMatch = trimmed.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
219
+ const rgbMatch = lower.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
129
220
  if (rgbMatch) {
130
221
  const r = parseInt(rgbMatch[1], 10);
131
222
  const g = parseInt(rgbMatch[2], 10);
@@ -140,13 +231,15 @@ function minifyColor(color) {
140
231
 
141
232
  // Try to shorten if possible
142
233
  if (hexColor[1] === hexColor[2] && hexColor[3] === hexColor[4] && hexColor[5] === hexColor[6]) {
143
- return '#' + hexColor[1] + hexColor[3] + hexColor[5];
234
+ const shortened = '#' + hexColor[1] + hexColor[3] + hexColor[5];
235
+ return NAMED_COLORS[shortened] || shortened;
144
236
  }
145
- return hexColor;
237
+ return NAMED_COLORS[hexColor] || hexColor;
146
238
  }
147
239
  }
148
240
 
149
- return color;
241
+ // Not a recognized color format, return as-is (preserves case)
242
+ return trimmed;
150
243
  }
151
244
 
152
245
  // Attributes that contain numeric sequences or path data
@@ -176,13 +269,58 @@ const COLOR_ATTRS = new Set([
176
269
  'lighting-color'
177
270
  ]);
178
271
 
272
+ // Pre-compiled regexes for identity transform detection (compiled once at module load)
273
+ // Separator pattern: Accepts comma with optional spaces or one or more spaces
274
+ const SEP = '(?:\\s*,\\s*|\\s+)';
275
+
276
+ // `translate(0)`, `translate(0,0)`, `translate(0 0)` (matches 0, 0.0, 0.00, etc.)
277
+ const IDENTITY_TRANSLATE_RE = new RegExp(`^translate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}0(?:\\.0+)?\\s*)?\\)$`, 'i');
278
+
279
+ // `scale(1)`, `scale(1,1)`, `scale(1 1)` (matches 1, 1.0, 1.00, etc.)
280
+ const IDENTITY_SCALE_RE = new RegExp(`^scale\\s*\\(\\s*1(?:\\.0+)?\\s*(?:${SEP}1(?:\\.0+)?\\s*)?\\)$`, 'i');
281
+
282
+ // `rotate(0)`, `rotate(0 cx cy)`, `rotate(0, cx, cy)` (matches 0, 0.0, 0.00, etc.)
283
+ // Note: `cx` and `cy` must be valid numbers if present
284
+ const IDENTITY_ROTATE_RE = new RegExp(`^rotate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}-?\\d+(?:\\.\\d+)?${SEP}-?\\d+(?:\\.\\d+)?)?\\s*\\)$`, 'i');
285
+
286
+ // `skewX(0)`, `skewY(0)` (matches 0, 0.0, 0.00, etc.)
287
+ const IDENTITY_SKEW_RE = /^skew[XY]\s*\(\s*0(?:\.0+)?\s*\)$/i;
288
+
289
+ // `matrix(1,0,0,1,0,0)`, `matrix(1 0 0 1 0 0)`—identity matrix (matches 1.0/0.0 variants)
290
+ const IDENTITY_MATRIX_RE = new RegExp(`^matrix\\s*\\(\\s*1(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}1(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*\\)$`, 'i');
291
+
292
+ /**
293
+ * Check if a transform attribute has no effect (identity transform)
294
+ * @param {string} transform - Transform attribute value
295
+ * @returns {boolean} True if transform is an identity (has no effect)
296
+ */
297
+ function isIdentityTransform(transform) {
298
+ if (!transform || typeof transform !== 'string') return false;
299
+
300
+ const trimmed = transform.trim();
301
+
302
+ // Check for common identity transforms using pre-compiled regexes
303
+ return IDENTITY_TRANSLATE_RE.test(trimmed) ||
304
+ IDENTITY_SCALE_RE.test(trimmed) ||
305
+ IDENTITY_ROTATE_RE.test(trimmed) ||
306
+ IDENTITY_SKEW_RE.test(trimmed) ||
307
+ IDENTITY_MATRIX_RE.test(trimmed);
308
+ }
309
+
179
310
  /**
180
311
  * Check if an attribute should be removed based on default value
312
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
181
313
  * @param {string} name - Attribute name
182
314
  * @param {string} value - Attribute value
183
315
  * @returns {boolean} True if attribute can be removed
184
316
  */
185
- function isDefaultAttribute(name, value) {
317
+ function isDefaultAttribute(tag, name, value) {
318
+ // Special case: `overflow="visible"` is unsafe for root `<svg>` element
319
+ // Root SVG may need explicit `overflow="visible"` to show clipped content
320
+ if (name === 'overflow' && value === 'visible') {
321
+ return tag !== 'svg'; // Only remove for non-root SVG elements
322
+ }
323
+
186
324
  const checker = SVG_DEFAULT_ATTRS[name];
187
325
  if (!checker) return false;
188
326
 
@@ -233,17 +371,23 @@ export function minifySVGAttributeValue(name, value, options = {}) {
233
371
 
234
372
  /**
235
373
  * Check if an SVG attribute can be removed
374
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
236
375
  * @param {string} name - Attribute name
237
376
  * @param {string} value - Attribute value
238
377
  * @param {Object} options - Minification options
239
378
  * @returns {boolean} True if attribute should be removed
240
379
  */
241
- export function shouldRemoveSVGAttribute(name, value, options = {}) {
380
+ export function shouldRemoveSVGAttribute(tag, name, value, options = {}) {
242
381
  const { removeDefaults = true } = options;
243
382
 
244
383
  if (!removeDefaults) return false;
245
384
 
246
- return isDefaultAttribute(name, value);
385
+ // Check for identity transforms
386
+ if (name === 'transform' && isIdentityTransform(value)) {
387
+ return true;
388
+ }
389
+
390
+ return isDefaultAttribute(tag, name, value);
247
391
  }
248
392
 
249
393
  /**
package/src/tokenchain.js CHANGED
@@ -3,37 +3,29 @@ class Sorter {
3
3
  for (let i = 0, len = this.keys.length; i < len; i++) {
4
4
  const token = this.keys[i];
5
5
 
6
- // Build position map for this token to avoid repeated `indexOf`
7
- const positions = [];
6
+ // Single pass: Count matches and collect non-matches
7
+ let matchCount = 0;
8
+ const others = [];
9
+
8
10
  for (let j = fromIndex; j < tokens.length; j++) {
9
11
  if (tokens[j] === token) {
10
- positions.push(j);
12
+ matchCount++;
13
+ } else {
14
+ others.push(tokens[j]);
11
15
  }
12
16
  }
13
17
 
14
- if (positions.length > 0) {
15
- // Build new array with tokens in sorted order instead of splicing
16
- const result = [];
17
-
18
- // Add all instances of the current token first
19
- for (let j = 0; j < positions.length; j++) {
20
- result.push(token);
21
- }
22
-
23
- // Add other tokens, skipping positions where current token was
24
- const posSet = new Set(positions);
25
- for (let j = fromIndex; j < tokens.length; j++) {
26
- if (!posSet.has(j)) {
27
- result.push(tokens[j]);
28
- }
18
+ if (matchCount > 0) {
19
+ // Rebuild: `matchCount` instances of token first, then others
20
+ let writeIdx = fromIndex;
21
+ for (let j = 0; j < matchCount; j++) {
22
+ tokens[writeIdx++] = token;
29
23
  }
30
-
31
- // Copy sorted portion back to tokens array
32
- for (let j = 0; j < result.length; j++) {
33
- tokens[fromIndex + j] = result[j];
24
+ for (let j = 0; j < others.length; j++) {
25
+ tokens[writeIdx++] = others[j];
34
26
  }
35
27
 
36
- const newFromIndex = fromIndex + positions.length;
28
+ const newFromIndex = fromIndex + matchCount;
37
29
  return this.sorterMap.get(token).sort(tokens, newFromIndex);
38
30
  }
39
31
  }