@thejaredwilcurt/csslop 0.0.43 → 0.0.44

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.43",
5
+ "version": "0.0.44",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/pre/start.js",
@@ -35,7 +35,7 @@
35
35
  "eslint-config-tjw-import-x": "^1.0.1",
36
36
  "eslint-config-tjw-jsdoc": "^2.0.1",
37
37
  "eslint-plugin-import-x": "^4.17.0",
38
- "eslint-plugin-jsdoc": "^65.3.8",
38
+ "eslint-plugin-jsdoc": "^65.3.11",
39
39
  "fflate": "^0.8.3",
40
40
  "globals": "^17.13.0",
41
41
  "pretty-ms": "^10.0.0",
@@ -57,31 +57,104 @@ function formatOklch (L, C, H, alpha) {
57
57
  }
58
58
 
59
59
  /**
60
- * Detect which channel indices have 'none' in a raw color function string.
60
+ * Splits the simple, non-nested components of a static color function.
61
61
  *
62
- * @param {string} rawColorStr The raw CSS color function string (e.g. "rgb(none 0 0)").
63
- * @return {Array} An array of zero-based channel indices where 'none' was found.
62
+ * @param {string} argumentText The text inside a color function's parentheses.
63
+ * @return {Array} The whitespace, comma, and slash-delimited components.
64
64
  */
