html-minifier-next 4.15.2 → 4.16.0

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,40 @@ 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/) | 260 | **203** | 231 | 235 | 236 | 238 | 238 |
362
+ | [BBC](https://www.bbc.co.uk/) | 704 | **641** | 661 | 661 | 662 | 698 | n/a |
363
363
  | [CERN](https://home.cern/) | 152 | **83** | 91 | 91 | 91 | 93 | 96 |
364
- | [CSS-Tricks](https://css-tricks.com/) | 162 | **119** | 127 | 143 | 143 | 148 | 144 |
364
+ | [CSS-Tricks](https://css-tricks.com/) | 162 | **119** | 128 | 143 | 143 | 148 | 145 |
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** | 48 | 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/) | 1537 | 1429 | **1378** | 1462 | 1473 | 1484 | 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 | 224 | 225 | 244 | 225 |
372
+ | [Google](https://www.google.com/) | 18 | **16** | 17 | 17 | 17 | 18 | 18 |
373
+ | [Ground News](https://ground.news/) | 2437 | **2150** | 2246 | 2272 | 2275 | 2424 | n/a |
374
374
  | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | 148 | 153 | **147** | 149 | 155 | 149 |
375
375
  | [Igalia](https://www.igalia.com/) | 50 | **33** | 36 | 36 | 36 | 37 | 36 |
376
- | [Leanpub](https://leanpub.com/) | 229 | **199** | 214 | 213 | 214 | 224 | 226 |
376
+ | [Leanpub](https://leanpub.com/) | 233 | **203** | 217 | 217 | 218 | 228 | 230 |
377
377
  | [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 |
378
+ | [MDN](https://developer.mozilla.org/en-US/) | 106 | **60** | 62 | 63 | 63 | 66 | 66 |
379
+ | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **197** | 203 | 201 | 201 | 203 | 204 |
380
+ | [Mistral AI](https://mistral.ai/) | 361 | **319** | 324 | 326 | 327 | 357 | n/a |
381
381
  | [Mozilla](https://www.mozilla.org/) | 45 | **31** | 34 | 34 | 34 | 35 | 35 |
382
382
  | [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 |
383
+ | [SitePoint](https://www.sitepoint.com/) | 482 | **351** | 422 | 456 | 460 | 478 | n/a |
384
384
  | [Startup-Verband](https://startupverband.de/) | 42 | **29** | 30 | 30 | 30 | 31 | 30 |
385
- | [TetraLogical](https://tetralogical.com/) | 44 | 39 | **35** | 38 | 39 | 39 | 39 |
386
385
  | [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 |
386
+ | [United Nations](https://www.un.org/en/) | 152 | **112** | 121 | 125 | 125 | 130 | 123 |
388
387
  | [Vivaldi](https://vivaldi.com/) | 92 | **74** | n/a | 79 | 81 | 83 | 81 |
389
388
  | [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) |
389
+ | **Average processing time** | | 132 ms (29/29) | 169 ms (28/29) | 54 ms (29/29) | **14 ms (29/29)** | 293 ms (29/29) | 1355 ms (23/29) |
391
390
 
392
- (Last updated: Dec 23, 2025)
391
+ (Last updated: Dec 26, 2025)
393
392
  <!-- End auto-generated -->
394
393
 
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.
394
+ 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
395
 
397
396
  ## Examples
398
397
 
@@ -1260,9 +1260,32 @@ async function processScript(text, options, currentAttrs, minifyHTML) {
1260
1260
  * - Numeric precision reduction for coordinates and path data
1261
1261
  * - Whitespace removal in attribute values (numeric sequences)
1262
1262
  * - Default attribute removal (safe, well-documented defaults)
1263
- * - Color minification (hex shortening, rgb() to hex)
1263
+ * - Color minification (hex shortening, rgb() to hex, named colors)
1264
+ * - Identity transform removal
1265
+ * - Path data space optimization
1264
1266
  */
1265
1267
 
1268
+
1269
+ // Cache for minified numbers
1270
+ const numberCache = new LRU(100);
1271
+
1272
+ /**
1273
+ * Named colors that are shorter than their hex equivalents
1274
+ * Only includes cases where using the name saves bytes
1275
+ */
1276
+ const NAMED_COLORS = {
1277
+ '#f00': 'red', // #f00 (4) → red (3), saves 1
1278
+ '#c0c0c0': 'silver', // #c0c0c0 (7) → silver (6), saves 1
1279
+ '#808080': 'gray', // #808080 (7) → gray (4), saves 3
1280
+ '#800000': 'maroon', // #800000 (7) → maroon (6), saves 1
1281
+ '#808000': 'olive', // #808000 (7) → olive (5), saves 2
1282
+ '#008000': 'green', // #008000 (7) → green (5), saves 2
1283
+ '#800080': 'purple', // #800080 (7) → purple (6), saves 1
1284
+ '#008080': 'teal', // #008080 (7) → teal (4), saves 3
1285
+ '#000080': 'navy', // #000080 (7) → navy (4), saves 3
1286
+ '#ffa500': 'orange' // #ffa500 (7) → orange (6), saves 1
1287
+ };
1288
+
1266
1289
  /**
1267
1290
  * Default SVG attribute values that can be safely removed
1268
1291
  * Only includes well-documented, widely-supported defaults
@@ -1295,7 +1318,22 @@ const SVG_DEFAULT_ATTRS = {
1295
1318
  opacity: value => value === '1',
1296
1319
  visibility: value => value === 'visible',
1297
1320
  display: value => value === 'inline',
1298
- overflow: value => value === 'visible'
1321
+ // Note: Overflow handled especially in `isDefaultAttribute` (not safe for root `<svg>`)
1322
+
1323
+ // Clipping and masking defaults
1324
+ 'clip-rule': value => value === 'nonzero',
1325
+ 'clip-path': value => value === 'none',
1326
+ mask: value => value === 'none',
1327
+
1328
+ // Marker defaults
1329
+ 'marker-start': value => value === 'none',
1330
+ 'marker-mid': value => value === 'none',
1331
+ 'marker-end': value => value === 'none',
1332
+
1333
+ // Filter and color defaults
1334
+ filter: value => value === 'none',
1335
+ 'color-interpolation': value => value === 'sRGB',
1336
+ 'color-interpolation-filters': value => value === 'linearRGB'
1299
1337
  };
1300
1338
 
1301
1339
  /**
@@ -1305,6 +1343,20 @@ const SVG_DEFAULT_ATTRS = {
1305
1343
  * @returns {string} Minified numeric string
1306
1344
  */
1307
1345
  function minifyNumber(num, precision = 3) {
1346
+ // Fast path for common values (avoids parsing and caching)
1347
+ if (num === '0' || num === '1') return num;
1348
+ // Common decimal variants that tools export
1349
+ if (num === '0.0' || num === '0.00' || num === '0.000') return '0';
1350
+ if (num === '1.0' || num === '1.00' || num === '1.000') return '1';
1351
+
1352
+ // Check cache
1353
+ // (Note: uses input string as key, so “0.0000” and “0.00000” create separate entries.
1354
+ // This is intentional to avoid parsing overhead.
1355
+ // Real-world SVG files from export tools typically use consistent formats.)
1356
+ const cacheKey = `${num}:${precision}`;
1357
+ const cached = numberCache.get(cacheKey);
1358
+ if (cached !== undefined) return cached;
1359
+
1308
1360
  const parsed = parseFloat(num);
1309
1361
 
1310
1362
  // Handle special cases
@@ -1316,11 +1368,13 @@ function minifyNumber(num, precision = 3) {
1316
1368
  const fixed = parsed.toFixed(precision);
1317
1369
  const trimmed = fixed.replace(/\.?0+$/, '');
1318
1370
 
1319
- return trimmed || '0';
1371
+ const result = trimmed || '0';
1372
+ numberCache.set(cacheKey, result);
1373
+ return result;
1320
1374
  }
1321
1375
 
1322
1376
  /**
1323
- * Minify SVG path data by reducing numeric precision
1377
+ * Minify SVG path data by reducing numeric precision and removing unnecessary spaces
1324
1378
  * @param {string} pathData - SVG path data string
1325
1379
  * @param {number} precision - Decimal precision for coordinates
1326
1380
  * @returns {string} Minified path data
@@ -1328,11 +1382,25 @@ function minifyNumber(num, precision = 3) {
1328
1382
  function minifyPathData(pathData, precision = 3) {
1329
1383
  if (!pathData || typeof pathData !== 'string') return pathData;
1330
1384
 
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) => {
1385
+ // First, minify all numbers
1386
+ let result = pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
1334
1387
  return minifyNumber(match, precision);
1335
1388
  });
1389
+
1390
+ // Remove unnecessary spaces around path commands
1391
+ // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
1392
+ // M 10 20 → M10 20, L -5 -3 → L-5-3
1393
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
1394
+
1395
+ // Safe to remove space before command letter when preceded by a number
1396
+ // 0 L → 0L, 20 M → 20M
1397
+ result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
1398
+
1399
+ // Safe to remove space before negative number when preceded by a number
1400
+ // 10 -20 → 10-20 (numbers are separated by the minus sign)
1401
+ result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
1402
+
1403
+ return result;
1336
1404
  }
1337
1405
 
1338
1406
  /**
@@ -1361,27 +1429,49 @@ function minifyAttributeWhitespace(value) {
1361
1429
  }
1362
1430
 
1363
1431
  /**
1364
- * Minify color values (hex shortening, rgb to hex conversion)
1432
+ * Minify color values (hex shortening, rgb to hex conversion, named colors)
1433
+ * Only processes simple color values; preserves case-sensitive references like `url(#id)`
1365
1434
  * @param {string} color - Color value to minify
1366
1435
  * @returns {string} Minified color value
1367
1436
  */
1368
1437
  function minifyColor(color) {
1369
1438
  if (!color || typeof color !== 'string') return color;
1370
1439
 
1371
- const trimmed = color.trim().toLowerCase();
1440
+ const trimmed = color.trim();
1441
+
1442
+ // Don’t process values that aren’t simple colors (preserve case-sensitive references)
1443
+ // `url(#id)`, `var(--name)`, `inherit`, `currentColor`, etc.
1444
+ if (trimmed.includes('url(') || trimmed.includes('var(') ||
1445
+ trimmed === 'inherit' || trimmed === 'currentColor') {
1446
+ return trimmed;
1447
+ }
1448
+
1449
+ // Now safe to lowercase for color matching
1450
+ const lower = trimmed.toLowerCase();
1372
1451
 
1373
1452
  // Shorten 6-digit hex to 3-digit when possible
1374
1453
  // #aabbcc → #abc, #000000 → #000
1375
- const hexMatch = trimmed.match(/^#([0-9a-f]{6})$/);
1454
+ const hexMatch = lower.match(/^#([0-9a-f]{6})$/);
1376
1455
  if (hexMatch) {
1377
1456
  const hex = hexMatch[1];
1378
1457
  if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) {
1379
- return '#' + hex[0] + hex[2] + hex[4];
1458
+ const shortened = '#' + hex[0] + hex[2] + hex[4];
1459
+ // Try to use named color if shorter
1460
+ return NAMED_COLORS[shortened] || shortened;
1380
1461
  }
1462
+ // Can’t shorten, but check for named color
1463
+ return NAMED_COLORS[lower] || lower;
1464
+ }
1465
+
1466
+ // Match 3-digit hex colors
1467
+ const hex3Match = lower.match(/^#[0-9a-f]{3}$/);
1468
+ if (hex3Match) {
1469
+ // Check if there’s a shorter named color
1470
+ return NAMED_COLORS[lower] || lower;
1381
1471
  }
1382
1472
 
1383
1473
  // Convert rgb(255,255,255) to hex
1384
- const rgbMatch = trimmed.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
1474
+ const rgbMatch = lower.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
1385
1475
  if (rgbMatch) {
1386
1476
  const r = parseInt(rgbMatch[1], 10);
1387
1477
  const g = parseInt(rgbMatch[2], 10);
@@ -1396,13 +1486,15 @@ function minifyColor(color) {
1396
1486
 
1397
1487
  // Try to shorten if possible
1398
1488
  if (hexColor[1] === hexColor[2] && hexColor[3] === hexColor[4] && hexColor[5] === hexColor[6]) {
1399
- return '#' + hexColor[1] + hexColor[3] + hexColor[5];
1489
+ const shortened = '#' + hexColor[1] + hexColor[3] + hexColor[5];
1490
+ return NAMED_COLORS[shortened] || shortened;
1400
1491
  }
1401
- return hexColor;
1492
+ return NAMED_COLORS[hexColor] || hexColor;
1402
1493
  }
1403
1494
  }
1404
1495
 
1405
- return color;
1496
+ // Not a recognized color format, return as-is (preserves case)
1497
+ return trimmed;
1406
1498
  }
1407
1499
 
1408
1500
  // Attributes that contain numeric sequences or path data
@@ -1432,13 +1524,58 @@ const COLOR_ATTRS = new Set([
1432
1524
  'lighting-color'
1433
1525
  ]);
1434
1526
 
1527
+ // Pre-compiled regexes for identity transform detection (compiled once at module load)
1528
+ // Separator pattern: Accepts comma with optional spaces or one or more spaces
1529
+ const SEP = '(?:\\s*,\\s*|\\s+)';
1530
+
1531
+ // `translate(0)`, `translate(0,0)`, `translate(0 0)` (matches 0, 0.0, 0.00, etc.)
1532
+ const IDENTITY_TRANSLATE_RE = new RegExp(`^translate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}0(?:\\.0+)?\\s*)?\\)$`, 'i');
1533
+
1534
+ // `scale(1)`, `scale(1,1)`, `scale(1 1)` (matches 1, 1.0, 1.00, etc.)
1535
+ const IDENTITY_SCALE_RE = new RegExp(`^scale\\s*\\(\\s*1(?:\\.0+)?\\s*(?:${SEP}1(?:\\.0+)?\\s*)?\\)$`, 'i');
1536
+
1537
+ // `rotate(0)`, `rotate(0 cx cy)`, `rotate(0, cx, cy)` (matches 0, 0.0, 0.00, etc.)
1538
+ // Note: `cx` and `cy` must be valid numbers if present
1539
+ const IDENTITY_ROTATE_RE = new RegExp(`^rotate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}-?\\d+(?:\\.\\d+)?${SEP}-?\\d+(?:\\.\\d+)?)?\\s*\\)$`, 'i');
1540
+
1541
+ // `skewX(0)`, `skewY(0)` (matches 0, 0.0, 0.00, etc.)
1542
+ const IDENTITY_SKEW_RE = /^skew[XY]\s*\(\s*0(?:\.0+)?\s*\)$/i;
1543
+
1544
+ // `matrix(1,0,0,1,0,0)`, `matrix(1 0 0 1 0 0)`—identity matrix (matches 1.0/0.0 variants)
1545
+ 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');
1546
+
1547
+ /**
1548
+ * Check if a transform attribute has no effect (identity transform)
1549
+ * @param {string} transform - Transform attribute value
1550
+ * @returns {boolean} True if transform is an identity (has no effect)
1551
+ */
1552
+ function isIdentityTransform(transform) {
1553
+ if (!transform || typeof transform !== 'string') return false;
1554
+
1555
+ const trimmed = transform.trim();
1556
+
1557
+ // Check for common identity transforms using pre-compiled regexes
1558
+ return IDENTITY_TRANSLATE_RE.test(trimmed) ||
1559
+ IDENTITY_SCALE_RE.test(trimmed) ||
1560
+ IDENTITY_ROTATE_RE.test(trimmed) ||
1561
+ IDENTITY_SKEW_RE.test(trimmed) ||
1562
+ IDENTITY_MATRIX_RE.test(trimmed);
1563
+ }
1564
+
1435
1565
  /**
1436
1566
  * Check if an attribute should be removed based on default value
1567
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
1437
1568
  * @param {string} name - Attribute name
1438
1569
  * @param {string} value - Attribute value
1439
1570
  * @returns {boolean} True if attribute can be removed
1440
1571
  */
1441
- function isDefaultAttribute(name, value) {
1572
+ function isDefaultAttribute(tag, name, value) {
1573
+ // Special case: `overflow="visible"` is unsafe for root `<svg>` element
1574
+ // Root SVG may need explicit `overflow="visible"` to show clipped content
1575
+ if (name === 'overflow' && value === 'visible') {
1576
+ return tag !== 'svg'; // Only remove for non-root SVG elements
1577
+ }
1578
+
1442
1579
  const checker = SVG_DEFAULT_ATTRS[name];
1443
1580
  if (!checker) return false;
1444
1581
 
@@ -1489,17 +1626,23 @@ function minifySVGAttributeValue(name, value, options = {}) {
1489
1626
 
1490
1627
  /**
1491
1628
  * Check if an SVG attribute can be removed
1629
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
1492
1630
  * @param {string} name - Attribute name
1493
1631
  * @param {string} value - Attribute value
1494
1632
  * @param {Object} options - Minification options
1495
1633
  * @returns {boolean} True if attribute should be removed
1496
1634
  */
1497
- function shouldRemoveSVGAttribute(name, value, options = {}) {
1635
+ function shouldRemoveSVGAttribute(tag, name, value, options = {}) {
1498
1636
  const { removeDefaults = true } = options;
1499
1637
 
1500
1638
  if (!removeDefaults) return false;
1501
1639
 
1502
- return isDefaultAttribute(name, value);
1640
+ // Check for identity transforms
1641
+ if (name === 'transform' && isIdentityTransform(value)) {
1642
+ return true;
1643
+ }
1644
+
1645
+ return isDefaultAttribute(tag, name, value);
1503
1646
  }
1504
1647
 
1505
1648
  /**
@@ -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
 
@@ -6402,9 +6402,32 @@ async function processScript(text, options, currentAttrs, minifyHTML) {
6402
6402
  * - Numeric precision reduction for coordinates and path data
6403
6403
  * - Whitespace removal in attribute values (numeric sequences)
6404
6404
  * - Default attribute removal (safe, well-documented defaults)
6405
- * - Color minification (hex shortening, rgb() to hex)
6405
+ * - Color minification (hex shortening, rgb() to hex, named colors)
6406
+ * - Identity transform removal
6407
+ * - Path data space optimization
6406
6408
  */
6407
6409
 
6410
+
6411
+ // Cache for minified numbers
6412
+ const numberCache = new LRU(100);
6413
+
6414
+ /**
6415
+ * Named colors that are shorter than their hex equivalents
6416
+ * Only includes cases where using the name saves bytes
6417
+ */
6418
+ const NAMED_COLORS = {
6419
+ '#f00': 'red', // #f00 (4) → red (3), saves 1
6420
+ '#c0c0c0': 'silver', // #c0c0c0 (7) → silver (6), saves 1
6421
+ '#808080': 'gray', // #808080 (7) → gray (4), saves 3
6422
+ '#800000': 'maroon', // #800000 (7) → maroon (6), saves 1
6423
+ '#808000': 'olive', // #808000 (7) → olive (5), saves 2
6424
+ '#008000': 'green', // #008000 (7) → green (5), saves 2
6425
+ '#800080': 'purple', // #800080 (7) → purple (6), saves 1
6426
+ '#008080': 'teal', // #008080 (7) → teal (4), saves 3
6427
+ '#000080': 'navy', // #000080 (7) → navy (4), saves 3
6428
+ '#ffa500': 'orange' // #ffa500 (7) → orange (6), saves 1
6429
+ };
6430
+
6408
6431
  /**
6409
6432
  * Default SVG attribute values that can be safely removed
6410
6433
  * Only includes well-documented, widely-supported defaults
@@ -6437,7 +6460,22 @@ const SVG_DEFAULT_ATTRS = {
6437
6460
  opacity: value => value === '1',
6438
6461
  visibility: value => value === 'visible',
6439
6462
  display: value => value === 'inline',
6440
- overflow: value => value === 'visible'
6463
+ // Note: Overflow handled especially in `isDefaultAttribute` (not safe for root `<svg>`)
6464
+
6465
+ // Clipping and masking defaults
6466
+ 'clip-rule': value => value === 'nonzero',
6467
+ 'clip-path': value => value === 'none',
6468
+ mask: value => value === 'none',
6469
+
6470
+ // Marker defaults
6471
+ 'marker-start': value => value === 'none',
6472
+ 'marker-mid': value => value === 'none',
6473
+ 'marker-end': value => value === 'none',
6474
+
6475
+ // Filter and color defaults
6476
+ filter: value => value === 'none',
6477
+ 'color-interpolation': value => value === 'sRGB',
6478
+ 'color-interpolation-filters': value => value === 'linearRGB'
6441
6479
  };
6442
6480
 
6443
6481
  /**
@@ -6447,6 +6485,20 @@ const SVG_DEFAULT_ATTRS = {
6447
6485
  * @returns {string} Minified numeric string
6448
6486
  */
6449
6487
  function minifyNumber(num, precision = 3) {
6488
+ // Fast path for common values (avoids parsing and caching)
6489
+ if (num === '0' || num === '1') return num;
6490
+ // Common decimal variants that tools export
6491
+ if (num === '0.0' || num === '0.00' || num === '0.000') return '0';
6492
+ if (num === '1.0' || num === '1.00' || num === '1.000') return '1';
6493
+
6494
+ // Check cache
6495
+ // (Note: uses input string as key, so “0.0000” and “0.00000” create separate entries.
6496
+ // This is intentional to avoid parsing overhead.
6497
+ // Real-world SVG files from export tools typically use consistent formats.)
6498
+ const cacheKey = `${num}:${precision}`;
6499
+ const cached = numberCache.get(cacheKey);
6500
+ if (cached !== undefined) return cached;
6501
+
6450
6502
  const parsed = parseFloat(num);
6451
6503
 
6452
6504
  // Handle special cases
@@ -6458,11 +6510,13 @@ function minifyNumber(num, precision = 3) {
6458
6510
  const fixed = parsed.toFixed(precision);
6459
6511
  const trimmed = fixed.replace(/\.?0+$/, '');
6460
6512
 
6461
- return trimmed || '0';
6513
+ const result = trimmed || '0';
6514
+ numberCache.set(cacheKey, result);
6515
+ return result;
6462
6516
  }
6463
6517
 
6464
6518
  /**
6465
- * Minify SVG path data by reducing numeric precision
6519
+ * Minify SVG path data by reducing numeric precision and removing unnecessary spaces
6466
6520
  * @param {string} pathData - SVG path data string
6467
6521
  * @param {number} precision - Decimal precision for coordinates
6468
6522
  * @returns {string} Minified path data
@@ -6470,11 +6524,25 @@ function minifyNumber(num, precision = 3) {
6470
6524
  function minifyPathData(pathData, precision = 3) {
6471
6525
  if (!pathData || typeof pathData !== 'string') return pathData;
6472
6526
 
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) => {
6527
+ // First, minify all numbers
6528
+ let result = pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
6476
6529
  return minifyNumber(match, precision);
6477
6530
  });
6531
+
6532
+ // Remove unnecessary spaces around path commands
6533
+ // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
6534
+ // M 10 20 → M10 20, L -5 -3 → L-5-3
6535
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
6536
+
6537
+ // Safe to remove space before command letter when preceded by a number
6538
+ // 0 L → 0L, 20 M → 20M
6539
+ result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
6540
+
6541
+ // Safe to remove space before negative number when preceded by a number
6542
+ // 10 -20 → 10-20 (numbers are separated by the minus sign)
6543
+ result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
6544
+
6545
+ return result;
6478
6546
  }
6479
6547
 
6480
6548
  /**
@@ -6503,27 +6571,49 @@ function minifyAttributeWhitespace(value) {
6503
6571
  }
6504
6572
 
6505
6573
  /**
6506
- * Minify color values (hex shortening, rgb to hex conversion)
6574
+ * Minify color values (hex shortening, rgb to hex conversion, named colors)
6575
+ * Only processes simple color values; preserves case-sensitive references like `url(#id)`
6507
6576
  * @param {string} color - Color value to minify
6508
6577
  * @returns {string} Minified color value
6509
6578
  */
6510
6579
  function minifyColor(color) {
6511
6580
  if (!color || typeof color !== 'string') return color;
6512
6581
 
6513
- const trimmed = color.trim().toLowerCase();
6582
+ const trimmed = color.trim();
6583
+
6584
+ // Don’t process values that aren’t simple colors (preserve case-sensitive references)
6585
+ // `url(#id)`, `var(--name)`, `inherit`, `currentColor`, etc.
6586
+ if (trimmed.includes('url(') || trimmed.includes('var(') ||
6587
+ trimmed === 'inherit' || trimmed === 'currentColor') {
6588
+ return trimmed;
6589
+ }
6590
+
6591
+ // Now safe to lowercase for color matching
6592
+ const lower = trimmed.toLowerCase();
6514
6593
 
6515
6594
  // Shorten 6-digit hex to 3-digit when possible
6516
6595
  // #aabbcc → #abc, #000000 → #000
6517
- const hexMatch = trimmed.match(/^#([0-9a-f]{6})$/);
6596
+ const hexMatch = lower.match(/^#([0-9a-f]{6})$/);
6518
6597
  if (hexMatch) {
6519
6598
  const hex = hexMatch[1];
6520
6599
  if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) {
6521
- return '#' + hex[0] + hex[2] + hex[4];
6600
+ const shortened = '#' + hex[0] + hex[2] + hex[4];
6601
+ // Try to use named color if shorter
6602
+ return NAMED_COLORS[shortened] || shortened;
6522
6603
  }
6604
+ // Can’t shorten, but check for named color
6605
+ return NAMED_COLORS[lower] || lower;
6606
+ }
6607
+
6608
+ // Match 3-digit hex colors
6609
+ const hex3Match = lower.match(/^#[0-9a-f]{3}$/);
6610
+ if (hex3Match) {
6611
+ // Check if there’s a shorter named color
6612
+ return NAMED_COLORS[lower] || lower;
6523
6613
  }
6524
6614
 
6525
6615
  // Convert rgb(255,255,255) to hex
6526
- const rgbMatch = trimmed.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
6616
+ const rgbMatch = lower.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
6527
6617
  if (rgbMatch) {
6528
6618
  const r = parseInt(rgbMatch[1], 10);
6529
6619
  const g = parseInt(rgbMatch[2], 10);
@@ -6538,13 +6628,15 @@ function minifyColor(color) {
6538
6628
 
6539
6629
  // Try to shorten if possible
6540
6630
  if (hexColor[1] === hexColor[2] && hexColor[3] === hexColor[4] && hexColor[5] === hexColor[6]) {
6541
- return '#' + hexColor[1] + hexColor[3] + hexColor[5];
6631
+ const shortened = '#' + hexColor[1] + hexColor[3] + hexColor[5];
6632
+ return NAMED_COLORS[shortened] || shortened;
6542
6633
  }
6543
- return hexColor;
6634
+ return NAMED_COLORS[hexColor] || hexColor;
6544
6635
  }
6545
6636
  }
6546
6637
 
6547
- return color;
6638
+ // Not a recognized color format, return as-is (preserves case)
6639
+ return trimmed;
6548
6640
  }
6549
6641
 
6550
6642
  // Attributes that contain numeric sequences or path data
@@ -6574,13 +6666,58 @@ const COLOR_ATTRS = new Set([
6574
6666
  'lighting-color'
6575
6667
  ]);
6576
6668
 
6669
+ // Pre-compiled regexes for identity transform detection (compiled once at module load)
6670
+ // Separator pattern: Accepts comma with optional spaces or one or more spaces
6671
+ const SEP = '(?:\\s*,\\s*|\\s+)';
6672
+
6673
+ // `translate(0)`, `translate(0,0)`, `translate(0 0)` (matches 0, 0.0, 0.00, etc.)
6674
+ const IDENTITY_TRANSLATE_RE = new RegExp(`^translate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}0(?:\\.0+)?\\s*)?\\)$`, 'i');
6675
+
6676
+ // `scale(1)`, `scale(1,1)`, `scale(1 1)` (matches 1, 1.0, 1.00, etc.)
6677
+ const IDENTITY_SCALE_RE = new RegExp(`^scale\\s*\\(\\s*1(?:\\.0+)?\\s*(?:${SEP}1(?:\\.0+)?\\s*)?\\)$`, 'i');
6678
+
6679
+ // `rotate(0)`, `rotate(0 cx cy)`, `rotate(0, cx, cy)` (matches 0, 0.0, 0.00, etc.)
6680
+ // Note: `cx` and `cy` must be valid numbers if present
6681
+ const IDENTITY_ROTATE_RE = new RegExp(`^rotate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}-?\\d+(?:\\.\\d+)?${SEP}-?\\d+(?:\\.\\d+)?)?\\s*\\)$`, 'i');
6682
+
6683
+ // `skewX(0)`, `skewY(0)` (matches 0, 0.0, 0.00, etc.)
6684
+ const IDENTITY_SKEW_RE = /^skew[XY]\s*\(\s*0(?:\.0+)?\s*\)$/i;
6685
+
6686
+ // `matrix(1,0,0,1,0,0)`, `matrix(1 0 0 1 0 0)`—identity matrix (matches 1.0/0.0 variants)
6687
+ 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');
6688
+
6689
+ /**
6690
+ * Check if a transform attribute has no effect (identity transform)
6691
+ * @param {string} transform - Transform attribute value
6692
+ * @returns {boolean} True if transform is an identity (has no effect)
6693
+ */
6694
+ function isIdentityTransform(transform) {
6695
+ if (!transform || typeof transform !== 'string') return false;
6696
+
6697
+ const trimmed = transform.trim();
6698
+
6699
+ // Check for common identity transforms using pre-compiled regexes
6700
+ return IDENTITY_TRANSLATE_RE.test(trimmed) ||
6701
+ IDENTITY_SCALE_RE.test(trimmed) ||
6702
+ IDENTITY_ROTATE_RE.test(trimmed) ||
6703
+ IDENTITY_SKEW_RE.test(trimmed) ||
6704
+ IDENTITY_MATRIX_RE.test(trimmed);
6705
+ }
6706
+
6577
6707
  /**
6578
6708
  * Check if an attribute should be removed based on default value
6709
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
6579
6710
  * @param {string} name - Attribute name
6580
6711
  * @param {string} value - Attribute value
6581
6712
  * @returns {boolean} True if attribute can be removed
6582
6713
  */
6583
- function isDefaultAttribute(name, value) {
6714
+ function isDefaultAttribute(tag, name, value) {
6715
+ // Special case: `overflow="visible"` is unsafe for root `<svg>` element
6716
+ // Root SVG may need explicit `overflow="visible"` to show clipped content
6717
+ if (name === 'overflow' && value === 'visible') {
6718
+ return tag !== 'svg'; // Only remove for non-root SVG elements
6719
+ }
6720
+
6584
6721
  const checker = SVG_DEFAULT_ATTRS[name];
6585
6722
  if (!checker) return false;
6586
6723
 
@@ -6631,17 +6768,23 @@ function minifySVGAttributeValue(name, value, options = {}) {
6631
6768
 
6632
6769
  /**
6633
6770
  * Check if an SVG attribute can be removed
6771
+ * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
6634
6772
  * @param {string} name - Attribute name
6635
6773
  * @param {string} value - Attribute value
6636
6774
  * @param {Object} options - Minification options
6637
6775
  * @returns {boolean} True if attribute should be removed
6638
6776
  */
6639
- function shouldRemoveSVGAttribute(name, value, options = {}) {
6777
+ function shouldRemoveSVGAttribute(tag, name, value, options = {}) {
6640
6778
  const { removeDefaults = true } = options;
6641
6779
 
6642
6780
  if (!removeDefaults) return false;
6643
6781
 
6644
- return isDefaultAttribute(name, value);
6782
+ // Check for identity transforms
6783
+ if (name === 'transform' && isIdentityTransform(value)) {
6784
+ return true;
6785
+ }
6786
+
6787
+ return isDefaultAttribute(tag, name, value);
6645
6788
  }
6646
6789
 
6647
6790
  /**
@@ -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
 
@@ -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"}
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.0"
97
97
  }
@@ -400,7 +400,7 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
400
400
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
401
401
  attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) ||
402
402
  (options.insideSVG && options.minifySVG &&
403
- shouldRemoveSVGAttribute(attrName, attrValue, options.minifySVG))) {
403
+ shouldRemoveSVGAttribute(tag, attrName, attrValue, options.minifySVG))) {
404
404
  return;
405
405
  }
406
406
 
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
  /**