Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement path stringify #1387

Merged
merged 5 commits into from Mar 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
78 changes: 78 additions & 0 deletions lib/path.js
Expand Up @@ -208,3 +208,81 @@ const parsePathData = (string) => {
return pathData;
};
exports.parsePathData = parsePathData;

const stringifyNumber = ({ number, precision }) => {
let result;
if (precision == null) {
result = number.toString();
} else {
result = number.toFixed(precision);
if (result.includes('.')) {
result = result.replace(/\.?0+$/, '')
}
}
// remove zero whole from decimal number
if (result !== '0') {
result = result.replace(/^0/, '').replace(/^-0/, '-');
}
return result;
};

// elliptical arc large-arc and sweep flags are rendered with spaces
// because many non-browser environments are not able to parse such paths
const stringifyArgs = ({ args, precision }) => {
let result = '';
let prev;
for (let i = 0; i < args.length; i += 1) {
const number = args[i];
const numberString = stringifyNumber({ number, precision });
// avoid space before first and negative numbers
if (i === 0 || numberString.startsWith('-')) {
result += numberString;
} else if (prev.includes('.') && numberString.startsWith('.')) {
result += numberString;
} else {
result += ` ${numberString}`;
}
prev = numberString;
}
return result;
};

const stringifyPathData = ({ pathData, precision }) => {
// combine sequence of the same commands
let combined = [];
for (let i = 0; i < pathData.length; i += 1) {
const { command, args } = pathData[i];
if (i === 0) {
combined.push({ command, args });
} else {
const last = combined[combined.length - 1];
// match leading moveto with following lineto
if (i === 1) {
if (command === 'L') {
last.command = 'M';
}
if (command === 'l') {
last.command = 'm';
}
}
if (
(last.command === command &&
last.command !== 'M' &&
last.command !== 'm') ||
// combine matching moveto and lineto sequences
(last.command === 'M' && command === 'L') ||
(last.command === 'm' && command === 'l')
) {
last.args = [...last.args, ...args];
} else {
combined.push({ command, args });
}
}
}
let result = '';
for (const { command, args } of combined) {
result += command + stringifyArgs({ args, precision });
}
return result;
};
exports.stringifyPathData = stringifyPathData;
87 changes: 86 additions & 1 deletion lib/path.test.js
@@ -1,7 +1,7 @@
'use strict';

const { expect } = require('chai');
const { parsePathData } = require('./path.js');
const { parsePathData, stringifyPathData } = require('./path.js');

describe('parse path data', () => {
it('should allow spaces between commands', () => {
Expand Down Expand Up @@ -74,3 +74,88 @@ describe('parse path data', () => {
]);
});
});

describe('stringify path data', () => {
it('should combine sequence of the same commands', () => {
expect(
stringifyPathData({
pathData: [
{ command: 'M', args: [0, 0] },
{ command: 'h', args: [10] },
{ command: 'h', args: [20] },
{ command: 'h', args: [30] },
{ command: 'H', args: [40] },
{ command: 'H', args: [50] },
],
})
).to.equal('M0 0h10 20 30H40 50');
});
it('should not combine sequence of moveto', () => {
expect(
stringifyPathData({
pathData: [
{ command: 'M', args: [0, 0] },
{ command: 'M', args: [10, 10] },
{ command: 'm', args: [20, 30] },
{ command: 'm', args: [40, 50] },
],
})
).to.equal('M0 0M10 10m20 30m40 50');
});
it('should combine moveto and sequence of lineto', () => {
expect(
stringifyPathData({
pathData: [
{ command: 'M', args: [0, 0] },
{ command: 'l', args: [10, 10] },
{ command: 'M', args: [0, 0] },
{ command: 'l', args: [10, 10] },
{ command: 'M', args: [0, 0] },
{ command: 'L', args: [10, 10] },
],
})
).to.equal('m0 0 10 10M0 0l10 10M0 0 10 10');
expect(
stringifyPathData({
pathData: [
{ command: 'm', args: [0, 0] },
{ command: 'L', args: [10, 10] },
],
})
).to.equal('M0 0 10 10');
});
it('should avoid space before first, negative and decimals', () => {
expect(
stringifyPathData({
pathData: [
{ command: 'M', args: [0, -1.2] },
{ command: 'L', args: [0.3, 4] },
{ command: 'L', args: [5, -0.6] },
{ command: 'L', args: [7, 0.8] },
],
})
).to.equal('M0-1.2.3 4 5-.6 7 .8');
});
it('should configure precision', () => {
expect(
stringifyPathData({
pathData: [
{ command: 'M', args: [0, -1.9876] },
{ command: 'L', args: [0.3, 3.14159265] },
{ command: 'L', args: [100, 200] },
],
precision: 3,
})
).to.equal('M0-1.988.3 3.142 100 200');
expect(
stringifyPathData({
pathData: [
{ command: 'M', args: [0, -1.9876] },
{ command: 'L', args: [0.3, 3.14159265] },
{ command: 'L', args: [100, 200] },
],
precision: 0,
})
).to.equal('M0-2 0 3 100 200');
});
});
79 changes: 23 additions & 56 deletions plugins/_path.js
@@ -1,6 +1,6 @@
'use strict';

