// Parse SVG PathData // http://www.w3.org/TR/SVG/paths.html#PathDataBNF import { COMMAND_ARG_COUNTS, SVGPathData } from "./SVGPathData"; import { TransformableSVG } from "./TransformableSVG"; import { SVGCommand, TransformFunction } from "./types"; // Private consts : Char groups const isWhiteSpace = (c: string) => " " === c || "\t" === c || "\r" === c || "\n" === c; const isDigit = (c: string) => "0".charCodeAt(0) <= c.charCodeAt(0) && c.charCodeAt(0) <= "9".charCodeAt(0); const COMMANDS = "mMzZlLhHvVcCsSqQtTaA"; export class SVGPathDataParser extends TransformableSVG { private curNumber: string = ""; private curCommandType: SVGCommand["type"] | -1 = -1; private curCommandRelative = false; private canParseCommandOrComma = true; private curNumberHasExp = false; private curNumberHasExpDigits = false; private curNumberHasDecimal = false; private curArgs: number[] = []; constructor() { super(); } finish(commands: SVGCommand[] = []) { this.parse(" ", commands); // Adding residual command if (0 !== this.curArgs.length || !this.canParseCommandOrComma) { throw new SyntaxError("Unterminated command at the path end."); } return commands; } parse(str: string, commands: SVGCommand[] = []) { const finishCommand = (command: SVGCommand) => { commands.push(command); this.curArgs.length = 0; this.canParseCommandOrComma = true; }; for (let i = 0; i < str.length; i++) { const c = str[i]; // White spaces parsing const isAArcFlag = this.curCommandType === SVGPathData.ARC && (this.curArgs.length === 3 || this.curArgs.length === 4) && this.curNumber.length === 1 && (this.curNumber === "0" || this.curNumber === "1"); const isEndingDigit = isDigit(c) && ( (this.curNumber === "0" && c === "0") || isAArcFlag ); if ( isDigit(c) && !isEndingDigit ) { this.curNumber += c; this.curNumberHasExpDigits = this.curNumberHasExp; continue; } if ("e" === c || "E" === c) { this.curNumber += c; this.curNumberHasExp = true; continue; } if ( ("-" === c || "+" === c) && this.curNumberHasExp && !this.curNumberHasExpDigits ) { this.curNumber += c; continue; } // if we already have a ".", it means we are starting a new number if ("." === c && !this.curNumberHasExp && !this.curNumberHasDecimal && !isAArcFlag) { this.curNumber += c; this.curNumberHasDecimal = true; continue; } // New number if (this.curNumber && -1 !== this.curCommandType) { const val = Number(this.curNumber); if (isNaN(val)) { throw new SyntaxError(`Invalid number ending at ${i}`); } if (this.curCommandType === SVGPathData.ARC) { if (0 === this.curArgs.length || 1 === this.curArgs.length) { if (0 > val) { throw new SyntaxError( `Expected positive number, got "${val}" at index "${i}"`, ); } } else if (3 === this.curArgs.length || 4 === this.curArgs.length) { if ("0" !== this.curNumber && "1" !== this.curNumber) { throw new SyntaxError( `Expected a flag, got "${this.curNumber}" at index "${i}"`, ); } } } this.curArgs.push(val); if (this.curArgs.length === COMMAND_ARG_COUNTS[this.curCommandType]) { if (SVGPathData.HORIZ_LINE_TO === this.curCommandType) { finishCommand({ type: SVGPathData.HORIZ_LINE_TO, relative: this.curCommandRelative, x: val, }); } else if (SVGPathData.VERT_LINE_TO === this.curCommandType) { finishCommand({ type: SVGPathData.VERT_LINE_TO, relative: this.curCommandRelative, y: val, }); // Move to / line to / smooth quadratic curve to commands (x, y) } else if ( this.curCommandType === SVGPathData.MOVE_TO || this.curCommandType === SVGPathData.LINE_TO || this.curCommandType === SVGPathData.SMOOTH_QUAD_TO ) { finishCommand({ type: this.curCommandType, relative: this.curCommandRelative, x: this.curArgs[0], y: this.curArgs[1], } as SVGCommand); // Switch to line to state if (SVGPathData.MOVE_TO === this.curCommandType) { this.curCommandType = SVGPathData.LINE_TO; } } else if (this.curCommandType === SVGPathData.CURVE_TO) { finishCommand({ type: SVGPathData.CURVE_TO, relative: this.curCommandRelative, x1: this.curArgs[0], y1: this.curArgs[1], x2: this.curArgs[2], y2: this.curArgs[3], x: this.curArgs[4], y: this.curArgs[5], }); } else if (this.curCommandType === SVGPathData.SMOOTH_CURVE_TO) { finishCommand({ type: SVGPathData.SMOOTH_CURVE_TO, relative: this.curCommandRelative, x2: this.curArgs[0], y2: this.curArgs[1], x: this.curArgs[2], y: this.curArgs[3], }); } else if (this.curCommandType === SVGPathData.QUAD_TO) { finishCommand({ type: SVGPathData.QUAD_TO, relative: this.curCommandRelative, x1: this.curArgs[0], y1: this.curArgs[1], x: this.curArgs[2], y: this.curArgs[3], }); } else if (this.curCommandType === SVGPathData.ARC) { finishCommand({ type: SVGPathData.ARC, relative: this.curCommandRelative, rX: this.curArgs[0], rY: this.curArgs[1], xRot: this.curArgs[2], lArcFlag: this.curArgs[3] as 0 | 1, sweepFlag: this.curArgs[4] as 0 | 1, x: this.curArgs[5], y: this.curArgs[6], }); } } this.curNumber = ""; this.curNumberHasExpDigits = false; this.curNumberHasExp = false; this.curNumberHasDecimal = false; this.canParseCommandOrComma = true; } // Continue if a white space or a comma was detected if (isWhiteSpace(c)) { continue; } if ("," === c && this.canParseCommandOrComma) { // L 0,0, H is not valid: this.canParseCommandOrComma = false; continue; } // if a sign is detected, then parse the new number if ("+" === c || "-" === c || "." === c) { this.curNumber = c; this.curNumberHasDecimal = "." === c; continue; } // if a 0 is detected, then parse the new number if (isEndingDigit) { this.curNumber = c; this.curNumberHasDecimal = false; continue; } // Adding residual command if (0 !== this.curArgs.length) { throw new SyntaxError(`Unterminated command at index ${i}.`); } if (!this.canParseCommandOrComma) { throw new SyntaxError( `Unexpected character "${c}" at index ${i}. Command cannot follow comma`, ); } this.canParseCommandOrComma = false; // Detecting the next command if ("z" === c || "Z" === c) { commands.push({ type: SVGPathData.CLOSE_PATH, }); this.canParseCommandOrComma = true; this.curCommandType = -1; continue; // Horizontal move to command } else if ("h" === c || "H" === c) { this.curCommandType = SVGPathData.HORIZ_LINE_TO; this.curCommandRelative = "h" === c; // Vertical move to command } else if ("v" === c || "V" === c) { this.curCommandType = SVGPathData.VERT_LINE_TO; this.curCommandRelative = "v" === c; // Move to command } else if ("m" === c || "M" === c) { this.curCommandType = SVGPathData.MOVE_TO; this.curCommandRelative = "m" === c; // Line to command } else if ("l" === c || "L" === c) { this.curCommandType = SVGPathData.LINE_TO; this.curCommandRelative = "l" === c; // Curve to command } else if ("c" === c || "C" === c) { this.curCommandType = SVGPathData.CURVE_TO; this.curCommandRelative = "c" === c; // Smooth curve to command } else if ("s" === c || "S" === c) { this.curCommandType = SVGPathData.SMOOTH_CURVE_TO; this.curCommandRelative = "s" === c; // Quadratic bezier curve to command } else if ("q" === c || "Q" === c) { this.curCommandType = SVGPathData.QUAD_TO; this.curCommandRelative = "q" === c; // Smooth quadratic bezier curve to command } else if ("t" === c || "T" === c) { this.curCommandType = SVGPathData.SMOOTH_QUAD_TO; this.curCommandRelative = "t" === c; // Elliptic arc command } else if ("a" === c || "A" === c) { this.curCommandType = SVGPathData.ARC; this.curCommandRelative = "a" === c; } else { throw new SyntaxError(`Unexpected character "${c}" at index ${i}.`); } } return commands; } /** * Return a wrapper around this parser which applies the transformation on parsed commands. */ transform(transform: TransformFunction) { const result = Object.create(this, { parse: { value(chunk: string, commands: SVGCommand[] = []) { const parsedCommands = Object.getPrototypeOf(this).parse.call( this, chunk, ); for (const c of parsedCommands) { const cT = transform(c); if (Array.isArray(cT)) { commands.push(...cT); } else { commands.push(cT); } } return commands; }, }, }); return result as this; } }