/// SVG Path specification parser import '../common/point.dart'; import 'path.dart'; const _commands = { 'M', 'm', 'Z', 'z', 'L', 'l', 'H', 'h', 'V', 'v', 'C', 'c', 'S', 's', 'Q', 'q', 'T', 't', 'A', 'a' }; // const _uppercaseCommands = {'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A'}; final _commandPattern = RegExp("(?=[${_commands.join('')}])"); final _floatPattern = RegExp(r"^[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?"); class ParserResult<T> { final T value; final String remaining; const ParserResult({required this.value, required this.remaining}); } class InvalidPathError implements Exception { final String msg; const InvalidPathError(this.msg); @override String toString() => 'InvalidPathError: $msg'; } // The argument sequences from the grammar, made sane. // u: Non-negative number // s: Signed number or coordinate // c: coordinate-pair, which is two coordinates/numbers, separated by whitespace // f: A one character flag, doesn't need whitespace, 1 or 0 const _argumentSequence = { "M": "c", "Z": "", "L": "c", "H": "s", "V": "s", "C": "ccc", "S": "cc", "Q": "cc", "T": "c", "A": "uusffc", }; /// Strips whitespace and commas String _stripArray(String stringToParse) { // EBNF wsp:(#x20 | #x9 | #xD | #xA) + comma: 0x2C while (stringToParse.isNotEmpty && ' \t\n\r,'.contains(stringToParse[0])) { stringToParse = stringToParse.substring(1); } return stringToParse; } ParserResult<num> _parseNumber(String stringToParse) { final res = _floatPattern.firstMatch(stringToParse); if (res == null) { throw InvalidPathError("Expected a number, got '$stringToParse'."); } final number = num.parse(res.group(0)!); final start = res.start; final end = res.end; stringToParse = stringToParse.substring(0, start) + stringToParse.substring(end); stringToParse = _stripArray(stringToParse); return ParserResult(value: number, remaining: stringToParse); } ParserResult<num> _parseUnsignedNumber(String stringToParse) { final number = _parseNumber(stringToParse); if (number.value < 0) { throw InvalidPathError("Expected a non-negative number, got '$number'."); } return number; } ParserResult<Point> _parseCoordinatePair(String stringToParse) { final x = _parseNumber(stringToParse); final y = _parseNumber(x.remaining); return ParserResult(value: Point(x.value, y.value), remaining: y.remaining); } ParserResult<bool> _parseflag(String stringToParse) { final flag = stringToParse[0]; stringToParse = stringToParse.substring(1); stringToParse = _stripArray(stringToParse); if (flag == '0') return ParserResult(value: false, remaining: stringToParse); if (flag == '1') return ParserResult(value: true, remaining: stringToParse); throw InvalidPathError("Expected either 1 or 0, got '$flag'"); } const fieldParsers = { "u": _parseUnsignedNumber, "s": _parseNumber, "c": _parseCoordinatePair, "f": _parseflag, }; class Command { final String command; final String args; const Command({required this.command, required this.args}); @override bool operator ==(Object other) => other is Command && command == other.command && args == other.args; @override int get hashCode => command.hashCode ^ args.hashCode; @override String toString() => '$command $args'; } // Splits path into commands and arguments List<Command> commandifyPath(String pathdef) { List<Command> tokens = []; List<String> token = []; for (String c in pathdef.split(_commandPattern)) { String x = c[0]; String? y = (c.length > 1) ? c.substring(1).trim() : null; if (!_commands.contains(x)) { throw InvalidPathError("Path does not start with a command: $pathdef"); } if (token.isNotEmpty) { tokens.add(Command(command: token[0], args: token[1])); // yield token; } if (x == "z" || x == "Z") { // The end command takes no arguments, so add a blank one token = [x, ""]; } else { token = [x]; } if (y != null) { token.add(y); } } tokens.add(Command(command: token[0], args: token[1])); // yield token; return tokens; } class Token { final String command; final List<Object> args; const Token({required this.command, required this.args}); @override bool operator ==(Object other) => other is Token && command == other.command && args.length == other.args.length && ![for (int i = 0; i < args.length; i++) args[i] == other.args[i]] .any((b) => !b); @override int get hashCode => command.hashCode ^ args.hashCode; @override String toString() => '$command ($args)'; } List<Token> tokenizePath(String pathdef) { List<Token> tokens = []; for (final token in commandifyPath(pathdef)) { String command = token.command; String args = token.args; // Shortcut this for the close command, that doesn't have arguments: if (command == "z" || command == "Z") { tokens.add(Token(command: command, args: [])); continue; } // For the rest of the commands, we parse the arguments and // yield one command per full set of arguments final String stringToParse = _argumentSequence[command.toUpperCase()]!; String arguments = args; while (arguments.isNotEmpty) { final List<Object> commandArguments = []; for (final arg in stringToParse.split('')) { try { final result = fieldParsers[arg]!.call(arguments); arguments = result.remaining; commandArguments.add(result.value); } on InvalidPathError { throw InvalidPathError("Invalid path element $command $args"); } } tokens.add(Token(command: command, args: commandArguments)); // Implicit Moveto commands should be treated as Lineto commands. if (command == "m") { command = "l"; } else if (command == "M") { command = "L"; } } } return tokens; } Path parsePath(String pathdef) { final segments = Path(); Point? startPos; String? lastCommand; Point currentPos = Point.zero; for (final token in tokenizePath(pathdef)) { final command = token.command.toUpperCase(); final absolute = token.command.toUpperCase() == token.command; if (command == "M") { final pos = token.args[0] as Point; if (absolute) { currentPos = pos; } else { currentPos += pos; } segments.add(Move(to: currentPos)); startPos = currentPos; } else if (command == "Z") { // TODO Throw error if not available: segments.add(Close(start: currentPos, end: startPos!)); currentPos = startPos; } else if (command == "L") { Point pos = token.args[0] as Point; if (!absolute) { pos += currentPos; } segments.add(Line(start: currentPos, end: pos)); currentPos = pos; } else if (command == "H") { num hpos = token.args[0] as num; if (!absolute) { hpos += currentPos.x; } final pos = Point(hpos, currentPos.y); segments.add(Line(start: currentPos, end: pos)); currentPos = pos; } else if (command == "V") { num vpos = token.args[0] as num; if (!absolute) { vpos += currentPos.y; } final pos = Point(currentPos.x, vpos); segments.add(Line(start: currentPos, end: pos)); currentPos = pos; } else if (command == "C") { Point control1 = token.args[0] as Point; Point control2 = token.args[1] as Point; Point end = token.args[2] as Point; if (!absolute) { control1 += currentPos; control2 += currentPos; end += currentPos; } segments.add( CubicBezier( start: currentPos, control1: control1, control2: control2, end: end, ), ); currentPos = end; } else if (command == "S") { // Smooth curve. First control point is the "reflection" of // the second control point in the previous path. Point control2 = token.args[0] as Point; Point end = token.args[1] as Point; if (!absolute) { control2 += currentPos; end += currentPos; } late final Point control1; if (lastCommand == 'C' || lastCommand == 'S') { // The first control point is assumed to be the reflection of // the second control point on the previous command relative // to the current point. control1 = currentPos + currentPos - (segments.last as CubicBezier).control2; } else { // If there is no previous command or if the previous command // was not an C, c, S or s, assume the first control point is // coincident with the current point. control1 = currentPos; } segments.add( CubicBezier( start: currentPos, control1: control1, control2: control2, end: end, ), ); currentPos = end; } else if (command == "Q") { Point control = token.args[0] as Point; Point end = token.args[1] as Point; if (!absolute) { control += currentPos; end += currentPos; } segments.add( QuadraticBezier(start: currentPos, control: control, end: end), ); currentPos = end; } else if (command == "T") { // Smooth curve. Control point is the "reflection" of // the second control point in the previous path. Point end = token.args[0] as Point; if (!absolute) { end += currentPos; } late final Point control; if (lastCommand == "Q" || lastCommand == 'T') { // The control point is assumed to be the reflection of // the control point on the previous command relative // to the current point. control = currentPos + currentPos - (segments.last as QuadraticBezier).control; } else { // If there is no previous command or if the previous command // was not an Q, q, T or t, assume the first control point is // coincident with the current point. control = currentPos; } segments.add( QuadraticBezier(start: currentPos, control: control, end: end), ); currentPos = end; } else if (command == "A") { // For some reason I implemented the Arc with a complex radius. // That doesn't really make much sense, but... *shrugs* final radius = Point(token.args[0] as num, token.args[1] as num); final rotation = token.args[2] as num; final arc = token.args[3] as bool; final sweep = token.args[4] as bool; Point end = token.args[5] as Point; if (!absolute) { end += currentPos; } segments.add( Arc( start: currentPos, radius: radius, rotation: rotation, arc: arc, sweep: sweep, end: end, ), ); currentPos = end; } // Finish up the loop in preparation for next command lastCommand = command; } return segments; }