Skip to content

Commit

Permalink
Copy all functions exported by _path.js and path.js into minifyPathDa…
Browse files Browse the repository at this point in the history
…ta.js (#12)

* Move all stringifying functions into plugin.
  • Loading branch information
johnkenny54 authored Sep 22, 2024
1 parent 0d79f28 commit bfaca84
Showing 1 changed file with 321 additions and 3 deletions.
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;
}

0 comments on commit bfaca84

Please sign in to comment.