2022-02-03 01:24:41 +01:00

405 lines
11 KiB
Dart

/// 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;
}