You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
347 lines
8.2 KiB
347 lines
8.2 KiB
'use strict'; |
|
|
|
/** |
|
* @typedef {import('./types').PathDataItem} PathDataItem |
|
* @typedef {import('./types').PathDataCommand} PathDataCommand |
|
*/ |
|
|
|
// Based on https://www.w3.org/TR/SVG11/paths.html#PathDataBNF |
|
|
|
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, |
|
}; |
|
|
|
/** |
|
* @type {(c: string) => c is PathDataCommand} |
|
*/ |
|
const isCommand = (c) => { |
|
return c in argsCountPerCommand; |
|
}; |
|
|
|
/** |
|
* @type {(c: string) => boolean} |
|
*/ |
|
const isWsp = (c) => { |
|
const codePoint = c.codePointAt(0); |
|
return ( |
|
codePoint === 0x20 || |
|
codePoint === 0x9 || |
|
codePoint === 0xd || |
|
codePoint === 0xa |
|
); |
|
}; |
|
|
|
/** |
|
* @type {(c: string) => boolean} |
|
*/ |
|
const isDigit = (c) => { |
|
const codePoint = c.codePointAt(0); |
|
if (codePoint == null) { |
|
return false; |
|
} |
|
return 48 <= codePoint && codePoint <= 57; |
|
}; |
|
|
|
/** |
|
* @typedef {'none' | 'sign' | 'whole' | 'decimal_point' | 'decimal' | 'e' | 'exponent_sign' | 'exponent'} ReadNumberState |
|
*/ |
|
|
|
/** |
|
* @type {(string: string, cursor: number) => [number, number | null]} |
|
*/ |
|
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]; |
|
} |
|
}; |
|
|
|
/** |
|
* @type {(string: string) => Array<PathDataItem>} |
|
*/ |
|
const parsePathData = (string) => { |
|
/** |
|
* @type {Array<PathDataItem>} |
|
*/ |
|
const pathData = []; |
|
/** |
|
* @type {null | PathDataCommand} |
|
*/ |
|
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 (isWsp(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 { |
|
// stop if previous command arguments are not flushed |
|
if (args.length !== 0) { |
|
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 threated as implicit lineto commands |
|
if (command === 'M') { |
|
command = 'L'; |
|
} |
|
if (command === 'm') { |
|
command = 'l'; |
|
} |
|
args = []; |
|
} |
|
} |
|
return pathData; |
|
}; |
|
exports.parsePathData = parsePathData; |
|
|
|
/** |
|
* @type {(number: number, precision?: number) => string} |
|
*/ |
|
const stringifyNumber = (number, precision) => { |
|
if (precision != null) { |
|
const ratio = 10 ** precision; |
|
number = Math.round(number * ratio) / ratio; |
|
} |
|
// remove zero whole from decimal number |
|
return number.toString().replace(/^0\./, '.').replace(/^-0\./, '-.'); |
|
}; |
|
|
|
/** |
|
* Elliptical arc large-arc and sweep flags are rendered with spaces |
|
* because many non-browser environments are not able to parse such paths |
|
* |
|
* @type {( |
|
* command: string, |
|
* args: number[], |
|
* precision?: number, |
|
* disableSpaceAfterFlags?: boolean |
|
* ) => string} |
|
*/ |
|
const stringifyArgs = (command, args, precision, disableSpaceAfterFlags) => { |
|
let result = ''; |
|
let prev = ''; |
|
for (let i = 0; i < args.length; i += 1) { |
|
const number = args[i]; |
|
const numberString = stringifyNumber(number, precision); |
|
if ( |
|
disableSpaceAfterFlags && |
|
(command === 'A' || command === 'a') && |
|
// consider combined arcs |
|
(i % 7 === 4 || i % 7 === 5) |
|
) { |
|
result += numberString; |
|
} else if (i === 0 || numberString.startsWith('-')) { |
|
// avoid space before first and negative numbers |
|
result += numberString; |
|
} else if (prev.includes('.') && numberString.startsWith('.')) { |
|
// remove space before decimal with zero whole |
|
// only when previous number is also decimal |
|
result += numberString; |
|
} else { |
|
result += ` ${numberString}`; |
|
} |
|
prev = numberString; |
|
} |
|
return result; |
|
}; |
|
|
|
/** |
|
* @typedef {{ |
|
* pathData: Array<PathDataItem>; |
|
* precision?: number; |
|
* disableSpaceAfterFlags?: boolean; |
|
* }} StringifyPathDataOptions |
|
*/ |
|
|
|
/** |
|
* @type {(options: StringifyPathDataOptions) => string} |
|
*/ |
|
const stringifyPathData = ({ pathData, precision, disableSpaceAfterFlags }) => { |
|
// 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 { |
|
/** |
|
* @type {PathDataItem} |
|
*/ |
|
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(command, args, precision, disableSpaceAfterFlags); |
|
} |
|
return result; |
|
}; |
|
exports.stringifyPathData = stringifyPathData;
|
|
|