348 lines
		
	
	
		
			8.2 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			348 lines
		
	
	
		
			8.2 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| '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;
 |