diff --git a/lib/builtin.js b/lib/builtin.js
index a77cef2..48a2f08 100644
--- a/lib/builtin.js
+++ b/lib/builtin.js
@@ -22,6 +22,7 @@ import * as convertStyleToAttrs from '../plugins/convertStyleToAttrs.js';
import * as createGroups from '../plugins/createGroups.js';
import * as inlineStyles from '../plugins/inlineStyles.js';
import * as mergePaths from '../plugins/mergePaths.js';
+import * as minifyColors from '../plugins/minifyColors.js';
import * as minifyPathData from '../plugins/minifyPathData.js';
import * as minifyStyles from '../plugins/minifyStyles.js';
import * as minifyTransforms from '../plugins/minifyTransforms.js';
@@ -89,6 +90,7 @@ export const builtin = Object.freeze([
createGroups,
inlineStyles,
mergePaths,
+ minifyColors,
minifyPathData,
minifyStyles,
minifyTransforms,
diff --git a/lib/color.js b/lib/color.js
new file mode 100644
index 0000000..c8588c7
--- /dev/null
+++ b/lib/color.js
@@ -0,0 +1,251 @@
+import { colorsNames, colorsShortNames } from '../plugins/_collections.js';
+import { AttValue } from './attvalue.js';
+
+const REGEX_RGB = /rgb\((.*)\)/i;
+const REGEX_RGB_ARGS = /\s|,/;
+
+export class ColorValue extends AttValue {
+ /**
+ * @param {string|undefined} strVal
+ */
+ constructor(strVal) {
+ super(strVal);
+ }
+
+ /**
+ * @param {string} value
+ * @returns {ColorValue}
+ */
+ static #createColorObj(value) {
+ value = value.trim();
+ const lower = value.toLowerCase();
+ if (value.startsWith('#')) {
+ const obj = HexColor.create(value.substring(1));
+ if (obj) {
+ return obj;
+ }
+ } else if (colorsNames[lower]) {
+ return new ExtendedColor(value);
+ } else if (lower.startsWith('rgb(')) {
+ const obj = value.includes('%')
+ ? RGBPctColor.create(value)
+ : RGBColor.create(value);
+ if (obj) {
+ return obj;
+ }
+ }
+ return new ColorValue(value);
+ }
+
+ /**
+ * @param {string|AttValue} value
+ * @returns {ColorValue}
+ */
+ static getColorObj(value) {
+ if (typeof value === 'string') {
+ return this.#createColorObj(value);
+ }
+ if (value instanceof ColorValue) {
+ return value;
+ }
+ throw value;
+ }
+
+ /**
+ * @returns {ColorValue}
+ */
+ getMinifiedValue() {
+ return this;
+ }
+}
+
+class ExtendedColor extends ColorValue {
+ /**
+ * @returns {ColorValue}
+ */
+ getMinifiedValue() {
+ let value = this.toString().toLowerCase();
+ const hexString = colorsNames[value];
+ if (hexString.length < value.length) {
+ return new HexColor(hexString);
+ }
+ return new ExtendedColor(value);
+ }
+}
+
+class HexColor extends ColorValue {
+ /**
+ * @param {string} hexDigits
+ * @returns {HexColor|undefined}
+ */
+ static create(hexDigits) {
+ if (hexDigits.length !== 3 && hexDigits.length !== 6) {
+ return;
+ }
+ for (const char of hexDigits.toLowerCase()) {
+ switch (char) {
+ case 'a':
+ case 'b':
+ case 'c':
+ case 'd':
+ case 'e':
+ case 'f':
+ case '0':
+ case '1':
+ case '2':
+ case '3':
+ case '4':
+ case '5':
+ case '6':
+ case '7':
+ case '8':
+ case '9':
+ break;
+ default:
+ return;
+ }
+ }
+
+ return new HexColor('#' + hexDigits);
+ }
+
+ /**
+ * @returns {ColorValue}
+ */
+ getMinifiedValue() {
+ let value = this.toString().toLowerCase();
+
+ if (value.length === 7) {
+ // See if it can be shortened to 3 characters.
+ if (
+ value[1] === value[2] &&
+ value[3] === value[4] &&
+ value[5] === value[6]
+ ) {
+ return new HexColor(
+ '#' + value[1] + value[3] + value[5],
+ ).getMinifiedValue();
+ }
+ }
+ const name = colorsShortNames[value];
+ if (name && name.length <= value.length) {
+ return new ExtendedColor(name);
+ }
+ return new HexColor(value);
+ }
+}
+
+class RGBColor extends ColorValue {
+ #rgb;
+
+ /**
+ * @param {string} fn
+ * @param {[number,number,number]} rgb
+ */
+ constructor(fn, rgb) {
+ super(fn);
+ this.#rgb = rgb;
+ }
+
+ /**
+ * @param {string} fn
+ * @returns {RGBColor|undefined}
+ */
+ static create(fn) {
+ const match = REGEX_RGB.exec(fn);
+ if (!match) {
+ return;
+ }
+ const strArgs = match[1];
+ if (!strArgs) {
+ return;
+ }
+ const rawArgs = strArgs.split(REGEX_RGB_ARGS);
+ const args = rawArgs.filter((a) => a !== '');
+ if (args.length !== 3) {
+ return;
+ }
+ const nums = args.map((str) => {
+ const n = parseInt(str);
+ if (n < 0 || n > 255) {
+ return;
+ }
+ if (str === n.toString()) {
+ return n;
+ }
+ });
+ if (nums.some((n) => n === undefined)) {
+ return;
+ }
+ // @ts-ignore - undefined values excluded above
+ return new RGBColor(fn, nums);
+ }
+
+ /**
+ * @returns {ColorValue}
+ */
+ getMinifiedValue() {
+ return new HexColor(
+ this.#rgb.reduce((str, val) => {
+ const hex = val.toString(16);
+ return str + (hex.length === 1 ? '0' + hex : hex);
+ }, '#'),
+ ).getMinifiedValue();
+ }
+}
+
+class RGBPctColor extends ColorValue {
+ #rgb;
+
+ /**
+ * @param {string} fn
+ * @param {[number,number,number]} rgb
+ */
+ constructor(fn, rgb) {
+ super(fn);
+ this.#rgb = rgb;
+ }
+
+ /**
+ * @param {string} fn
+ * @returns {RGBColor|undefined}
+ */
+ static create(fn) {
+ const match = REGEX_RGB.exec(fn);
+ if (!match) {
+ return;
+ }
+ const strArgs = match[1];
+ if (!strArgs) {
+ return;
+ }
+ const rawArgs = strArgs.split(REGEX_RGB_ARGS);
+ const args = rawArgs.filter((a) => a !== '');
+ if (args.length !== 3) {
+ return;
+ }
+ const nums = args.map((str) => {
+ if (str.length === 1 || !str.endsWith('%')) {
+ return;
+ }
+ const numStr = str.substring(0, str.length - 1);
+ const n = parseFloat(numStr);
+ if (n < 0 || n > 100 || numStr !== n.toString()) {
+ return;
+ }
+ return n;
+ });
+ if (nums.some((n) => n === undefined)) {
+ return;
+ }
+ // @ts-ignore - undefined values excluded above
+ return new RGBPctColor(fn, nums);
+ }
+
+ /**
+ * @returns {ColorValue}
+ */
+ getMinifiedValue() {
+ return new ColorValue(`rgb(${this.#rgb.map((n) => n + '%').join(',')})`);
+ }
+}
diff --git a/plugins/minifyColors.js b/plugins/minifyColors.js
new file mode 100644
index 0000000..d7708b6
--- /dev/null
+++ b/plugins/minifyColors.js
@@ -0,0 +1,79 @@
+import { ColorValue } from '../lib/color.js';
+import { getStyleDeclarations } from '../lib/css-tools.js';
+import { writeStyleAttribute } from '../lib/css.js';
+import { svgSetAttValue } from '../lib/svg-parse-att.js';
+
+export const name = 'minifyColors';
+export const description =
+ 'minifies color values used in attributes and style properties';
+
+/**
+ * @type {import('./plugins-types.js').Plugin<'minifyColors'>};
+'>}
+ */
+export const fn = (root, params, info) => {
+ const styleData = info.docData.getStyles();
+ if (
+ info.docData.hasScripts() ||
+ styleData === null ||
+ styleData.hasAttributeSelector()
+ ) {
+ return;
+ }
+
+ return {
+ element: {
+ enter: (element) => {
+ // Minify attribute values.
+ for (const [attName, attVal] of Object.entries(element.attributes)) {
+ switch (attName) {
+ case 'fill':
+ case 'flood-color':
+ case 'lighting-color':
+ case 'stop-color':
+ case 'stroke':
+ {
+ const value = ColorValue.getColorObj(attVal);
+ const min = value.getMinifiedValue();
+ if (min) {
+ svgSetAttValue(element, attName, min);
+ }
+ }
+ break;
+ }
+ }
+
+ // Minify style properties.
+ const props = getStyleDeclarations(element);
+ if (!props) {
+ return;
+ }
+ let propChanged = false;
+ for (const [propName, propValue] of props.entries()) {
+ switch (propName) {
+ case 'fill':
+ case 'flood-color':
+ case 'lighting-color':
+ case 'stop-color':
+ case 'stroke':
+ {
+ const value = ColorValue.getColorObj(propValue.value);
+ const min = value.getMinifiedValue();
+ if (min) {
+ propChanged = true;
+ props.set(propName, {
+ value: min,
+ important: propValue.important,
+ });
+ }
+ }
+ break;
+ }
+ }
+ if (propChanged) {
+ writeStyleAttribute(element, props);
+ }
+ },
+ },
+ };
+};
diff --git a/plugins/plugins-types.d.ts b/plugins/plugins-types.d.ts
index 607a83e..8f8907d 100644
--- a/plugins/plugins-types.d.ts
+++ b/plugins/plugins-types.d.ts
@@ -93,6 +93,7 @@ type DefaultPlugins = {
floatPrecision?: number;
noSpaceAfterFlags?: boolean;
};
+ minifyColors: void;
minifyPathData: void;
minifyStyles: void;
minifyTransforms: void;
diff --git a/plugins/preset-default.js b/plugins/preset-default.js
index e728829..b8f5123 100644
--- a/plugins/preset-default.js
+++ b/plugins/preset-default.js
@@ -4,11 +4,11 @@ import * as cleanupStyleAttributes from './cleanupStyleAttributes.js';
import * as cleanupXlink from './cleanupXlink.js';
import * as collapseGroups from './collapseGroups.js';
import * as combineStyleElements from './combineStyleElements.js';
-import * as convertColors from './convertColors.js';
import * as convertEllipseToCircle from './convertEllipseToCircle.js';
import * as convertShapeToPath from './convertShapeToPath.js';
import * as createGroups from './createGroups.js';
import * as inlineStyles from './inlineStyles.js';
+import * as minifyColors from './minifyColors.js';
import * as minifyPathData from './minifyPathData.js';
import * as minifyStyles from './minifyStyles.js';
import * as minifyTransforms from './minifyTransforms.js';
@@ -42,7 +42,7 @@ const presetDefault = createPreset({
inlineStyles,
minifyStyles,
cleanupIds,
- convertColors,
+ minifyColors,
removeUnknownsAndDefaults,
removeNonInheritableGroupAttrs,
removeUselessStrokeAndFill,
diff --git a/plugins/preset-next.js b/plugins/preset-next.js
index bde6835..c947499 100644
--- a/plugins/preset-next.js
+++ b/plugins/preset-next.js
@@ -4,11 +4,11 @@ import * as cleanupStyleAttributes from './cleanupStyleAttributes.js';
import * as cleanupXlink from './cleanupXlink.js';
import * as collapseGroups from './collapseGroups.js';
import * as combineStyleElements from './combineStyleElements.js';
-import * as convertColors from './convertColors.js';
import * as convertEllipseToCircle from './convertEllipseToCircle.js';
import * as convertShapeToPath from './convertShapeToPath.js';
import * as createGroups from './createGroups.js';
import * as inlineStyles from './inlineStyles.js';
+import * as minifyColors from './minifyColors.js';
import * as minifyPathData from './minifyPathData.js';
import * as minifyStyles from './minifyStyles.js';
import * as minifyTransforms from './minifyTransforms.js';
@@ -42,7 +42,7 @@ const presetNext = createPreset({
inlineStyles,
minifyStyles,
cleanupIds,
- convertColors,
+ minifyColors,
removeUnknownsAndDefaults,
removeNonInheritableGroupAttrs,
removeUselessStrokeAndFill,
diff --git a/test/fixtures/files/colors.1.svg b/test/fixtures/files/colors.1.svg
new file mode 100644
index 0000000..32b2039
--- /dev/null
+++ b/test/fixtures/files/colors.1.svg
@@ -0,0 +1,18 @@
+
diff --git a/test/lib/colors.test.js b/test/lib/colors.test.js
new file mode 100644
index 0000000..144b0a4
--- /dev/null
+++ b/test/lib/colors.test.js
@@ -0,0 +1,26 @@
+import { ColorValue } from '../../lib/color.js';
+
+describe('test parsing and minifying', () => {
+ /** @type {{in:string,minified?:string}[]} */
+ const testCases = [
+ { in: '#ffffff', minified: '#fff' },
+ { in: '#aABBcc', minified: '#abc' },
+ { in: '#F00', minified: 'red' },
+ { in: 'cadetBlue', minified: '#5f9ea0' },
+ { in: 'coRal', minified: 'coral' },
+ { in: 'wHatEveR' },
+ { in: 'rGb( 50 100 , 150 )', minified: '#326496' },
+ { in: 'rgb(203,0,254)', minified: '#cb00fe' },
+ { in: 'rgb( 49.5%, 33.49% ,22.5% )', minified: 'rgb(49.5%,33.49%,22.5%)' },
+ { in: 'rgb(165,42,42)', minified: 'brown' },
+ { in: 'rgb( 50 100 150 /.1)', minified: 'rgb( 50 100 150 /.1)' },
+ ];
+ for (const testCase of testCases) {
+ it(`${testCase.in}`, () => {
+ const attValue = ColorValue.getColorObj(testCase.in);
+ expect(attValue.toString()).toBe(testCase.in);
+ const minified = attValue.getMinifiedValue();
+ expect(minified.toString()).toBe(testCase.minified ?? testCase.in);
+ });
+ }
+});
diff --git a/test/plugins/minifyColors.01.svg.txt b/test/plugins/minifyColors.01.svg.txt
new file mode 100644
index 0000000..9af2e74
--- /dev/null
+++ b/test/plugins/minifyColors.01.svg.txt
@@ -0,0 +1,24 @@
+Convert attribute values.
+
+===
+
+
+
+@@@
+
+
+
diff --git a/test/plugins/minifyColors.02.svg.txt b/test/plugins/minifyColors.02.svg.txt
new file mode 100644
index 0000000..d41aad8
--- /dev/null
+++ b/test/plugins/minifyColors.02.svg.txt
@@ -0,0 +1,27 @@
+Convert flood-color values.
+
+===
+
+
+
+@@@
+
+
diff --git a/test/plugins/minifyColors.03.svg.txt b/test/plugins/minifyColors.03.svg.txt
new file mode 100644
index 0000000..a0c32fd
--- /dev/null
+++ b/test/plugins/minifyColors.03.svg.txt
@@ -0,0 +1,35 @@
+Convert lighting-color values.
+
+===
+
+
+
+@@@
+
+
diff --git a/test/plugins/minifyColors.04.svg.txt b/test/plugins/minifyColors.04.svg.txt
new file mode 100644
index 0000000..9e38ab5
--- /dev/null
+++ b/test/plugins/minifyColors.04.svg.txt
@@ -0,0 +1,23 @@
+Convert property values.
+
+===
+
+
+
+@@@
+
+
diff --git a/test/regression.js b/test/regression.js
index c1871ba..0f86b4d 100644
--- a/test/regression.js
+++ b/test/regression.js
@@ -63,13 +63,6 @@ async function performTests(options) {
* @param {string} name
*/
const processFile = async (page, name) => {
- const fileStats = {
- lengthOrig: 0,
- lengthOpt: 0,
- pixels: -1,
- };
- stats.set(name.replace(/\\/g, '/'), fileStats);
-
await page.goto(`http://localhost:5000/original/${name}`);
const originalBuffer = await page.screenshot(screenshotOptions);
await page.goto(`http://localhost:5000/optimized/${name}`);
@@ -90,6 +83,10 @@ async function performTests(options) {
return;
}
+ const fileStats = stats.get(name.replace(/\\/g, '/'));
+ if (!fileStats) {
+ throw new Error();
+ }
fileStats.pixels = mismatchCount;
totalPixelMismatches += mismatchCount;
if (mismatchCount <= 0) {
@@ -176,13 +173,18 @@ async function performTests(options) {
if (req.url === undefined) {
throw new Error();
}
- const name = decodeURI(req.url.slice(req.url.indexOf('/', 1)));
+ const name = decodeURI(req.url.slice(req.url.indexOf('/', 1)))
+ .replaceAll('(', '%28')
+ .replaceAll(')', '%29');
const statsName = name.substring(1);
let file;
try {
file = await fs.readFile(path.join(fixturesDir, name), 'utf-8');
} catch {
- console.error(`error reading file ${name}`);
+ if (stats.has(statsName)) {
+ console.error(`error reading file ${name} (url=${req.url})`);
+ notOptimized.add(statsName);
+ }
res.statusCode = 404;
res.end();
return;
@@ -218,6 +220,16 @@ async function performTests(options) {
server.listen(5000, resolve);
});
const list = (await filesPromise).filter((name) => name.endsWith('.svg'));
+
+ // Initialize statistics array.
+ list.forEach((name) =>
+ stats.set(name, {
+ lengthOrig: 0,
+ lengthOpt: 0,
+ pixels: -1,
+ }),
+ );
+
const passed = await runTests(list);
server.close();
const end = process.hrtime.bigint();
@@ -252,7 +264,7 @@ program
.option(
'-b, --browser ',
'Browser engine to use in testing',
- 'chromium',
+ 'webkit',
)
.option(
'-i, --inputdir ',
diff --git a/test/svgo/keyframe-selectors.svg.txt b/test/svgo/keyframe-selectors.svg.txt
index 628a633..8314859 100644
--- a/test/svgo/keyframe-selectors.svg.txt
+++ b/test/svgo/keyframe-selectors.svg.txt
@@ -9,5 +9,5 @@
-
+