From b590a576742b11396cabd17ab1322e2af66c121d Mon Sep 17 00:00:00 2001 From: johnkenny54 <45182853+johnkenny54@users.noreply.github.com> Date: Thu, 17 Oct 2024 18:12:55 -0700 Subject: [PATCH] Add inlineUse plugin (#65) --- lib/builtin.js | 2 + plugins/_collections.js | 68 ++++++++++ plugins/_styles.js | 33 ++++- plugins/cleanupStyleAttributes.js | 69 +--------- plugins/inlineUse.js | 217 ++++++++++++++++++++++++++++++ plugins/plugins-types.d.ts | 1 + test/plugins/inlineUse.01.svg.txt | 21 +++ test/plugins/inlineUse.02.svg.txt | 21 +++ test/plugins/inlineUse.03.svg.txt | 21 +++ test/plugins/inlineUse.04.svg.txt | 23 ++++ test/plugins/inlineUse.05.svg.txt | 21 +++ test/plugins/inlineUse.06.svg.txt | 17 +++ test/plugins/inlineUse.07.svg.txt | 17 +++ test/plugins/inlineUse.08.svg.txt | 17 +++ 14 files changed, 475 insertions(+), 73 deletions(-) create mode 100644 plugins/inlineUse.js create mode 100644 test/plugins/inlineUse.01.svg.txt create mode 100644 test/plugins/inlineUse.02.svg.txt create mode 100644 test/plugins/inlineUse.03.svg.txt create mode 100644 test/plugins/inlineUse.04.svg.txt create mode 100644 test/plugins/inlineUse.05.svg.txt create mode 100644 test/plugins/inlineUse.06.svg.txt create mode 100644 test/plugins/inlineUse.07.svg.txt create mode 100644 test/plugins/inlineUse.08.svg.txt diff --git a/lib/builtin.js b/lib/builtin.js index c9fcf20..ef786bf 100644 --- a/lib/builtin.js +++ b/lib/builtin.js @@ -20,6 +20,7 @@ import * as convertShapeToPath from '../plugins/convertShapeToPath.js'; import * as convertStyleToAttrs from '../plugins/convertStyleToAttrs.js'; import * as createGroups from '../plugins/createGroups.js'; import * as inlineStyles from '../plugins/inlineStyles.js'; +import * as inlineUse from '../plugins/inlineUse.js'; import * as mergePaths from '../plugins/mergePaths.js'; import * as minifyColors from '../plugins/minifyColors.js'; import * as minifyPathData from '../plugins/minifyPathData.js'; @@ -86,6 +87,7 @@ export const builtin = Object.freeze([ convertStyleToAttrs, createGroups, inlineStyles, + inlineUse, mergePaths, minifyColors, minifyPathData, diff --git a/plugins/_collections.js b/plugins/_collections.js index 92fe8f9..37ce7be 100644 --- a/plugins/_collections.js +++ b/plugins/_collections.js @@ -2361,6 +2361,74 @@ export const colorsProps = new Set([ 'stroke', ]); +/** + * See https://www.w3.org/TR/SVG2/styling.html#PresentationAttributes. + * The list below also includes the shorthand property "font" and the "transform-origin" property. + */ +export const presentationProperties = new Set([ + 'alignment-baseline', + 'baseline-shift', + 'clip-path', + 'clip-rule', + 'color', + 'color-interpolation', + 'color-interpolation-filters', + 'color-rendering', + 'cursor', + 'direction', + 'display', + 'dominant-baseline', + 'fill', + 'fill-opacity', + 'fill-rule', + 'filter', + 'flood-color', + 'flood-opacity', + 'font', + 'font-family', + 'font-size', + 'font-size-adjust', + 'font-stretch', + 'font-style', + 'font-variant', + 'font-weight', + 'glyph-orientation-horizontal', + 'glyph-orientation-vertical', + 'image-rendering', + 'letter-spacing', + 'lighting-color', + 'marker-end', + 'marker-mid', + 'marker-start', + 'mask', + 'opacity', + 'overflow', + 'paint-order', + 'pointer-events', + 'shape-rendering', + 'stop-color', + 'stop-opacity', + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + 'text-anchor', + 'text-decoration', + 'text-overflow', + 'text-rendering', + 'transform', + 'unicode-bidi', + 'vector-effect', + 'visibility', + 'white-space', + 'word-spacing', + 'writing-mode', +]); + // See https://www.w3.org/TR/SVG2/geometry.html#geometry-properties /** @type {Object>} */ export const geometryProperties = { diff --git a/plugins/_styles.js b/plugins/_styles.js index 7331f38..a3e2f9d 100644 --- a/plugins/_styles.js +++ b/plugins/_styles.js @@ -1,6 +1,6 @@ import { getStyleDeclarations } from '../lib/css-tools.js'; import { svgAttTransformToCSS } from '../lib/svg-to-css.js'; -import { inheritableAttrs } from './_collections.js'; +import { inheritableAttrs, presentationProperties } from './_collections.js'; export const TRANSFORM_PROP_NAMES = ['transform', 'transform-origin']; @@ -9,20 +9,43 @@ export const TRANSFORM_PROP_NAMES = ['transform', 'transform-origin']; * @returns {import('../lib/types.js').CSSDeclarationMap} */ export function getInheritableProperties(element) { + return _getProperties( + element, + (name) => inheritableAttrs.has(name) || TRANSFORM_PROP_NAMES.includes(name), + ); +} + +/** + * @param {import('../lib/types.js').XastElement} element + * @returns {import('../lib/types.js').CSSDeclarationMap} + */ +export function getPresentationProperties(element) { + return _getProperties(element, (name) => presentationProperties.has(name)); +} + +/** + * @param {import('../lib/types.js').XastElement} element + * @param {function(string):boolean} fnInclude + * @returns {import('../lib/types.js').CSSDeclarationMap} + */ +function _getProperties(element, fnInclude) { /** @type {import('../lib/types.js').CSSDeclarationMap} */ const props = new Map(); // Gather all inheritable attributes. for (const [name, value] of Object.entries(element.attributes)) { - if (inheritableAttrs.has(name)) { - props.set(name, { value: value, important: false }); - } else if (name === 'transform') { + if (!fnInclude(name)) { + continue; + } + if (name === 'transform') { const cssValue = svgAttTransformToCSS(value); if (cssValue) { props.set(name, cssValue); } } else if (TRANSFORM_PROP_NAMES.includes(name)) { props.set(name, { value: value, important: false }); + } else { + props.set(name, { value: value, important: false }); } } @@ -30,7 +53,7 @@ export function getInheritableProperties(element) { const styleProps = getStyleDeclarations(element); if (styleProps) { styleProps.forEach((v, k) => { - if (inheritableAttrs.has(k) || TRANSFORM_PROP_NAMES.includes(k)) { + if (fnInclude(k)) { if (v === null) { props.delete(k); } else { diff --git a/plugins/cleanupStyleAttributes.js b/plugins/cleanupStyleAttributes.js index cc87cd6..38d0fab 100644 --- a/plugins/cleanupStyleAttributes.js +++ b/plugins/cleanupStyleAttributes.js @@ -4,6 +4,7 @@ import { visitSkip } from '../lib/xast.js'; import { elemsGroups, geometryProperties, + presentationProperties, uselessShapeProperties, } from './_collections.js'; @@ -12,74 +13,6 @@ export const description = 'removes invalid properties from style attributes'; const CLASS_SPLITTER = /\s/; -/** - * See https://www.w3.org/TR/SVG2/styling.html#PresentationAttributes. - * The list below also includes the shorthand property "font". - */ -const presentationProperties = new Set([ - 'alignment-baseline', - 'baseline-shift', - 'clip-path', - 'clip-rule', - 'color', - 'color-interpolation', - 'color-interpolation-filters', - 'color-rendering', - 'cursor', - 'direction', - 'display', - 'dominant-baseline', - 'fill', - 'fill-opacity', - 'fill-rule', - 'filter', - 'flood-color', - 'flood-opacity', - 'font', - 'font-family', - 'font-size', - 'font-size-adjust', - 'font-stretch', - 'font-style', - 'font-variant', - 'font-weight', - 'glyph-orientation-horizontal', - 'glyph-orientation-vertical', - 'image-rendering', - 'letter-spacing', - 'lighting-color', - 'marker-end', - 'marker-mid', - 'marker-start', - 'mask', - 'opacity', - 'overflow', - 'paint-order', - 'pointer-events', - 'shape-rendering', - 'stop-color', - 'stop-opacity', - 'stroke', - 'stroke-dasharray', - 'stroke-dashoffset', - 'stroke-linecap', - 'stroke-linejoin', - 'stroke-miterlimit', - 'stroke-opacity', - 'stroke-width', - 'text-anchor', - 'text-decoration', - 'text-overflow', - 'text-rendering', - 'transform', - 'unicode-bidi', - 'vector-effect', - 'visibility', - 'white-space', - 'word-spacing', - 'writing-mode', -]); - /** * @type {import('./plugins-types.js').Plugin<'cleanupStyleAttributes'>} */ diff --git a/plugins/inlineUse.js b/plugins/inlineUse.js new file mode 100644 index 0000000..6294e53 --- /dev/null +++ b/plugins/inlineUse.js @@ -0,0 +1,217 @@ +import { writeStyleAttribute } from '../lib/css.js'; +import { cssTransformToSVGAtt } from '../lib/svg-to-css.js'; +import { getHrefId, getReferencedIds } from '../lib/svgo/tools.js'; +import { getPresentationProperties } from './_styles.js'; + +export const name = 'inlineUse'; +export const description = 'move inline when only once'; + +/** + * @type {import('./plugins-types.js').Plugin<'inlineUse'>}; +'>} + */ +export const fn = (root, params, info) => { + const styleData = info.docData.getStyles(); + if ( + info.docData.hasScripts() || + styleData === null || + styleData.hasStyles() + ) { + return; + } + + /** @type {Map}*/ + const defIds = new Map(); + + /** @type {Map} */ + const referencedIds = new Map(); + + /** @type {Map} */ + const usedIds = new Map(); + + return { + element: { + enter: (element) => { + if (element.name === 'defs') { + // Record the ids of all children as potential candidates for inlining. + for (const child of element.children) { + if (child.type === 'element' && child.attributes.id) { + defIds.set(child.attributes.id.toString(), child); + } + } + } else if (element.name === 'use') { + const id = getHrefId(element); + if (id) { + let usingEls = usedIds.get(id); + if (!usingEls) { + usingEls = []; + usedIds.set(id, usingEls); + } + usingEls.push(element); + } + } + + // Record all referenced ids. + for (const { id } of getReferencedIds(element)) { + let referencingEls = referencedIds.get(id); + if (!referencingEls) { + referencingEls = []; + referencedIds.set(id, referencingEls); + } + referencingEls.push(element); + } + }, + }, + root: { + exit: () => { + /** @type {Map>} */ + const defsToDelete = new Map(); + + for (const [id, def] of defIds.entries()) { + // If it is only referenced by a single , try to inline it. + const usingEls = usedIds.get(id); + if (usingEls && usingEls.length === 1) { + const referencingEls = referencedIds.get(id); + if (referencingEls && referencingEls.length === 1) { + // Record parent in case it is changed by inlining. + const parent = def.parentNode; + if (inlineUse(usingEls[0], def)) { + // Add def to list to be deleted. + let defsChildren = defsToDelete.get(parent); + if (!defsChildren) { + defsChildren = new Set(); + defsToDelete.set(parent, defsChildren); + } + defsChildren.add(def); + } + } + } + } + + // Remove any deleted defs. + for (const [parent, deletedChildren] of defsToDelete.entries()) { + parent.children = parent.children.filter( + (c) => !deletedChildren.has(c), + ); + } + }, + }, + }; +}; + +/** + * + * @param {import('../lib/types.js').XastElement} use + * @param {import('../lib/types.js').XastElement} def + * @returns {boolean} + */ +function inlineUse(use, def) { + // Don't inline if has children. + if (use.children.length > 0) { + return false; + } + + // Don't inline symbols that are d with a width/height. + if ( + (use.attributes.width || use.attributes.height) && + def.name === 'symbol' + ) { + return false; + } + + // Check referenced element. + let isContainer = false; + switch (def.name) { + case 'path': + break; + case 'symbol': + isContainer = true; + break; + default: + return false; + } + + const defProperties = getPresentationProperties(def); + // Don't convert unless overflow is visible. + if (def.name === 'symbol') { + const overflow = defProperties.get('overflow'); + if (!overflow || overflow.value !== 'visible') { + return false; + } + // Remove overflow since there is no need to carry it over to ; remove transform properties since they are ignored. + ['overflow', 'transform', 'transform-origin'].forEach((name) => + defProperties.delete(name), + ); + } + + const useProperties = getPresentationProperties(use); + + // Overwrite properties with def properties. + if (defProperties) { + for (const [propName, propValue] of defProperties.entries()) { + useProperties.set(propName, propValue); + } + } + + // If there is a transform property, convert to an attribute. + let transform = ''; + const cssTransform = useProperties.get('transform'); + if (cssTransform) { + const svgTransform = cssTransformToSVGAtt(cssTransform); + if (!svgTransform) { + return false; + } + transform = svgTransform.toString(); + useProperties.delete('transform'); + } + + // Convert the . + use.name = isContainer ? 'g' : def.name; + + // Update attributes. + let tx = '0'; + let ty = '0'; + for (const [attName, attValue] of Object.entries(use.attributes)) { + switch (attName) { + case 'x': + tx = attValue.toString(); + break; + case 'y': + ty = attValue.toString(); + break; + } + delete use.attributes[attName]; + } + + // Copy any non-presentation properties from def. + for (const [attName, attValue] of Object.entries(def.attributes)) { + switch (attName) { + case 'id': + case 'overflow': + case 'style': + case 'transform': + case 'transform-origin': + continue; + default: + if (!useProperties.has(attName)) { + use.attributes[attName] = attValue.toString(); + } + break; + } + } + + // Add translation if necessary. + if (tx !== '0' || ty !== '0') { + const translate = `translate(${tx},${ty})`; + transform = isContainer ? transform + translate : translate + transform; + } + if (transform !== '') { + use.attributes.transform = transform; + } + writeStyleAttribute(use, useProperties); + + use.children = def.children; + use.children.forEach((c) => (c.parentNode = use)); + + return true; +} diff --git a/plugins/plugins-types.d.ts b/plugins/plugins-types.d.ts index 2fc357d..2165c3a 100644 --- a/plugins/plugins-types.d.ts +++ b/plugins/plugins-types.d.ts @@ -190,6 +190,7 @@ export type BuiltinsWithOptionalParams = DefaultPlugins & { convertStyleToAttrs: { keepImportant?: boolean; }; + inlineUse: void; prefixIds: { prefix?: | boolean diff --git a/test/plugins/inlineUse.01.svg.txt b/test/plugins/inlineUse.01.svg.txt new file mode 100644 index 0000000..b14667d --- /dev/null +++ b/test/plugins/inlineUse.01.svg.txt @@ -0,0 +1,21 @@ +Inline symbol. + +=== + + + + + + + + + + +@@@ + + + + + + + diff --git a/test/plugins/inlineUse.02.svg.txt b/test/plugins/inlineUse.02.svg.txt new file mode 100644 index 0000000..4dcacfd --- /dev/null +++ b/test/plugins/inlineUse.02.svg.txt @@ -0,0 +1,21 @@ +Inline symbol, and make sure attribute in symbol takes priority over style in . + +=== + + + + + + + + + + +@@@ + + + + + + + diff --git a/test/plugins/inlineUse.03.svg.txt b/test/plugins/inlineUse.03.svg.txt new file mode 100644 index 0000000..74a6765 --- /dev/null +++ b/test/plugins/inlineUse.03.svg.txt @@ -0,0 +1,21 @@ +Inline symbol, include properties that are not overridden. + +=== + + + + + + + + + + +@@@ + + + + + + + diff --git a/test/plugins/inlineUse.04.svg.txt b/test/plugins/inlineUse.04.svg.txt new file mode 100644 index 0000000..689b899 --- /dev/null +++ b/test/plugins/inlineUse.04.svg.txt @@ -0,0 +1,23 @@ +Don't inline symbol unless overflow="visible". + +=== + + + + + + + + + + +@@@ + + + + + + + + + diff --git a/test/plugins/inlineUse.05.svg.txt b/test/plugins/inlineUse.05.svg.txt new file mode 100644 index 0000000..783a605 --- /dev/null +++ b/test/plugins/inlineUse.05.svg.txt @@ -0,0 +1,21 @@ +If use has a transform, include it as an attribute rather than property. + +=== + + + + + + + + + + +@@@ + + + + + + + diff --git a/test/plugins/inlineUse.06.svg.txt b/test/plugins/inlineUse.06.svg.txt new file mode 100644 index 0000000..dce2b4c --- /dev/null +++ b/test/plugins/inlineUse.06.svg.txt @@ -0,0 +1,17 @@ +Inline path element. + +=== + + + + + + + + +@@@ + + + + + diff --git a/test/plugins/inlineUse.07.svg.txt b/test/plugins/inlineUse.07.svg.txt new file mode 100644 index 0000000..013c567 --- /dev/null +++ b/test/plugins/inlineUse.07.svg.txt @@ -0,0 +1,17 @@ +Inline path element with transform. + +=== + + + + + + + + +@@@ + + + + + diff --git a/test/plugins/inlineUse.08.svg.txt b/test/plugins/inlineUse.08.svg.txt new file mode 100644 index 0000000..a2661d2 --- /dev/null +++ b/test/plugins/inlineUse.08.svg.txt @@ -0,0 +1,17 @@ +Ignore width and height when inlining a path. + +=== + + + + + + + + +@@@ + + + + +