diff --git a/README.md b/README.md index 4296cae1e..7bab3ba65 100644 --- a/README.md +++ b/README.md @@ -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 `` to `` | 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 `` | Yes | | [convertStyleToAttrs](https://github.com/svg/svgo/blob/main/plugins/convertStyleToAttrs.js) | convert styles into attributes | | diff --git a/lib/builtin.js b/lib/builtin.js index e9fada7ae..c962f4358 100644 --- a/lib/builtin.js +++ b/lib/builtin.js @@ -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'), diff --git a/package.json b/package.json index 8b85284e5..de42f79f8 100644 --- a/package.json +++ b/package.json @@ -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 diff --git a/plugins/convertOneStopGradients.js b/plugins/convertOneStopGradients.js new file mode 100644 index 000000000..bf94439ff --- /dev/null +++ b/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 + * @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} + */ + const effectedDefs = new Set(); + + /** + * @type {Map} + */ + const allDefs = new Map(); + + /** + * @type {Map} + */ + 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); + } + } + } + }, + }, + }; +}; diff --git a/plugins/plugins-types.ts b/plugins/plugins-types.ts index a4cca210b..7c4132d1a 100644 --- a/plugins/plugins-types.ts +++ b/plugins/plugins-types.ts @@ -202,6 +202,7 @@ export type BuiltinsWithOptionalParams = DefaultPlugins & { defaultPx?: boolean; convertToPx?: boolean; }; + convertOneStopGradients: void; convertStyleToAttrs: { keepImportant?: boolean; }; diff --git a/test/plugins/convertOneStopGradients.01.svg b/test/plugins/convertOneStopGradients.01.svg new file mode 100644 index 000000000..1e599f626 --- /dev/null +++ b/test/plugins/convertOneStopGradients.01.svg @@ -0,0 +1,23 @@ +Convert both a one-stop gradient configured from attribute and styles. + +=== + + + + + + + + + + + + + + +@@@ + + + + + diff --git a/test/plugins/convertOneStopGradients.02.svg b/test/plugins/convertOneStopGradients.02.svg new file mode 100644 index 000000000..b63d3eb58 --- /dev/null +++ b/test/plugins/convertOneStopGradients.02.svg @@ -0,0 +1,21 @@ +Convert a one-stop gradient that references another one-stop gradient. Remove +xlink:href namespace since we remove the only reference to it. + +=== + + + + + + + + + + + +@@@ + + + + diff --git a/test/plugins/convertOneStopGradients.03.svg b/test/plugins/convertOneStopGradients.03.svg new file mode 100644 index 000000000..b2defea64 --- /dev/null +++ b/test/plugins/convertOneStopGradients.03.svg @@ -0,0 +1,21 @@ +If a one-stop gradient has the color defined via both attribute and style, style +takes presedence. + +=== + + + + + + + + + + + +@@@ + + + +