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

Copy all functions exported by _path.js and path.js into minifyPathData.js #12

Merged
merged 3 commits into from
Sep 22, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 321 additions & 3 deletions plugins/minifyPathData.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
import { path2js, js2path } from './_path.js';
import { pathElems } from './_collections.js';
import { cleanupOutData, exactAdd } from '../lib/svgo/tools.js';
import { cleanupOutData, exactAdd, minifyNumber } from '../lib/svgo/tools.js';

/**
* @typedef {import('../lib/types.js').PathDataItem} PathDataItem
* @typedef {import('../lib/types.js').PathDataCommand} PathDataCommand
* @typedef {'none' | 'sign' | 'whole' | 'decimal_point' | 'decimal' | 'e' | 'exponent_sign' | 'exponent'} ReadNumberState
* @typedef {import('../lib/types.js').PathDataItem&{base?:number[],coords?:[number,number]}} InitialExtendedPathDataItem
* @typedef {import('../lib/types.js').PathDataItem&{base:number[],coords:[number,number]}} ExtendedPathDataItem
*/

export const name = 'minifyPathData';
export const description = 'writes path data in shortest form';

const argsCountPerCommand = {
M: 2,
m: 2,
Z: 0,
z: 0,
L: 2,
l: 2,
H: 1,
h: 1,
V: 1,
v: 1,
C: 6,
c: 6,
S: 4,
s: 4,
Q: 4,
q: 4,
T: 2,
t: 2,
A: 7,
a: 7,
};

/**
* @see https://www.w3.org/TR/SVG11/paths.html#PathData
*
Expand All @@ -36,7 +60,7 @@ export const fn = (root, params, info) => {
data = filterCommands(data, computedStyle);
data = convertToMixed(data);
if (data.length) {
js2path(node, data, {});
node.attributes.d = stringifyPathData(data);
}
}
},
Expand Down Expand Up @@ -347,3 +371,297 @@ function filterCommands(pathData, styles) {
return true;
});
}

/**
* @type {(c: string) => c is PathDataCommand}
*/
const isCommand = (c) => {
return c in argsCountPerCommand;
};

/**
* @param {string} c
* @returns {boolean}
*/
const isDigit = (c) => {
const codePoint = c.codePointAt(0);
if (codePoint == null) {
return false;
}
return 48 <= codePoint && codePoint <= 57;
};

/**
* @param {string} c
* @returns {boolean}
*/
const isWhiteSpace = (c) => {
return c === ' ' || c === '\t' || c === '\r' || c === '\n';
};

/**
* @param {string} string
* @returns {PathDataItem[]}
*/
export const parsePathData = (string) => {
/**
* @type {PathDataItem[]}
*/
const pathData = [];
/**
* @type {PathDataCommand|null}
*/
let command = null;
let args = /** @type {number[]} */ ([]);
let argsCount = 0;
let canHaveComma = false;
let hadComma = false;
for (let i = 0; i < string.length; i += 1) {
const c = string.charAt(i);
if (isWhiteSpace(c)) {
continue;
}
// allow comma only between arguments
if (canHaveComma && c === ',') {
if (hadComma) {
break;
}
hadComma = true;
continue;
}
if (isCommand(c)) {
if (hadComma) {
return pathData;
}
if (command == null) {
// moveto should be leading command
if (c !== 'M' && c !== 'm') {
return pathData;
}
} else if (args.length !== 0) {
// stop if previous command arguments are not flushed
return pathData;
}
command = c;
args = [];
argsCount = argsCountPerCommand[command];
canHaveComma = false;
// flush command without arguments
if (argsCount === 0) {
pathData.push({ command, args });
}
continue;
}
// avoid parsing arguments if no command detected
if (command == null) {
return pathData;
}
// read next argument
let newCursor = i;
let number = null;
if (command === 'A' || command === 'a') {
const position = args.length;
if (position === 0 || position === 1) {
// allow only positive number without sign as first two arguments
if (c !== '+' && c !== '-') {
[newCursor, number] = readNumber(string, i);
}
}
if (position === 2 || position === 5 || position === 6) {
[newCursor, number] = readNumber(string, i);
}
if (position === 3 || position === 4) {
// read flags
if (c === '0') {
number = 0;
}
if (c === '1') {
number = 1;
}
}
} else {
[newCursor, number] = readNumber(string, i);
}
if (number == null) {
return pathData;
}
args.push(number);
canHaveComma = true;
hadComma = false;
i = newCursor;
// flush arguments when necessary count is reached
if (args.length === argsCount) {
pathData.push({ command, args });
// subsequent moveto coordinates are treated as implicit lineto commands
if (command === 'M') {
command = 'L';
}
if (command === 'm') {
command = 'l';
}
args = [];
}
}
return pathData;
};

