Skip to content

Commit

Permalink
feat: convert one stop gradients plugin (#1790)
Browse files Browse the repository at this point in the history
Detects if a redundant linearGradient or radialGradient is used with
only a single stop, which effectively means a solid color.

If this is found, just remove the gradient and replace references to it
with the color of the first and only stop defined.
  • Loading branch information
SethFalco committed Oct 22, 2023
1 parent 3966c10 commit 6eac770
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -171,6 +171,7 @@ const config = await loadConfig(configFile, cwd);
| [collapseGroups](https://github.com/svg/svgo/blob/main/plugins/collapseGroups.js) | collapse useless groups | Yes |
| [convertColors](https://github.com/svg/svgo/blob/main/plugins/convertColors.js) | convert colors (from `rgb()` to `#rrggbb`, from `#rrggbb` to `#rgb`) | Yes |
| [convertEllipseToCircle](https://github.com/svg/svgo/blob/main/plugins/convertEllipseToCircle.js) | convert non-eccentric `<ellipse>` to `<circle>` | Yes |
| [convertOneStopGradients](https://github.com/svg/svgo/blob/main/plugins/convertOneStopGradients.js) | converts one-stop (single color) gradients to a plain color | |
| [convertPathData](https://github.com/svg/svgo/blob/main/plugins/convertPathData.js) | convert Path data to relative or absolute (whichever is shorter), convert one segment to another, trim useless delimiters, smart rounding, and much more | Yes |
| [convertShapeToPath](https://github.com/svg/svgo/blob/main/plugins/convertShapeToPath.js) | convert some basic shapes to `<path>` | Yes |
| [convertStyleToAttrs](https://github.com/svg/svgo/blob/main/plugins/convertStyleToAttrs.js) | convert styles into attributes | |
Expand Down
1 change: 1 addition & 0 deletions lib/builtin.js
Expand Up @@ -12,6 +12,7 @@ exports.builtin = [
require('../plugins/collapseGroups.js'),
require('../plugins/convertColors.js'),
require('../plugins/convertEllipseToCircle.js'),
require('../plugins/convertOneStopGradients.js'),
require('../plugins/convertPathData.js'),
require('../plugins/convertShapeToPath.js'),
require('../plugins/convertStyleToAttrs.js'),
Expand Down
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -69,7 +69,8 @@
"typecheck": "tsc",
"test-browser": "rollup -c && node ./test/browser.js",
"test-regression": "node ./test/regression-extract.js && NO_DIFF=1 node ./test/regression.js",
"prepublishOnly": "rm -rf dist && rollup -c"
"prepublishOnly": "rm -rf dist && rollup -c",
"qa": "yarn lint && yarn typecheck && yarn test && yarn test-browser && yarn test-regression"
},
"prettier": {
"singleQuote": true
Expand Down
168 changes: 168 additions & 0 deletions plugins/convertOneStopGradients.js
@@ -0,0 +1,168 @@
'use strict';

/**
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastParent} XastParent
*/

const { attrsGroupsDefaults, colorsProps } = require('./_collections');
const {
detachNodeFromParent,
querySelectorAll,
querySelector,
} = require('../lib/xast');
const { computeStyle, collectStylesheet } = require('../lib/style');

exports.name = 'convertOneStopGradients';
exports.description =
'converts one-stop (single color) gradients to a plain color';

/**
* Converts one-stop (single color) gradients to a plain color.
*
* @author Seth Falco <seth@falco.fun>
* @type {import('./plugins-types').Plugin<'convertOneStopGradients'>}
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/linearGradient
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/radialGradient
*/
exports.fn = (root) => {
const stylesheet = collectStylesheet(root);

/**
* Parent defs that had gradients elements removed from them.
*
* @type {Set<XastElement>}
*/
const effectedDefs = new Set();

/**
* @type {Map<XastElement, XastParent>}
*/
const allDefs = new Map();

/**
* @type {Map<XastElement, XastParent>}
*/
const gradientsToDetach = new Map();

/** Number of references to the xlink:href attribute. */
let xlinkHrefCount = 0;

return {
element: {
enter: (node, parentNode) => {
if (node.attributes['xlink:href'] != null) {
xlinkHrefCount++;
}

if (node.name === 'defs') {
allDefs.set(node, parentNode);
return;
}

if (node.name !== 'linearGradient' && node.name !== 'radialGradient') {
return;
}

const stops = node.children.filter((child) => {
return child.type === 'element' && child.name === 'stop';
});

const href = node.attributes['xlink:href'] || node.attributes['href'];
let effectiveNode =
stops.length === 0 && href != null && href.startsWith('#')
? querySelector(root, href)
: node;

if (effectiveNode == null || effectiveNode.type !== 'element') {
gradientsToDetach.set(node, parentNode);
return;
}

const effectiveStops = effectiveNode.children.filter((child) => {
return child.type === 'element' && child.name === 'stop';
});

if (
effectiveStops.length !== 1 ||
effectiveStops[0].type !== 'element'
) {
return;
}

if (parentNode.type === 'element' && parentNode.name === 'defs') {
effectedDefs.add(parentNode);
}

gradientsToDetach.set(node, parentNode);

let color;
const style = computeStyle(stylesheet, effectiveStops[0])['stop-color'];
if (style != null && style.type === 'static') {
color = style.value;
}

const selectorVal = `url(#${node.attributes.id})`;

const selector = colorsProps
.map((attr) => `[${attr}="${selectorVal}"]`)
.join(',');
const elements = querySelectorAll(root, selector);
for (const element of elements) {
if (element.type !== 'element') {
continue;
}

for (const attr of colorsProps) {
if (element.attributes[attr] !== selectorVal) {
continue;
}

if (color != null) {
element.attributes[attr] = color;
} else {
delete element.attributes[attr];
}
}
}

const styledElements = querySelectorAll(
root,
`[style*=${selectorVal}]`
);
for (const element of styledElements) {
if (element.type !== 'element') {
continue;
}

element.attributes.style = element.attributes.style.replace(
selectorVal,
color || attrsGroupsDefaults.presentation['stop-color']
);
}
},

exit: (node) => {
if (node.name === 'svg') {
for (const [gradient, parent] of gradientsToDetach.entries()) {
if (gradient.attributes['xlink:href'] != null) {
xlinkHrefCount--;
}

detachNodeFromParent(gradient, parent);
}

if (xlinkHrefCount === 0) {
delete node.attributes['xmlns:xlink'];
}

for (const [defs, parent] of allDefs.entries()) {
if (effectedDefs.has(defs) && defs.children.length === 0) {
detachNodeFromParent(defs, parent);
}
}
}
},
},
};
};
1 change: 1 addition & 0 deletions plugins/plugins-types.ts
Expand Up @@ -202,6 +202,7 @@ export type BuiltinsWithOptionalParams = DefaultPlugins & {
defaultPx?: boolean;
convertToPx?: boolean;
};
convertOneStopGradients: void;
convertStyleToAttrs: {
keepImportant?: boolean;
};
Expand Down
23 changes: 23 additions & 0 deletions test/plugins/convertOneStopGradients.01.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions test/plugins/convertOneStopGradients.02.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions test/plugins/convertOneStopGradients.03.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 6eac770

Please sign in to comment.