diff --git a/docs/04-plugins/dereferenceUses.mdx b/docs/04-plugins/dereferenceUses.mdx new file mode 100644 index 000000000..dc4535f50 --- /dev/null +++ b/docs/04-plugins/dereferenceUses.mdx @@ -0,0 +1,15 @@ +--- +title: dereferenceUses +svgo: + pluginId: dereferenceUses + defaultPlugin: false + parameters: + keepHref: + description: keep (xlink:)href attributes + default: false + symbolContainer: + description: browsers use as container of content (e.g. could also be used) + default: 'svg' +--- + +Dereferences elements. diff --git a/lib/builtin.js b/lib/builtin.js index 36248c12f..274dc05be 100644 --- a/lib/builtin.js +++ b/lib/builtin.js @@ -14,6 +14,7 @@ import * as convertPathData from '../plugins/convertPathData.js'; import * as convertShapeToPath from '../plugins/convertShapeToPath.js'; import * as convertStyleToAttrs from '../plugins/convertStyleToAttrs.js'; import * as convertTransform from '../plugins/convertTransform.js'; +import * as dereferenceUses from '../plugins/dereferenceUses.js'; import * as mergeStyles from '../plugins/mergeStyles.js'; import * as inlineStyles from '../plugins/inlineStyles.js'; import * as mergePaths from '../plugins/mergePaths.js'; @@ -70,6 +71,7 @@ export const builtin = Object.freeze([ convertShapeToPath, convertStyleToAttrs, convertTransform, + dereferenceUses, inlineStyles, mergePaths, mergeStyles, diff --git a/plugins/dereferenceUses.js b/plugins/dereferenceUses.js new file mode 100644 index 000000000..5ee137c16 --- /dev/null +++ b/plugins/dereferenceUses.js @@ -0,0 +1,172 @@ +import * as csstree from 'css-tree'; + +import { visitSkip, querySelector } from '../lib/xast.js'; + +/** + * @typedef {import('../lib/types.js').XastElement} XastElement + * @typedef {import('../lib/types.js').XastParent} XastParent + */ + +export const name = 'dereferenceUses'; +export const description = 'dereferences elements'; + +export const params = { + keepHref: false, // keep (xlink:)href attributes + symbolContainer: 'svg', // browsers use as container of content (e.g. could also be used) +}; + +const OverridingUseAttributeNames = [ + 'x', + 'y', + 'width', + 'height', + 'href', + 'xlink:href', +]; + +const HrefAttributeNames = [ + 'href', // By spec, `href` has precedence over (deprecated) `xlink:href` + 'xlink:href', +]; + +/** + * Dereferences elements + * + * @author strarsis + * + * @type {import('./plugins-types.js').Plugin<'dereferenceUses'>} + */ +export const fn = (root, params) => { + const { keepHref = false, symbolContainer = 'svg' } = params; + + /** + * @type {Array<{ node: XastElement, parentNode: XastParent, targetNode?: XastElement }>} + */ + let useElements = []; + + return { + element: { + enter: (node, parentNode) => { + if (node.name === 'use') { + useElements.push({ + node, + parentNode, + }); + return visitSkip; + } + }, + }, + + root: { + exit: (root) => { + // replace each with its referenced node + for (const useElement of useElements) { + // `href`/`xlink:href` value + let href = ''; + for (let hrefAttributeName of HrefAttributeNames) { + href = useElement.node.attributes[hrefAttributeName]; + if (href) { + break; // The first occurence is to be used (spec). + } + } + if (!href) { + continue; + } + + // look up referenced element + const targetElement = querySelector(root, href); + if (!targetElement || targetElement.type !== 'element') { + continue; + } + + // clone referenced element for insertion + const insertElement = structuredClone(targetElement); + + // Attribute inheritance of the referenced element + // @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use + // "Only the attributes x, y, width, height and href on the use element will override those set on the referenced element. + // However, any other attributes not set on the referenced element will be applied to the use element." + const insertElementAttributeNames = Object.keys( + insertElement.attributes, + ); + for (const attributeName in useElement.node.attributes) { + // don't remove attributes from the referenced element that, by spec, override the one of the element + if ( + insertElementAttributeNames.includes(attributeName) && + !OverridingUseAttributeNames.includes(attributeName) + ) { + continue; + } + + // don't remove href attribute with keepHref option turned on + if (!keepHref && HrefAttributeNames.includes(attributeName)) { + continue; + } + + // styles must be appended, not overriden + if (attributeName === 'style') { + continue; + } + + // set overriding attributes from referenced node + insertElement.attributes[attributeName] = + useElement.node.attributes[attributeName]; + } + + // only the original node is allowed to have this ID (IDs must be unique) + delete insertElement.attributes['id']; // remove ID attribute + + // elements are template elements (hence not visible), + // browsers would place a element as a different element + if (insertElement.name === 'symbol') { + insertElement.name = symbolContainer; + } + + // apply styles of element also on top of the referenced element + const useElementStyles = useElement.node.attributes.style; + const insertElementStyles = insertElement.attributes.style; + const styleParseOpts = { context: 'declarationList' }; + if (useElementStyles) { + const useElementStylesAst = csstree.parse( + useElementStyles, + styleParseOpts, + ); + if (useElementStylesAst.type !== 'DeclarationList') { + continue; + } + + const insertElementStylesAst = csstree.parse( + insertElementStyles, + styleParseOpts, + ); + if (insertElementStylesAst.type !== 'DeclarationList') { + continue; + } + + insertElementStylesAst.children.appendList( + useElementStylesAst.children, + ); + + const insertElementStylesAppended = csstree.generate( + insertElementStylesAst, + ); + insertElement.attributes.style = insertElementStylesAppended; // append styles (styles from have higher priority) + } + + // replace the element with the referenced, resolved element + + // position of in parent + const useElementPosition = useElement.parentNode.children.indexOf( + useElement.node, + ); + + useElement.parentNode.children.splice( + useElementPosition, + 1, + insertElement, + ); + } + }, + }, + }; +}; diff --git a/plugins/plugins-types.d.ts b/plugins/plugins-types.d.ts index bdd5de8ba..f432ac2ef 100644 --- a/plugins/plugins-types.d.ts +++ b/plugins/plugins-types.d.ts @@ -208,6 +208,10 @@ type DefaultPlugins = { xmlnsOrder?: 'front' | 'alphabetical'; }; sortDefsChildren: void; + dereferenceUses: { + keepHref?: boolean; + symbolContainer?: 'svg'; + }; }; type PresetDefaultOverrides = { diff --git a/test/plugins/dereferenceUses.01.svg.txt b/test/plugins/dereferenceUses.01.svg.txt new file mode 100644 index 000000000..dc1c34507 --- /dev/null +++ b/test/plugins/dereferenceUses.01.svg.txt @@ -0,0 +1,21 @@ +Dereference multiple elements while keeping the href attributes. + +=== + + + + + + + +@@@ + + + + + + + +@@@ + +{"keepHref":true} diff --git a/test/plugins/dereferenceUses.02.svg.txt b/test/plugins/dereferenceUses.02.svg.txt new file mode 100644 index 000000000..cd914f851 --- /dev/null +++ b/test/plugins/dereferenceUses.02.svg.txt @@ -0,0 +1,19 @@ +Works with elements that reference to IDs without element, while keeping the href attributes. + +=== + + + + + + +@@@ + + + + + + +@@@ + +{"keepHref":true} diff --git a/test/plugins/dereferenceUses.03.svg.txt b/test/plugins/dereferenceUses.03.svg.txt new file mode 100644 index 000000000..ba7d68a8c --- /dev/null +++ b/test/plugins/dereferenceUses.03.svg.txt @@ -0,0 +1,21 @@ +Works with dereferencing multiple elements that reference the same element, while keeping the href attributes. + +=== + + + + + + + +@@@ + + + + + + + +@@@ + +{"keepHref":true} diff --git a/test/plugins/dereferenceUses.04.svg.txt b/test/plugins/dereferenceUses.04.svg.txt new file mode 100644 index 000000000..6899b3b21 --- /dev/null +++ b/test/plugins/dereferenceUses.04.svg.txt @@ -0,0 +1,21 @@ +Correctly keeps and overrides attributes of and referenced elements adhering to spec, while keeping the href attributes. + +=== + + + + + + + +@@@ + + + + + + + +@@@ + +{"keepHref":true} diff --git a/test/plugins/dereferenceUses.05.svg.txt b/test/plugins/dereferenceUses.05.svg.txt new file mode 100644 index 000000000..79c493be1 --- /dev/null +++ b/test/plugins/dereferenceUses.05.svg.txt @@ -0,0 +1,17 @@ +Correctly keeps and overrides attributes that exist on both, the element and the references element, without keeping the href attributes. + +=== + + + + + + + +@@@ + + + + + + diff --git a/test/plugins/dereferenceUses.06.svg.txt b/test/plugins/dereferenceUses.06.svg.txt new file mode 100644 index 000000000..3988f6bfb --- /dev/null +++ b/test/plugins/dereferenceUses.06.svg.txt @@ -0,0 +1,29 @@ +Use a symbol container explicitly passed as option and handle more complex SVGs. + +=== + + + + + + + + + + +@@@ + + + + + + + + + + + + +@@@ + +{"symbolContainer":"g"} diff --git a/test/plugins/dereferenceUses.07.svg.txt b/test/plugins/dereferenceUses.07.svg.txt new file mode 100644 index 000000000..6c6c5f34a --- /dev/null +++ b/test/plugins/dereferenceUses.07.svg.txt @@ -0,0 +1,29 @@ +Correctly overrides attributes of referenced elements adhering to spec, while keeping the href attributes. + +=== + + + + + + + + + + +@@@ + + + + + + + + + + + + +@@@ + +{"keepHref":true} diff --git a/test/plugins/dereferenceUses.08.svg.txt b/test/plugins/dereferenceUses.08.svg.txt new file mode 100644 index 000000000..bcbd5e8f6 --- /dev/null +++ b/test/plugins/dereferenceUses.08.svg.txt @@ -0,0 +1,13 @@ +Does not break when there are actually no elements at all. + +=== + + + + + +@@@ + + + + diff --git a/test/plugins/dereferenceUses.09.svg.txt b/test/plugins/dereferenceUses.09.svg.txt new file mode 100644 index 000000000..d6d27d987 --- /dev/null +++ b/test/plugins/dereferenceUses.09.svg.txt @@ -0,0 +1,17 @@ +Does not break when encountering elements with missing `href` attribute. + +=== + + + + + + + +@@@ + + + + + +