const { parsePathData } = require('../lib/path.js');
const { parsePathData, stringifyPathData } = require('../lib/path.js');

var regNumericValues = /[-+]?(\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g,
transform2js = require('./_transforms').transform2js,
Expand All @@ -9,7 +9,6 @@ var regNumericValues = /[-+]?(\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g,
collections = require('./_collections.js'),
referencesProps = collections.referencesProps,
defaultStrokeWidth = collections.attrsGroupsDefaults.presentation['stroke-width'],
cleanupOutData = require('../lib/svgo/tools').cleanupOutData,
removeLeadingZero = require('../lib/svgo/tools').removeLeadingZero,
prevCtrlPoint;

Expand Down Expand Up @@ -500,64 +499,32 @@ function computeQuadraticFirstDerivativeRoot(a, b, c) {
*/
exports.js2path = function(path, data, params) {

path.pathJS = data;

if (params.collapseRepeated) {
data = collapseRepeated(data);
path.pathJS = data;

const pathData = [];
for (const item of data) {
// remove moveto commands which are followed by moveto commands
if (
pathData.length !== 0 &&
(item.instruction === 'M' || item.instruction === 'm')
) {
const last = pathData[pathData.length - 1];
if (last.command === 'M' || last.command === 'm') {
pathData.pop();
}
}
pathData.push({
command: item.instruction,
args: item.data || [],
});
}

path.attr('d').value = data.reduce(function(pathString, item) {
var strData = '';
if (item.data) {
strData = cleanupOutData(item.data, params, item.instruction);
}
return pathString += item.instruction + strData;
}, '');

path.attr('d').value = stringifyPathData({
pathData,
precision: params.floatPrecision,
});
};

/**
* Collapse repeated instructions data
*
* @param {Array} path input path data
* @return {Array} output path data
*/
function collapseRepeated(data) {

var prev,
prevIndex;

// copy an array and modifieds item to keep original data untouched
data = data.reduce(function(newPath, item) {
if (
prev && item.data &&
item.instruction == prev.instruction
) {
// concat previous data with current
if (item.instruction != 'M') {
prev = newPath[prevIndex] = {
instruction: prev.instruction,
data: prev.data.concat(item.data),
coords: item.coords,
base: prev.base
};
} else {
prev.data = item.data;
prev.coords = item.coords;
}
} else {
newPath.push(item);
prev = item;
prevIndex = newPath.length - 1;
}

return newPath;
}, []);

return data;

}

function set(dest, source) {
dest[0] = source[source.length - 2];
dest[1] = source[source.length - 1];
Expand Down
4 changes: 2 additions & 2 deletions test/plugins/convertPathData.01.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/plugins/convertPathData.02.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions test/plugins/convertPathData.03.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/plugins/convertPathData.04.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions test/plugins/convertPathData.05.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions test/plugins/convertPathData.06.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions test/plugins/convertPathData.08.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/plugins/convertPathData.10.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.