65
- function findNoneChannels (rawColorStr) {
65
+ function splitSimpleColorComponents (argumentText) {
66
+ // Split CSS color components at whitespace, commas, or the alpha slash.
67
+ return argumentText.trim().split(/[\s,/]+/).filter((part) => {
68
+ return part.length > 0;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Collects the zero-based positions of missing color components.
74
+ *
75
+ * @param {Array} components The parsed color components.
76
+ * @param {number} startIndex The component index represented by the first item.
77
+ * @return {Array} The channel indices whose value is `none`.
78
+ */
79
+ function collectNoneChannelIndices (components, startIndex) {
66
80
  const indices = [];
67
- // Match rgb/rgba/hsl/hsla/hwb function calls and extract their arguments
68
- const functionMatch = rawColorStr.match(/\b(?:rgba?|hsla?|hwb)\(([^)]*)\)/i);
69
- if (functionMatch) {
70
- // Split arguments on whitespace, commas, or slash separators
71
- const parts = functionMatch[1].trim().split(/[\s,/]+/).map((part) => {
72
- return part.trim();
73
- }).filter((part) => {
74
- return part.length > 0;
75
- });
76
- parts.forEach((part, index) => {
77
- if (part.toLowerCase() === 'none') {
78
- indices.push(index);
79
- }
80
- });
81
+ for (let index = 0; index < components.length; index++) {
82
+ if (components[index].toLowerCase() === 'none') {
83
+ indices.push(startIndex + index);
84
+ }
81
85
  }
82
86
  return indices;
83
87
  }
84
88
 
89
+ /**
90
+ * Finds the first CSS whitespace character in a string.
91
+ *
92
+ * @param {string} text The text to inspect.
93
+ * @return {number} The first whitespace index, or -1 when there is none.
94
+ */
95
+ function findFirstWhitespaceIndex (text) {
96
+ for (let index = 0; index < text.length; index++) {
97
+ if (' \t\n\r\f'.includes(text[index])) {
98
+ return index;
99
+ }
100
+ }
101
+ return -1;
102
+ }
103
+
104
+ /**
105
+ * Detect which channel indices have `none` in a raw static color function.
106
+ *
107
+ * @param {string} rawColorString The raw CSS color function string.
108
+ * @return {Array} An array of zero-based channel indices where `none` was found.
109
+ */
110
+ function findNoneChannels (rawColorString) {
111
+ const openParenthesisIndex = rawColorString.indexOf('(');
112
+ const closeParenthesisIndex = rawColorString.lastIndexOf(')');
113
+ if (openParenthesisIndex === -1 || closeParenthesisIndex <= openParenthesisIndex) {
114
+ return [];
115
+ }
116
+
117
+ const functionName = rawColorString.slice(0, openParenthesisIndex).trim().toLowerCase();
118
+ const argumentText = rawColorString.slice(openParenthesisIndex + 1, closeParenthesisIndex).trim();
119
+ if (['rgb', 'rgba', 'hsl', 'hsla', 'hwb'].includes(functionName)) {
120
+ return collectNoneChannelIndices(splitSimpleColorComponents(argumentText), 0);
121
+ }
122
+
123
+ if (functionName !== 'color') {
124
+ return [];
125
+ }
126
+
127
+ const firstComponentEnd = findFirstWhitespaceIndex(argumentText);
128
+ if (firstComponentEnd === -1 || argumentText.slice(0, firstComponentEnd).toLowerCase() !== 'srgb') {
129
+ return [];
130
+ }
131
+ const channelText = argumentText.slice(firstComponentEnd).trim();
132
+ return collectNoneChannelIndices(splitSimpleColorComponents(channelText), 0);
133
+ }
134
+
135
+ /**
136
+ * Fills missing interpolation components from the matching component in the
137
+ * other color before either color is premultiplied by alpha.
138
+ *
139
+ * @param {object} firstColor The first parsed color-mix argument.
140
+ * @param {object} secondColor The second parsed color-mix argument.
141
+ * @return {void}
142
+ */
143
+ function fillMissingColorChannels (firstColor, secondColor) {
144
+ const firstMissingChannels = findNoneChannels(firstColor.raw);
145
+ const secondMissingChannels = findNoneChannels(secondColor.raw);
146
+ for (const channelIndex of firstMissingChannels) {
147
+ if (channelIndex < firstColor.color.length) {
148
+ firstColor.color[channelIndex] = secondColor.color[channelIndex];
149
+ }
150
+ }
151
+ for (const channelIndex of secondMissingChannels) {
152
+ if (channelIndex < secondColor.color.length) {
153
+ secondColor.color[channelIndex] = firstColor.color[channelIndex];
154
+ }
155
+ }
156
+ }
157
+
85
158
  /**
86
159
  * Evaluate an N-color (3+) color-mix() expression. Returns a minified CSS color string or null.
87
160
  *
@@ -208,17 +281,26 @@ function normalizeUnresolvableNColorMix (colorSpace, parsedArgs) {
208
281
  * @return {string} A hex color string.
209
282
  */
210
283
  function mixNColorsSrgb (colors, weights, alphaMultiplier) {
211
- let r = 0;
212
- let g = 0;
213
- let b = 0;
214
- let alpha = 0;
284
+ let premultipliedRed = 0;
285
+ let premultipliedGreen = 0;
286
+ let premultipliedBlue = 0;
287
+ let interpolatedAlpha = 0;
215
288
  for (let i = 0; i < colors.length; i++) {
216
- r += colors[i][0] * weights[i];
217
- g += colors[i][1] * weights[i];
218
- b += colors[i][2] * weights[i];
219
- alpha += colors[i][3] * weights[i];
289
+ const weightedAlpha = colors[i][3] * weights[i];
290
+ premultipliedRed += colors[i][0] * weightedAlpha;
291
+ premultipliedGreen += colors[i][1] * weightedAlpha;
292
+ premultipliedBlue += colors[i][2] * weightedAlpha;
293
+ interpolatedAlpha += weightedAlpha;
220
294
  }
221
- return rgbaToHex(Math.round(r), Math.round(g), Math.round(b), alpha * alphaMultiplier);
295
+ if (interpolatedAlpha === 0) {
296
+ return rgbaToHex(0, 0, 0, 0);
297
+ }
298
+ return rgbaToHex(
299
+ Math.round(premultipliedRed / interpolatedAlpha),
300
+ Math.round(premultipliedGreen / interpolatedAlpha),
301
+ Math.round(premultipliedBlue / interpolatedAlpha),
302
+ interpolatedAlpha * alphaMultiplier
303
+ );
222
304
  }
223
305
 
224
306
  /**
@@ -236,16 +318,28 @@ function mixNColorsOklab (colors, weights, alphaMultiplier) {
236
318
  let L = 0;
237
319
  let a = 0;
238
320
  let b = 0;
239
- let alpha = 0;
321
+ let interpolatedAlpha = 0;
240
322
  for (let i = 0; i < oklabValues.length; i++) {
241
- L += oklabValues[i].L * weights[i];
242
- a += oklabValues[i].a * weights[i];
243
- b += oklabValues[i].b * weights[i];
244
- alpha += colors[i][3] * weights[i];
245
- }
246
- alpha *= alphaMultiplier;
247
- const rgb = oklabToRgb(L, a, b);
248
- return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha >= 1 ? 1 : alpha);
323
+ const weightedAlpha = colors[i][3] * weights[i];
324
+ L += oklabValues[i].L * weightedAlpha;
325
+ a += oklabValues[i].a * weightedAlpha;
326
+ b += oklabValues[i].b * weightedAlpha;
327
+ interpolatedAlpha += weightedAlpha;
328
+ }
329
+ if (interpolatedAlpha === 0) {
330
+ return rgbaToHex(0, 0, 0, 0);
331
+ }
332
+ const rgb = oklabToRgb(
333
+ L / interpolatedAlpha,
334
+ a / interpolatedAlpha,
335
+ b / interpolatedAlpha
336
+ );
337
+ return rgbaToHex(
338
+ rgb[0],
339
+ rgb[1],
340
+ rgb[2],
341
+ interpolatedAlpha * alphaMultiplier
342
+ );
249
343
  }
250
344
 
251
345
  /**
@@ -329,19 +423,7 @@ function evaluateColorMix (expr) {
329
423
  return rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
330
424
  }
331
425
 
332
- // CSS spec: 'none' channels are missing — fill from the other color before mixing
333
- const nones1 = findNoneChannels(parsed1.raw);
334
- const nones2 = findNoneChannels(parsed2.raw);
335
- for (const idx of nones1) {
336
- if (idx < parsed1.color.length) {
337
- parsed1.color[idx] = parsed2.color[idx];
338
- }
339
- }
340
- for (const idx of nones2) {
341
- if (idx < parsed2.color.length) {
342
- parsed2.color[idx] = parsed1.color[idx];
343
- }
344
- }
426
+ fillMissingColorChannels(parsed1, parsed2);
345
427
 
346
428
  const t1 = p1 / (p1 + p2);
347
429
  const t2 = p2 / (p1 + p2);
@@ -349,37 +431,44 @@ function evaluateColorMix (expr) {
349
431
  const [r2, g2, b2, a2] = parsed2.color;
350
432
 
351
433
  if (colorSpace === 'srgb') {
352
- const r = Math.round(r1 * t1 + r2 * t2);
353
- const g = Math.round(g1 * t1 + g2 * t2);
354
- const b = Math.round(b1 * t1 + b2 * t2);
355
- const a = (a1 * t1 + a2 * t2) * alphaMultiplier;
356
- return rgbaToHex(r, g, b, a);
434
+ return mixNColorsSrgb(
435
+ [parsed1.color, parsed2.color],
436
+ [t1, t2],
437
+ alphaMultiplier
438
+ );
357
439
  }
358
440
 
359
441
  if (colorSpace === 'oklab') {
360
442
  const lab1 = rgbToOklab(r1, g1, b1);
361
443
  const lab2 = rgbToOklab(r2, g2, b2);
362
- const L = lab1.L * t1 + lab2.L * t2;
363
- const a = lab1.a * t1 + lab2.a * t2;
364
- const b = lab1.b * t1 + lab2.b * t2;
365
- const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
366
- // Check if result fits in sRGB gamut
367
- const rgb = oklabToRgb(L, a, b);
368
- if (alpha >= 1) {
369
- return rgbaToHex(rgb[0], rgb[1], rgb[2], 1);
444
+ const firstWeightedAlpha = a1 * t1;
445
+ const secondWeightedAlpha = a2 * t2;
446
+ const interpolatedAlpha = firstWeightedAlpha + secondWeightedAlpha;
447
+ if (interpolatedAlpha === 0) {
448
+ return rgbaToHex(0, 0, 0, 0);
370
449
  }
371
- return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha);
450
+ const L = (lab1.L * firstWeightedAlpha + lab2.L * secondWeightedAlpha) / interpolatedAlpha;
451
+ const a = (lab1.a * firstWeightedAlpha + lab2.a * secondWeightedAlpha) / interpolatedAlpha;
452
+ const b = (lab1.b * firstWeightedAlpha + lab2.b * secondWeightedAlpha) / interpolatedAlpha;
453
+ const rgb = oklabToRgb(L, a, b);
454
+ return rgbaToHex(rgb[0], rgb[1], rgb[2], interpolatedAlpha * alphaMultiplier);
372
455
  }
373
456
 
374
457
  if (colorSpace === 'oklch') {
375
458
  const lch1 = rgbToOklch(r1, g1, b1);
376
459
  const lch2 = rgbToOklch(r2, g2, b2);
377
- const L = lch1.L * t1 + lch2.L * t2;
378
- const C = lch1.C * t1 + lch2.C * t2;
379
- const H = interpolateHueShorter(lch1.H, lch2.H, t2);
380
- const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
460
+ const firstWeightedAlpha = a1 * t1;
461
+ const secondWeightedAlpha = a2 * t2;
462
+ const interpolatedAlpha = firstWeightedAlpha + secondWeightedAlpha;
463
+ if (interpolatedAlpha === 0) {
464
+ return rgbaToHex(0, 0, 0, 0);
465
+ }
466
+ const L = (lch1.L * firstWeightedAlpha + lch2.L * secondWeightedAlpha) / interpolatedAlpha;
467
+ const C = (lch1.C * firstWeightedAlpha + lch2.C * secondWeightedAlpha) / interpolatedAlpha;
468
+ const H = interpolateHueShorter(lch1.H, lch2.H, secondWeightedAlpha / interpolatedAlpha);
469
+ const alpha = interpolatedAlpha * alphaMultiplier;
381
470
  // In-gamut results have an exact sRGB equivalent, which is always shorter than oklch()
382
- const hex = convertOklchToHex(L, C, H, alpha >= 1 ? 1 : alpha);
471
+ const hex = convertOklchToHex(L, C, H, alpha);
383
472
  if (hex) {
384
473
  return hex;
385
474
  }
@@ -302,6 +302,29 @@ function parseHex (hex) {
302
302
  return null;
303
303
  }
304
304
 
305
+ /**
306
+ * Matches a static `color(srgb ...)` function. The three color components may
307
+ * be unit-interval numbers or percentages, followed by an optional alpha.
308
+ *
309
+ * @type {RegExp}
310
+ */
311
+ const SRGB_COLOR_FUNCTION_PATTERN = /^color\(\s*srgb\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/;
312
+
313
+ /**
314
+ * Converts one `color(srgb ...)` component to the 0-255 range.
315
+ *
316
+ * @param {string} component A unit-interval number or percentage.
317
+ * @return {number|null} The channel value, or null when invalid.
318
+ */
319
+ function parseSrgbColorComponent (component) {
320
+ const numericValue = parseFloat(component);
321
+ if (!Number.isFinite(numericValue)) {
322
+ return null;
323
+ }
324
+ const normalizedValue = component.endsWith('%') ? numericValue / 100 : numericValue;
325
+ return Math.round(normalizedValue * 255);
326
+ }
327
+
305
328
  /**
306
329
  * Parse a CSS color string to [r, g, b, a] (r,g,b: 0-255, a: 0-1) or null if unresolvable.
307
330
  *
@@ -326,8 +349,20 @@ function parseColor (colorString) {
326
349
  return parseHex(normalized);
327
350
  }
328
351
 
352
+ // color(srgb ...) uses unit-interval color channels rather than rgb's 0-255 channels.
353
+ let match = normalized.match(SRGB_COLOR_FUNCTION_PATTERN);
354
+ if (match) {
355
+ const red = parseSrgbColorComponent(match[1]);
356
+ const green = parseSrgbColorComponent(match[2]);
357
+ const blue = parseSrgbColorComponent(match[3]);
358
+ if (red === null || green === null || blue === null) {
359
+ return null;
360
+ }
361
+ return [red, green, blue, Math.max(0, Math.min(1, parseAlphaString(match[4])))];
362
+ }
363
+
329
364
  // rgb() / rgba() space syntax
330
- let match = normalized.match(/^rgba?\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/);
365
+ match = normalized.match(/^rgba?\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/);
331
366
  if (match) {
332
367
  const r = Math.round(parseFloat(match[1]));
333
368
  const g = Math.round(parseFloat(match[2]));
@@ -44,6 +44,7 @@ import {
44
44
  parseAngleToDegrees,
45
45
  roundCompactNumber
46
46
  } from './shared.js';
47
+ import { minifySvgDataUri } from './svg-data-uri.js';
47
48
  import { elideRedundantSeparators } from './tokens.js';
48
49
  import { minifyTransformValue } from './transforms.js';
49
50
  import { optimizeUnicodeRange } from './unicode-range.js';
@@ -274,12 +275,114 @@ function minifyUrlContent (rawContent) {
274
275
  }
275
276
  }
276
277
 
278
+ const minifiedSvgDataUri = minifySvgDataUri(path);
279
+ if (minifiedSvgDataUri) {
280
+ return minifiedSvgDataUri;
281
+ }
282
+
277
283
  // Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
278
284
  path = path.replace(/^\.\//, '');
279
285
 
280
286
  return formatUrlPath(path);
281
287
  }
282
288
 
289
+ /**
290
+ * Builds a marker that cannot appear in a parsed CSS value. URL functions are
291
+ * temporarily replaced with these markers while generic string operations run.
292
+ *
293
+ * @param {number} index The URL token index.
294
+ * @return {string} The marker representing that URL token.
295
+ */
296
+ function createUrlTokenMarker (index) {
297
+ return '\u0000csslop-url-' + index + '\u0000';
298
+ }
299
+
300
+ /**
301
+ * Replaces URL tokens with stable markers so generic value normalization does
302
+ * not alter data carried inside a URL.
303
+ *
304
+ * @param {string} value The CSS value to protect.
305
+ * @return {object} The protected value and the original URL tokens.
306
+ */
307
+ function protectUrlTokens (value) {
308
+ const urlTokens = [];
309
+ let protectedValue = '';
310
+ for (const segment of splitValueSegments(value)) {
311
+ const isUrlToken = segment.isLiteral && startsUrlToken(segment.text, 0);
312
+ if (isUrlToken) {
313
+ protectedValue += createUrlTokenMarker(urlTokens.length);
314
+ urlTokens.push(segment.text);
315
+ } else {
316
+ protectedValue += segment.text;
317
+ }
318
+ }
319
+ return { value: protectedValue, urlTokens };
320
+ }
321
+
322
+ /**
323
+ * Restores the URL tokens removed by `protectUrlTokens`.
324
+ *
325
+ * @param {string} value The value containing URL markers.
326
+ * @param {Array} urlTokens The original URL token text.
327
+ * @return {string} The value with URL tokens restored.
328
+ */
329
+ function restoreUrlTokens (value, urlTokens) {
330
+ let restoredValue = value;
331
+ for (let index = 0; index < urlTokens.length; index++) {
332
+ const marker = createUrlTokenMarker(index);
333
+ restoredValue = restoredValue.split(marker).join(urlTokens[index]);
334
+ }
335
+ return restoredValue;
336
+ }
337
+
338
+ /**
339
+ * Removes whitespace after protected URL tokens. A CSS URL token closes itself
340
+ * at `)`, so the following component remains distinguishable without a space.
341
+ *
342
+ * @param {string} value The protected CSS value.
343
+ * @param {Array} urlTokens The original URL token text.
344
+ * @return {string} The protected value without trailing URL spacing.
345
+ */
346
+ function removeWhitespaceAfterUrlTokens (value, urlTokens) {
347
+ let compactValue = value;
348
+ for (let index = 0; index < urlTokens.length; index++) {
349
+ const marker = createUrlTokenMarker(index);
350
+ const markerIndex = compactValue.indexOf(marker);
351
+ if (markerIndex === -1) {
352
+ continue;
353
+ }
354
+ const contentStart = markerIndex + marker.length;
355
+ let nextContentIndex = contentStart;
356
+ while (' \t\n\r\f'.includes(compactValue[nextContentIndex])) {
357
+ nextContentIndex++;
358
+ }
359
+ compactValue = compactValue.slice(0, contentStart) + compactValue.slice(nextContentIndex);
360
+ }
361
+ return compactValue;
362
+ }
363
+
364
+ /**
365
+ * Removes an explicit default background position directly after a protected
366
+ * URL marker, where the ordinary close-parenthesis optimization cannot see it.
367
+ *
368
+ * @param {string} value The protected background value.
369
+ * @param {Array} urlTokens The original URL token text.
370
+ * @return {string} The value with redundant URL positions removed.
371
+ */
372
+ function removeDefaultBackgroundPositionsAfterUrlTokens (value, urlTokens) {
373
+ let normalizedValue = value;
374
+ for (let index = 0; index < urlTokens.length; index++) {
375
+ const marker = createUrlTokenMarker(index);
376
+ // Match a URL marker followed by the default 0 0 background-position pair.
377
+ const defaultPositionPattern = new RegExp(
378
+ marker + '\\s*0(?:%|px)?\\s+0(?:%|px)?(?=\\s|$)',
379
+ 'g'
380
+ );
381
+ normalizedValue = normalizedValue.replace(defaultPositionPattern, marker);
382
+ }
383
+ return normalizedValue;
384
+ }
385
+
283
386
  /**
284
387
  * Rewrites every `url(...)` token in a CSS value to its shortest valid form,
285
388
  * skipping any quoted strings so an embedded `url(` inside a string is ignored.
@@ -347,6 +450,9 @@ function minifyUrls (value) {
347
450
  * @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
348
451
  */
349
452
  function normalizeWhitespaceAndQuotes (val, property) {
453
+ const protectedUrls = protectUrlTokens(val);
454
+ val = protectedUrls.value;
455
+
350
456
  // Unescape unicode (skip control characters — they must stay escaped in CSS strings).
351
457
  // Only resolve escapes when the charset is unicode-compatible (UTF-8/UTF-16 or default).
352
458
  if (isUnicodeCharset()) {
@@ -385,7 +491,8 @@ function normalizeWhitespaceAndQuotes (val, property) {
385
491
  });
386
492
  }
387
493
 
388
- return val;
494
+ val = removeWhitespaceAfterUrlTokens(val, protectedUrls.urlTokens);
495
+ return restoreUrlTokens(val, protectedUrls.urlTokens);
389
496
  }
390
497
 
391
498
  /**
@@ -1135,11 +1242,12 @@ function minifyValueForOutput (declaration) {
1135
1242
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
1136
1243
  * background, display, scale, border-radius, shorthand collapsing, etc.).
1137
1244
  *
1138
- * @param {string} val The CSS value string after generic minification.
1139
- * @param {string} property The CSS property name.
1140
- * @return {string} The value with property-specific optimizations applied.
1245
+ * @param {string} val The CSS value string after generic minification.
1246
+ * @param {string} property The CSS property name.
1247
+ * @param {Array} urlTokens URL tokens currently represented by markers.
1248
+ * @return {string} The value with property-specific optimizations applied.
1141
1249
  */
1142
- function applyPropertyOptimizations (val, property) {
1250
+ function applyPropertyOptimizations (val, property, urlTokens) {
1143
1251
  if (property === 'font-weight' && isUnicodeCharset()) {
1144
1252
  // Replace font-weight keyword "bold" with its numeric equivalent
1145
1253
  val = val.replace(/\bbold\b/gi, '700');
@@ -1326,7 +1434,7 @@ function applyPropertyOptimizations (val, property) {
1326
1434
  }
1327
1435
 
1328
1436
  if (property === 'background' && val !== 'none') {
1329
- const normalized = val
1437
+ let normalized = val
1330
1438
  // Remove default "0 0" background-position values
1331
1439
  .replace(/(?:^|\s)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ' ')
1332
1440
  // Remove "0 0" background-position after a close-paren (e.g. after url())
@@ -1340,6 +1448,7 @@ function applyPropertyOptimizations (val, property) {
1340
1448
  // Collapse whitespace to single space
1341
1449
  .replace(/\s+/g, ' ')
1342
1450
  .trim();
1451
+ normalized = removeDefaultBackgroundPositionsAfterUrlTokens(normalized, urlTokens);
1343
1452
  if (normalized) {
1344
1453
  val = normalized;
1345
1454
  }
@@ -1425,11 +1534,14 @@ function computeMinifiedValue (declaration) {
1425
1534
  return 'none';
1426
1535
  }
1427
1536
  let val = declaration.value;
1537
+ let protectedUrlState = null;
1428
1538
 
1429
1539
  if (typeof val === 'string') {
1430
1540
  val = val.trim();
1431
1541
  val = normalizeWhitespaceAndQuotes(val, declaration.property);
1432
1542
  val = minifyUrls(val);
1543
+ protectedUrlState = protectUrlTokens(val);
1544
+ val = protectedUrlState.value;
1433
1545
 
1434
1546
  // Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
1435
1547
  // Collapse spaces around division operator
@@ -1482,7 +1594,11 @@ function computeMinifiedValue (declaration) {
1482
1594
  val = simplifyEquivalentLightDarkFunctions(val);
1483
1595
 
1484
1596
  // Property-specific optimizations
1485
- val = applyPropertyOptimizations(val, declaration.property);
1597
+ val = applyPropertyOptimizations(
1598
+ val,
1599
+ declaration.property,
1600
+ protectedUrlState ? protectedUrlState.urlTokens : []
1601
+ );
1486
1602
 
1487
1603
  // Minify relative color syntax (identity resolution and whitespace collapsing)
1488
1604
  val = minifyRelativeColorSyntax(val);
@@ -1494,6 +1610,11 @@ function computeMinifiedValue (declaration) {
1494
1610
  val = minifyGradients(val);
1495
1611
  }
1496
1612
 
1613
+ if (protectedUrlState) {
1614
+ val = removeWhitespaceAfterUrlTokens(val, protectedUrlState.urlTokens);
1615
+ val = restoreUrlTokens(val, protectedUrlState.urlTokens);
1616
+ }
1617
+
1497
1618
  // Unicode range optimization: dedup, merge overlapping/adjacent, wildcard compression
1498
1619
  if (declaration.property === 'unicode-range') {
1499
1620
  val = optimizeUnicodeRange(val);
@@ -0,0 +1,280 @@
1
+ /**
2
+ * @file Decodes, minifies, and safely reserializes SVG data URLs.
3
+ */
4
+
5
+ import { minifySvgMarkup } from './svg-markup.js';
6
+
7
+ const SVG_MEDIA_TYPE = 'data:image/svg+xml';
8
+
9
+ /**
10
+ * Determines whether a character is an ASCII hexadecimal digit.
11
+ *
12
+ * @param {string} character The character to inspect.
13
+ * @return {boolean} Whether the character is hexadecimal.
14
+ */
15
+ function isHexDigit (character) {
16
+ return (
17
+ (character >= '0' && character <= '9') ||
18
+ (character >= 'a' && character <= 'f') ||
19
+ (character >= 'A' && character <= 'F')
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Determines whether a character is CSS whitespace.
25
+ *
26
+ * @param {string} character The character to inspect.
27
+ * @return {boolean} Whether the character is whitespace.
28
+ */
29
+ function isCssWhitespace (character) {
30
+ return ' \t\n\r\f'.includes(character);
31
+ }
32
+
33
+ /**
34
+ * Resolves CSS string escapes so that embedded SVG markup can be parsed.
35
+ *
36
+ * @param {string} value The raw CSS string content.
37
+ * @return {string} The decoded string content.
38
+ */
39
+ function decodeCssStringEscapes (value) {
40
+ let result = '';
41
+ for (let index = 0; index < value.length; index++) {
42
+ const character = value[index];
43
+ if (character !== '\\' || index === value.length - 1) {
44
+ result += character;
45
+ continue;
46
+ }
47
+
48
+ const escapedCharacter = value[index + 1];
49
+ if (escapedCharacter === '\n') {
50
+ index++;
51
+ continue;
52
+ }
53
+ if (escapedCharacter === '\r') {
54
+ index++;
55
+ if (value[index + 1] === '\n') {
56
+ index++;
57
+ }
58
+ continue;
59
+ }
60
+ if (!isHexDigit(escapedCharacter)) {
61
+ result += escapedCharacter;
62
+ index++;
63
+ continue;
64
+ }
65
+
66
+ let escapeEnd = index + 1;
67
+ while (escapeEnd < value.length && escapeEnd < index + 7 && isHexDigit(value[escapeEnd])) {
68
+ escapeEnd++;
69
+ }
70
+ const codePoint = parseInt(value.slice(index + 1, escapeEnd), 16);
71
+ try {
72
+ result += String.fromCodePoint(codePoint);
73
+ } catch {
74
+ result += value.slice(index, escapeEnd);
75
+ }
76
+ index = escapeEnd - 1;
77
+ if (isCssWhitespace(value[index + 1])) {
78
+ index++;
79
+ }
80
+ }
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Decodes valid percent escapes without treating a literal percent sign as an
86
+ * error. Malformed UTF-8 stays unmodified so the URL is left untouched.
87
+ *
88
+ * @param {string} value The URI payload to decode.
89
+ * @return {string|null} The decoded payload, or null when unsafe.
90
+ */
91
+ function decodePercentEncodedText (value) {
92
+ let safelyEncodedValue = '';
93
+ for (let index = 0; index < value.length; index++) {
94
+ const isPercentEscape = (
95
+ value[index] === '%' &&
96
+ isHexDigit(value[index + 1]) &&
97
+ isHexDigit(value[index + 2])
98
+ );
99
+ if (value[index] === '%' && !isPercentEscape) {
100
+ safelyEncodedValue += '%25';
101
+ continue;
102
+ }
103
+ safelyEncodedValue += value[index];
104
+ }
105
+ try {
106
+ return decodeURIComponent(safelyEncodedValue);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Decodes base64 data to UTF-8 without depending on Node-only APIs.
114
+ *
115
+ * @param {string} value The base64 data.
116
+ * @return {string|null} The decoded SVG text, or null when unavailable or invalid.
117
+ */
118
+ function decodeBase64Utf8 (value) {
119
+ if (typeof atob !== 'function' || typeof TextDecoder !== 'function') {
120
+ return null;
121
+ }
122
+ try {
123
+ const binaryText = atob(value);
124
+ const bytes = new Uint8Array(binaryText.length);
125
+ for (let index = 0; index < binaryText.length; index++) {
126
+ bytes[index] = binaryText.codePointAt(index);
127
+ }
128
+ return new TextDecoder().decode(bytes);
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Parses the supported SVG data URL forms and chooses the output media prefix.
136
+ *
137
+ * @param {string} urlContent The decoded content inside `url(...)`.
138
+ * @return {object|null} The SVG markup and output prefix, or null when unsupported.
139
+ */
140
+ function parseSvgDataUri (urlContent) {
141
+ const separatorIndex = urlContent.indexOf(',');
142
+ if (separatorIndex === -1) {
143
+ return null;
144
+ }
145
+ const header = urlContent.slice(0, separatorIndex);
146
+ const normalizedHeader = header.toLowerCase();
147
+ if (
148
+ !normalizedHeader.startsWith(SVG_MEDIA_TYPE) ||
149
+ (header.length > SVG_MEDIA_TYPE.length && header[SVG_MEDIA_TYPE.length] !== ';')
150
+ ) {
151
+ return null;
152
+ }
153
+
154
+ const parameterText = header.slice(SVG_MEDIA_TYPE.length);
155
+ const parameters = parameterText ? parameterText.slice(1).split(';') : [];
156
+ let usesBase64 = false;
157
+ let usesUtf8 = false;
158
+ for (const parameter of parameters) {
159
+ const normalizedParameter = parameter.toLowerCase();
160
+ if (normalizedParameter === 'base64') {
161
+ usesBase64 = true;
162
+ } else if (
163
+ normalizedParameter === 'utf8' ||
164
+ normalizedParameter === 'charset=utf-8' ||
165
+ normalizedParameter === 'charset=utf8'
166
+ ) {
167
+ usesUtf8 = true;
168
+ } else {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ const encodedPayload = urlContent.slice(separatorIndex + 1);
174
+ const decodedPayload = decodePercentEncodedText(encodedPayload);
175
+ if (decodedPayload === null) {
176
+ return null;
177
+ }
178
+ const svgMarkup = usesBase64 ? decodeBase64Utf8(decodedPayload) : decodedPayload;
179
+ if (svgMarkup === null) {
180
+ return null;
181
+ }
182
+ return {
183
+ svgMarkup,
184
+ outputPrefix: SVG_MEDIA_TYPE + (usesBase64 || usesUtf8 ? ';utf8' : '')
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Checks whether decoded data begins with an SVG root element, allowing an XML
190
+ * declaration in front of it.
191
+ *
192
+ * @param {string} svgMarkup The decoded data URI payload.
193
+ * @return {boolean} Whether the payload appears to be SVG markup.
194
+ */
195
+ function startsWithSvgElement (svgMarkup) {
196
+ let index = 0;
197
+ while (isCssWhitespace(svgMarkup[index])) {
198
+ index++;
199
+ }
200
+ if (svgMarkup.startsWith('<?xml', index)) {
201
+ const declarationEnd = svgMarkup.indexOf('?>', index + 5);
202
+ if (declarationEnd === -1) {
203
+ return false;
204
+ }
205
+ index = declarationEnd + 2;
206
+ while (isCssWhitespace(svgMarkup[index])) {
207
+ index++;
208
+ }
209
+ }
210
+ const candidate = svgMarkup.slice(index, index + 4).toLowerCase();
211
+ const nextCharacter = svgMarkup[index + 4];
212
+ return candidate === '<svg' && (
213
+ isCssWhitespace(nextCharacter) ||
214
+ nextCharacter === '>' ||
215
+ nextCharacter === '/'
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Escapes text for one CSS string delimiter.
221
+ *
222
+ * @param {string} value The string content.
223
+ * @param {string} quoteCharacter The surrounding CSS string delimiter.
224
+ * @return {string} The escaped CSS string content.
225
+ */
226
+ function escapeCssStringContent (value, quoteCharacter) {
227
+ let result = '';
228
+ for (const character of value) {
229
+ if (character === '\\' || character === quoteCharacter) {
230
+ result += '\\' + character;
231
+ } else if (character === '\n') {
232
+ result += '\\a ';
233
+ } else if (character === '\r') {
234
+ result += '\\d ';
235
+ } else if (character === '\f') {
236
+ result += '\\c ';
237
+ } else {
238
+ result += character;
239
+ }
240
+ }
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * Chooses the CSS string wrapper that needs the fewest escaping backslashes.
246
+ *
247
+ * @param {string} dataUri The normalized SVG data URI.
248
+ * @return {string} The URL content with surrounding quotes.
249
+ */
250
+ function formatSvgDataUri (dataUri) {
251
+ const singleQuotedContent = escapeCssStringContent(dataUri, '\'');
252
+ const doubleQuotedContent = escapeCssStringContent(dataUri, '"');
253
+ if (singleQuotedContent.length < doubleQuotedContent.length) {
254
+ return '\'' + singleQuotedContent + '\'';
255
+ }
256
+ return '"' + doubleQuotedContent + '"';
257
+ }
258
+
259
+ /**
260
+ * Minifies an SVG data URI and returns CSS-ready URL content.
261
+ *
262
+ * @param {string} urlContent The unwrapped content of a CSS `url(...)`.
263
+ * @return {string|null} The formatted replacement, or null when not SVG data.
264
+ */
265
+ function minifySvgDataUri (urlContent) {
266
+ const decodedUrlContent = decodeCssStringEscapes(urlContent);
267
+ const parsedDataUri = parseSvgDataUri(decodedUrlContent);
268
+ if (!parsedDataUri || !startsWithSvgElement(parsedDataUri.svgMarkup)) {
269
+ return null;
270
+ }
271
+ const minifiedSvg = minifySvgMarkup(parsedDataUri.svgMarkup);
272
+ if (minifiedSvg === null) {
273
+ return null;
274
+ }
275
+ // A literal hash starts a URL fragment, so SVG IDs and hex colors need URI encoding.
276
+ const encodedSvg = minifiedSvg.split('#').join('%23');
277
+ return formatSvgDataUri(parsedDataUri.outputPrefix + ',' + encodedSvg);
278
+ }
279
+
280
+ export { minifySvgDataUri };
@@ -0,0 +1,557 @@
1
+ /**
2
+ * @file Safely minifies the SVG markup carried by UTF-8 data URLs.
3
+ */
4
+
5
+ import {
6
+ namedColors,
7
+ parseColor,
8
+ rgbaToHex
9
+ } from './colors.js';
10
+
11
+ const COLOR_ATTRIBUTE_NAMES = new Set([
12
+ 'color',
13
+ 'fill',
14
+ 'flood-color',
15
+ 'lighting-color',
16
+ 'solid-color',
17
+ 'stop-color',
18
+ 'stroke'
19
+ ]);
20
+
21
+ const SELF_CLOSING_SVG_ELEMENTS = new Set([
22
+ 'animate',
23
+ 'animatemotion',
24
+ 'animatetransform',
25
+ 'circle',
26
+ 'ellipse',
27
+ 'image',
28
+ 'line',
29
+ 'path',
30
+ 'polygon',
31
+ 'polyline',
32
+ 'rect',
33
+ 'set',
34
+ 'stop',
35
+ 'use'
36
+ ]);
37
+
38
+ const TEXT_WHITESPACE_ELEMENTS = new Set([
39
+ 'desc',
40
+ 'foreignobject',
41
+ 'metadata',
42
+ 'script',
43
+ 'style',
44
+ 'text',
45
+ 'textpath',
46
+ 'title',
47
+ 'tspan'
48
+ ]);
49
+
50
+ /**
51
+ * Determines whether a character is CSS or XML whitespace.
52
+ *
53
+ * @param {string} character The character to inspect.
54
+ * @return {boolean} Whether the character is whitespace.
55
+ */
56
+ function isWhitespace (character) {
57
+ return ' \t\n\r\f'.includes(character);
58
+ }
59
+
60
+ /**
61
+ * Determines whether a character may occur in an XML element or attribute name.
62
+ *
63
+ * @param {string} character The character to inspect.
64
+ * @return {boolean} Whether the character is a supported XML name character.
65
+ */
66
+ function isXmlNameCharacter (character) {
67
+ return (
68
+ (character >= 'a' && character <= 'z') ||
69
+ (character >= 'A' && character <= 'Z') ||
70
+ (character >= '0' && character <= '9') ||
71
+ '_:-.'.includes(character)
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Reads an XML name beginning at an index.
77
+ *
78
+ * @param {string} text The SVG markup being read.
79
+ * @param {number} index The index where the name starts.
80
+ * @return {object} The name and the index immediately after it.
81
+ */
82
+ function readXmlName (text, index) {
83
+ const startIndex = index;
84
+ while (index < text.length && isXmlNameCharacter(text[index])) {
85
+ index++;
86
+ }
87
+ return {
88
+ name: text.slice(startIndex, index),
89
+ nextIndex: index
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Converts a full hex color to its short notation when each channel pair matches.
95
+ *
96
+ * @param {string} hexColor A CSS hex color beginning with `#`.
97
+ * @return {string} The shortest equivalent hex color.
98
+ */
99
+ function shortenHexColor (hexColor) {
100
+ const digits = hexColor.slice(1);
101
+ if (digits.length !== 6 && digits.length !== 8) {
102
+ return hexColor;
103
+ }
104
+ let shortenedDigits = '';
105
+ for (let index = 0; index < digits.length; index += 2) {
106
+ if (digits[index] !== digits[index + 1]) {
107
+ return hexColor;
108
+ }
109
+ shortenedDigits += digits[index];
110
+ }
111
+ return '#' + shortenedDigits;
112
+ }
113
+
114
+ /**
115
+ * Chooses the shortest data-URL-safe spelling of a parsed SVG color.
116
+ *
117
+ * @param {Array} color The parsed [red, green, blue, alpha] channels.
118
+ * @return {string} The shortest encoded hex or named color spelling.
119
+ */
120
+ function formatSvgColor (color) {
121
+ const hexColor = shortenHexColor(rgbaToHex(color[0], color[1], color[2], color[3]));
122
+ let shortest = '%23' + hexColor.slice(1);
123
+ const isOpaque = Math.round(color[3] * 255) === 255;
124
+ if (!isOpaque) {
125
+ return shortest;
126
+ }
127
+
128
+ for (const [name, channels] of Object.entries(namedColors)) {
129
+ const channelsMatch = (
130
+ channels[0] === color[0] &&
131
+ channels[1] === color[1] &&
132
+ channels[2] === color[2]
133
+ );
134
+ if (channelsMatch && name.length < shortest.length) {
135
+ shortest = name;
136
+ }
137
+ }
138
+ return shortest;
139
+ }
140
+
141
+ /**
142
+ * Reads past one CSS escape sequence. A non-hex escape consumes its following
143
+ * character, which keeps an escaped comma, quote, or parenthesis from being
144
+ * interpreted as font-family syntax.
145
+ *
146
+ * @param {string} value The CSS value containing the escape.
147
+ * @param {number} index The index of the escape's backslash.
148
+ * @return {number} The index immediately after the escape.
149
+ */
150
+ function skipCssEscape (value, index) {
151
+ return Math.min(index + 2, value.length);
152
+ }
153
+
154
+ /**
155
+ * Reads past a CSS string, preserving escaped delimiters within it.
156
+ *
157
+ * @param {string} value The CSS value containing the string.
158
+ * @param {number} index The index of the opening quote.
159
+ * @return {number} The index immediately after the string, or the end.
160
+ */
161
+ function skipCssString (value, index) {
162
+ const quoteCharacter = value[index];
163
+ index++;
164
+ while (index < value.length) {
165
+ if (value[index] === '\\') {
166
+ index = skipCssEscape(value, index);
167
+ continue;
168
+ }
169
+ if (value[index] === quoteCharacter) {
170
+ return index + 1;
171
+ }
172
+ index++;
173
+ }
174
+ return index;
175
+ }
176
+
177
+ /**
178
+ * Reads past a CSS comment so punctuation in comment text does not affect
179
+ * font-family list parsing.
180
+ *
181
+ * @param {string} value The CSS value containing the comment.
182
+ * @param {number} index The index of the comment's opening slash.
183
+ * @return {number} The index immediately after the comment, or the end.
184
+ */
185
+ function skipCssComment (value, index) {
186
+ const commentEndIndex = value.indexOf('*/', index + 2);
187
+ if (commentEndIndex === -1) {
188
+ return value.length;
189
+ }
190
+ return commentEndIndex + 2;
191
+ }
192
+
193
+ /**
194
+ * Removes CSS whitespace after top-level font-family separators. Quotes,
195
+ * escapes, comments, and function arguments are kept opaque so that commas
196
+ * belonging to a family name or a substituted value are not altered.
197
+ *
198
+ * @param {string} attributeValue The unquoted SVG font-family value.
199
+ * @return {string} The compacted font-family value.
200
+ */
201
+ function minifySvgFontFamilyValue (attributeValue) {
202
+ // XML entities are resolved before this presentation attribute is parsed as
203
+ // CSS, so an encoded quote could otherwise be mistaken for ordinary text.
204
+ if (attributeValue.includes('&')) {
205
+ return attributeValue;
206
+ }
207
+
208
+ let minifiedValue = '';
209
+ let index = 0;
210
+ let parenthesisDepth = 0;
211
+ while (index < attributeValue.length) {
212
+ const character = attributeValue[index];
213
+ if (character === '\\') {
214
+ const escapeEndIndex = skipCssEscape(attributeValue, index);
215
+ minifiedValue += attributeValue.slice(index, escapeEndIndex);
216
+ index = escapeEndIndex;
217
+ continue;
218
+ }
219
+ if (character === '"' || character === '\'') {
220
+ const stringEndIndex = skipCssString(attributeValue, index);
221
+ minifiedValue += attributeValue.slice(index, stringEndIndex);
222
+ index = stringEndIndex;
223
+ continue;
224
+ }
225
+ if (character === '/' && attributeValue[index + 1] === '*') {
226
+ const commentEndIndex = skipCssComment(attributeValue, index);
227
+ minifiedValue += attributeValue.slice(index, commentEndIndex);
228
+ index = commentEndIndex;
229
+ continue;
230
+ }
231
+ if (character === '(') {
232
+ parenthesisDepth++;
233
+ } else if (character === ')' && parenthesisDepth > 0) {
234
+ parenthesisDepth--;
235
+ } else if (character === ',' && parenthesisDepth === 0) {
236
+ minifiedValue += character;
237
+ index++;
238
+ while (isWhitespace(attributeValue[index])) {
239
+ index++;
240
+ }
241
+ continue;
242
+ }
243
+ minifiedValue += character;
244
+ index++;
245
+ }
246
+ return minifiedValue;
247
+ }
248
+
249
+ /**
250
+ * Minifies supported SVG presentation attribute values when their syntax is
251
+ * safe to rewrite.
252
+ *
253
+ * @param {string} attributeName The attribute name.
254
+ * @param {string} attributeValue The attribute value without its quotes.
255
+ * @return {string} The minified value, or the original value.
256
+ */
257
+ function minifySvgAttributeValue (attributeName, attributeValue) {
258
+ const normalizedAttributeName = attributeName.toLowerCase();
259
+ if (normalizedAttributeName === 'font-family') {
260
+ return minifySvgFontFamilyValue(attributeValue);
261
+ }
262
+ if (!COLOR_ATTRIBUTE_NAMES.has(normalizedAttributeName)) {
263
+ return attributeValue;
264
+ }
265
+ const color = parseColor(attributeValue);
266
+ if (!color) {
267
+ return attributeValue;
268
+ }
269
+ return formatSvgColor(color);
270
+ }
271
+
272
+ /**
273
+ * Finds the closing angle bracket of an XML tag without treating a quoted
274
+ * attribute value as markup.
275
+ *
276
+ * @param {string} text The SVG markup being read.
277
+ * @param {number} startIndex The index of the opening `<`.
278
+ * @return {number} The closing `>` index, or -1 when unterminated.
279
+ */
280
+ function findSvgTagEnd (text, startIndex) {
281
+ let quoteCharacter = '';
282
+ for (let index = startIndex + 1; index < text.length; index++) {
283
+ const character = text[index];
284
+ if (quoteCharacter) {
285
+ if (character === quoteCharacter) {
286
+ quoteCharacter = '';
287
+ }
288
+ continue;
289
+ }
290
+ if (character === '"' || character === '\'') {
291
+ quoteCharacter = character;
292
+ continue;
293
+ }
294
+ if (character === '>') {
295
+ return index;
296
+ }
297
+ }
298
+ return -1;
299
+ }
300
+
301
+ /**
302
+ * Parses and minifies an SVG start or end tag while preserving all attribute
303
+ * values except static color values.
304
+ *
305
+ * @param {string} rawTag The complete source tag, including angle brackets.
306
+ * @return {object|null} A normalized token, or null when unsupported.
307
+ */
308
+ function minifySvgTag (rawTag) {
309
+ let index = 1;
310
+ if (rawTag[index] === '/') {
311
+ index++;
312
+ while (isWhitespace(rawTag[index])) {
313
+ index++;
314
+ }
315
+ const nameResult = readXmlName(rawTag, index);
316
+ if (!nameResult.name) {
317
+ return null;
318
+ }
319
+ index = nameResult.nextIndex;
320
+ while (isWhitespace(rawTag[index])) {
321
+ index++;
322
+ }
323
+ if (rawTag[index] !== '>') {
324
+ return null;
325
+ }
326
+ return {
327
+ type: 'end',
328
+ name: nameResult.name.toLowerCase(),
329
+ value: '</' + nameResult.name + '>'
330
+ };
331
+ }
332
+
333
+ while (isWhitespace(rawTag[index])) {
334
+ index++;
335
+ }
336
+ const nameResult = readXmlName(rawTag, index);
337
+ if (!nameResult.name) {
338
+ return null;
339
+ }
340
+ index = nameResult.nextIndex;
341
+ const attributes = [];
342
+ let selfClosing = false;
343
+
344
+ while (index < rawTag.length) {
345
+ while (isWhitespace(rawTag[index])) {
346
+ index++;
347
+ }
348
+ if (rawTag[index] === '>') {
349
+ break;
350
+ }
351
+ if (rawTag[index] === '/' && rawTag[index + 1] === '>') {
352
+ selfClosing = true;
353
+ break;
354
+ }
355
+
356
+ const attributeResult = readXmlName(rawTag, index);
357
+ if (!attributeResult.name) {
358
+ return null;
359
+ }
360
+ index = attributeResult.nextIndex;
361
+ while (isWhitespace(rawTag[index])) {
362
+ index++;
363
+ }
364
+
365
+ let attribute = attributeResult.name;
366
+ if (rawTag[index] === '=') {
367
+ index++;
368
+ while (isWhitespace(rawTag[index])) {
369
+ index++;
370
+ }
371
+ const quoteCharacter = rawTag[index];
372
+ if (quoteCharacter === '"' || quoteCharacter === '\'') {
373
+ const valueStart = index + 1;
374
+ index = valueStart;
375
+ while (index < rawTag.length && rawTag[index] !== quoteCharacter) {
376
+ index++;
377
+ }
378
+ if (index >= rawTag.length) {
379
+ return null;
380
+ }
381
+ const attributeValue = rawTag.slice(valueStart, index);
382
+ index++;
383
+ attribute += '=' + quoteCharacter +
384
+ minifySvgAttributeValue(attributeResult.name, attributeValue) +
385
+ quoteCharacter;
386
+ } else {
387
+ const valueStart = index;
388
+ while (index < rawTag.length && !isWhitespace(rawTag[index]) && rawTag[index] !== '>') {
389
+ index++;
390
+ }
391
+ const attributeValue = rawTag.slice(valueStart, index);
392
+ attribute += '=' + minifySvgAttributeValue(attributeResult.name, attributeValue);
393
+ }
394
+ }
395
+ attributes.push(attribute);
396
+ }
397
+
398
+ return {
399
+ type: 'start',
400
+ name: nameResult.name.toLowerCase(),
401
+ selfClosing,
402
+ value: '<' + nameResult.name + (attributes.length ? ' ' + attributes.join(' ') : '') + (selfClosing ? '/>' : '>')
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Turns SVG markup into tag and text tokens while preserving opaque XML
408
+ * constructs that could carry script or declaration content.
409
+ *
410
+ * @param {string} svgMarkup The decoded SVG markup.
411
+ * @return {Array|null} The tokens, or null when markup cannot be safely read.
412
+ */
413
+ function tokenizeSvgMarkup (svgMarkup) {
414
+ const tokens = [];
415
+ let index = 0;
416
+ while (index < svgMarkup.length) {
417
+ const tagStartIndex = svgMarkup.indexOf('<', index);
418
+ if (tagStartIndex === -1) {
419
+ tokens.push({ type: 'text', value: svgMarkup.slice(index) });
420
+ break;
421
+ }
422
+ if (tagStartIndex > index) {
423
+ tokens.push({ type: 'text', value: svgMarkup.slice(index, tagStartIndex) });
424
+ }
425
+ if (svgMarkup.startsWith('<!--', tagStartIndex)) {
426
+ const commentEndIndex = svgMarkup.indexOf('-->', tagStartIndex + 4);
427
+ if (commentEndIndex === -1) {
428
+ return null;
429
+ }
430
+ tokens.push({ type: 'other', value: svgMarkup.slice(tagStartIndex, commentEndIndex + 3) });
431
+ index = commentEndIndex + 3;
432
+ continue;
433
+ }
434
+ if (svgMarkup.startsWith('<![CDATA[', tagStartIndex)) {
435
+ const cdataEndIndex = svgMarkup.indexOf(']]>', tagStartIndex + 9);
436
+ if (cdataEndIndex === -1) {
437
+ return null;
438
+ }
439
+ tokens.push({ type: 'text', value: svgMarkup.slice(tagStartIndex, cdataEndIndex + 3) });
440
+ index = cdataEndIndex + 3;
441
+ continue;
442
+ }
443
+ if (svgMarkup.startsWith('<!', tagStartIndex)) {
444
+ return null;
445
+ }
446
+
447
+ const tagEndIndex = findSvgTagEnd(svgMarkup, tagStartIndex);
448
+ if (tagEndIndex === -1) {
449
+ return null;
450
+ }
451
+ const rawTag = svgMarkup.slice(tagStartIndex, tagEndIndex + 1);
452
+ if (rawTag.startsWith('<?')) {
453
+ tokens.push({ type: 'other', value: rawTag });
454
+ } else {
455
+ const tag = minifySvgTag(rawTag);
456
+ if (!tag) {
457
+ return null;
458
+ }
459
+ tokens.push(tag);
460
+ }
461
+ index = tagEndIndex + 1;
462
+ }
463
+ return tokens;
464
+ }
465
+
466
+ /**
467
+ * Determines whether a text token contains no non-whitespace content.
468
+ *
469
+ * @param {string} text The text to inspect.
470
+ * @return {boolean} Whether the text contains only whitespace.
471
+ */
472
+ function hasOnlyWhitespace (text) {
473
+ for (const character of text) {
474
+ if (!isWhitespace(character)) {
475
+ return false;
476
+ }
477
+ }
478
+ return true;
479
+ }
480
+
481
+ /**
482
+ * Drops formatting whitespace outside SVG elements whose text content matters.
483
+ *
484
+ * @param {Array} tokens The SVG tokens in source order.
485
+ * @return {Array} Tokens with insignificant text whitespace removed.
486
+ */
487
+ function dropInsignificantWhitespace (tokens) {
488
+ const result = [];
489
+ const openElements = [];
490
+ for (const token of tokens) {
491
+ const currentElement = openElements[openElements.length - 1];
492
+ const preservesWhitespace = TEXT_WHITESPACE_ELEMENTS.has(currentElement);
493
+ if (token.type !== 'text' || !hasOnlyWhitespace(token.value) || preservesWhitespace) {
494
+ result.push(token);
495
+ }
496
+ if (token.type === 'start' && !token.selfClosing) {
497
+ openElements.push(token.name);
498
+ }
499
+ if (token.type === 'end' && openElements[openElements.length - 1] === token.name) {
500
+ openElements.pop();
501
+ }
502
+ }
503
+ return result;
504
+ }
505
+
506
+ /**
507
+ * Rewrites supported SVG elements with no content into their XML self-closing form.
508
+ *
509
+ * @param {Array} tokens The compact SVG tokens.
510
+ * @return {Array} The compact tokens with empty elements collapsed.
511
+ */
512
+ function collapseEmptySvgElements (tokens) {
513
+ const result = [];
514
+ for (let index = 0; index < tokens.length; index++) {
515
+ const token = tokens[index];
516
+ const nextToken = tokens[index + 1];
517
+ const canCollapse = (
518
+ token.type === 'start' &&
519
+ !token.selfClosing &&
520
+ SELF_CLOSING_SVG_ELEMENTS.has(token.name) &&
521
+ nextToken?.type === 'end' &&
522
+ nextToken.name === token.name
523
+ );
524
+ if (canCollapse) {
525
+ result.push({
526
+ ...token,
527
+ selfClosing: true,
528
+ value: token.value.slice(0, -1) + '/>'
529
+ });
530
+ index++;
531
+ continue;
532
+ }
533
+ result.push(token);
534
+ }
535
+ return result;
536
+ }
537
+
538
+ /**
539
+ * Minifies SVG markup while leaving unrecognized XML constructs untouched.
540
+ *
541
+ * @param {string} svgMarkup The decoded SVG data.
542
+ * @return {string|null} The minified SVG, or null when it is unsafe to rewrite.
543
+ */
544
+ function minifySvgMarkup (svgMarkup) {
545
+ const tokens = tokenizeSvgMarkup(svgMarkup);
546
+ if (!tokens) {
547
+ return null;
548
+ }
549
+ const compactTokens = collapseEmptySvgElements(dropInsignificantWhitespace(tokens));
550
+ let result = '';
551
+ for (const token of compactTokens) {
552
+ result += token.value;
553
+ }
554
+ return result;
555
+ }
556
+
557
+ export { minifySvgMarkup };