diff --git a/docs/03-plugins/apply-transforms-shapes.mdx b/docs/03-plugins/apply-transforms-shapes.mdx new file mode 100644 index 000000000..34bbafb7b --- /dev/null +++ b/docs/03-plugins/apply-transforms-shapes.mdx @@ -0,0 +1,31 @@ +--- +title: Apply Transforms to Shapes +svgo: + pluginId: applyTransformsShapes + parameters: + floatPrecision: + description: Number of decimal places to round to, using conventional rounding rules. + default: 3 + leadingZero: + description: If to trim leading zeros. + default: true + defaultPlugin: true +--- + +Applies the `transform` attribute directly to ``, ``, ``, and `` when possible. + +## Usage + + + +### Parameters + + + +## Demo + + + +## Implementation + +- https://github.com/svg/svgo/blob/main/plugins/applyTransformsShapes.js diff --git a/lib/builtin.js b/lib/builtin.js index 36248c12f..a9fddbe8c 100644 --- a/lib/builtin.js +++ b/lib/builtin.js @@ -1,6 +1,7 @@ import presetDefault from '../plugins/preset-default.js'; import * as addAttributesToSVGElement from '../plugins/addAttributesToSVGElement.js'; import * as addClassesToSVGElement from '../plugins/addClassesToSVGElement.js'; +import * as applyTransformsShapes from '../plugins/applyTransformsShapes.js'; import * as cleanupAttrs from '../plugins/cleanupAttrs.js'; import * as cleanupEnableBackground from '../plugins/cleanupEnableBackground.js'; import * as cleanupIds from '../plugins/cleanupIds.js'; @@ -57,6 +58,7 @@ export const builtin = Object.freeze([ presetDefault, addAttributesToSVGElement, addClassesToSVGElement, + applyTransformsShapes, cleanupAttrs, cleanupEnableBackground, cleanupIds, diff --git a/lib/svgo/tools.js b/lib/svgo/tools.js index f56e8d939..514c667ac 100644 --- a/lib/svgo/tools.js +++ b/lib/svgo/tools.js @@ -192,6 +192,28 @@ export const includesUrlReference = (body) => { return new RegExp(regReferencesUrl).test(body); }; +/** + * Checks if changing the position or size of an element would + * cause the element to look different. + * @param {import('../types.js').ComputedStyles} computedStyle + * @returns {boolean} If it's safe to change the position. + */ +export const canChangePosition = (computedStyle) => { + if (computedStyle['marker-start']) return false; + if (computedStyle['marker-mid']) return false; + if (computedStyle['marker-end']) return false; + if (computedStyle['clip-path']) return false; + if (computedStyle['mask']) return false; + if (computedStyle['mask-image']) return false; + for (const name of ['fill', 'filter', 'stroke']) { + const value = computedStyle[name]; + if (value?.type == 'static' && includesUrlReference(value.value)) + return false; + } + + return true; +}; + /** * @param {string} attribute * @param {string} value diff --git a/plugins/applyTransformsShapes.js b/plugins/applyTransformsShapes.js new file mode 100644 index 000000000..d73f89ffc --- /dev/null +++ b/plugins/applyTransformsShapes.js @@ -0,0 +1,291 @@ +import { collectStylesheet, computeStyle } from '../lib/style.js'; +import { + toFixed, + removeLeadingZero, + canChangePosition, +} from '../lib/svgo/tools.js'; +import { transform2js, transformsMultiply } from './_transforms.js'; + +export const name = 'applyTransformsShapes'; +export const description = 'Applies transforms to some shapes.'; + +const APPLICABLE_SHAPES = ['circle', 'ellipse', 'rect', 'image']; + +/** + * Applies transforms to some shapes. + * + * @author Kendell R + * + * @type {import('./plugins-types.js').Plugin<'applyTransformsShapes'>} + */ +export const fn = (root, params) => { + const { floatPrecision = 3, leadingZero = true } = params; + const stylesheet = collectStylesheet(root); + return { + element: { + enter: (node) => { + if ( + !APPLICABLE_SHAPES.includes(node.name) || + !node.attributes.transform + ) { + return; + } + + const computedStyle = computeStyle(stylesheet, node); + if (computedStyle.filter) return; + if (!canChangePosition(computedStyle)) { + return; + } + + const transformStyle = computedStyle.transform; + if ( + transformStyle.type !== 'static' || + transformStyle.value !== node.attributes.transform + ) { + return; + } + const matrix = transformsMultiply( + transform2js(node.attributes.transform), + ); + + if ( + computedStyle.stroke?.type === 'dynamic' || + computedStyle['stroke-width']?.type === 'dynamic' + ) { + return; + } + let strokeWidth = 0; + if (computedStyle['stroke-width']) { + if (!node.attributes['stroke-width']) { + return; + } + strokeWidth = +computedStyle['stroke-width'].value; + } else if ( + computedStyle.stroke && + computedStyle.stroke.value !== 'none' + ) { + strokeWidth = 1; + } + + const isSimilar = + (matrix.data[0] === matrix.data[3] && + matrix.data[1] === -matrix.data[2]) || + (matrix.data[0] === -matrix.data[3] && + matrix.data[1] === matrix.data[2]); + const isLinear = + (matrix.data[0] != 0 && + matrix.data[1] == 0 && + matrix.data[2] == 0 && + matrix.data[3] != 0) || + (matrix.data[0] == 0 && + matrix.data[1] != 0 && + matrix.data[2] != 0 && + matrix.data[3] == 0); + const isTranslation = + matrix.data[0] == 1 && + matrix.data[1] == 0 && + matrix.data[2] == 0 && + matrix.data[3] == 1; + const scale = Math.sqrt( + matrix.data[0] * matrix.data[0] + matrix.data[1] * matrix.data[1], + ); + if (node.name == 'circle') { + if (!isSimilar) return; + + const cx = Number(node.attributes.cx || '0'); + const cy = Number(node.attributes.cy || '0'); + const r = Number(node.attributes.r || '0'); + + const newCenter = transformAbsolutePoint(matrix.data, cx, cy); + if (strokeWidth) { + node.attributes['stroke-width'] = stringifyNumber( + strokeWidth * scale, + floatPrecision, + leadingZero, + ); + } + node.attributes.cx = stringifyNumber( + newCenter[0], + floatPrecision, + leadingZero, + ); + node.attributes.cy = stringifyNumber( + newCenter[1], + floatPrecision, + leadingZero, + ); + node.attributes.r = stringifyNumber( + r * scale, + floatPrecision, + leadingZero, + ); + delete node.attributes.transform; + } else if (node.name == 'ellipse') { + if (!isLinear) return; + if (strokeWidth && !isSimilar) return; + + const cx = Number(node.attributes.cx || '0'); + const cy = Number(node.attributes.cy || '0'); + const rx = Number(node.attributes.rx || '0'); + const ry = Number(node.attributes.ry || '0'); + + const newCenter = transformAbsolutePoint(matrix.data, cx, cy); + const [newRx, newRy] = transformSize(matrix.data, rx, ry); + + if (strokeWidth) { + node.attributes['stroke-width'] = stringifyNumber( + strokeWidth * scale, + floatPrecision, + leadingZero, + ); + } + node.attributes.cx = stringifyNumber( + newCenter[0], + floatPrecision, + leadingZero, + ); + node.attributes.cy = stringifyNumber( + newCenter[1], + floatPrecision, + leadingZero, + ); + node.attributes.rx = stringifyNumber( + newRx, + floatPrecision, + leadingZero, + ); + node.attributes.ry = stringifyNumber( + newRy, + floatPrecision, + leadingZero, + ); + delete node.attributes.transform; + } else if (node.name == 'rect') { + if (!isLinear) return; + if (strokeWidth && !isSimilar) return; + + const x = Number(node.attributes.x || '0'); + const y = Number(node.attributes.y || '0'); + const width = Number(node.attributes.width || '0'); + const height = Number(node.attributes.height || '0'); + let rx = node.attributes.rx ? Number(node.attributes.rx) : NaN; + let ry = node.attributes.ry ? Number(node.attributes.ry) : NaN; + rx = Number.isNaN(rx) ? ry || 0 : rx; + ry = Number.isNaN(ry) ? rx || 0 : ry; + + const cornerA = transformAbsolutePoint(matrix.data, x, y); + const cornerB = transformAbsolutePoint( + matrix.data, + x + width, + y + height, + ); + const cornerX = Math.min(cornerA[0], cornerB[0]); + const cornerY = Math.min(cornerA[1], cornerB[1]); + const [newW, newH] = transformSize(matrix.data, width, height); + const [newRx, newRy] = transformSize(matrix.data, rx, ry); + + if (strokeWidth) { + node.attributes['stroke-width'] = stringifyNumber( + strokeWidth * scale, + floatPrecision, + leadingZero, + ); + } + node.attributes.x = stringifyNumber( + cornerX, + floatPrecision, + leadingZero, + ); + node.attributes.y = stringifyNumber( + cornerY, + floatPrecision, + leadingZero, + ); + node.attributes.width = stringifyNumber( + newW, + floatPrecision, + leadingZero, + ); + node.attributes.height = stringifyNumber( + newH, + floatPrecision, + leadingZero, + ); + if (newRx < 1 / floatPrecision && newRy < 1 / floatPrecision) { + delete node.attributes.rx; + delete node.attributes.ry; + } else if (Math.abs(newRx - newRy) < 1 / floatPrecision) { + node.attributes.rx = stringifyNumber( + newRx, + floatPrecision, + leadingZero, + ); + delete node.attributes.ry; + } else { + node.attributes.rx = stringifyNumber( + newRx, + floatPrecision, + leadingZero, + ); + node.attributes.ry = stringifyNumber( + newRy, + floatPrecision, + leadingZero, + ); + } + delete node.attributes.transform; + } else if (node.name == 'image') { + if (!isTranslation) return; + + const x = Number(node.attributes.x || '0'); + const y = Number(node.attributes.y || '0'); + const corner = transformAbsolutePoint(matrix.data, x, y); + + node.attributes.x = stringifyNumber( + corner[0], + floatPrecision, + leadingZero, + ); + node.attributes.y = stringifyNumber( + corner[1], + floatPrecision, + leadingZero, + ); + delete node.attributes.transform; + } + }, + }, + }; +}; + +/** + * @param {number[]} matrix + * @param {number} x + * @param {number} y + */ +const transformAbsolutePoint = (matrix, x, y) => { + const newX = matrix[0] * x + matrix[2] * y + matrix[4]; + const newY = matrix[1] * x + matrix[3] * y + matrix[5]; + return [newX, newY]; +}; + +/** + * @param {number[]} matrix + * @param {number} w + * @param {number} h + */ +const transformSize = (matrix, w, h) => { + const newW = matrix[0] * w + matrix[1] * h; + const newH = matrix[2] * w + matrix[3] * h; + return [Math.abs(newW), Math.abs(newH)]; +}; + +/** + * @param {number} number + * @param {number} precision + * @param {boolean} leadingZero + */ +const stringifyNumber = (number, precision, leadingZero) => { + const rounded = toFixed(number, precision); + return leadingZero ? removeLeadingZero(rounded) : rounded.toString(); +}; diff --git a/plugins/mergePaths.js b/plugins/mergePaths.js index bcfc294f9..bb4172ae6 100644 --- a/plugins/mergePaths.js +++ b/plugins/mergePaths.js @@ -9,26 +9,11 @@ import { collectStylesheet, computeStyle } from '../lib/style.js'; import { path2js, js2path, intersects } from './_path.js'; -import { includesUrlReference } from '../lib/svgo/tools.js'; +import { canChangePosition } from '../lib/svgo/tools.js'; export const name = 'mergePaths'; export const description = 'merges multiple paths in one if possible'; -/** - * @param {ComputedStyles} computedStyle - * @param {string} attName - * @returns {boolean} - */ -function elementHasUrl(computedStyle, attName) { - const style = computedStyle[attName]; - - if (style?.type === 'static') { - return includesUrlReference(style.value); - } - - return false; -} - /** * Merge multiple Paths into one. * @@ -98,17 +83,7 @@ export const fn = (root, params) => { } const computedStyle = computeStyle(stylesheet, child); - if ( - computedStyle['marker-start'] || - computedStyle['marker-mid'] || - computedStyle['marker-end'] || - computedStyle['clip-path'] || - computedStyle['mask'] || - computedStyle['mask-image'] || - ['fill', 'filter', 'stroke'].some((attName) => - elementHasUrl(computedStyle, attName), - ) - ) { + if (!canChangePosition(computedStyle)) { if (prevPathData) { updatePreviousPath(prevChild, prevPathData); } diff --git a/plugins/plugins-types.d.ts b/plugins/plugins-types.d.ts index bdd5de8ba..99d1ff459 100644 --- a/plugins/plugins-types.d.ts +++ b/plugins/plugins-types.d.ts @@ -5,6 +5,10 @@ import type { } from '../lib/types.js'; type DefaultPlugins = { + applyTransformsShapes: { + floatPrecision?: number; + leadingZero?: boolean; + }; cleanupAttrs: { newlines?: boolean; trim?: boolean; diff --git a/plugins/preset-default.js b/plugins/preset-default.js index 79066b44d..1f1ec40bd 100644 --- a/plugins/preset-default.js +++ b/plugins/preset-default.js @@ -26,6 +26,7 @@ import * as moveGroupAttrsToElems from './moveGroupAttrsToElems.js'; import * as collapseGroups from './collapseGroups.js'; import * as convertPathData from './convertPathData.js'; import * as convertTransform from './convertTransform.js'; +import * as applyTransformsShapes from './applyTransformsShapes.js'; import * as removeEmptyAttrs from './removeEmptyAttrs.js'; import * as removeEmptyContainers from './removeEmptyContainers.js'; import * as mergePaths from './mergePaths.js'; @@ -63,6 +64,7 @@ const presetDefault = createPreset({ moveGroupAttrsToElems, collapseGroups, convertPathData, + applyTransformsShapes, convertTransform, removeEmptyAttrs, removeEmptyContainers, diff --git a/test/plugins/applyTransformsShapes.01.svg.txt b/test/plugins/applyTransformsShapes.01.svg.txt new file mode 100644 index 000000000..d2470ae6d --- /dev/null +++ b/test/plugins/applyTransformsShapes.01.svg.txt @@ -0,0 +1,19 @@ + + + + + + + + + +@@@ + + + + + + + + + diff --git a/test/plugins/applyTransformsShapes.02.svg.txt b/test/plugins/applyTransformsShapes.02.svg.txt new file mode 100644 index 000000000..bd264e303 --- /dev/null +++ b/test/plugins/applyTransformsShapes.02.svg.txt @@ -0,0 +1,19 @@ + + + + + + + + + +@@@ + + + + + + + + + diff --git a/test/plugins/applyTransformsShapes.03.svg.txt b/test/plugins/applyTransformsShapes.03.svg.txt new file mode 100644 index 000000000..769e75daf --- /dev/null +++ b/test/plugins/applyTransformsShapes.03.svg.txt @@ -0,0 +1,19 @@ + + + + + + + + + +@@@ + + + + + + + + + diff --git a/test/plugins/applyTransformsShapes.04.svg.txt b/test/plugins/applyTransformsShapes.04.svg.txt new file mode 100644 index 000000000..114a918c5 --- /dev/null +++ b/test/plugins/applyTransformsShapes.04.svg.txt @@ -0,0 +1,11 @@ + + + + + +@@@ + + + + +