/**
* @param {import('../lib/types.js').XastElement} path
* @returns {PathDataItem[]}
*/
function path2js(path) {
/**
* @type {PathDataItem[]}
*/
const pathData = []; // JS representation of the path data
const newPathData = parsePathData(path.attributes.d);
for (const { command, args } of newPathData) {
pathData.push({ command, args });
}
// First moveto is actually absolute. Subsequent coordinates were separated above.
if (pathData.length && pathData[0].command == 'm') {
pathData[0].command = 'M';
}
return pathData;
}

/**
* @type {(string: string, cursor: number) => [number, ?number]}
*/
const readNumber = (string, cursor) => {
let i = cursor;
let value = '';
let state = /** @type {ReadNumberState} */ ('none');
for (; i < string.length; i += 1) {
const c = string[i];
if (c === '+' || c === '-') {
if (state === 'none') {
state = 'sign';
value += c;
continue;
}
if (state === 'e') {
state = 'exponent_sign';
value += c;
continue;
}
}
if (isDigit(c)) {
if (state === 'none' || state === 'sign' || state === 'whole') {
state = 'whole';
value += c;
continue;
}
if (state === 'decimal_point' || state === 'decimal') {
state = 'decimal';
value += c;
continue;
}
if (state === 'e' || state === 'exponent_sign' || state === 'exponent') {
state = 'exponent';
value += c;
continue;
}
}
if (c === '.') {
if (state === 'none' || state === 'sign' || state === 'whole') {
state = 'decimal_point';
value += c;
continue;
}
}
if (c === 'E' || c == 'e') {
if (
state === 'whole' ||
state === 'decimal_point' ||
state === 'decimal'
) {
state = 'e';
value += c;
continue;
}
}
break;
}
const number = Number.parseFloat(value);
if (Number.isNaN(number)) {
return [cursor, null];
} else {
// step back to delegate iteration to parent loop
return [i - 1, number];
}
};

/**
* @param {PathDataItem[]} pathData
* @returns {string}
*/
function stringifyPathData(pathData) {
if (pathData.length === 1) {
const { command, args } = pathData[0];
return command + stringifyArgs(command, args);
}

const commands = [];
let prev = { ...pathData[0] };

// match leading moveto with following lineto
if (pathData[1].command === 'L') {
prev.command = 'M';
} else if (pathData[1].command === 'l') {
prev.command = 'm';
}

for (let i = 1; i < pathData.length; i++) {
const { command, args } = pathData[i];
if (
(prev.command === command &&
prev.command !== 'M' &&
prev.command !== 'm') ||
// combine matching moveto and lineto sequences
(prev.command === 'M' && command === 'L') ||
(prev.command === 'm' && command === 'l')
) {
prev.args = [...prev.args, ...args];
if (i === pathData.length - 1) {
commands.push(prev.command + stringifyArgs(prev.command, prev.args));
}
} else {
commands.push(prev.command + stringifyArgs(prev.command, prev.args));

if (i === pathData.length - 1) {
commands.push(command + stringifyArgs(command, args));
} else {
prev = { command, args };
}
}
}

return commands.join('');
}

/**
* @param {string} command
* @param {number[]} args
*/
function stringifyArgs(command, args) {
let result = '';
let previous;

for (let i = 0; i < args.length; i++) {
const roundedStr = minifyNumber(args[i]);
if (i === 0 || args[i] < 0) {
// avoid space before first and negative numbers
result += roundedStr;
} else if (!Number.isInteger(previous) && !isDigit(roundedStr[0])) {
// remove space before decimal with zero whole
// only when previous number is also decimal
result += roundedStr;
} else {
result += ` ${roundedStr}`;
}
previous = args[i];
}

return result;
}