@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 +2 -2
- package/src/value/color-mix.js +157 -68
- package/src/value/colors.js +36 -1
- package/src/value/minify.js +128 -7
- package/src/value/svg-data-uri.js +280 -0
- package/src/value/svg-markup.js +557 -0
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.
|
|
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.
|
|
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",
|
package/src/value/color-mix.js
CHANGED
|
@@ -57,31 +57,104 @@ function formatOklch (L, C, H, alpha) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
60
|
+
* Splits the simple, non-nested components of a static color function.
|
|
61
61
|
*
|
|
62
|
-
* @param {string}
|
|
63
|
-
* @return {Array}
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
212
|
-
let
|
|
213
|
-
let
|
|
214
|
-
let
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
321
|
+
let interpolatedAlpha = 0;
|
|
240
322
|
for (let i = 0; i < oklabValues.length; i++) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
|
|
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
|
|
471
|
+
const hex = convertOklchToHex(L, C, H, alpha);
|
|
383
472
|
if (hex) {
|
|
384
473
|
return hex;
|
|
385
474
|
}
|
package/src/value/colors.js
CHANGED
|
@@ -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
|
-
|
|
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]));
|
package/src/value/minify.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1139
|
-
* @param {string} property
|
|
1140
|
-
* @
|
|
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
|
-
|
|
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(
|
|
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 };
|