123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- '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;
|