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 @@ - +