diff --git a/plugins/_applyTransforms.js b/plugins/_applyTransforms.js new file mode 100644 index 00000000..14a80ae3 --- /dev/null +++ b/plugins/_applyTransforms.js @@ -0,0 +1,328 @@ +'use strict'; + +// TODO implement as separate plugin + +const { + transformsMultiply, + transform2js, + transformArc, +} = require('./_transforms.js'); +const { removeLeadingZero } = require('../lib/svgo/tools.js'); +const { referencesProps, attrsGroupsDefaults } = require('./_collections.js'); + +const regNumericValues = /[-+]?(\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g; +const defaultStrokeWidth = attrsGroupsDefaults.presentation['stroke-width']; + +/** + * Apply transformation(s) to the Path data. + * + * @param {Object} elem current element + * @param {Array} path input path data + * @param {Object} params whether to apply transforms to stroked lines and transform precision (used for stroke width) + * @return {Array} output path data + */ +const applyTransforms = (elem, pathData, params) => { + // if there are no 'stroke' attr and references to other objects such as + // gradiends or clip-path which are also subjects to transform. + if ( + !elem.hasAttr('transform') || + !elem.attr('transform').value || + // styles are not considered when applying transform + // can be fixed properly with new style engine + elem.hasAttr('style') || + elem.someAttr( + (attr) => + referencesProps.includes(attr.name) && attr.value.includes('url(') + ) + ) { + return; + } + + const matrix = transformsMultiply(transform2js(elem.attr('transform').value)); + const stroke = elem.computedAttr('stroke'); + const id = elem.computedAttr('id'); + const transformPrecision = params.transformPrecision; + + if (stroke && stroke != 'none') { + if ( + !params.applyTransformsStroked || + ((matrix.data[0] != matrix.data[3] || + matrix.data[1] != -matrix.data[2]) && + (matrix.data[0] != -matrix.data[3] || matrix.data[1] != matrix.data[2])) + ) + return; + + // "stroke-width" should be inside the part with ID, otherwise it can be overrided in + if (id) { + let idElem = elem; + let hasStrokeWidth = false; + + do { + if (idElem.hasAttr('stroke-width')) hasStrokeWidth = true; + } while ( + !idElem.hasAttr('id', id) && + !hasStrokeWidth && + (idElem = idElem.parentNode) + ); + + if (!hasStrokeWidth) return; + } + + const scale = +Math.sqrt( + matrix.data[0] * matrix.data[0] + matrix.data[1] * matrix.data[1] + ).toFixed(transformPrecision); + + if (scale !== 1) { + const strokeWidth = + elem.computedAttr('stroke-width') || defaultStrokeWidth; + + if ( + !elem.hasAttr('vector-effect') || + elem.attr('vector-effect').value !== 'non-scaling-stroke' + ) { + if (elem.hasAttr('stroke-width')) { + elem.attrs['stroke-width'].value = elem.attrs['stroke-width'].value + .trim() + .replace(regNumericValues, (num) => removeLeadingZero(num * scale)); + } else { + elem.addAttr({ + name: 'stroke-width', + value: strokeWidth.replace(regNumericValues, (num) => + removeLeadingZero(num * scale) + ), + }); + } + + if (elem.hasAttr('stroke-dashoffset')) { + elem.attrs['stroke-dashoffset'].value = elem.attrs[ + 'stroke-dashoffset' + ].value + .trim() + .replace(regNumericValues, (num) => removeLeadingZero(num * scale)); + } + + if (elem.hasAttr('stroke-dasharray')) { + elem.attrs['stroke-dasharray'].value = elem.attrs[ + 'stroke-dasharray' + ].value + .trim() + .replace(regNumericValues, (num) => removeLeadingZero(num * scale)); + } + } + } + } else if (id) { + // Stroke and stroke-width can be redefined with + return; + } + + applyMatrixToPathData(pathData, matrix.data); + + // remove transform attr + elem.removeAttr('transform'); + + return; +}; + +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]; +}; + +const transformRelativePoint = (matrix, x, y) => { + const newX = matrix[0] * x + matrix[2] * y; + const newY = matrix[1] * x + matrix[3] * y; + return [newX, newY]; +}; + +const applyMatrixToPathData = (pathData, matrix) => { + let start = [0, 0]; + let cursor = [0, 0]; + + for (const pathItem of pathData) { + let { instruction: command, data: args } = pathItem; + // moveto (x y) + if (command === 'M') { + cursor[0] = args[0]; + cursor[1] = args[1]; + start[0] = cursor[0]; + start[1] = cursor[1]; + const [x, y] = transformAbsolutePoint(matrix, args[0], args[1]); + args[0] = x; + args[1] = y; + } + if (command === 'm') { + cursor[0] += args[0]; + cursor[1] += args[1]; + start[0] = cursor[0]; + start[1] = cursor[1]; + const [x, y] = transformRelativePoint(matrix, args[0], args[1]); + args[0] = x; + args[1] = y; + } + + // horizontal lineto (x) + // convert to lineto to handle two-dimentional transforms + if (command === 'H') { + command = 'L'; + args = [args[0], cursor[1]]; + } + if (command === 'h') { + command = 'l'; + args = [args[0], 0]; + } + + // vertical lineto (y) + // convert to lineto to handle two-dimentional transforms + if (command === 'V') { + command = 'L'; + args = [cursor[0], args[0]]; + } + if (command === 'v') { + command = 'l'; + args = [0, args[0]]; + } + + // lineto (x y) + if (command === 'L') { + cursor[0] = args[0]; + cursor[1] = args[1]; + const [x, y] = transformAbsolutePoint(matrix, args[0], args[1]); + args[0] = x; + args[1] = y; + } + if (command === 'l') { + cursor[0] += args[0]; + cursor[1] += args[1]; + const [x, y] = transformRelativePoint(matrix, args[0], args[1]); + args[0] = x; + args[1] = y; + } + + // curveto (x1 y1 x2 y2 x y) + if (command === 'C') { + cursor[0] = args[4]; + cursor[1] = args[5]; + const [x1, y1] = transformAbsolutePoint(matrix, args[0], args[1]); + const [x2, y2] = transformAbsolutePoint(matrix, args[2], args[3]); + const [x, y] = transformAbsolutePoint(matrix, args[4], args[5]); + args[0] = x1; + args[1] = y1; + args[2] = x2; + args[3] = y2; + args[4] = x; + args[5] = y; + } + if (command === 'c') { + cursor[0] += args[4]; + cursor[1] += args[5]; + const [x1, y1] = transformRelativePoint(matrix, args[0], args[1]); + const [x2, y2] = transformRelativePoint(matrix, args[2], args[3]); + const [x, y] = transformRelativePoint(matrix, args[4], args[5]); + args[0] = x1; + args[1] = y1; + args[2] = x2; + args[3] = y2; + args[4] = x; + args[5] = y; + } + + // smooth curveto (x2 y2 x y) + if (command === 'S') { + cursor[0] = args[2]; + cursor[1] = args[3]; + const [x2, y2] = transformAbsolutePoint(matrix, args[0], args[1]); + const [x, y] = transformAbsolutePoint(matrix, args[2], args[3]); + args[0] = x2; + args[1] = y2; + args[2] = x; + args[3] = y; + } + if (command === 's') { + cursor[0] += args[2]; + cursor[1] += args[3]; + const [x2, y2] = transformRelativePoint(matrix, args[0], args[1]); + const [x, y] = transformRelativePoint(matrix, args[2], args[3]); + args[0] = x2; + args[1] = y2; + args[2] = x; + args[3] = y; + } + + // quadratic Bézier curveto (x1 y1 x y) + if (command === 'Q') { + cursor[0] = args[2]; + cursor[1] = args[3]; + const [x1, y1] = transformAbsolutePoint(matrix, args[0], args[1]); + const [x, y] = transformAbsolutePoint(matrix, args[2], args[3]); + args[0] = x1; + args[1] = y1; + args[2] = x; + args[3] = y; + } + if (command === 'q') { + cursor[0] += args[2]; + cursor[1] += args[3]; + const [x1, y1] = transformRelativePoint(matrix, args[0], args[1]); + const [x, y] = transformRelativePoint(matrix, args[2], args[3]); + args[0] = x1; + args[1] = y1; + args[2] = x; + args[3] = y; + } + + // smooth quadratic Bézier curveto (x y) + if (command === 'T') { + cursor[0] = args[0]; + cursor[1] = args[1]; + const [x, y] = transformAbsolutePoint(matrix, args[0], args[1]); + args[0] = x; + args[1] = y; + } + if (command === 't') { + cursor[0] += args[0]; + cursor[1] += args[1]; + const [x, y] = transformRelativePoint(matrix, args[0], args[1]); + args[0] = x; + args[1] = y; + } + + // elliptical arc (rx ry x-axis-rotation large-arc-flag sweep-flag x y) + if (command === 'A') { + transformArc(cursor, args, matrix); + cursor[0] = args[5]; + cursor[1] = args[6]; + // reduce number of digits in rotation angle + if (Math.abs(args[2]) > 80) { + const a = args[0]; + const rotation = args[2]; + args[0] = args[1]; + args[1] = a; + args[2] = rotation + (rotation > 0 ? -90 : 90); + } + const [x, y] = transformAbsolutePoint(matrix, args[5], args[6]); + args[5] = x; + args[6] = y; + } + if (command === 'a') { + transformArc([0, 0], args, matrix); + cursor[0] += args[5]; + cursor[1] += args[6]; + // reduce number of digits in rotation angle + if (Math.abs(args[2]) > 80) { + const a = args[0]; + const rotation = args[2]; + args[0] = args[1]; + args[1] = a; + args[2] = rotation + (rotation > 0 ? -90 : 90); + } + const [x, y] = transformRelativePoint(matrix, args[5], args[6]); + args[5] = x; + args[6] = y; + } + + pathItem.instruction = command; + pathItem.data = args; + } +}; +exports.applyTransforms = applyTransforms; diff --git a/plugins/_path.js b/plugins/_path.js index 933ab7a5..4793102b 100644 --- a/plugins/_path.js +++ b/plugins/_path.js @@ -2,16 +2,7 @@ const { parsePathData, stringifyPathData } = require('../lib/path.js'); -var regNumericValues = /[-+]?(\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g, - transform2js = require('./_transforms').transform2js, - transformsMultiply = require('./_transforms').transformsMultiply, - transformArc = require('./_transforms').transformArc, - collections = require('./_collections.js'), - referencesProps = collections.referencesProps, - defaultStrokeWidth = - collections.attrsGroupsDefaults.presentation['stroke-width'], - removeLeadingZero = require('../lib/svgo/tools').removeLeadingZero, - prevCtrlPoint; +var prevCtrlPoint; /** * Convert path string to JS representation. @@ -95,216 +86,6 @@ var relative2absolute = (exports.relative2absolute = function (data) { }); }); -/** - * Apply transformation(s) to the Path data. - * - * @param {Object} elem current element - * @param {Array} path input path data - * @param {Object} params whether to apply transforms to stroked lines and transform precision (used for stroke width) - * @return {Array} output path data - */ -exports.applyTransforms = function (elem, path, params) { - // if there are no 'stroke' attr and references to other objects such as - // gradiends or clip-path which are also subjects to transform. - if ( - !elem.hasAttr('transform') || - !elem.attr('transform').value || - // styles are not considered when applying transform - // can be fixed properly with new style engine - elem.hasAttr('style') || - elem.someAttr( - (attr) => - referencesProps.includes(attr.name) && attr.value.includes('url(') - ) - ) { - return path; - } - - var matrix = transformsMultiply(transform2js(elem.attr('transform').value)), - stroke = elem.computedAttr('stroke'), - id = elem.computedAttr('id'), - transformPrecision = params.transformPrecision, - scale; - - if (stroke && stroke != 'none') { - if ( - !params.applyTransformsStroked || - ((matrix.data[0] != matrix.data[3] || - matrix.data[1] != -matrix.data[2]) && - (matrix.data[0] != -matrix.data[3] || matrix.data[1] != matrix.data[2])) - ) - return path; - - // "stroke-width" should be inside the part with ID, otherwise it can be overrided in - if (id) { - var idElem = elem, - hasStrokeWidth = false; - - do { - if (idElem.hasAttr('stroke-width')) hasStrokeWidth = true; - } while ( - !idElem.hasAttr('id', id) && - !hasStrokeWidth && - (idElem = idElem.parentNode) - ); - - if (!hasStrokeWidth) return path; - } - - scale = +Math.sqrt( - matrix.data[0] * matrix.data[0] + matrix.data[1] * matrix.data[1] - ).toFixed(transformPrecision); - - if (scale !== 1) { - var strokeWidth = elem.computedAttr('stroke-width') || defaultStrokeWidth; - - if ( - !elem.hasAttr('vector-effect') || - elem.attr('vector-effect').value !== 'non-scaling-stroke' - ) { - if (elem.hasAttr('stroke-width')) { - elem.attrs['stroke-width'].value = elem.attrs['stroke-width'].value - .trim() - .replace(regNumericValues, function (num) { - return removeLeadingZero(num * scale); - }); - } else { - elem.addAttr({ - name: 'stroke-width', - value: strokeWidth.replace(regNumericValues, function (num) { - return removeLeadingZero(num * scale); - }), - }); - } - - if (elem.hasAttr('stroke-dashoffset')) { - elem.attrs['stroke-dashoffset'].value = elem.attrs[ - 'stroke-dashoffset' - ].value - .trim() - .replace(regNumericValues, (num) => removeLeadingZero(num * scale)); - } - - if (elem.hasAttr('stroke-dasharray')) { - elem.attrs['stroke-dasharray'].value = elem.attrs[ - 'stroke-dasharray' - ].value - .trim() - .replace(regNumericValues, (num) => removeLeadingZero(num * scale)); - } - } - } - } else if (id) { - // Stroke and stroke-width can be redefined with - return path; - } - - let lastMovetoCoords = [0, 0]; - - path.forEach(function (pathItem) { - // h -> l - if (pathItem.instruction === 'h') { - pathItem.instruction = 'l'; - pathItem.data[1] = 0; - - // v -> l - } else if (pathItem.instruction === 'v') { - pathItem.instruction = 'l'; - pathItem.data[1] = pathItem.data[0]; - pathItem.data[0] = 0; - } - - // if there is a translate() transform - if (pathItem.instruction === 'M') { - // then apply it only to the first absoluted M - const newPoint = transformPoint( - matrix.data, - pathItem.data[0], - pathItem.data[1] - ); - pathItem.data[0] = newPoint[0]; - pathItem.data[1] = newPoint[1]; - pathItem.coords[0] = newPoint[0]; - pathItem.coords[1] = newPoint[1]; - lastMovetoCoords[0] = pathItem.coords[0]; - lastMovetoCoords[1] = pathItem.coords[1]; - // clear translate() data from transform matrix - matrix.data[4] = 0; - matrix.data[5] = 0; - } else if (pathItem.instruction === 'm') { - const newPoint = transformPoint( - matrix.data, - pathItem.data[0], - pathItem.data[1] - ); - pathItem.data[0] = newPoint[0]; - pathItem.data[1] = newPoint[1]; - pathItem.coords[0] = pathItem.base[0] + newPoint[0]; - pathItem.coords[1] = pathItem.base[1] + newPoint[1]; - lastMovetoCoords[0] = pathItem.coords[0]; - lastMovetoCoords[1] = pathItem.coords[1]; - } else if (pathItem.instruction === 'Z' || pathItem.instruction === 'z') { - pathItem.coords[0] = lastMovetoCoords[0]; - pathItem.coords[1] = lastMovetoCoords[1]; - } else { - if (pathItem.instruction == 'a') { - transformArc(pathItem.data, matrix.data); - - // reduce number of digits in rotation angle - if (Math.abs(pathItem.data[2]) > 80) { - var a = pathItem.data[0], - rotation = pathItem.data[2]; - pathItem.data[0] = pathItem.data[1]; - pathItem.data[1] = a; - pathItem.data[2] = rotation + (rotation > 0 ? -90 : 90); - } - - const newPoint = transformPoint( - matrix.data, - pathItem.data[5], - pathItem.data[6] - ); - pathItem.data[5] = newPoint[0]; - pathItem.data[6] = newPoint[1]; - } else { - for (var i = 0; i < pathItem.data.length; i += 2) { - const newPoint = transformPoint( - matrix.data, - pathItem.data[i], - pathItem.data[i + 1] - ); - pathItem.data[i] = newPoint[0]; - pathItem.data[i + 1] = newPoint[1]; - } - } - - pathItem.coords[0] = - pathItem.base[0] + pathItem.data[pathItem.data.length - 2]; - pathItem.coords[1] = - pathItem.base[1] + pathItem.data[pathItem.data.length - 1]; - } - }); - - // remove transform attr - elem.removeAttr('transform'); - - return path; -}; - -/** - * Apply transform 3x3 matrix to x-y point. - * - * @param {Array} matrix transform 3x3 matrix - * @param {Array} point x-y point - * @return {Array} point with new coordinates - */ -function transformPoint(matrix, x, y) { - return [ - matrix[0] * x + matrix[2] * y + matrix[4], - matrix[1] * x + matrix[3] * y + matrix[5], - ]; -} - /** * Compute Cubic Bézie bounding box. * diff --git a/plugins/_transforms.js b/plugins/_transforms.js index 699d5f81..b615292b 100644 --- a/plugins/_transforms.js +++ b/plugins/_transforms.js @@ -261,19 +261,22 @@ function transformToMatrix(transform) { * rotate(θ)·scale(a b)·rotate(φ). This gives us new ellipse params a, b and θ. * SVD is being done with the formulae provided by Wolffram|Alpha (svd {{m0, m2}, {m1, m3}}) * + * @param {Array} cursor [x, y] * @param {Array} arc [a, b, rotation in deg] * @param {Array} transform transformation matrix * @return {Array} arc transformed input arc */ -exports.transformArc = function (arc, transform) { +exports.transformArc = function (cursor, arc, transform) { + const x = arc[5] - cursor[0]; + const y = arc[6] - cursor[1]; var a = arc[0], b = arc[1], rot = (arc[2] * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot), h = - Math.pow(arc[5] * cos + arc[6] * sin, 2) / (4 * a * a) + - Math.pow(arc[6] * cos - arc[5] * sin, 2) / (4 * b * b); + Math.pow(x * cos + y * sin, 2) / (4 * a * a) + + Math.pow(y * cos - x * sin, 2) / (4 * b * b); if (h > 1) { h = Math.sqrt(h); a *= h; diff --git a/plugins/convertPathData.js b/plugins/convertPathData.js index 985a779b..780bdd28 100644 --- a/plugins/convertPathData.js +++ b/plugins/convertPathData.js @@ -2,7 +2,8 @@ const { computeStyle } = require('../lib/style.js'); const { pathElems } = require('./_collections.js'); -const { path2js, js2path, applyTransforms } = require('./_path.js'); +const { path2js, js2path } = require('./_path.js'); +const { applyTransforms } = require('./_applyTransforms.js'); const { cleanupOutData } = require('../lib/svgo/tools'); exports.type = 'perItem'; @@ -82,12 +83,12 @@ exports.fn = function (item, params) { // TODO: get rid of functions returns if (data.length) { - convertToRelative(data); - if (params.applyTransforms) { - data = applyTransforms(item, data, params); + applyTransforms(item, data, params); } + convertToRelative(data); + data = filters(data, params, { maybeHasStrokeAndLinecap, hasMarkerMid, @@ -257,7 +258,7 @@ const convertToRelative = (pathData) => { // closepath if (command === 'Z' || command === 'z') { - // reset current cursor + // reset cursor cursor[0] = start[0]; cursor[1] = start[1]; } diff --git a/test/plugins/convertPathData.24.svg b/test/plugins/convertPathData.24.svg new file mode 100644 index 00000000..4b88b65e --- /dev/null +++ b/test/plugins/convertPathData.24.svg @@ -0,0 +1,17 @@ +Apply transforms + +- both absolute and relative arcs should be transformed correctly + +=== + + + + + + +@@@ + + + + +